Skip to main content

evm_oracle_state/
overlay.rs

1use std::collections::BTreeMap;
2
3use alloy_primitives::{Address, B256, Bytes, I256, U256, Uint};
4use alloy_sol_types::{SolCall, sol};
5
6use crate::{
7    FeedRegistration, FeedSource, OracleError, OracleRoundStatus, OracleSnapshot, OracleTracker,
8    OracleValueStatus,
9};
10
11sol! {
12    interface AggregatorV3Interface {
13        function latestRoundData() external view returns (
14            uint80 roundId,
15            int256 answer,
16            uint256 startedAt,
17            uint256 updatedAt,
18            uint80 answeredInRound
19        );
20        function decimals() external view returns (uint8);
21        function description() external view returns (string);
22        function version() external view returns (uint256);
23    }
24
25    interface MorphoOracleInterface {
26        function price() external view returns (uint256);
27    }
28
29    interface EulerPriceOracleInterface {
30        function getQuote(uint256 inAmount, address base, address quote) external view returns (uint256 outAmount);
31        function getQuotes(uint256 inAmount, address base, address quote) external view returns (uint256 bidOutAmount, uint256 askOutAmount);
32    }
33
34    struct PythPrice {
35        int64 price;
36        uint64 conf;
37        int32 expo;
38        uint256 publishTime;
39    }
40
41    interface PythInterface {
42        function getPriceUnsafe(bytes32 id) external view returns (PythPrice memory price);
43        function getPriceNoOlderThan(bytes32 id, uint256 age) external view returns (PythPrice memory price);
44    }
45}
46
47use AggregatorV3Interface::{decimalsCall, descriptionCall, latestRoundDataCall, versionCall};
48use EulerPriceOracleInterface::{getQuoteCall, getQuotesCall, getQuotesReturn};
49use MorphoOracleInterface::priceCall;
50use PythInterface::{getPriceNoOlderThanCall, getPriceUnsafeCall};
51
52/// Simulation-facing ABI call overlay backed by typed oracle state.
53///
54/// # Fast-path semantics
55///
56/// The overlay intentionally serves values whose lifecycle is still
57/// [`OracleValueStatus::EventPending`]: its purpose is to let a simulation
58/// observe a decoded oracle event *before* the follow-up authoritative proxy
59/// read completes. This is a deliberate exception to the crate's
60/// "proxy reads are authoritative" contract, scoped to registered view calls:
61///
62/// - Values classified `RequiresRepair` or `Unknown`, and rounds that are not
63///   [`OracleRoundStatus::Fresh`], are never served — the overlay returns
64///   `Ok(None)` and the caller falls back to the raw cache/RPC read, which the
65///   reactive handler keeps conservatively purged.
66/// - Decision-making callers should keep promoting typed prices through
67///   [`crate::PricePolicy`], which rejects `EventPending` values unless the
68///   caller opted in via `allow_event_pending()`.
69///
70/// Consequently a raw `try_call` user can observe an event-derived value at
71/// the same moment the underlying cache storage was purged for refetch; the
72/// two read paths reconverge after reconciliation.
73pub struct OracleReadOverlay<'a> {
74    tracker: &'a OracleTracker,
75    pyth_feeds: BTreeMap<(Address, B256), PythOverlayFeed>,
76    // Euler feeds are registered under synthetic per-feed state keys, so
77    // getQuote/getQuotes calls addressed to the real user-facing contract
78    // (router or direct adapter) route through this index. Multiple pairs can
79    // share one serve address; dispatch matches the calldata base/quote.
80    euler_feeds: BTreeMap<Address, Vec<EulerOverlayFeed>>,
81}
82
83#[derive(Clone, Copy, Debug)]
84struct PythOverlayFeed {
85    proxy: Address,
86    expo: i32,
87    conf: u64,
88}
89
90#[derive(Clone, Copy, Debug)]
91struct EulerOverlayFeed {
92    proxy: Address,
93    base: Address,
94    quote: Address,
95}
96
97impl<'a> OracleReadOverlay<'a> {
98    /// Create a new read overlay over the current tracker state.
99    pub fn new(tracker: &'a OracleTracker) -> Self {
100        let mut pyth_feeds = BTreeMap::new();
101        let mut euler_feeds: BTreeMap<Address, Vec<EulerOverlayFeed>> = BTreeMap::new();
102        for registration in tracker.registrations_iter() {
103            match &registration.source {
104                FeedSource::EulerQuote {
105                    source,
106                    base,
107                    quote,
108                    ..
109                }
110                | FeedSource::EulerCross {
111                    source,
112                    base,
113                    quote,
114                    ..
115                } => {
116                    euler_feeds
117                        .entry(*source)
118                        .or_default()
119                        .push(EulerOverlayFeed {
120                            proxy: registration.proxy,
121                            base: *base,
122                            quote: *quote,
123                        });
124                    continue;
125                }
126                _ => {}
127            }
128            let Some((pyth, price_id, expo, conf)) = registration.source.pyth_source() else {
129                continue;
130            };
131            pyth_feeds
132                .entry((pyth, price_id))
133                .or_insert(PythOverlayFeed {
134                    proxy: registration.proxy,
135                    expo,
136                    conf,
137                });
138        }
139        Self {
140            tracker,
141            pyth_feeds,
142            euler_feeds,
143        }
144    }
145
146    /// Try to serve a Chainlink proxy view call from typed oracle state.
147    pub fn try_call(&self, target: Address, calldata: &[u8]) -> Result<Option<Bytes>, OracleError> {
148        if let Some(bytes) = self.try_pyth_call(target, calldata)? {
149            return Ok(Some(bytes));
150        }
151        if let Some(bytes) = self.try_euler_target_call(target, calldata)? {
152            return Ok(Some(bytes));
153        }
154
155        let Some(snapshot) = self.tracker.latest(target) else {
156            return Ok(None);
157        };
158        let Some(registration) = self.tracker.registration_for_proxy(target) else {
159            return Ok(None);
160        };
161        if !is_actionable(snapshot) || calldata.len() != 4 {
162            if is_actionable(snapshot)
163                && let Some(bytes) = try_euler_call(registration, snapshot, calldata)?
164            {
165                return Ok(Some(bytes));
166            }
167            return Ok(None);
168        }
169
170        if calldata == priceCall::SELECTOR
171            && matches!(registration.source, FeedSource::MorphoChainlinkV2 { .. })
172        {
173            return Ok(Some(Bytes::from(priceCall::abi_encode_returns(
174                &i256_to_u256(snapshot.round.answer, "price")?,
175            ))));
176        }
177
178        if !supports_chainlink_overlay(&registration.source) {
179            return Ok(None);
180        }
181
182        if calldata == latestRoundDataCall::SELECTOR {
183            let round_id = u256_to_uint80(snapshot.round.round_id, "round_id")?;
184            let answered_in_round =
185                u256_to_uint80(snapshot.round.answered_in_round, "answered_in_round")?;
186            return Ok(Some(Bytes::from(
187                latestRoundDataCall::abi_encode_returns_tuple(&(
188                    round_id,
189                    snapshot.round.answer,
190                    U256::from(snapshot.round.started_at),
191                    U256::from(snapshot.round.updated_at),
192                    answered_in_round,
193                )),
194            )));
195        }
196
197        if calldata == decimalsCall::SELECTOR {
198            return Ok(Some(Bytes::from(decimalsCall::abi_encode_returns(
199                &snapshot.metadata.decimals,
200            ))));
201        }
202
203        if calldata == descriptionCall::SELECTOR {
204            return Ok(Some(Bytes::from(descriptionCall::abi_encode_returns(
205                &snapshot.metadata.description,
206            ))));
207        }
208
209        if calldata == versionCall::SELECTOR {
210            return Ok(Some(Bytes::from(versionCall::abi_encode_returns(
211                &snapshot.metadata.version,
212            ))));
213        }
214
215        Ok(None)
216    }
217
218    fn try_pyth_call(
219        &self,
220        target: Address,
221        calldata: &[u8],
222    ) -> Result<Option<Bytes>, OracleError> {
223        if calldata.len() < 4 {
224            return Ok(None);
225        }
226
227        let price_id = if calldata[..4] == getPriceUnsafeCall::SELECTOR {
228            getPriceUnsafeCall::abi_decode(calldata)
229                .map_err(|error| {
230                    OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
231                        "decode Pyth getPriceUnsafe failed: {error}"
232                    )))
233                })?
234                .id
235        } else if calldata[..4] == getPriceNoOlderThanCall::SELECTOR {
236            getPriceNoOlderThanCall::abi_decode(calldata)
237                .map_err(|error| {
238                    OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
239                        "decode Pyth getPriceNoOlderThan failed: {error}"
240                    )))
241                })?
242                .id
243        } else {
244            return Ok(None);
245        };
246
247        let Some((snapshot, expo, conf)) = self.pyth_registration_for_call(target, price_id) else {
248            return Ok(None);
249        };
250        if !is_actionable(snapshot) {
251            return Ok(None);
252        }
253
254        let price = pyth_abi_price(snapshot.round.answer, expo)?;
255        let publish_time = U256::from(snapshot.round.updated_at);
256        let pyth_price = PythPrice {
257            price,
258            conf,
259            expo,
260            publishTime: publish_time,
261        };
262
263        let bytes = if calldata[..4] == getPriceUnsafeCall::SELECTOR {
264            getPriceUnsafeCall::abi_encode_returns(&pyth_price)
265        } else {
266            getPriceNoOlderThanCall::abi_encode_returns(&pyth_price)
267        };
268        Ok(Some(Bytes::from(bytes)))
269    }
270
271    fn pyth_registration_for_call(
272        &self,
273        target: Address,
274        price_id: B256,
275    ) -> Option<(&OracleSnapshot, i32, u64)> {
276        let feed = self.pyth_feeds.get(&(target, price_id))?;
277        let snapshot = self.tracker.latest(feed.proxy)?;
278        Some((snapshot, feed.expo, feed.conf))
279    }
280
281    /// Serve Euler `getQuote`/`getQuotes` calls addressed to a real
282    /// user-facing quote contract (router or direct adapter), dispatching to
283    /// the registered pair whose base/quote matches the calldata. Exact
284    /// direction matches win over inverse matches so a router carrying both
285    /// directions of one pair stays unambiguous.
286    fn try_euler_target_call(
287        &self,
288        target: Address,
289        calldata: &[u8],
290    ) -> Result<Option<Bytes>, OracleError> {
291        let Some(candidates) = self.euler_feeds.get(&target) else {
292            return Ok(None);
293        };
294        let (given_base, given_quote) =
295            if calldata.len() >= 4 && calldata[..4] == getQuoteCall::SELECTOR {
296                let call = getQuoteCall::abi_decode(calldata).map_err(|error| {
297                    OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
298                        "decode Euler getQuote failed: {error}"
299                    )))
300                })?;
301                (call.base, call.quote)
302            } else if calldata.len() >= 4 && calldata[..4] == getQuotesCall::SELECTOR {
303                let call = getQuotesCall::abi_decode(calldata).map_err(|error| {
304                    OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
305                        "decode Euler getQuotes failed: {error}"
306                    )))
307                })?;
308                (call.base, call.quote)
309            } else {
310                return Ok(None);
311            };
312        let feed = candidates
313            .iter()
314            .find(|feed| feed.base == given_base && feed.quote == given_quote)
315            .or_else(|| {
316                candidates
317                    .iter()
318                    .find(|feed| feed.base == given_quote && feed.quote == given_base)
319            });
320        let Some(feed) = feed else {
321            return Err(OracleError::Config(
322                crate::error::OracleConfigError::InvalidRequest(
323                    "Euler overlay request does not match registered base/quote".to_string(),
324                ),
325            ));
326        };
327        let Some(registration) = self.tracker.registration_for_proxy(feed.proxy) else {
328            return Ok(None);
329        };
330        let Some(snapshot) = self.tracker.latest(feed.proxy) else {
331            return Ok(None);
332        };
333        if !is_actionable(snapshot) {
334            return Ok(None);
335        }
336        try_euler_call(registration, snapshot, calldata)
337    }
338}
339
340fn supports_chainlink_overlay(source: &FeedSource) -> bool {
341    !matches!(
342        source,
343        FeedSource::MorphoChainlinkV2 { .. }
344            | FeedSource::EulerQuote { .. }
345            | FeedSource::EulerCross { .. }
346            | FeedSource::Pyth { .. }
347    )
348}
349
350fn try_euler_call(
351    registration: &FeedRegistration,
352    snapshot: &OracleSnapshot,
353    calldata: &[u8],
354) -> Result<Option<Bytes>, OracleError> {
355    let (base, quote, base_decimals, _quote_decimals) = match &registration.source {
356        FeedSource::EulerQuote {
357            base,
358            quote,
359            base_decimals,
360            quote_decimals,
361            ..
362        }
363        | FeedSource::EulerCross {
364            base,
365            quote,
366            base_decimals,
367            quote_decimals,
368            ..
369        } => (*base, *quote, *base_decimals, *quote_decimals),
370        _ => return Ok(None),
371    };
372
373    if calldata.len() >= 4 && calldata[..4] == getQuoteCall::SELECTOR {
374        let call = getQuoteCall::abi_decode(calldata).map_err(|error| {
375            OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
376                "decode Euler getQuote failed: {error}"
377            )))
378        })?;
379        let out = euler_quote_amount(
380            call.inAmount,
381            call.base,
382            call.quote,
383            base,
384            quote,
385            base_decimals,
386            snapshot.round.answer,
387        )?;
388        return Ok(Some(Bytes::from(getQuoteCall::abi_encode_returns(&out))));
389    }
390
391    if calldata.len() >= 4 && calldata[..4] == getQuotesCall::SELECTOR {
392        let call = getQuotesCall::abi_decode(calldata).map_err(|error| {
393            OracleError::Config(crate::error::OracleConfigError::InvalidRequest(format!(
394                "decode Euler getQuotes failed: {error}"
395            )))
396        })?;
397        let out = euler_quote_amount(
398            call.inAmount,
399            call.base,
400            call.quote,
401            base,
402            quote,
403            base_decimals,
404            snapshot.round.answer,
405        )?;
406        return Ok(Some(Bytes::from(getQuotesCall::abi_encode_returns(
407            &getQuotesReturn {
408                bidOutAmount: out,
409                askOutAmount: out,
410            },
411        ))));
412    }
413
414    Ok(None)
415}
416
417fn euler_quote_amount(
418    in_amount: U256,
419    given_base: Address,
420    given_quote: Address,
421    base: Address,
422    quote: Address,
423    base_decimals: u8,
424    raw_price: I256,
425) -> Result<U256, OracleError> {
426    let price = i256_to_u256(raw_price, "Euler price")?;
427    if given_base == base && given_quote == quote {
428        // out = in * price / 10^base_decimals. Caller-supplied `inAmount` is
429        // unbounded, so overflow must error instead of saturating into a
430        // silently capped quote.
431        let scaled = in_amount.checked_mul(price).ok_or_else(|| {
432            OracleError::Unsupported("Euler quote multiplication overflowed".to_string())
433        })?;
434        return scaled
435            .checked_div(decimal_scale_u256(base_decimals))
436            .ok_or_else(|| {
437                OracleError::Unsupported("Euler quote scale division failed".to_string())
438            });
439    }
440    if given_base == quote && given_quote == base {
441        if price.is_zero() {
442            return Err(OracleError::Unsupported(
443                "cannot invert a zero Euler price".to_string(),
444            ));
445        }
446        let scaled = in_amount
447            .checked_mul(decimal_scale_u256(base_decimals))
448            .ok_or_else(|| {
449                OracleError::Unsupported(
450                    "Euler inverse quote multiplication overflowed".to_string(),
451                )
452            })?;
453        return scaled.checked_div(price).ok_or_else(|| {
454            OracleError::Unsupported("Euler inverse quote division failed".to_string())
455        });
456    }
457    Err(OracleError::Config(
458        crate::error::OracleConfigError::InvalidRequest(
459            "Euler overlay request does not match registered base/quote".to_string(),
460        ),
461    ))
462}
463
464fn is_actionable(snapshot: &OracleSnapshot) -> bool {
465    snapshot.round_status == OracleRoundStatus::Fresh
466        && matches!(
467            snapshot.value_status,
468            OracleValueStatus::EventPending
469                | OracleValueStatus::Confirmed
470                | OracleValueStatus::Corrected
471        )
472}
473
474fn u256_to_uint80(value: U256, field: &'static str) -> Result<Uint<80, 2>, OracleError> {
475    let value = u128::try_from(value)
476        .map_err(|_| OracleError::Unsupported(format!("{field} does not fit uint80")))?;
477    if value >= (1_u128 << 80) {
478        return Err(OracleError::Unsupported(format!(
479            "{field} does not fit uint80"
480        )));
481    }
482    Uint::<80, 2>::try_from(value)
483        .map_err(|_| OracleError::Unsupported(format!("{field} does not fit uint80")))
484}
485
486fn i256_to_u256(value: I256, field: &'static str) -> Result<U256, OracleError> {
487    U256::try_from(value)
488        .map_err(|_| OracleError::Unsupported(format!("{field} is negative or too large")))
489}
490
491fn i256_to_i64(value: I256, field: &'static str) -> Result<i64, OracleError> {
492    i128::try_from(value)
493        .ok()
494        .and_then(|value| i64::try_from(value).ok())
495        .ok_or_else(|| OracleError::Unsupported(format!("{field} does not fit int64")))
496}
497
498fn pyth_abi_price(raw_answer: I256, expo: i32) -> Result<i64, OracleError> {
499    let price = if expo > 0 {
500        let scale = I256::unchecked_from(10_i8)
501            .checked_pow(U256::from(expo as u32))
502            .ok_or_else(|| {
503                OracleError::Unsupported(format!("Pyth expo {expo} scale overflowed"))
504            })?;
505        raw_answer
506            .checked_div(scale)
507            .ok_or_else(|| OracleError::Unsupported("Pyth price unscale failed".to_string()))?
508    } else {
509        raw_answer
510    };
511    i256_to_i64(price, "Pyth price")
512}
513
514fn decimal_scale_u256(decimals: u8) -> U256 {
515    let mut scale = U256::from(1_u8);
516    for _ in 0..decimals {
517        scale = scale.saturating_mul(U256::from(10_u8));
518    }
519    scale
520}