Core Contracts

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

Low Findings Consolidated

[L-01] Wrong returned value assigned to amountMinted in ReserveLibrary.deposit()

It's kind of confusing with the misnomer naming in the returned values from RToken.mint(). Regardless, the supposedly scaled (reduced) amount of RTokens minted has been wrongly assigned in ReserveLibrary.deposit(). Apparently, it's amountUnderlying that is referencing the 4th returned parameter, amountScaled in RToken.mint().

Consider making the following refactoring:

ReserveLibrary.sol#L336-L344

// Mint RToken to the depositor (scaling handled inside RToken)
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
amount, // amount
reserve.liquidityIndex // index
);
- amountMinted = amountScaled;
+ amountMinted = amountUnderlying;

[L-02] amountToMint.toUint128() and amount.toUint128() should be done in the earlier logic

It's best practice making the earlier check preferably from the initiating Lending.deposit(). That way, it will assuredly guarantee the amountToMint.rayDiv(index) operation to succeed too when checking a <= (type(uint256).max - halfB) / RAY where a == amountToMint and B == index.

Here's the detected code line:

RToken.sol#L136

_mint(onBehalfOf, amountToMint.toUint128());

Similarly, the same concern should be applicable when making RToken redeeming or reserves asset token withdrawal:

RToken.sol#L176

_burn(from, amount.toUint128());

[L-03] Double Scaling in RToken.mint() and Unused balanceIncrease Calculation

In RToken.sol, the mint() function incorrectly double scales the balance when computing balanceIncrease. The balanceOf() function already scales up the user’s balance using rayMul(),

RToken.sol#L194-L197

/**
* @notice Returns the scaled balance of the user
* @param account The address of the user
* @return The user's balance (scaled by the liquidity index)
*/
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}

but balanceIncrease further scales it up again, leading to an inflated and incorrect calculation.

RToken.sol#L126-L132

uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}

Additionally, balanceIncrease is never referenced or emitted, making the entire computation redundant and misleading. While this does not cause direct fund loss, it introduces unnecessary gas costs and potential inconsistencies if later utilized. The function should remove the redundant scaling and emit balanceIncrease for transparency and future use.

[L-04] 1 Wei Overshoot in RToken Full Withdrawals and Transfers

Rounding discrepancies during RToken withdrawals or transfers due to the use of two successive rounding-half-up operations when scaling (rayMul()) and descaling (rayDiv()) token amounts using ray arithmetic can cause a 1 wei overshoot under edge cases, where the user’s requested withdrawal or transfer amount slightly exceeds their scaled balance, leading to unexpected transaction failures or reverts during full withdrawals or full transfers. This could inconvenience users by requiring manual adjustments.

Consider making the following refactoring:

RToken.sol#L307-L311

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());
+ if (scaledAmount > super.balanceOf(from)) {
+ scaledAmount = super.balanceOf(from); // Correct for rounding discrepancies
+ }
super._update(from, to, scaledAmount);
}

[L-05] Lack of Helper Function for Pre-Scaling Transfers

The protocol provides a balanceOf() function that returns a scaled-up balance adjusted for the liquidity index, but lacks a corresponding helper function for pre-scaling amounts before calling transfer() or transferFrom(). This omission forces users to manually compute the correct input amount to send an exact number of RTokens, leading to potential underpayments or transaction inaccuracies. While this does not break functionality, it creates unnecessary usability friction and increases the risk of miscalculations, particularly for smart contract integrations. Adding a helper function to pre-scale transfer amounts would improve accuracy and user experience.

Consider implementing the following helper function in RToken.sol:

/**
* @notice Calculates the required input amount to send a specific net amount.
* @param targetAmount The desired amount to be received.
* @return The scaled-up amount the user should input in `transfer()` or `transferFrom()`.
*/
function getTransferInputAmount(uint256 targetAmount) public view returns (uint256) {
return targetAmount.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}

[L-06] Static _liquidityIndex and Dead Code in updateLiquidityIndex()

The _liquidityIndex in RToken.sol is initialized to 1e27 (RAY),

RToken.sol#L75

_liquidityIndex = WadRayMath.RAY;

and is never updated because updateLiquidityIndex() is not called anywhere in LendingPool.sol. As a result, _liquidityIndex remains static, effectively making amount.rayDiv(_liquidityIndex) in transferFrom() a 1:1 passthrough operation,

RToken.sol#L223-L226

function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}

and thus unintentionally preventing the double scaling issue later in the overridden _update().

RToken.sol#L307-L311

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);
}

While this behavior mitigates a potential miscalculation, it also renders updateLiquidityIndex() dead code, as it has onlyReservePool visibility but is never utilized by Lending.sol. This suggests an incomplete implementation where either updateLiquidityIndex() should be removed or integrated properly within the protocol’s interest rate update logic.

