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:
amountToMint.toUint128()
and amount.toUint128()
should be done in the earlier logicIt'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:
Similarly, the same concern should be applicable when making RToken redeeming or reserves asset token withdrawal:
RToken.mint()
and Unused balanceIncrease
CalculationIn 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()
,
but balanceIncrease
further scales it up again, leading to an inflated and incorrect calculation.
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.
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:
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:
_liquidityIndex
and Dead Code in updateLiquidityIndex()
The _liquidityIndex in RToken.sol is initialized to 1e27 (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,
and thus unintentionally preventing the double scaling issue later in the overridden _update()
.
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.
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:
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.
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()
.
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.
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.
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:
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:
mintRewards()
Function Leads to Redundant CodeThe 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.
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:
The burn()
function in RAACToken.sol applies a burn tax on tokens before transferring the tax amount to feeCollector
.
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:
getNormalizedDebt()
May Cause Future MiscalculationsThe 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:
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:
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:
struct Lock
in veRAACToken.sol is typically at default values all timesHere's the detected code, and fortunately the second input parameter isn't used when invoking _updateBoostState()
:
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
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.
Informational
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
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.
Informational
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.