Closed sherlock-admin closed 9 months ago
Acknowledgement of the issue. However, disagree with the severity. storePrice
is permissioned, so only a policy installed by the owner, the DAO MS, can call it, and the policy can only be called by a whitelisted address.
Low-medium severity
Escalate
Function is permissioned and actors calling it are trusted.
With a result that is likely to surprise nobody, the test with the two 10 hours intervals at the beginning score a larger MA (~0.9% larger MA, over an underlying price increase of ~18%).
Watson's example is small variance from true price. Given the permissioned nature and small impact to price, this should be low.
Escalate
Function is permissioned and actors calling it are trusted.
With a result that is likely to surprise nobody, the test with the two 10 hours intervals at the beginning score a larger MA (~0.9% larger MA, over an underlying price increase of ~18%).
Watson's example is small variance from true price. Given the permissioned nature and small impact to price, this should be low.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
@0xJem Can you verify the complexity of ensuring such a storePrice
is appropriately called to ensure accurate movingAverage? If its not complex, I agree with @IAm0x52 that this could be low severity.
Escalate
Function is permissioned and actors calling it are trusted.
With a result that is likely to surprise nobody, the test with the two 10 hours intervals at the beginning score a larger MA (~0.9% larger MA, over an underlying price increase of ~18%).
Watson's example is small variance from true price. Given the permissioned nature and small impact to price, this should be low.
Agreed on this - storePrice
is permissioned and called by the Heart contract, which can only be called every 8 hours.
We have added additional checks to prevent prices from being stored too early (e.g. if a policy were configured to be called more frequently), but the system in its current form would not do that.
storePrice
is permissioned, so only a policy installed by the owner, the DAO MS, can call it, and the policy can only be called by a whitelisted address.
Although the function is permissioned and role trusted, the problem is caused by a parameter the caller has no directly control over, the block (and block timestamp) their transaction invoking storePrice()
is included in.
We have added additional checks to prevent prices from being stored too early (e.g. if a policy were configured to be called more frequently), but the system in its current form would not do that.
Is there a control over how long there is between updates of storePrice
?
An incentivized keep model (assuming no alternative motivation beside economic self-interest) needs the rewards to exceed the gas consumed to trigger the storePrice
transaction.
Historically in times of market volatility, gas prices were also volatile, and setting transaction low prices to execute at a later time rather than the current market price for quick inclusion, would be a profitable approach.
Without knowing the incentive model, I can't really weigh in on the broader impact within the scope of the RBS system, nor how any potential drift affects that system, without knowing the tolerances in the models being used.
Although the function is permissioned and role trusted, the problem is caused by a parameter the caller has no directly control over, the block (and block timestamp) their transaction invoking storePrice() is included in.
The only way in the current system design for storePrice
to be called is through the beat()
function in the Heart
contract (deployed as a user-facing policy). The Heart
contract can only be called after 8 hours (it has a protection against that, and has been deployed with that functionality for some time), which de-facto means that storePrice
is only called in the current system after 8 hours, so the MA price storage cannot be more frequent.
If storePrice()
were an un-permissioned function, we would of course be having a very different discussion. But it is permissioned and callable only by a policy. There is a need for a check, to prevent a hypothetical mis-configured policy from triggering this issue, which was acknowledged earlier, and hence the suggestion to include this as a valid issue.
Fix looks good. Store price now reverts if insufficient time has passed.
Although the function is permissioned and role trusted, the problem is caused by a parameter the caller has no directly control over, the block (and block timestamp) their transaction invoking storePrice() is included in.
The only way in the current system design for
storePrice
to be called is through thebeat()
function in theHeart
contract (deployed as a user-facing policy). TheHeart
contract can only be called after 8 hours (it has a protection against that, and has been deployed with that functionality for some time), which de-facto means thatstorePrice
is only called in the current system after 8 hours, so the MA price storage cannot be more frequent.If
storePrice()
were an un-permissioned function, we would of course be having a very different discussion. But it is permissioned and callable only by a policy. There is a need for a check, to prevent a hypothetical mis-configured policy from triggering this issue, which was acknowledged earlier, and hence the suggestion to include this as a valid issue.
The issue is for when the time between calls by the heartbeat are substantially longer than the desired 8, not shorter.
My takeaway from the docs and Discord conversations being there is a limit on the lower bounds (time between updates cannot be shorter than 8 hours), but not on the upper bounds. With an incentivize keeper being relied on to trigger the heartbeat (and subsequent storePrice()
).
Does that hold true, or did I miss understand the setup?
Assuming the MA price is stored by the Heart contract (which is how it's setup currently), there is a minimum (8 hours), but no maximum. The heartbeat system has been running for over a year now and the current increasing reward auction has worked very well at causing the heartbeat to occur within a few minutes of the minimum.
It seems that the incentive part is out of scope as well, since it concerns the Heart.beat()
function?
Planning to accept the escalation and consider this issue invalid.
Result: Invalid Has duplicates
squeaky_cactus
high
Allowing inconsistent time slices, the moving average can misrepresent the mean price
Summary
On creation
OlympusPricev2
is provided anobservationFrequency
, the expected frequency at which prices are stored for moving average (MA).OlympusPricev2.storePrice()
calculates the latest asset price and updates the moving average, but lacks any logic to ensure the timeliness of updates, or consistency in the time between updates.The MA in
OlympusPricev2
is a simple moving average (SMA), where the points of time-series data taken for the SMA can have inconsistent time spacing, meaning the SMA will contain a degree of error in the following of the line graph of all the points, due to even weighting of points irrespective of intervals. (The average produced may not be accurately following movements in the underlying data).Vulnerability Detail
The expectation of how much time will be between moving average updates if given as
observationFrequency_
in the OlympusPricev2 constructorUpdating the MA
When the update of the MA happens, there are no guards or checks enforcing the
currentTime
isobservationFrequency
away from thelastObservationTime
in OlympusPricev2.storePriceThe
currentTime
is a return value, but that is simply returning the currentblock.timestamp
in __getCurrentPriceWith
storePrice
having no restriction on how short or long after thecurrentTime
could be away from thelastObservationTime
, the time between the data points in the SMA are not guaranteed to be consistent.When the MA is calculated, each data point is given equal weighting by [OlympusPricev2._getMovingAveragePrice]()
Providing an equal weighting to each value, irrespective of the time interval between them, introduces the potential for distortion in the MA.
As
OlympusPricev2.storePrice()
exists as a part of a larger system, lets looks how further into upstream interactions.Heartbeat
The call to
OlympusPricev2.storePrice()
occurs in the RBS PolicyHeart.beat()
(out of scope for the audit, but included to provide context), that itself is called by an incentivized keeper, with a target interval of 8 hours.(currentTime < lastBeat + frequency())
checks that at leastobservationFrequency
time has passed since the lastbeat
.OlympusPricev2.storePrice()
will not be called at intervals of less than observationFrequency`, but there is nothing preventing longer time intervals.Incentivized keeper
An external actor who performs operations usually driven by financial incentives, where timeliness of actions rely on the incentives providing a return on performing the action. Incentivizes are subject to free market conditions, for
Heart.beat()
the variables being the valuation of the incentives and the cost of gas.The effect of time intervals
When selecting data points from a time-series data set and no accounting for different time intervals between them is made, there can be unintended effects.
For a quick example to shows a small average during a change from a time of sidewards moving price into a strong upward trending price, lets assume the following properties:
Two tests, each with two 10 hour intervals and three 8 hour intervals, the different being one has the longer intervals in the beginning, the other at the end.
A quick test logging the MA after each update produces these results:
With a result that is likely to surprise nobody, the test with the two 10 hours intervals at the beginning score a larger MA (~0.9% larger MA, over an underlying price increase of ~18%).
Irregular time intervals can have a real impact during a strong, consistent trend.
Proof of Concept - Sample Data
To generate the sample data, add the below Solidity to
PRICE.v2.t.sol
and runforge test --match-test test_linear_price_ma -vv
Impact
These three areas impacted by an inaccurate MA: price retrieval, MA timespan and MA retrieval.
Price retrieval
The MA can be included in the current price calculation by the strategies in SimplePriceFeedStrategy, with the degree of impact varying by strategy and whether the feeds are live (e.g. Tests have OHM and RSV with multiple feeds), which seriously reduce the likelihood of every encountering them i.e. as multiple simultaneous feed failures are required for the MA to constitute the price or a large component of it.
Moving average timespan
When adding an asset the param
movingAverageDuration_
is used in combination withobservationFrequency
to calculate the number of data points to use in the SMA. Due to the incentivized keeper update mechanism, the time between observations is likely to differ fromobservationFrequency
, where the timespan of the entire MA could be materially different to the duration given when adding the asset. If the timespan was chosen due to risk modelling (rather than an implicit calculation for the number of SMA points), this would be unfortunate, as the models would not be match the actual behaviour.Moving average retrieval
The more impactful use of the
OlympusPricev2
MA is with the RBS policy, accessed in Operator.targetPrice() as part of updating the target price for theRANGE
module. Range Bound Stability being the mechanism that stabilizes the price of OHM, by using the MA as the anchor point to decide upper and lower bounds, and consequent actions to stabilize the OHM price.An inaccurate MA would lead to an incorrect target price, then the wrong amount of corrective action when attempting the price stabilization would follow, ultimately costing the protocol (by selling less OHM during up-trends, deploying less of the Treasury during down-trends, while aso providing less stability to OHM then intended).
Code Snippet
https://github.com/sherlock-audit/2023-11-olympus/blob/9c8df76dc9820b4c6605d2e1e6d87dcfa9e50070/bophades/src/modules/PRICE/OlympusPrice.v2.sol#L312-L335 https://github.com/sherlock-audit/2023-11-olympus/blob/9c8df76dc9820b4c6605d2e1e6d87dcfa9e50070/bophades/src/modules/PRICE/OlympusPrice.v2.sol#L160-L160 https://github.com/sherlock-audit/2023-11-olympus/blob/9c8df76dc9820b4c6605d2e1e6d87dcfa9e50070/bophades/src/modules/PRICE/OlympusPrice.v2.sol#L227-L227 https://github.com/sherlock-audit/2023-11-olympus/blob/9c8df76dc9820b4c6605d2e1e6d87dcfa9e50070/bophades/src/modules/PRICE/OlympusPrice.v2.sol#L696-L705
Tool used
Manual Review, Forge test
Recommendation
There are two parts, the price weighting and timespan drift.
Price weighting
Switching from a SMA to a time-weight average price (TWAP).
Timespan drift
Keeping with the idea of a protocol that does require manual intervention, a feedback loop to trigger amending the incentives for calling
Heat.beat()
, that would be the nice approach of steering the timespan of the moving average to move towards the expectedmovingAverageDuration_
. It could either be included as part of the heartbeat or a separate monitor.