[L-07] Misleading Total Allocation Calculation in Stability Pool

The getTotalAllocation() function in StabilityPool.sol returns a single totalAllocation value that aggregates both manager allocations and market allocations, without distinguishing between them. However, per the function NatSpec, it's meant to get the total allocation across all managers:

StabilityPool.sol#L279-L285

/**
* @notice Gets the total allocation across all managers.
* @return Total allocation amount.
*/
function getTotalAllocation() external view returns (uint256) {
return totalAllocation;
}

This can lead to misinterpretation when querying the total allocation assigned to managers, potentially causing incorrect assumptions in governance decisions or integrations. While this does not pose an immediate security risk or fund loss, it introduces ambiguity in allocation tracking. A clearer approach would involve separate tracking of manager and market allocations to prevent confusion and ensure accurate resource management.

[L-08] Lack of Option to Increase Collateral for Liquidation Avoidance

The current liquidation process in LendingPool.sol does not allow borrowers to increase their collateral to restore their health factor above the liquidation threshold, leaving full debt repayment as the only means of avoiding liquidation via closeLiquidation().

LendingPool.sol#L484

if (userDebt > DUST_THRESHOLD) revert DebtNotZero();

This design choice limits borrower flexibility and may lead to unnecessary liquidations, especially in volatile market conditions where users could otherwise secure their positions by adding more collateral. Introducing an option to increase collateral and thereby restoring the health factor before the grace period to close liquidation would enhance borrower protection and improve the protocol’s capital efficiency.

[L-09] Bypassing Allocation Limits in Liquidations Allows Arbitrage Opportunities

The liquidateBorrower() function in StabilityPool.sol does not enforce that managers use only their allocated funds when performing liquidations. This allows managers to bypass allocation limits by using their own crvUSDToken instead of protocol-allocated funds. While managers are trusted entities, this creates a potential arbitrage opportunity, enabling them to liquidate positions without restriction and potentially acquire discounted collateral outside the intended governance-imposed limits. This loophole undermines the allocation tracking system and may lead to unintended liquidation behavior. To ensure proper fund tracking and governance enforcement, the function should explicitly restrict liquidations to allocated funds and log the source of funds used.

[L-10] RAACMinter Cannot Update RAAC Token Parameters Due to Ownership Restriction

The RAACMinter.sol contract attempts to call setSwapTaxRate(), setBurnTaxRate(), and setFeeCollector() in RAACToken.sol, but these functions are restricted by onlyOwner, which refers to the owner of RAACToken.sol. Since RAACMinter.sol is not the owner, these calls will always fail, making the functions in RAACMinter.sol effectively moot. While this does not break the protocol’s functionality—since the owner of RAACToken.sol can still manually execute these functions—it results in redundant logic and potential governance inefficiencies. The recommended fix is to allow these functions to be executed by either the owner or RAACMinter.sol to ensure proper delegation.

In RAACToken.sol, the mint function is already restricted to onlyMinter(), where the minter is set as RAACMinter.sol. Consider implementing the following:

+ modifier onlyAuthorized() {
+ if (msg.sender != owner() && msg.sender != minter) revert UnauthorizedCaller();
+ _;
+ }
- function setFeeCollector(address _feeCollector) external onlyOwner {
+ function setFeeCollector(address _feeCollector) external onlyAuthorized {
- function setSwapTaxRate(uint256 rate) external onlyOwner { _setTaxRate(rate, true); }
+ function setSwapTaxRate(uint256 rate) external onlyAuthorized { _setTaxRate(rate, true); }
- function setBurnTaxRate(uint256 rate) external onlyOwner { _setTaxRate(rate, false); }
+ function setBurnTaxRate(uint256 rate) external onlyAuthorized { _setTaxRate(rate, false); }

[L-11] Lack of Automated RAAC Emission Minting Before Minter Update

When updating the RAAC minter in Stability Pool via setRAACMinter(), any unminted RAAC emissions calculated by the previous minter may be lost if tick() is not manually called beforehand.

Since tick() can be executed permissionlessly, the owner is expected to trigger it before changing the minter, but this process is not enforced at the contract level. If the owner forgets to do so, Stability Pool users might not receive their expected RAAC rewards, causing a temporary loss of emissions. While this issue does not result in direct fund loss if properly managed, automating the minting process before switching minters would improve protocol robustness.

Consider implementing the following refactoring:

StabilityPool.sol#L153-L159

/**
* @notice Sets the RAACMinter contract address.
* @param _raacMinter Address of the new RAACMinter contract.
*/
function setRAACMinter(address _raacMinter) external onlyOwner {
+ _mintRAACRewards();
raacMinter = IRAACMinter(_raacMinter);
}

[L-12] Unused mintRewards() Function Leads to Redundant Code

The mintRewards() function in RAACMinter.sol is never called within the StabilityPool contract, rendering it effectively dead code. While the function is designed to manage the controlled minting of RAAC tokens based on excess tokens, this logic is never utilized, making it redundant. Additionally, tick() already handles RAAC token minting, further reducing the necessity of mintRewards(). Keeping unused functions increases contract complexity and gas overhead without any functional benefit. Removing or integrating this function properly within StabilityPool would improve code clarity and efficiency. The latter will after all make getExcessTokens() meaningful.

[L-13] Inefficient Token Burn When Fee Collection Is Disabled

When calling the burn() function in RAACToken.sol, if the fee collector address (feeCollector) is set to address(0) as can be optionally disabled via the logic design of setFeeCollector(), the function incorrectly burns amount - taxAmount instead of the full amount. This results in fewer tokens being burned than expected, causing a minor inefficiency in the tokenomics. While this does not pose a security risk or lead to direct fund loss, it affects the intended token supply reduction mechanics. A simple fix would be to check if feeCollector == address(0) and burn the full amount to maintain expected behavior when fee collection is disabled.

Consider implementing the following refactoring:

RAACToken.sol#L76-L86

/**
* @dev Burns tokens from the caller's balance
* @param amount The amount of tokens to burn
*/
function burn(uint256 amount) external {
- uint256 taxAmount = amount.percentMul(burnTaxRate);
+ uint256 taxAmount = feeCollector != address(0) ? amount.percentMul(burnTaxRate) : 0;
_burn(msg.sender, amount - taxAmount);
if (taxAmount > 0 && feeCollector != address(0)) {
_transfer(msg.sender, feeCollector, taxAmount);
}
}

[L-14] Unintended Double Taxation on Transfers to Fee Collector

The burn() function in RAACToken.sol applies a burn tax on tokens before transferring the tax amount to feeCollector.

RAACToken.sol#L76-L86

/**
* @dev Burns tokens from the caller's balance
* @param amount The amount of tokens to burn
*/
function burn(uint256 amount) external {
uint256 taxAmount = amount.percentMul(burnTaxRate);
_burn(msg.sender, amount - taxAmount);
if (taxAmount > 0 && feeCollector != address(0)) {
_transfer(msg.sender, feeCollector, taxAmount);
}
}

However, since the _update() function is overridden to apply taxes on transfers, this means that when _transfer(msg.sender, feeCollector, taxAmount) is executed, an additional tax is inadvertently applied (Note: only burnAmount is deducted since to == feeCollector in this case). This results in higher-than-intended deductions from the sender’s balance. Since feeCollector is a designated recipient for tax collection, it should be whitelisted to prevent double taxation and ensure tax efficiency.

Consider implementing the following refactoring:

RAACToken.sol#L92-L103

function setFeeCollector(address _feeCollector) external onlyOwner {
// Fee collector can be set to zero address to disable fee collection
if(feeCollector == address(0) && _feeCollector != address(0)){
emit FeeCollectionEnabled(_feeCollector);
}
if (_feeCollector == address(0)){
emit FeeCollectionDisabled();
}
feeCollector = _feeCollector;
+ whitelistAddress[_feeCollector] = true; // Automatically whitelist feeCollector
emit FeeCollectorSet(_feeCollector);
}

[L-15] Incorrect Return Value in getNormalizedDebt() May Cause Future Miscalculations

The function getNormalizedDebt() in ReserveLibrary.sol currently returns reserve.totalUsage when timeDelta < 1, instead of reserve.usageIndex. While this function is not used anywhere in the protocol at the moment, if it were to be referenced in the future, it could lead to significant overestimation of a borrower's debt. This is because reserve.totalUsage represents the raw total borrowed amount, whereas reserve.usageIndex is used to scale individual borrower debt over time. Using the wrong value could cause mispricing in calculations, incorrect liquidations, or accounting discrepancies. Although this is not an active issue, fixing it preemptively ensures accurate calculations if the function is used later.

Consider implementing the following refactoring:

/**
* @notice Gets the normalized debt of the reserve.
* @param reserve The reserve data.
* @return The normalized debt (in underlying asset units).
*/
function getNormalizedDebt(ReserveData storage reserve, ReserveRateData storage rateData) internal view returns (uint256) {
uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
- return reserve.totalUsage;
+ return reserve.usageIndex;
}
return calculateCompoundedInterest(rateData.currentUsageRate, timeDelta).rayMul(reserve.usageIndex);
}

[L-16] Immutable Optimal Utilization Rate Reduces Protocol Flexibility

The optimalUtilizationRate in Lending.sol is initialized as 80% (WadRayMath.RAY.percentMul(80_00)) in the constructor and remains immutable, unlike other rate parameters such as primeRate, which can be updated. While the protocol allows adjustments to protocolFeeRate, primeRate, and (via a report submitted separately) should enable updates for baseRate, optimalRate, and maxRate, the optimalUtilizationRate remains fixed. This rigidity could reduce the protocol’s ability to dynamically adapt interest rate curves based on market conditions. While not an immediate exploit risk, a static optimal utilization rate may lead to suboptimal lending dynamics, especially in volatile market conditions where liquidity supply and demand fluctuate. Allowing governance-controlled updates to optimalUtilizationRate would improve adaptability without introducing security concerns.

Here's the detected code:

LendingPool.sol#L210

rateData.optimalUtilizationRate = WadRayMath.RAY.percentMul(80_00); // 80% in RAY (27 decimals)

[L-17] Misleading Emission of Debt Growth Data Due to Double Scaling

The balanceIncrease variable in mint() and burn() functions within DebtToken.sol undergoes an unnecessary double scaling operation by applying rayMul() to an already scaled balanceOf(). This results in inflated values being emitted in the Mint and Burn events, misleading external tracking mechanisms that rely on event logs for debt accumulation analysis. Although this issue does not impact actual debt calculations or balances, it can cause confusion for on-chain analytics, reporting tools, or monitoring dashboards that track debt growth over time. To resolve this, the calculation should either be corrected to avoid double scaling or removed entirely if it serves no functional purpose.

Consider making the following refactoring:

DebtToken.sol#L150

- uint256 scaledBalance = balanceOf(onBehalfOf);
+ uint256 scaledBalance = super.balanceOf(onBehalfOf);

DebtToken.sol#L191

- uint256 userBalance = balanceOf(from);
+ uint256 userBalance = super.balanceOf(from);

[L-18] struct Lock in veRAACToken.sol is typically at default values all times

Here's the detected code, and fortunately the second input parameter isn't used when invoking _updateBoostState():

veRAACToken.sol#L254

_updateBoostState(msg.sender, locks[msg.sender].amount);
Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 month ago
inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

veRAACToken::increase uses locks[msg.sender] instead of _lockState.locks[msg.sender] inside _updateBoostState call

LendingPool::finalizeLiquidation() never checks if debt is still unhealthy

RAACToken::burn incorrectly deducts tax amount but doesn't burn or transfer it when feeCollector is address(0), preventing complete token burns

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

RAACMinter::mintRewards function is never called by StabilityPool despite being the only authorized caller, leaving intended reward functionality unused

RAACMinter lacks critical ownership transfer functionality and parameter management after receiving RAACToken ownership, causing permanent protocol rigidity

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

RToken::mint doesn't return data in the right order, making the protocol emit wrong events

RToken::mint calculates balanceIncrease (interest accrued since last interaction) but never mints it, causing users to lose earned interest between deposits

The balanceIncrease is the interest that has already accrued on the user's existing scaledBalance since their last interaction. It's not something you mint as new tokens in the _mint function.

RToken::mint incorrectly uses balanceOf instead of super.balanceOf for calculating balanceIncrease, causing double-scaling and inflated interest values in events

Informational

getNormalizedDebt returns totalUsage (amount) instead of usageIndex (rate) when timeDelta < 1, breaking interest calculations across the protocol

RToken::_update fails to handle 1 wei rounding errors when transferring full balances, causing transaction failures

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

veRAACToken::increase uses locks[msg.sender] instead of _lockState.locks[msg.sender] inside _updateBoostState call

LendingPool::finalizeLiquidation() never checks if debt is still unhealthy

RAACToken::burn incorrectly deducts tax amount but doesn't burn or transfer it when feeCollector is address(0), preventing complete token burns

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

RAACMinter::mintRewards function is never called by StabilityPool despite being the only authorized caller, leaving intended reward functionality unused

RAACMinter lacks critical ownership transfer functionality and parameter management after receiving RAACToken ownership, causing permanent protocol rigidity

RToken::updateLiquidityIndex() has onlyReservePool modifier but LendingPool never calls it, causing transferFrom() to use stale liquidity index values

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

RToken::mint doesn't return data in the right order, making the protocol emit wrong events

RToken::mint calculates balanceIncrease (interest accrued since last interaction) but never mints it, causing users to lose earned interest between deposits

The balanceIncrease is the interest that has already accrued on the user's existing scaledBalance since their last interaction. It's not something you mint as new tokens in the _mint function.

RToken::mint incorrectly uses balanceOf instead of super.balanceOf for calculating balanceIncrease, causing double-scaling and inflated interest values in events

Informational

getNormalizedDebt returns totalUsage (amount) instead of usageIndex (rate) when timeDelta < 1, breaking interest calculations across the protocol

RToken::_update fails to handle 1 wei rounding errors when transferring full balances, causing transaction failures

Support

FAQs

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