Summary
The _obtain_price_params
function is intended to simulate vault reward periods without incurring approximation losses, relying on a cap defined by the admin-controlled max_v2_duration
.
However, the loop in this function is incorrectly bounded using a constant MAX_V2_DURATION
, causing it to run extra iterations beyond the intended cap. This over-simulation can inflate the computed raw price, which may be exploited in stableswap pools.
Vulnerability Details
The oracle is designed to replicate scrvUSD’s growth with high precision.
@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)
self.max_v2_duration,
)
gain: uint256 = (
params.balance_of_self * (params.total_idle + params.total_debt)
)
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.balance_of_self * params.balance_of_self
)
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
The function calculates:
number_of_periods: uint256 = min(
(parameters_ts - params.last_profit_update)
self.max_v2_duration
)
which should cap the number of simulated reward periods to self.max_v2_duration
. However, the simulation loop is written as:
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
This loop iterates from number_of_periods
up to the constant MAX_V2_DURATION
of 192, regardless of the actual cap defined by self.max_v2_duration
.
For instance, if number_of_periods
is 24
due to the configured cap, the loop executes an additional 168 iterations. These extra iterations simulate reward accrual for periods that never occurred, leading to an overestimation of rewards and an inflated raw price.
https://github.com/CodeHawks-Contests/2025-03-curve?tab=readme-ov-file#solution
This project contains a solution that fetches scrvUSD vault parameters from Ethereum, and provides them on other chains, with the goal of being able to compute the growth rate in a safe (non-manipulable) and precise (no losses due to approximation) way. Furthermore, this oracle can allow creating stableswap-ng pools for other assets like USDC/scrvUSD, FRAX/scrvUSD, etc.
Impact
The excessive simulation overestimates the rewards, resulting in an inflated raw price. An inflated price can be exploited by arbitrageurs to manipulate stableswap pool balances, potentially draining funds.
This vulnerability arises from a fundamental coding mistake that violates the project's "no approximation losses" guarantee, independent of the trust model for verifier roles.
Tools Used
Manual Review
Recommendations
Modify the loop to iterate exactly number_of_periods
times, ensuring that the simulation does not exceed the configured max_v2_duration
.
number_of_periods: uint256 = min((parameters_ts - params.last_profit_update)
for _ in range(number_of_periods):
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