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)
self.max_v2_duration,
)
# ...calculations for multiple 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
Simulate how rewards have accumulated over those periods
Why This Causes Inaccuracies
If profit_max_unlock_time
has changed historically:
The actual historical periods would have different durations than what's currently configured
The number of periods calculated would be incorrect
The reward distribution simulation would be inaccurate
This affects the calculation of all derived values including total_idle, balance_of_self, etc.
Example Scenario
Consider this sequence of events:
For 6 months, profit_max_unlock_time
was set to 7 days (1 week)
Then it was changed to 14 days (2 weeks)
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
if params.last_profit_update + period >= parameters_ts: # No full period passed
return params
number_of_periods: uint256 = min( # Calculate periods
(parameters_ts - params.last_profit_update)
self.max_v2_duration, # Maximum limit
)
# Calculate reward gain
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt)
)
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.balance_of_self * params.balance_of_self
)
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
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt)
) * 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.balance_of_self * params.balance_of_self
)
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
)
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