The 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
.
- I believe all issues do not provide a sufficient proof that this latency lags can cause a dangerous arbitrage - Sponsor Comments - There is no issues with small lags if used in liquidity pools for example because of fees. Fees generate spread within which price can be lagged. - Looking at the price charts [here](https://coinmarketcap.com/currencies/savings-crvusd/), there is never a large spike in price (in absolute values), that can be exploited, combined with the fact that prices are smoothed and updates are not immediate - Not even the most trusted oracles e.g. chainlink/redstone can guarantee a one-to-one synchronized value, so in my eyes, the price smoothening protection is sufficient in protecting such issues
- 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.