Skip to main content

evm_oracle_state/
mocking.rs

1use std::collections::BTreeMap;
2
3use alloy_primitives::{Address, I256, U256};
4use evm_fork_cache::{StateUpdate, StateView, cache::EvmOverlay, reactive::StateEffectQuality};
5use thiserror::Error;
6
7use crate::{
8    FeedId, FeedRegistration, OracleError, OraclePriceUpdate, OracleRuntime, OracleSnapshot,
9    OracleStorageEffect, OracleStorageError, OracleValueSource, OracleValueStatus,
10    state::classify_round,
11};
12
13/// A requested oracle price mock.
14///
15/// The price is expressed in the feed's raw answer units, matching
16/// `latestRoundData().answer`. For an 8-decimal ETH/USD feed, `250_000_000_000`
17/// means `2500.00000000`.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct OraclePriceMock {
20    /// Raw oracle answer to expose.
21    pub raw_answer: I256,
22    /// Aggregator-local round id. Defaults to the next local round inferred from
23    /// the current snapshot.
24    pub round_id: Option<U256>,
25    /// `startedAt` timestamp. Defaults to `updated_at`.
26    pub started_at: Option<u64>,
27    /// `updatedAt` timestamp. Defaults to overlay block timestamp or the current
28    /// snapshot timestamp plus one second.
29    pub updated_at: Option<u64>,
30}
31
32impl OraclePriceMock {
33    /// Create a price mock from a raw feed answer.
34    pub fn new(raw_answer: I256) -> Self {
35        Self {
36            raw_answer,
37            round_id: None,
38            started_at: None,
39            updated_at: None,
40        }
41    }
42
43    /// Set the aggregator-local round id to write.
44    pub fn round_id(mut self, round_id: U256) -> Self {
45        self.round_id = Some(round_id);
46        self
47    }
48
49    /// Set the `startedAt` timestamp.
50    pub fn started_at(mut self, started_at: u64) -> Self {
51        self.started_at = Some(started_at);
52        self
53    }
54
55    /// Set the `updatedAt` timestamp.
56    pub fn updated_at(mut self, updated_at: u64) -> Self {
57        self.updated_at = Some(updated_at);
58        self
59    }
60
61    fn to_update(
62        &self,
63        registration: &FeedRegistration,
64        snapshot: &OracleSnapshot,
65        overlay_timestamp: Option<u64>,
66    ) -> OraclePriceUpdate {
67        let updated_at = self
68            .updated_at
69            .or(overlay_timestamp)
70            .unwrap_or_else(|| snapshot.round.updated_at.saturating_add(1));
71        let started_at = self.started_at.unwrap_or(updated_at);
72        let round_id = self
73            .round_id
74            .unwrap_or_else(|| next_local_round_id(snapshot.round.round_id));
75        let aggregator = registration
76            .current_aggregator
77            .or(snapshot.aggregator)
78            .unwrap_or(registration.proxy);
79
80        let round = crate::RoundData {
81            round_id,
82            answer: self.raw_answer,
83            started_at,
84            updated_at,
85            answered_in_round: round_id,
86        };
87        let round_status = classify_round(&round, updated_at, &registration.staleness);
88
89        OraclePriceUpdate {
90            id: registration.id.clone(),
91            proxy: registration.proxy,
92            aggregator,
93            label: registration.label.clone(),
94            base: registration.base.clone(),
95            quote: registration.quote.clone(),
96            raw_answer: self.raw_answer,
97            decimals: registration.metadata.decimals,
98            event_round_id: round_id,
99            started_at,
100            updated_at,
101            block_number: None,
102            block_hash: None,
103            log_index: None,
104            round_status,
105            value_status: OracleValueStatus::Confirmed,
106            source: OracleValueSource::Mock,
107        }
108    }
109}
110
111impl From<I256> for OraclePriceMock {
112    fn from(raw_answer: I256) -> Self {
113        Self::new(raw_answer)
114    }
115}
116
117/// Result of applying an overlay-scoped oracle mock.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct OracleMockReport {
120    /// Feed that was requested.
121    pub feed_id: FeedId,
122    /// User-facing oracle proxy/source address.
123    pub proxy: Address,
124    /// Current underlying aggregator, when known.
125    pub aggregator: Option<Address>,
126    /// Whether storage writes were applied to the overlay.
127    pub applied: bool,
128    /// Storage adapter that produced the writes.
129    pub adapter: Option<&'static str>,
130    /// State updates translated into overlay slot overrides.
131    pub updates: Vec<StateUpdate>,
132    /// Reliability of the mock's storage effect.
133    pub effect_quality: StateEffectQuality,
134}
135
136impl OracleMockReport {
137    /// Return true when the overlay was updated.
138    pub fn applied(&self) -> bool {
139        self.applied
140    }
141}
142
143/// Errors returned by oracle mocking helpers.
144#[derive(Debug, Error)]
145pub enum OracleMockError {
146    /// Requested feed id is not registered.
147    #[error("oracle feed `{0}` is not registered")]
148    FeedNotFound(String),
149    /// A registration exists but has no current snapshot.
150    #[error("oracle feed `{0}` has no current snapshot")]
151    MissingSnapshot(String),
152    /// Storage adapter failed while preparing the mock.
153    #[error(transparent)]
154    Storage(#[from] OracleStorageError),
155    /// A storage adapter emitted an update kind that overlay mocking cannot apply.
156    #[error("oracle mock cannot apply {kind} update to an overlay")]
157    UnsupportedStateUpdate {
158        /// Unsupported update kind.
159        kind: &'static str,
160    },
161    /// A masked packed-slot write required a current slot value that was not cached.
162    #[error("oracle mock needs hot slot {address:?}[{slot}] for masked write")]
163    ColdMaskedSlot {
164        /// Contract address.
165        address: Address,
166        /// Storage slot.
167        slot: U256,
168    },
169}
170
171impl From<OracleMockError> for OracleError {
172    fn from(value: OracleMockError) -> Self {
173        match value {
174            OracleMockError::FeedNotFound(_) => Self::FeedNotFound,
175            other => Self::Config(crate::error::OracleConfigError::InvalidRequest(
176                other.to_string(),
177            )),
178        }
179    }
180}
181
182/// Extension trait for overlay-scoped oracle price mocks.
183///
184/// This mirrors `evm-fork-cache`'s ERC20 `mock_balance` style: the mock is
185/// written only into the supplied [`EvmOverlay`], and dropping or resetting the
186/// overlay discards it. Pass the cache as `state` so packed Chainlink hot slots
187/// can preserve their unrelated bits.
188pub trait OracleOverlayMockExt {
189    /// Mock one registered oracle price in this overlay.
190    fn mock_oracle_price<P, F, M>(
191        &mut self,
192        state: &dyn StateView,
193        runtime: &OracleRuntime<P>,
194        feed_id: F,
195        mock: M,
196    ) -> Result<OracleMockReport, OracleMockError>
197    where
198        F: AsRef<str>,
199        M: Into<OraclePriceMock>;
200}
201
202impl OracleOverlayMockExt for EvmOverlay {
203    fn mock_oracle_price<P, F, M>(
204        &mut self,
205        state: &dyn StateView,
206        runtime: &OracleRuntime<P>,
207        feed_id: F,
208        mock: M,
209    ) -> Result<OracleMockReport, OracleMockError>
210    where
211        F: AsRef<str>,
212        M: Into<OraclePriceMock>,
213    {
214        runtime.mock_price(self, state, feed_id, mock)
215    }
216}
217
218impl<P> OracleRuntime<P> {
219    /// Mock one registered oracle price in an [`EvmOverlay`].
220    ///
221    /// The cache/runtime are not mutated. For Chainlink OCR1/OCR2 feeds with a
222    /// detected layout and warmed hot slot, this writes the same storage slots a
223    /// live oracle event would update. If the feed cannot be direct-written, the
224    /// report returns `applied = false`.
225    pub fn mock_price<F, M>(
226        &self,
227        overlay: &mut EvmOverlay,
228        state: &dyn StateView,
229        feed_id: F,
230        mock: M,
231    ) -> Result<OracleMockReport, OracleMockError>
232    where
233        F: AsRef<str>,
234        M: Into<OraclePriceMock>,
235    {
236        let feed_id_ref = feed_id.as_ref();
237        let registration = self
238            .tracker()
239            .registration_by_id_str(feed_id_ref)
240            .ok_or_else(|| OracleMockError::FeedNotFound(feed_id_ref.to_string()))?;
241        let snapshot = self
242            .tracker()
243            .latest(registration.proxy)
244            .ok_or_else(|| OracleMockError::MissingSnapshot(feed_id_ref.to_string()))?;
245        let update = mock
246            .into()
247            .to_update(registration, snapshot, overlay.timestamp());
248        let effect = self
249            .storage_sync()
250            .state_effect_for_answer(registration, &update, state)?;
251
252        let mut report = OracleMockReport {
253            feed_id: registration.id.clone(),
254            proxy: registration.proxy,
255            aggregator: registration.current_aggregator,
256            applied: false,
257            adapter: None,
258            updates: Vec::new(),
259            effect_quality: StateEffectQuality::RequiresRepair,
260        };
261
262        let OracleStorageEffect::StateUpdates { adapter, updates } = effect else {
263            return Ok(report);
264        };
265
266        let mut staged = OverlayWriteState::new(state);
267        for update in &updates {
268            staged.stage(update)?;
269        }
270        staged.apply_to(overlay);
271
272        report.applied = true;
273        report.adapter = Some(adapter);
274        report.updates = updates;
275        report.effect_quality = StateEffectQuality::ExactFromInput;
276        Ok(report)
277    }
278}
279
280struct OverlayWriteState<'a> {
281    base: &'a dyn StateView,
282    writes: BTreeMap<(Address, U256), U256>,
283}
284
285impl<'a> OverlayWriteState<'a> {
286    fn new(base: &'a dyn StateView) -> Self {
287        Self {
288            base,
289            writes: BTreeMap::new(),
290        }
291    }
292
293    fn stage(&mut self, update: &StateUpdate) -> Result<(), OracleMockError> {
294        match update {
295            StateUpdate::Slot {
296                address,
297                slot,
298                value,
299            } => {
300                self.writes.insert((*address, *slot), *value);
301                Ok(())
302            }
303            StateUpdate::SlotMasked {
304                address,
305                slot,
306                mask,
307                value,
308            } => {
309                let current =
310                    self.storage(*address, *slot)
311                        .ok_or(OracleMockError::ColdMaskedSlot {
312                            address: *address,
313                            slot: *slot,
314                        })?;
315                let next = (current & !*mask) | (*value & *mask);
316                self.writes.insert((*address, *slot), next);
317                Ok(())
318            }
319            StateUpdate::SlotDelta { .. } => {
320                Err(OracleMockError::UnsupportedStateUpdate { kind: "slot delta" })
321            }
322            StateUpdate::BalanceDelta { .. } => Err(OracleMockError::UnsupportedStateUpdate {
323                kind: "balance delta",
324            }),
325            StateUpdate::Account { .. } => Err(OracleMockError::UnsupportedStateUpdate {
326                kind: "account patch",
327            }),
328            StateUpdate::AccountUpsert { .. } => Err(OracleMockError::UnsupportedStateUpdate {
329                kind: "account upsert",
330            }),
331            StateUpdate::Purge { .. } => {
332                Err(OracleMockError::UnsupportedStateUpdate { kind: "purge" })
333            }
334            _ => Err(OracleMockError::UnsupportedStateUpdate { kind: "unknown" }),
335        }
336    }
337
338    fn apply_to(self, overlay: &mut EvmOverlay) {
339        for ((address, slot), value) in self.writes {
340            overlay.override_slot(address, slot, value);
341        }
342    }
343}
344
345impl StateView for OverlayWriteState<'_> {
346    fn storage(&self, address: Address, slot: U256) -> Option<U256> {
347        self.writes
348            .get(&(address, slot))
349            .copied()
350            .or_else(|| self.base.storage(address, slot))
351    }
352}
353
354fn next_local_round_id(round_id: U256) -> U256 {
355    let local = round_id & U256::from(u64::MAX);
356    if local == U256::ZERO {
357        U256::from(1_u64)
358    } else {
359        local.saturating_add(U256::from(1_u64))
360    }
361}