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

Incorrect Simulation of Historical Periods Due to profit_max_unlock_time Changes

Summary

Using the current profit_max_unlock_time to simulate past periods causes inaccuracies if the period length changed historically. The issue occurs in how the ScrvusdOracleV2 contract simulates past periods when calculating prices, particularly in the _obtain_price_params function.

looking at the _obtain_price_params function

def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
params: PriceParams = self.price_params
period: uint256 = self.profit_max_unlock_time # Using current value
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,
)
# ...calculations for multiple periods...

The function uses the current value of profit_max_unlock_time (stored in period) to:

  1. Calculate how many periods have passed since the last update

  2. Simulate how rewards have accumulated over those periods

Why This Causes Inaccuracies

If profit_max_unlock_time has changed historically:

  1. The actual historical periods would have different durations than what's currently configured

  2. The number of periods calculated would be incorrect

  3. The reward distribution simulation would be inaccurate

  4. This affects the calculation of all derived values including total_idle, balance_of_self, etc.

Example Scenario

Consider this sequence of events:

  1. For 6 months, profit_max_unlock_time was set to 7 days (1 week)

  2. Then it was changed to 14 days (2 weeks)

  3. The verifier updates this new value in the oracle

Now when the oracle tries to simulate what happened over the past several months:

  • It will incorrectly assume ALL historical periods were 14 days long

  • It will calculate fewer periods than actually occurred (e.g., 13 two-week periods instead of 26 one-week periods)

  • The compounding of rewards will be incorrect

  • The resulting price estimation will deviate from reality

Vulnerability Details

The profit_max_unlock_time is a parameter in the scrvUSD vault that determines the time over which profits are unlocked.

In ScrvusdOracleV2.vy, there's a method to update this parameter:

@external
def update_profit_max_unlock_time(_profit_max_unlock_time: uint256, _block_number: uint256) -> bool:
"""
@notice Update price using `_parameters`
@param _profit_max_unlock_time New `profit_max_unlock_time` value
@param _block_number Block number of parameters to linearize updates
@return Boolean whether value changed
"""
access_control._check_role(UNLOCK_TIME_VERIFIER, msg.sender) # Access control check
# Allowing same block updates for fixing bad blockhash provided (if possible)
assert self.last_block_number <= _block_number, "Outdated" # Freshness check
self.last_block_number = _block_number # Update block number
prev_value: uint256 = self.profit_max_unlock_time # Store old value
self.profit_max_unlock_time = _profit_max_unlock_time # Update period length
return prev_value != _profit_max_unlock_time # Return if changed

In the _obtain_price_params function, which is used to simulate price parameters at a given timestamp, the current profit_max_unlock_time is used as the period

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 # Get current parameters
period: uint256 = self.profit_max_unlock_time // # VULNERABILITY: Using current period length for historical simulation
if params.last_profit_update + period >= parameters_ts: # No full period passed
return params // # Return unchanged
number_of_periods: uint256 = min( # Calculate periods
(parameters_ts - params.last_profit_update) // period, # ⚠️ Using current period length for historical division
self.max_v2_duration, # Maximum limit
)
# Calculate reward gain
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt) // params.total_supply
)
params.total_idle += gain * number_of_periods # Add rewards based on period count
# Simulate multiple periods
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 # Update balance
# Calculate unlocking rate
if params.full_profit_unlock_date > params.last_profit_update:
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
# Update time parameters based on periods
params.full_profit_unlock_date += number_of_periods * period # ⚠️ Using current period again
params.last_profit_update += number_of_periods * period # ⚠️ Using current period again
return params # Return simulated parameters

When simulating what happens across periods, the function uses the current value of profit_max_unlock_time (stored in period) to calculate how many periods have passed since the last update. If the profit_max_unlock_time has changed historically, this would lead to an incorrect calculation of the number of periods, which would in turn affect the simulation of how rewards have been distributed and how the price has evolved.

For example:

  • If profit_max_unlock_time was 1 week for a year, and then changed to 2 weeks

  • The simulation trying to calculate price from 3 weeks ago would incorrectly use the 2-week period to calculate that only 1.5 periods have passed

  • This would lead to inaccurate simulation of rewards and therefore incorrect price calculations

Since the oracle is designed to replicate the behavior of the original scrvUSD vault as closely as possible, this is indeed a bug that could lead to inaccuracies in the price reported by the oracle.

Impact

This primarily affects price_v1 and price_v2 since they rely on simulation of past periods. It doesn't affect price_v0 which simply returns the price at the last verified update.

