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

`last_profit_update` is wrongly updated during profit evolution causing subsequential prices to be deflated and lack of oracle updates in an edge case

Summary

Contrast to how timestamps are managed in the original scrusd VaultV3 implementation where we have the last_profit_update timestamp to set to the current block timestamp when processing profit reports, ensuring proper time-based calculations. In ScrvusdOracleV2, this timestamp is incorrectly advanced by a fixed period increment instead of being updated to the current timestamp which should instead be only done to full_profit_unlock_date.

This discrepancy leads to inaccurate price calculations, potential underflow errors, and ultimately a complete breakdown of the price update mechanism.

Vulnerability Details

First note how during profit updates in VaultV3::_process_report() we correctly set the last_profit_update to the current block.timestamp:

from https://etherscan.io/address/0x0655977FEb2f289A4aB78af67BAB0d17aAb84367#code#L1302

# Calculate how many shares unlock per second.
self.profit_unlocking_rate = total_locked_shares * MAX_BPS_EXTENDED / new_profit_locking_period
# Calculate how long until the full amount of shares is unlocked.
self.full_profit_unlock_date = block.timestamp + new_profit_locking_period
# Update the last profitable report timestamp.
self.last_profit_update = block.timestamp

As seen, the Yearn Vault implementation correctly sets last_profit_update to the current block.timestamp whenever profit is processed. This anchors the unlocking rate calculation to the precise moment when the profit was reported and the new unlocking rate is calculated.

However, in the ScrvusdOracleV2 contract, the implementation incorrectly advances by the number of periods before full unlock:

Take a look at ScrvusdOracleV2#obtain_price_params()

def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
# ..snip
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,
)
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt) // params.total_supply
)
params.total_idle += gain * number_of_periods
params.full_profit_unlock_date += number_of_periods * period
|> params.last_profit_update += number_of_periods * period
return params

So instead of setting last_profit_update to the current timestamp, it's incremented by number_of_periods * period. Which should correctly only be done to full_profit_unlock_date. This creates a significant deviation from the original implementation and leads to severe issues in timestamp handling.

The proper implementation should be:

params.full_profit_unlock_date += number_of_periods * period
params.last_profit_update = block.timestamp

Impact

Subtly, the price_v0() function relies on last_profit_update for historical price calculations. With incorrect timestamp values on the last_profit_update, this trusted historical price becomes completely unreliable.

Now, share unlocking calculations depend on the time difference between the current timestamp and last_profit_update. When last_profit_update is artificially advanced beyond the current time, the calculation (ts - last_profit_update) in _unlocked_shares() will underflow even when the if full_profit_unlock_date > ts: condition is satisfied, causing an underflow, which blocks the updating of prices from the revert.

Since during price updates via update_price(), we query _raw_price(), which in turn calls _total_supply(), and then _unlocked_shares(), the timestamp underflow in _unlocked_shares() prevents any price updates from succeeding, effectively breaking the entire oracle mechanism as this would mean we would permanently have stale prices on that destination chain.

Key to note that the above is in the case where the e if full_profit_unlock_date > ts: condition is satisfied, in the case it's not satisfied and we don't revert for updates, the price returned would always be inaccurate, because to calculate the raw price we need to obtain the parameters that would give us the total supply and assets:

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

@view
def _raw_price(ts: uint256, parameters_ts: uint256) -> uint256:
"""
@notice Price replication from scrvUSD vault #@audit is this correct?
"""
|> parameters: PriceParams = self._obtain_price_params(parameters_ts)
return self._total_assets(parameters) * 10**18 // self._total_supply(parameters, ts)

But since our last profit update has been heavily inflated we always get in this block that just returns the param without updating it:

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

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
#@audit we would return above due to the inflation of last_profit_update
# 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
#@audit execution never reaches the above so total supply is inflated.

Since we have an inflated total supply the real price would be deflated cause the calculation is self._total_assets(parameters) * 10**18 // self._total_supply(parameters, ts) further on breaking the consistency with the real price on mainnet.

Tools Used

Manual review

Recommendations

Modify the _obtain_price_params() function to correctly update last_profit_update to the current timestamp:

def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
# ... existing code ...
#snip
params.full_profit_unlock_date += number_of_periods * period
- params.last_profit_update += number_of_periods * period
+ params.last_profit_update = block.timestamp
--

Which is exactly how it's done in the yearn vault.

Updates

Lead Judging Commences

0xnevi Lead Judge
5 months ago
0xnevi Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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