DeFiLayer 1Layer 2
14,723 OP
View results
Submission Details
Severity: high
Invalid

Logic Flaw in _obtain_price_params Loop Causing Excessive Parameter Adjustments and Price Distortion

Summary

In ScrvusdOracleV2::_obtain_price_params function the loop for _: uint256 in range(number_of_periods, bound=MAX_V2_DURATION) iterates an incorrect number of times specifically MAX_V2_DURATION - number_of_periods instead of the intended number_of_periods resulting in excessive adjustments to total_supply and balance_of_self. This bug distorts the scrvUSD price per share calculated in _raw_price, which undermines the oracle’s precision and reliability. this flaw affects every price calculation in the v2 mode when fewer than MAX_V2_DURATION periods have elapsed, with significant downstream consequences for stableswap-ng pools relying on accurate pricingg

Vulnerability Details

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L237

The loop’s syntax, for _: uint256 in range(number_of_periods, bound=MAX_V2_DURATION), misinterprets Vyper’s range(start, bound=N) behavior, which iterates from start to N-1. Here, it runs from number_of_periods to MAX_V2_DURATION - 1, executing MAX_V2_DURATION - number_of_periods iterations. The intent, based on the preceding gain * number_of_periods adjustment to total_idle, is to apply exactly number_of_periods iterations to reflect elapsed profit periods. This mismatch over-adjusts total_supply and balance_of_self, skewing the price computation.

@view
def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
"""
@notice Obtain Price parameters true or assumed to be true at `parameters_ts`.
Assumes constant gain(in crvUSD rewards) through distribution periods.
@param parameters_ts Timestamp to obtain parameters for
@return Assumed `PriceParams`
"""
params: PriceParams = self.price_params
period: uint256 = self.profit_max_unlock_time
if params.last_profit_update + period >= parameters_ts:
return params
number_of_periods: uint256 = min(
(parameters_ts - params.last_profit_update) // period,
self.max_v2_duration,
)
# locked shares at moment params.last_profit_update
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt) // params.total_supply
)
params.total_idle += gain * number_of_periods
# functions are reduced from `VaultV3._process_report()` given assumptions with constant gain
for _: uint256 in range(number_of_periods, bound=MAX_V2_DURATION):
new_balance_of_self: uint256 = (
params.balance_of_self
* (params.total_supply - params.balance_of_self) // params.total_supply
)
params.total_supply -= (
params.balance_of_self * params.balance_of_self // params.total_supply
)
params.balance_of_self = new_balance_of_self
if params.full_profit_unlock_date > params.last_profit_update:
# copy from `VaultV3._process_report()`
params.profit_unlocking_rate = params.balance_of_self * MAX_BPS_EXTENDED // (
params.full_profit_unlock_date - params.last_profit_update
)
else:
params.profit_unlocking_rate = 0
params.full_profit_unlock_date += number_of_periods * period
params.last_profit_update += number_of_periods * period
return params

MAX_V2_DURATION = 4 * 12 * 4 = 192 (4 years, assuming monthly periods, though period defaults to 1 week).

self.max_v2_duration defaults to 4 * 6 = 24 (6 months with weekly periods) but can be set up to 192 via set_max_v2_duration.

The bug activates whenever number_of_periods < MAX_V2_DURATION, which is the typical case given the default self.max_v2_duration = 24 and MAX_V2_DURATION = 192. For example, if number_of_periods = 10, the loop runs 192 - 10 = 182 times instead of 10.

The Intended Behavioris likely:

  • number_of_periods represents the number of profit unlock periods elapsed since

  • last_profit_update, capped by self.max_v2_duration. The function adjusts total_idle by

  • gain * number_of_periods to account for accrued rewards, and the loop should apply

  • number_of_periods iterations to update total_supply and balance_of_self consistently, simulating share unlocking over those periods.

While The Actual Behavior is:

  • The loop runs MAX_V2_DURATION - number_of_periods times:

    • If number_of_periods = 10, it iterates 182 times.

    • If number_of_periods = 24 (default cap), it iterates 168 times.

  • Each iteration reduces total_supply and adjusts balance_of_self using:

    vyper

    new_balance_of_self: uint256 = (
    params.balance_of_self
    * (params.total_supply - params.balance_of_self) // params.total_supply
    )
    params.total_supply -= (
    params.balance_of_self * params.balance_of_self // params.total_supply
    )

This over-shrinks total_supply, misaligning it with the total_idle adjustment.

Example:

  • Initial state: total_supply = 1000, balance_of_self = 200, total_idle = 800, total_debt = 0.

  • number_of_periods = 2, gain = 200 * 800 // 1000 = 160, MAX_V2_DURATION = 192.

  • Intended:

    • total_idle += 160 * 2 = 320 → total_idle = 1120.

    • Loop runs 2 times:

      • Iteration 1: new_balance_of_self = 200 * (1000 - 200) // 1000 = 160, total_supply -= 200 * 200 // 1000 = 40 → total_supply = 960, balance_of_self = 160.

      • Iteration 2: new_balance_of_self = 160 * (960 - 160) // 960 = 133, total_supply -= 160 * 160 // 960 = 26 → total_supply = 934, balance_of_self = 133.

    • Price = (1120 + 0) * 10^18 // 934 ≈ 1.199 * 10^18.

    Actual:

    • total_idle = 1120 (correct).

    • Loop runs 192 - 2 = 190 times, reducing total_supply excessively (e.g., after many iterations, total_supply approaches a much lower value, say ~500 after convergence).

    • Price = 1120 * 10^18 // 500 ≈ 2.24 * 10^18 (over 2x the intended price).

    This demonstrates significant price inflation due to an over-reduced denominator.

Impact

  • The loop’s over-iteration shrinks total_supply and balance_of_self far beyond the intended number_of_periods, creating a disconnect with the total_idle increase.

  • _total_assets = total_idle + total_debt reflects gain * number_of_periods.

  • _total_supplyis reduced excessively, inflating the price (assets / supply).

  • Impacts price_v1 and price_v2, which rely on _raw_price, skewing all v2-mode price queries and updates.

  • oracle’s v2 mode aims to replicate scrvUSD prices with “no losses due to approximation” by assuming equal rewards over number_of_periods. Over-iteration violates this, producing prices that deviate significantly from reality

  • In fact Stableswap-ng pools (e.g., USDC/scrvUSD) overvalue scrvUSD, leading to:

    • Arbitrage losses for liquidity providers as external traders exploit the inflated price.

    • Pool imbalances as the peg drifts, undermining stability.

Tools Used

Recommendations

  • Correct the loop to iterate exactly number_of_periods times

+ for _: uint256 in range(number_of_periods): # Fixed loop
new_balance_of_self: uint256 = (
params.balance_of_self * (params.total_supply - params.balance_of_self) // params.total_supply
)
params.total_supply -= (
params.balance_of_self * params.balance_of_self // params.total_supply
)
params.balance_of_self = new_balance_of_self
+ if number_of_periods > 0:
+ params.last_profit_update += number_of_periods * period
return params
Updates

Lead Judging Commences

0xnevi Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

[invalid] finding-incorrect-loop-bound

Invalid, `bound` here has a different meaning from Python's `range(a, b)`. It is a bound of maximum iterations, meaning the loop will only go to the bounded `MAX_V2_DURATION` when `number_of_periods >= MAX_V2_DURATION`

Support

FAQs

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