Ema-oracle will ignore accumulator spot prices in some cases, resulting in incorrect ema prices.
Proof of Concept
Ema-oracle has two mechanisms that need to work in tandem at all times to report accurate ema prices for various periods.
The two mechanisms: accumulator oracle entry (spot price entry, Accumulator) and update_oracles (period-based ema oracles, Oracles).
The above two are triggered and updated at different times:
(1) Accumulator: triggered by liquidity/trade hooks and atomically writes a spot oracle entry; Also, last_block oracle is the last Accumulator from the previous block. e.g. last_blockoracle (@1002 block) = Accumulator (@1002 block).
(2) Oracles: only updated at the end of each block to aggregate the last accumulator entry of the block to ema oracle values. This is triggered by on_finalize()-> update_oracles_from_accumulator()->last_block_oracle().
The problem is (2) might not always use the correct Accumulator in some cases. When on_finalize() is missed or failed for one or more blocks, (2) will use an Accumulator value from an incorrect block.
Scenario: Suppose last_block oracle (previous block Accumulator) hasn't been updated for a while.
For example,last_block oracle in storage is updated at 1000 block, and current block number is 1003, previous block is 1002. And each block has new trading activities. Since there is no last_block oracle for 1001 and 1002, last_block_oracle() will equate last_block at 1002 with Accumulator at 1000.
Step A: So last_block oracle (1002)= Accumulator(1000). This disregards trading activities in 1001 and 1002.
//HydraDX-node/pallets/ema-oracle/src/lib.rs
pub(crate) fn last_block_oracle(
source: Source,
assets: (AssetId, AssetId),
block: BlockNumberFor<T>,
) -> Option<(OracleEntry<BlockNumberFor<T>>, BlockNumberFor<T>)> {
Self::oracle((source, assets, LastBlock)).map(|(mut last_block, init)| {
// update the `LastBlock` oracle to the last block if it hasn't been updated for a while
// price and liquidity stay constant, volume becomes zero
//@audit note: last_block` oracle (1002)= `Accumulator`(1000)
if last_block.updated_at != block {
|> last_block.fast_forward_to(block);
}
(last_block, init)
})
}
//HydraDX-node/pallets/ema-oracle/src/types.rs
pub fn fast_forward_to(&mut self, new_updated_at: BlockNumber) {
//@audit note: this only updates blockNumber and refresh trading volume. However, old price and liquidity persist regardless.
//@audit note: last_block` oracle (1002)= `Accumulator`(1000)
|> self.updated_at = new_updated_at;
self.volume = Volume::default();
}
Step B: in update_oracle() following last_block_oracle(), all other period-based oracles will be updated with the incorrect last_block oracle value(Accumulator(1000)).
//HydraDX-node/pallets/ema-oracle/src/lib.rs
fn update_oracle(
src: Source,
assets: (AssetId, AssetId),
period: OraclePeriod,
incoming_entry: OracleEntry<BlockNumberFor<T>>,
) {
...
if parent > prev_entry.updated_at {
//@audit note: `last_block` oracle (1002)= `Accumulator`(1000)
|> Self::last_block_oracle(src, assets, parent)
.and_then(|(last_block, _)| {
//@audit note: other period oracle(1002) are updated based on `Accumulator`(1000) as well.
|> prev_entry.update_outdated_to_current(period, &last_block).map(|_| ())
})
.unwrap_or_else(|| {
log::warn!(
target: LOG_TARGET,
"Updating EMA oracle ({src:?}, {assets:?}, {period:?}) to parent block failed. Defaulting to previous value."
);
debug_assert!(false, "Updating to parent block should not fail.");
})
}
...
The above effectively skewed all other period-based oracle values because they are accumulative.
Missed or failed on_finialize() can be caused by reasons like consensus stall or other failures, but current code handles the case in a way that leads to skewed ema prices.
Tools Used
Manual
Recommended Mitigation Steps
Consider storing historic Accumulator values (similar to UniswapV3's observations), and use the correct historic Accumulator entry to handle missed ema entries.
Lines of code
https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/ema-oracle/src/lib.rs#L276-L277
Vulnerability details
Impact
Ema-oracle will ignore accumulator spot prices in some cases, resulting in incorrect ema prices.
Proof of Concept
Ema-oracle has two mechanisms that need to work in tandem at all times to report accurate ema prices for various periods.
The two mechanisms: accumulator oracle entry (spot price entry,
Accumulator
) and update_oracles (period-based ema oracles,Oracles
).The above two are triggered and updated at different times: (1)
Accumulator
: triggered by liquidity/trade hooks and atomically writes a spot oracle entry; Also,last_block
oracle is the lastAccumulator
from the previous block. e.g.last_block
oracle (@1002 block) = Accumulator (@1002 block). (2)Oracles
: only updated at the end of each block to aggregate the last accumulator entry of the block to ema oracle values. This is triggered byon_finalize()
->update_oracles_from_accumulator()
->last_block_oracle()
.The problem is (2) might not always use the correct
Accumulator
in some cases. Whenon_finalize()
is missed or failed for one or more blocks, (2) will use anAccumulator
value from an incorrect block.Scenario: Suppose
last_block
oracle (previous blockAccumulator
) hasn't been updated for a while.For example,
last_block
oracle in storage is updated at 1000 block, and current block number is 1003, previous block is 1002. And each block has new trading activities. Since there is nolast_block
oracle for 1001 and 1002,last_block_oracle()
will equatelast_block
at 1002 withAccumulator
at 1000.Step A: So
last_block
oracle (1002)=Accumulator
(1000). This disregards trading activities in 1001 and 1002.(https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/ema-oracle/src/lib.rs#L276-L277)
Step B: in
update_oracle()
followinglast_block_oracle()
, all other period-based oracles will be updated with the incorrectlast_block
oracle value(Accumulator
(1000)).(https://github.com/code-423n4/2024-02-hydradx/blob/603187123a20e0cb8a7ea85c6a6d718429caad8d/HydraDX-node/pallets/ema-oracle/src/lib.rs#L313-L315)
The above effectively skewed all other period-based oracle values because they are accumulative.
Missed or failed
on_finialize()
can be caused by reasons like consensus stall or other failures, but current code handles the case in a way that leads to skewed ema prices.Tools Used
Manual
Recommended Mitigation Steps
Consider storing historic
Accumulator
values (similar to UniswapV3'sobservations
), and use the correct historicAccumulator
entry to handle missed ema entries.Assessed type
Other