This issue demonstrates why maintaining historical parameter values or tracking parameter change events would be important for accurate price simulation in this type of oracle system.

Recommendations

Track historical profit_max_unlock_time values in the oracle and use the period length applicable at each past timestamp during simulations.

Consider this Implementation Strategy

Data Structure for Historical Tracking

# New data structure to track period changes
struct PeriodChange:
timestamp: uint256 # When the change occurred
period_length: uint256 # The new period length
# New state variables
period_history: PeriodChange[100] # Array of historical changes (with a reasonable limit)
period_history_count: uint256 # Number of recorded changes

Modified Update Function

@external
def update_profit_max_unlock_time(_profit_max_unlock_time: uint256, _block_number: uint256) -> bool:
"""
@notice Update price using `_parameters`
@param _profit_max_unlock_time New `profit_max_unlock_time` value
@param _block_number Block number of parameters to linearize updates
@return Boolean whether value changed
"""
access_control._check_role(UNLOCK_TIME_VERIFIER, msg.sender) # Access control check
assert self.last_block_number <= _block_number, "Outdated" # Freshness check
self.last_block_number = _block_number # Update block number
prev_value: uint256 = self.profit_max_unlock_time # Store old value
if prev_value != _profit_max_unlock_time: # Check if value changed
# Record the historical change with timestamp
if self.period_history_count < 100: # Prevent overflow
self.period_history[self.period_history_count] = PeriodChange({
timestamp: block.timestamp,
period_length: _profit_max_unlock_time
})
self.period_history_count += 1 # Increment counter
self.profit_max_unlock_time = _profit_max_unlock_time # Update current period length
return prev_value != _profit_max_unlock_time # Return if changed

Fixed Obtain Price Parameters Function

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 # Get current parameters
current_ts: uint256 = params.last_profit_update # Start from last update
if current_ts >= parameters_ts: # No simulation needed
return params # Return unchanged
# Simulate periods with potentially varying lengths
while current_ts < parameters_ts: # Loop until target timestamp
# Find the applicable period length for this timestamp
period: uint256 = self._get_period_at_timestamp(current_ts) # Get historical period
# Calculate end of current period or target timestamp
period_end: uint256 = min(
current_ts + period, # End of this period
parameters_ts # Don't go beyond target
)
# Calculate partial or full period rewards
fraction: uint256 = (period_end - current_ts) * 10**18 // period # Proportion of period
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt) // params.total_supply
) * fraction // 10**18 # Scale gain by fraction
params.total_idle += gain # Add scaled rewards
# Update simulation state for partial or full period
if fraction == 10**18: # If full period
# Normal balance updates as before
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
else: # Partial period handling
# Scale updates proportionally (simplified)
partial_update: uint256 = (
params.balance_of_self * params.balance_of_self * fraction // 10**18 // params.total_supply
)
params.total_supply -= partial_update
# Move to next timestamp
current_ts = period_end
# Final unlocking rate calculation
if params.full_profit_unlock_date > params.last_profit_update:
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
# Update time tracking
params.last_profit_update = parameters_ts
return params # Return simulated parameters

Helper Function to Get Historical Period

@view
def _get_period_at_timestamp(ts: uint256) -> uint256:
"""
@notice Get the profit_max_unlock_time that was active at a given timestamp
@param ts Timestamp to check
@return Period length at that timestamp
"""
# Default to current if no history or timestamp in future
if self.period_history_count == 0 or ts >= block.timestamp:
return self.profit_max_unlock_time
# Search history for applicable period
for i in range(self.period_history_count, bound=100):
idx: uint256 = self.period_history_count - 1 - i # Reverse iteration
if self.period_history[idx].timestamp <= ts:
return self.period_history[idx].period_length # Found applicable period
# If nothing found (should not happen with proper initialization)
return self.profit_max_unlock_time # Fallback to current
Updates

Lead Judging Commences

0xnevi Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality
Assigned finding tags:

[invalid] finding-change-in-max-update-time-influence

This issue and its duplicates lack sufficient proof of the impact of a sudden change in `profit_max_unlock_time`. Both price parameters and `profit_max_unlock_time` can be adjusted immediately, However, the whole purpose of `_smoothed_price` is to limit sudden updates. This is performed when the raw price and last price is compared within the `_price_v0/v1/v2` function calls to limit price updates to `max_change` The slowed price lag can then be safely arbitrage as mentioned in the docs > Smoothing is introduced for sudden updates, so the price slowly catches up with the price, while the pool is being arbitraged safely. Though, smoothing limits the upper bound of price growth. Therefore, we consider that scrvUSD will never be over 60% APR.

Support

FAQs

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