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

Price smoothening only working till `max_change` leads to price deviation on the destination chain post smooth

Summary

The ScrvusdOracleV2 contract implements a price smoothing mechanism with a max_change limitation that prevents an abrupt price changes for the three lastprice[3] or price_v0()|price_v1()|price_v2(), which has been relayed in the walkthrough video that it's needed in the case where the keeper goes off for a while, this max change however stops the price oracle from accurately reflecting verified mainnet prices on destination chains since it creates a hard cap on how much the price can change in a single update, regardless of the verified data from Ethereum.

As a result, when significant price movements occur on the source chain, the destination chain's price becomes immediately out of sync post the smoothing duration, breaking the critical security invariant of cross-chain price consistency and creating exploitable arbitrage opportunities.

Vulnerability Details

Take a look at ScrvusdOracleV2#smoothed_price()

@view
def _smoothed_price(last_price: uint256, raw_price: uint256) -> uint256:
# Ideally should be (max_price_increment / 10**18) ** (block.timestamp - self.last_update)
# Using linear approximation to simplify calculations
max_change: uint256 = (
self.max_price_increment * (block.timestamp - self.last_update) * last_price // 10**18
)
# -max_change <= (raw_price - last_price) <= max_change
if unsafe_sub(raw_price + max_change, last_price) > 2 * max_change:
return last_price + max_change if raw_price > last_price else last_price - max_change
return raw_price

The critical issue is in the conditional logic that limits price changes to max_change. When the difference between raw_price and last_price exceeds this limit, the function returns a capped value instead of the actual verified price:

return last_price + max_change if raw_price > last_price else last_price - max_change

This value is then what the three price_v0()|price_v1()|price_v2() are limited to:

@view
def _price_v0() -> uint256:
return self._smoothed_price(
self.last_prices[0],
self._raw_price(self.price_params_ts, self.price_params.last_profit_update),
)
@view
def _price_v1() -> uint256:
return self._smoothed_price(
self.last_prices[1], self._raw_price(block.timestamp, self.price_params_ts)
)
@view
def _price_v2() -> uint256:
return self._smoothed_price(
self.last_prices[2], self._raw_price(block.timestamp, block.timestamp)
)

But creates a permanent deviation from the true price on Ethereum that cannot be recovered from, even with subsequent updates. Each update is limited by the same max_change constraint, causing the destination chain to perpetually lag behind the source chain when significant price movements occur.

For example:

  1. Current price on destination chain: 100

  2. Maximum allowed change (max_change): 10

  3. True price on Ethereum (verified): 70

  4. First update: Price can only move to 90 (100 - 10)

  5. Second update: Even with the same verified price of 70, the price can only move to 80 (90 - 10)

  6. Third update assume verified price is 60: Price moves to 70, still not matching Ethereum.

In the above would be key to note that out of all three updates only one is more than the max_change, but the price oracle still relays wrong prices for all updates

So, if the Ethereum price continues to change during this time, the destination chain will never catch up. This creates a persistent price discrepancy that can be exploited.

The price_v2() function, which should theoretically provide the most accurate price approximation(i.e theoretically, price_v2() == raw_price post the smoothening duration), is also limited by the max_change constraint.

@view
def _price_v2() -> uint256:
return self._smoothed_price(
self.last_prices[2], self._raw_price(block.timestamp, block.timestamp)
)
@view
@external
def raw_price(
_i: uint256 = 0, _ts: uint256 = block.timestamp, _parameters_ts: uint256 = block.timestamp
) -> uint256:
p: uint256 = self._raw_price(_ts, _parameters_ts)
return p if _i == 0 else 10**36 // p

Which is because it uses the current timestamp just as the parameters of _raw_price(), the max_change limitation prevents it from accurately reflecting the true price.

NB: This seems to have been put as another security measure against ingesting an incorrect block hash so we dont have an outrageous price, however this is not sufficient as up till past the max change is allowed to be stored and can't be removed.

Impact

TLDR: This breaks the security invariant of not having deviated prices on dest chain.

First, this would cause for a broken price pattern in the case an incorrect block hash is ingested, cause where as we allow for it to be immediately updated by the prover, this wrong data has already been ingested into the lastPrices and can not be removed:

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

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
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"
self.last_block_number = _block_number
|> self.last_prices = [self._price_v0(), self._price_v1(), self._price_v2()]
self.last_update = block.timestamp
# ..snip

Also the fact that even post smooth we end up with wrong price intself means arbitrage opportunities are back on the table circa MEV issues and what not.

Tools Used

Manual review

Recommendations

Remove the hard cap on price changes for cryptographically verified updates from Ethereum. Since these updates are already to be proven legitimate via blockhash or state root verification, they should be trusted and applied directly.

The smoothening in itself is already a sufficient measure to prevent sharp price changes.

Alternatively have an admin backed method that can directly update the lastprices[3] on the destination chain.

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.