There is an intention to have a max deviation of price that could happen between updates and the ScrvusdOracleV2.vy contract implements a price smoothing mechanism to do this verification and set up the new raw price, issue however is we have an with asymmetric behavior, cause the _smoothed_price
function checks against 2 * max_change
in its conditional logic instead of max_change
, creating inconsistent price movement constraints.
Specifically, when the new raw price is higher than the last price but less than 2 times the max change from the last price, i.e (last_price + max_change < raw_price < last_price + 2*max_change
), the function allows the full price increase rather than limiting it. This asymmetry isn't done on the downside though, i.e when the raw price is less than last_price - max_change
,cause the unsafe_sub
operation would always return a very large sum in this case due to the underflow that would be more than 2 * max_change
and satisfy the condition to limit the price change.
First on the documentation side we can see how we expect the Oracle to correctly limit price change to max_acceleration
:
https://docs.curve.fi/scrvusd/crosschain/oracle-v0/oracle/
scrvUSD Oracle:
Contract that contains information about the price of scrvUSD. It uses amax_acceleration
parameter to limit the rate of price updates. The oracle includes a price_oracle method to ensure compatibility with other smart contracts, such as Stableswap implementations.
And in our case max_acceleration
is the max_change
as it's set as the max_price_increment
, that's used when calculating the max_change
.
Going further into the maths, take a look at ScrvusdOracleV2#smoothed_price()
As hinted under summary when smoothening the prices we want to restrict the change to max_change
regardless of which side up/down.
Note that the value of max*change
is relayed based on that of max_price_increment
which is 0.24 bps per block on Ethereum
, amounting to a lump sum of over 1725 bps/per day (+/- block mining), so for this report's sake if we consider looking at it from a day standpoint that would mean, we are allowing for a price change of over 3450 bps/per day on the up side which is unlike the correct restriction on the downside of 1725
bps.
And this happens because unlike the intended range of -max_change <= (raw_price - last_price) <= max_change
we actually double it on the RHS of the equation due to the actual code implementation:
Which creates distinct handling for price increases:
When raw_price > last_price
:
For outrageously large increases (raw_price > last_price + 2*max_change
):
The condition is true, limiting the price to last_price + max_change
For come large increases where the new raw price is bigger than our max change, i.e (last_price + max_change < raw_price < last_price + 2*max_change
):
The condition is false, allowing the full raw_price
without limitation
This contradicts the intended constraint of limiting to max_change
And we have inconsistent behavior between price increases and decreases, cause the above is not the case on the downside , cause for decreases, when raw_price < last_price - max_change
, regardless of whether this raw_price is less than 2x the max_change or 10x the max_change, the unsafe_sub
operation underflows, producing a very large value that always exceeds 2 * max_change
, forcing the price to be limited to last_price - max_change
.
First we have a broken functionality accross scope since the real range for the smoothening has been exaggerated 2X for increases and would then mean that the security measure used to maintain price stabilty and stop abrupt price changes in the upside is broken, allowing the main solution to the problem of MEV extraction or price manipulation to be relaxed and attackers to craft their way to extract value, since they can predict this assymetry based on what's happening on mainnet, i.e in our shared hypothetical case we allow for an increase of around 35%
on the up side.
NB: Whereas
_smoothed_price
is aview
modified function, it's used all round when updating prices to limit these abrupt changes with the flow to get to it being via:update_price()
->self._price_v0() && self._price_v1() && self._price_v2()
->_smoothed_price()
.
Manual review
Revise the conditional logic to properly enforce the intended constraint of limiting price changes to ±max_change
and not ±2max_change
MAX_V2_DURATION
curbs price growth projection at ~ 10% earlier than intendedThe ScrvusdOracleV2
contract incorrectly calculates the MAX*V2_DURATION constant, which is intended to represent 4 years in terms of weekly periods. The current implementation uses 192 weeks (4 * 12 _ 4), which assumes 48 weeks per year instead of the standard 52 weeks. This discrepancy results in premature limitation of price growth projection, affecting the accuracy of long-term price calculations.
Take a look at ScrvusdOracleV2.vy#L57
The comment indicates that this constant is meant to represent 4 years, but the calculation uses 4 _ 12 _ 4 = 192 weeks. A standard year has approximately 52 weeks (365 days / 7 days per week), so 4 years should be represented as 4 * 52 = 208 weeks.
This constant is used in the _obtain_price_params
function to limit how far into the future the price projection can go:
The max_v2_duration
value is set in the constructor to "half a year" (4 * 6 = 24 weeks), but can be updated through the set_max_v2_duration
function, which enforces that the new value doesn't exceed MAX_V2_DURATION
.
The incorrect calculation of MAX_V2_DURATION
means that price growth projection is limited to 192 weeks instead of the intended 208 weeks (4 years) when we inted to use the real max and this results in:
Premature limitation of price growth projection by approximately 16 weeks ~ 10% of the whole duration
Most crucially is the fact that this would cause for a deviation of the growth pattern on the destination chain than what is on mainnet which is a security invariant for this protocol.
Manual review
In the same light the current v2 duration if intended to be 6 months should be set to the below:
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L75-L105
last_profit_update
as a timestamp surrogate causes price calculation divergence and would stall period of profit evolution which breaks accountingThe ScrvusdVerifierV1.sol
contract contains a critical design flaw in how timestamps are used across different verification methods. In verifyScrvusdByStateRoot()
, the function uses last_profit_update
as a timestamp surrogate for price calculations, whereas the verifyScrvusdByBlockHash()
function uses the actual block timestamp. This inconsistency creates a divergence in price calculations between these two verification paths, as last_profit_update
is typically lagging behind the current block timestamp, resulting in inconsistent oracle prices based on which verification method is used.
Take a look at ScrvusdVerifierV1::verifyScrvusdByStateRoot()
The comment clearly indicates that last_profit_update
(params[5]) is being used as a "timestamp surrogate." However, this creates an inconsistency when compared to the other verification method:
This method uses the actual block_header.timestamp
instead of the last_profit_update
value.
The issue becomes critical when we examine how last_profit_update
is updated in the scrvusd system.
To go into more details, in scrvusd's VaultV3.vy
, this value is only updated during profit processing:
https://etherscan.io/address/0x0655977FEb2f289A4aB78af67BAB0d17aAb84367#code#L1301
And this update only occurs in the _process_report
function, which is exclusively called by accounts with the REPORTING_MANAGER
role:
https://etherscan.io/address/0x0655977FEb2f289A4aB78af67BAB0d17aAb84367#code#L1638
Since last_profit_update
is only updated when: 1) there's a profit report and 2) only by specific managers, we then have a significant time lag between the current block timestamp and the last_profit_update
value in most blocks.
This would then mean that the timestamp used in ScrvusdVerifierV1's verifyScrvusdByStateRoot
will always be older than the current block timestamp.
Now back in the oracle, i.e ScrvusdOracleV2.vy
, the contract's price calculation is highly dependent on this timestamp parameter:
When _ts
is set to the outdated last_profit_update
instead of the current timestamp, it leads to divergent price calculations between the two verification methods.
Cause technically we could have two consequent updates where we then pass in an older ts
:
Assume we verify a new block N using verifyScrvusdByBlockHash
We then verify the next block N+1 using verifyScrvusdByStateRoot
.
Since the last_profit_update
could be older than the block timestamp of block N, the price calculation will be different between the two verification methods and we actually would have a reversal logic on the smoothening in the oracle implementation.
This is because in the oracle:
The _raw_price
calculates the price based on these timestamps:
The _obtain_price_params
function however is where the timestamp critically affects calculations:
The timestamp also affects calculations in the _total_supply
function:
Cause this would mean we would have a return of unlocked shares being larger than it should be:
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L189-L215
Which would then mean that on the basis of this alone, our total supply would be less, since it's calculated as p.total_supply - self._unlocked_shares()
, and this goes ahead to flaw the raw price calculation and have the price higher than it should actually be.
So going back the two scenarios:
In scenario with Block N using verifyScrvusdByBlockHash
:
The oracle receives the actual block timestamp (let's say 100)
It calculates number_of_periods
based on (100 - last_profit_update) // period
This provides a relatively recent and accurate price calculation
In scenario with Block N+1 using verifyScrvusdByStateRoot
:
The oracle receives last_profit_update
as the timestamp (let's say 70, which is older)
It calculates number_of_periods
based on (70 - last_profit_update) // period
Since 70 is the actual value of last_profit_update
, that should be passed in the verification circa block N so this calculation results in 0 periods
This effectively means no profit evolution is calculated
The core issue is that _obtain_price_params
relies on the difference between parameters_ts
and params.last_profit_update
to calculate how many periods of profit evolution should be applied. When parameters_ts
equals params.last_profit_update
(which happens when passing last_profit_update
as the timestamp), the function sees 0 periods and doesn't evolve the parameters at all.
This timestamp inconsistency creates a critical vulnerability in the price oracle system with several severe consequences:
When consequently using the two different verification methods, prices calculated will diverge over time due to different timestamp handling:
verifyScrvusdByBlockHash
will properly account for time passage
verifyScrvusdByStateRoot
will use stale timing data, causing profit evolution calculations to stagnate and be broken.
The calculation of number_of_periods
in _obtain_price_params
will be 0 when using last_profit_update
, since:
The _unlocked_shares
that's used when getting the total supply depends on an accurate timestamp to calculate profit unlocking:
Note that this value from the total supply is used when setting the raw price:
Since we have a wrong denominator and a wrong numerator, among all other impacts we will have a wrong price calculation, which will be set in that update:
https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/oracles/ScrvusdOracleV2.vy#L294-L331
The practical implications include unreliable price data for any protocols relying on this oracle, mispriced assets, incorrect liquidations, and arbitrage opportunities that harm liquidity providers, etc all which fall under the main problem attempted to be solved by the system:
Problem: It is a hard problem to guarantee the correctness of the value provided by the oracle. If not precise enough, this can
lead to MEV in the liquidity pool, at a loss for the liquidity providers. Even worse, if someone is able to manipulate this rate, it can lead to the pool being drained from one side.
Create a new file under the tests/scrvusd/verifier/unitary
directory called test_timestamp_broken.py
:
Run test with:
Log output
Manual review
Properly account for the current block.timestamp even during verifications using verifyScrvusdByStateRoot
or do away with this verification method and only use verifyScrvusdByBlockHash
.
max_change
leads to price deviation on the destination chain post smoothThe 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.
Take a look at ScrvusdOracleV2#smoothed_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:
This value is then what the three price_v0()|price_v1()|price_v2() are limited to:
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:
Current price on destination chain: 100
Maximum allowed change (max_change): 10
True price on Ethereum (verified): 70
First update: Price can only move to 90 (100 - 10)
Second update: Even with the same verified price of 70, the price can only move to 80 (90 - 10)
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.
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.
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
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.
Manual review
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.
The ScrvusdOracleV2
contract initializes the price at a fixed value of 1 (10^18) regardless of the actual scrvUSD price on Ethereum mainnet at deployment time. This creates a significant discrepancy between the initial oracle price on the destination chain and the actual price on Ethereum, enabling price manipulation and breaking the fundamental security invariant of maintaining consistent prices across chains.
While the init
accepts an _initial_price
parameter, the comment "initial raw_price is 1" and the initialization of price_params
with total_idle=1
and total_supply=1
effectively sets the raw price to 1, regardless of the actual scrvUSD price on Ethereum at the time of deployment.
According to the project README, the primary purpose of this oracle is to ensure consistent pricing of scrvUSD across chains:
"To address this problem, we opted to have secondary scrvUSD markets on all chains where scrvUSD can be redeemed. Since the price of the asset is not stable, we cannot use a 'simple' stableswap-ng pool as the price of the asset would go up as the yield accrues."
The oracle's role is to "fetch scrvUSD vault parameters from Ethereum, and provide 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."
But this has then been broken and we then have to wait until a prover sends in a valid block hash or stateroot to then update the price, this is unlike what's done in the case of the profit_max_unlock time, since it's set correctly to 7 days.
Since the actual scrvUSD
price on Ethereum is not exactly 1 at deployment time (which is highly likely as scrvUSD accrues yield), there will be an immediate price discrepancy between chains, which would then allow for arbitrage opportunities where attackers can exploit the price difference between chains, potentially draining liquidity from pools on the destination chain.
The impact is particularly severe cause protocol intends to deploy on any/all EVM known chains, so now post the initial deployment period on any new EVM chain that's to be supported by the and before the first price update occurs, we have a window where the price on the destination chain is completely disconnected from the actual price on Ethereum and this all depends on how long it takes the prover to send in the update.
Manual review
The initialization should use the actual scrvUSD
price from Ethereum at deployment time which the deployer
should in this case be trusted and correctly provide, this can also be easily done by passing in a params
array during deployment.
Alternatively, pricing should not be updated during init, and instead there should be a flag that allows for querying prices only after the first price update has occurred, in the price getters.
- Sponsor Comments - State root oracles usually do not provide block.timestamp, so it's simply not available. That is why last_profit_update is intended. - In `update_price`, this value must be a future block, meaning this update is a state checked and allowed by the OOS verifier contracts. The impact is also increasingly limited given price is smoothen and any updates via the block hash `verifyScrvusdByBlockHash` can also update the prices appropriately, meaning the price will likely stay within safe arbitrage range aligning with protocol logic
- Sponsor Comments - State root oracles usually do not provide block.timestamp, so it's simply not available. That is why last_profit_update is intended. - In `update_price`, this value must be a future block, meaning this update is a state checked and allowed by the OOS verifier contracts. The impact is also increasingly limited given price is smoothen and any updates via the block hash `verifyScrvusdByBlockHash` can also update the prices appropriately, meaning the price will likely stay within safe arbitrage range aligning with protocol logic
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.