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

No checking the verifier should submit the latest proof, which destroy the price v1 assumption

Summary

There is no logic in checking the data should the latest data when the verifier updates the price along with history proof(also the history data), which will destroy the price v1assumption.

Vulnerability Details

For example, the current data in Ethereum is below.

full_profit_unlock_date : Sunday, March 23, 2025 3:59:23 PM

last_profit_update Thursday, March 13, 2025 2:04:11 PM

But the verifier submits the history data such as below

full_profit_unlock_date: Friday, March 14, 2025 5:31:21 PM

Friday, March 7, 2025 5:31:21 PM

Suppose the current block timestamp is 2025-03-17 18:31:46. The verifier submits the history data and updates the price, self.price_params_ts which will be changed to the current time. all the price_params will be the history data.

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
......
self.price_params = PriceParams(
total_debt=_parameters[0],
total_idle=_parameters[1],
total_supply=_parameters[2],
full_profit_unlock_date=_parameters[3],
profit_unlocking_rate=_parameters[4],
last_profit_update=_parameters[5],
balance_of_self=_parameters[6],
)
# after updating price, self.price_params_ts will be the current block.timestamp
self.price_params_ts = _ts
......

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

The problem is using price v2 logic instead of price v1 logic when querying price v1 in this situation(after updating price). Because params.last_profit_update + period >= parameters_ts(which is the time when updating the price) will skip, executing the following logic

As the doc describes

v1 introduces the assumption that no one interacts with scrvUSD further on.
This means that rewards are being distributed as is, stopping at the end of the period.
And no new deposits/withdrawals alter the rate.
This already gives a great assumption to the price over the distribution period,
because the rate is more or less stable over a 1-week period and small declines do not matter.

In this situation, _obtain_price_params does not return the submit price params, but returns the simulating price params which belong to price v2.

@view
def _price_v1() -> uint256:
return self._smoothed_price(
# self.price_params_ts which is the time updating price
self.last_prices[1], self._raw_price(block.timestamp, self.price_params_ts)
)
@view
def _raw_price(ts: uint256, parameters_ts: uint256) -> uint256:
"""
@notice Price replication from scrvUSD vault
"""
parameters: PriceParams = self._obtain_price_params(parameters_ts)
return self._total_assets(parameters) * 10**18
@view
def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
....
# self.price_params_ts which is the time updating price
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

Impact

If the verifier doesn't submit the latest proof and the contract doesn't check it, it will make price v1 use price v2's logic

Tools Used

Pytest

pytest -s tests/scrvusd/oracle/stateful/test_prices.py

To stimulate submitted history data, add the below code in tests/scrvusd/oracle/stateful/crvusd_state_machine.py, which simulated the last_profit_update as ten days before.

def __init__(self, crvusd, scrvusd, admin):
super().__init__()
self.crvusd = crvusd
self.scrvusd = scrvusd
self.admin = admin
self.user = boa.boa.env.generate_address()
boa.env.eoa = self.user
++++ current_timestamp = boa.env.evm.patch.timestamp
++++ historical_timestamp = current_timestamp - (10 * 86400)
++++ boa.env.time_travel(seconds=historical_timestamp - current_timestamp)
# premint and initial deposit
self.crvusd._mint_for_testing(self.user, 10 ** (18 * 3))
self.crvusd.approve(self.scrvusd, 2**256 - 1)
self.scrvusd.deposit(10**18, self.user)
self.add_rewards(10 ** (18 - 3))
++++ boa.env.time_travel(seconds=10 * 86400)

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/tests/scrvusd/oracle/stateful/crvusd_state_machine.py#L32C1-L36C41

Add the below code in tests/scrvusd/oracle/stateful/test_prices.py,

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/tests/scrvusd/oracle/stateful/test_prices.py#L251

