DeFiLayer 1Layer 2
14,723 OP
View results
Submission Details
Severity: low
Valid

Using `last_profit_update` as a timestamp surrogate causes price calculation divergence and would stall period of profit evolution which breaks accounting

Summary

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.

Vulnerability Details

Take a look at ScrvusdVerifierV1::verifyScrvusdByStateRoot()

function verifyScrvusdByStateRoot(
uint256 _block_number,
bytes memory _proof_rlp
) external returns (uint256) {
bytes32 state_root = IBlockHashOracle(BLOCK_HASH_ORACLE).get_state_root(_block_number);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(state_root, _proof_rlp);
// Use last_profit_update as the timestamp surrogate
return _updatePrice(params, params[5], _block_number);
}

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:

function verifyScrvusdByBlockHash(
bytes memory _block_header_rlp,
bytes memory _proof_rlp
) external returns (uint256) {
Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader(_block_header_rlp);
require(block_header.hash != bytes32(0), "Invalid blockhash");
require(
block_header.hash == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number),
"Blockhash mismatch"
);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(block_header.stateRootHash, _proof_rlp);
return _updatePrice(params, block_header.timestamp, block_header.number);
}

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

# Update the last profitable report timestamp.
self.last_profit_update = block.timestamp

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

@external
@nonreentrant("lock")
def process_report(strategy: address) -> (uint256, uint256):
"""
@notice Process the report of a strategy.
@param strategy The strategy to process the report for.
@return The gain and loss of the strategy.
"""
self._enforce_role(msg.sender, Roles.REPORTING_MANAGER)
return self._process_report(strategy)

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:

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
# ...
ts: uint256 = self.price_params_ts
current_price: uint256 = self._raw_price(ts, ts)
# ...
self.price_params_ts = _ts
new_price: uint256 = self._raw_price(_ts, _ts)
# ...

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:

def _raw_price(ts: uint256, parameters_ts: uint256) -> uint256:
parameters: PriceParams = self._obtain_price_params(parameters_ts)
return self._total_assets(parameters) * 10**18 // self._total_supply(parameters, ts)
  • The _obtain_price_params function however is where the timestamp critically affects calculations:

def _obtain_price_params(parameters_ts: uint256) -> PriceParams:
# ...
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,
)
# ... calculations based on number_of_periods ...
  • The timestamp also affects calculations in the _total_supply function:

def _total_supply(p: PriceParams, ts: uint256) -> uint256:
return p.total_supply - self._unlocked_shares(
p.full_profit_unlock_date,
p.profit_unlocking_rate,
p.last_profit_update,
p.balance_of_self,
ts, # block.timestamp
)

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

@view
def _unlocked_shares(
full_profit_unlock_date: uint256,
profit_unlocking_rate: uint256,
last_profit_update: uint256,
balance_of_self: uint256,
ts: uint256,
) -> uint256:
# ..snip
unlocked_shares: uint256 = 0
if full_profit_unlock_date > ts:
# If we have not fully unlocked, we need to calculate how much has been.
unlocked_shares = profit_unlocking_rate * (ts - last_profit_update) // MAX_BPS_EXTENDED
# @audit for the above since `ts` is lower than it should be, unlocked_shares would be higher than it should be
elif full_profit_unlock_date != 0:
# All shares have been unlocked
unlocked_shares = balance_of_self
return unlocked_shares

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:

  1. 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

  2. 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.

Impact

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:

# This calculation returns 0 when parameters_ts = last_profit_update
number_of_periods: uint256 = (parameters_ts - params.last_profit_update) // period

The _unlocked_shares that's used when getting the total supply depends on an accurate timestamp to calculate profit unlocking:

@view
def _total_supply(p: PriceParams, ts: uint256) -> uint256:
# Need to account for the shares issued to the vault that have unlocked.
return p.total_supply - self._unlocked_shares(
p.full_profit_unlock_date,
p.profit_unlocking_rate,
p.last_profit_update,
p.balance_of_self,
ts, # block.timestamp
)
def _unlocked_shares(..., ts: uint256) -> uint256:
if full_profit_unlock_date > ts:
unlocked_shares = profit_unlocking_rate * (ts - last_profit_update) // MAX_BPS_EXTENDED

Note that this value from the total supply is used when setting the raw price:

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 // self._total_supply(parameters, ts)

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

@external
def update_price(
_parameters: uint256[ALL_PARAM_CNT], _ts: uint256, _block_number: uint256
) -> uint256:
#snip
ts: uint256 = self.price_params_ts
current_price: uint256 = self._raw_price(ts, ts)
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],
)
self.price_params_ts = _ts
|> new_price: uint256 = self._raw_price(_ts, _ts)
log PriceUpdate(new_price, _ts, _block_number)
if new_price > current_price:
return (new_price - current_price) * 10**18 // current_price
return (current_price - new_price) * 10**18 // current_price

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.

Coded POC

Create a new file under the tests/scrvusd/verifier/unitary directory called test_timestamp_broken.py:

import pytest
import rlp
import boa
from scripts.scrvusd.proof import serialize_proofs
from tests.conftest import WEEK
from tests.scrvusd.verifier.conftest import MAX_BPS_EXTENDED
from tests.shared.verifier import get_block_and_proofs
@pytest.fixture(scope="module")
def scrvusd_slot_values(scrvusd, crvusd, admin, anne):
deposit = 10**18
with boa.env.prank(anne):
crvusd._mint_for_testing(anne, deposit)
crvusd.approve(scrvusd, deposit)
scrvusd.deposit(deposit, anne)
# New scrvusd parameters:
# scrvusd.total_idle = deposit,
# scrvusd.total_supply = deposit.
rewards = 10**17
with boa.env.prank(admin):
crvusd._mint_for_testing(scrvusd, rewards)
scrvusd.process_report(scrvusd)
# Minted `rewards` shares to scrvusd, because price is still == 1.
# Record the initial values
last_profit_update = boa.env.evm.patch.timestamp
# Travel forward in time to create a significant gap between current timestamp and last_profit_update
boa.env.time_travel(seconds=3*86400, block_delta=3*12) # 3 days forward
return {
"total_debt": 0,
"total_idle": deposit + rewards,
"total_supply": deposit + rewards,
"full_profit_unlock_date": last_profit_update + WEEK,
"profit_unlocking_rate": rewards * MAX_BPS_EXTENDED // WEEK,
"last_profit_update": last_profit_update,
"balance_of_self": rewards,
}
def test_using_last_profit_update_as_timestamp_surrogate_is_broken(
verifier, soracle_price_slots, soracle, boracle, scrvusd, scrvusd_slot_values
):
"""
Test demonstrates how using last_profit_update as a timestamp surrogate in verifyScrvusdByStateRoot
leads to divergent price calculations compared to verifyScrvusdByBlockHash which uses current timestamp.
"""
# Get current and previous timestamps for analysis
current_timestamp = boa.env.evm.patch.timestamp
last_profit_update = scrvusd_slot_values["last_profit_update"]
# CASE 1: Using verifyScrvusdByBlockHash (which uses current timestamp)
# --------------------------------------------------------------------------
block_header, proofs = get_block_and_proofs([(scrvusd, soracle_price_slots)])
boracle._set_block_hash(block_header.block_number, block_header.hash)
# Execute verification using blockHash method
tx1 = verifier.verifyScrvusdByBlockHash(
rlp.encode(block_header),
serialize_proofs(proofs[0]),
)
# Record the timestamp used after blockHash verification
blockhash_timestamp = soracle._storage.price_params_ts.get()
# Save the value after blockHash call to compare later
blockhash_block_number = soracle.last_block_number
# CASE 2: Using verifyScrvusdByStateRoot (which uses last_profit_update as timestamp)
# -----------------------------------------------------------------------------------
# Set up new state root verification
block_header, proofs = get_block_and_proofs([(scrvusd, soracle_price_slots)])
boracle._set_state_root(block_header.block_number, block_header.state_root)
# Execute verification using stateRoot method
tx2 = verifier.verifyScrvusdByStateRoot(
block_header.block_number,
serialize_proofs(proofs[0]),
)
# Record the timestamp used after stateRoot verification
stateroot_timestamp = soracle._storage.price_params_ts.get()
# ANALYSIS: Compare the results
# ------------------------------------
# Verify the timestamps used are different
assert blockhash_timestamp != stateroot_timestamp, "Timestamps should be different between methods"
# Calculate periods that would be applied in each method
daily_period = 86400 # 1 day in seconds
periods_blockHash = (blockhash_timestamp - last_profit_update) // daily_period
periods_stateRoot = (stateroot_timestamp - last_profit_update) // daily_period
print(f"\nProfit evolution periods with blockHash method: {periods_blockHash}")
print(f"Profit evolution periods with stateRoot method: {periods_stateRoot}")
assert abs(stateroot_timestamp - last_profit_update) == 0, "StateRoot should use last_profit_update as timestamp"
assert blockhash_timestamp != stateroot_timestamp, "Timestamps should differ between methods"
# Show impact on profit evolution since periods would be zero when verification is done with stateRoot
assert periods_blockHash != 0
assert periods_stateRoot == 0

Run test with:

python -m pytest tests/scrvusd/verifier/unitary/test_timestamp_broken.py -v

Log output

=================================================================================================================================================================================================================== test session starts ===================================================================================================================================================================================================================
platform darwin -- Python 3.12.6, pytest-8.3.4, pluggy-1.5.0 --..//codebases/2025-03-curve/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/Users/abdullahisuleimanaliyu/Desktop/codebases/2025-03-curve/.hypothesis/examples'))
rootdir:..//codebases/2025-03-curve
configfile: pyproject.toml
plugins: titanoboa-0.2.5, cov-6.0.0, hypothesis-6.122.3
collected 1 item
tests/scrvusd/verifier/unitary/test_timestamp_broken.py::test_using_last_profit_update_as_timestamp_surrogate_is_broken PASSED [100%]

Tools Used

Manual review

Recommendations

Properly account for the current block.timestamp even during verifications using verifyScrvusdByStateRoot or do away with this verification method and only use verifyScrvusdByBlockHash.

Updates

Lead Judging Commences

0xnevi Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[invalid] finding-cross-chain-price-latency-update-time-inconsistency

- 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

Appeal created

bauchibred Submitter
5 months ago
0xnevi Lead Judge
5 months ago
0xnevi Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-last_profit_update-used-instead-timestamp

- 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

Support

FAQs

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