def test_check_priveV1_afterUpdatingPrice(crvusd, scrvusd, admin, soracle, soracle_price_slots, verifier):
machine = SoracleTestStateMachine(
# ScrvusdStateMachine
crvusd=crvusd,
scrvusd=scrvusd,
admin=admin,
# SoracleStateMachine
soracle=soracle,
verifier=verifier,
soracle_slots=soracle_price_slots,
)
machine.beyondRange_update_price()
def beyondRange_update_price(self):
current_time = boa.env.evm.patch.timestamp
print(f"Current EVM timestamp: {current_time}", f"Current date: {datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S')}")
self.update_price()
print("after update price, the price params ********************** start")
price_params = self.soracle.price_params()
# Convert timestamps to human-readable dates
print("price_params", price_params)
print("price_params full_profit_unlock_date", price_params[3], f"date: {datetime.fromtimestamp(price_params[3]).strftime('%Y-%m-%d %H:%M:%S')}")
print("price_params last_profit_update", price_params[5], f"date: {datetime.fromtimestamp(price_params[5]).strftime('%Y-%m-%d %H:%M:%S')}")
print("price_params_ts (when calling update price)", self.soracle.price_params_ts(),f"date: {datetime.fromtimestamp(self.soracle.price_params_ts()).strftime('%Y-%m-%d %H:%M:%S')}")
print("after update price, the price params ********************** end")
# show the price v1 rate
# obtain_price_params_2: copy _obtain_price_params and rename it as obtain_price_params_2 along with adding external
calculated_price_params = self.soracle.obtain_price_params_2(current_time)
print("calculated obtain_price_params for profit_unlocking_rate :",calculated_price_params[4],"original profit_unlocking_rate:",price_params[4])

Test result

Notice: the price v1 's profit_unlocking_rate is 1651787366073080357142 which ls less than the submitted profit_unlocking_rate 1653439153439153439153

tests/scrvusd/oracle/stateful/test_prices.py Current EVM timestamp: 1742210600 Current date: 2025-03-17 19:23:20
after update price, the price params ********************** start
price_params PriceParams(total_debt=0, total_idle=1001000000000000000, total_supply=1001000000000000000, full_profit_unlock_date=1741951400, profit_unlocking_rate=1653439153439153439153, last_profit_update=1741346600, balance_of_self=1000000000000000)
price_params full_profit_unlock_date 1741951400 date: 2025-03-14 19:23:20
price_params last_profit_update 1741346600 date: 2025-03-07 19:23:20
price_params_ts (when calling update price) 1742210600 date: 2025-03-17 19:23:20
after update price, the price params ********************** end
calculated obtain_price_params for profit_unlocking_rate : 1651787366073080357142 original profit_unlocking_rate: 1653439153439153439153

If the verifier uses the latest proof, these values will be the same for price v1.

tests/scrvusd/oracle/stateful/test_prices.py Current EVM timestamp: 1742210935 Current date: 2025-03-17 19:28:55
after update price, the price params ********************** start
price_params PriceParams(total_debt=0, total_idle=1001000000000000000, total_supply=1001000000000000000, full_profit_unlock_date=1742815735, profit_unlocking_rate=1653439153439153439153, last_profit_update=1742210935, balance_of_self=1000000000000000)
price_params full_profit_unlock_date 1742815735 date: 2025-03-24 19:28:55
price_params last_profit_update 1742210935 date: 2025-03-17 19:28:55
price_params_ts (when calling update price) 1742210935 date: 2025-03-17 19:28:55
after update price, the price params ********************** end
calculated obtain_price_params for profit_unlocking_rate : 1653439153439153439153 original profit_unlocking_rate: 1653439153439153439153

Recommendations

Add assert block.timestamp <= _parameters[5] + self.profit_max_unlock_time ,"HistoryProof" in function update_price. Which makes sure the proof is the latest

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
"""
@notice Update price using `_parameters`
@param _parameters Parameters of Yearn Vault to calculate scrvUSD price
@param _ts Timestamp at which these parameters are true
@param _block_number Block number of parameters to linearize updates
@return Absolute relative price change of final price with 10^18 precision
"""
access_control._check_role(PRICE_PARAMETERS_VERIFIER, msg.sender)
# Allowing same block updates for fixing bad blockhash provided (if possible)
assert self.last_block_number <= _block_number, "Outdated"
+++ assert block.timestamp <= _parameters[5] + self.profit_max_unlock_time ,"HistoryProof"
...
return (current_price - new_price) * 10**18 // current_price
Updates

Lead Judging Commences

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

Support

FAQs

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