Skip to main content

evm_oracle_state/
registry.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use alloy_primitives::{Address, B256, I256, U256, U512};
4
5use crate::{
6    AggregatorLayout, AggregatorLayoutConfidence, AggregatorLayoutEvidence, ChainlinkFeedProvider,
7    FeedConfig, FeedMetadata, OracleBlockRef, OracleError, OracleFeedStatus, OracleSnapshot,
8    OracleSourceDescriptor, OracleTransformDescriptor, OracleValueSource, RoundData,
9    classify_type_and_version,
10};
11
12/// Stable id for a registered feed.
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct FeedId(Arc<str>);
15
16impl FeedId {
17    /// Create a feed id from its string form. Ids compare and order exactly
18    /// like their underlying string; cloning is a cheap `Arc` bump.
19    pub fn new(id: impl Into<String>) -> Self {
20        Self(Arc::from(id.into()))
21    }
22
23    /// Borrow the underlying string, e.g. for logging or map keys.
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29// Enables allocation-free `BTreeMap<FeedId, _>` lookups by `&str`. `FeedId`'s
30// derived `Ord`/`Eq`/`Hash` all delegate to the inner `str`, so the `Borrow`
31// consistency contract holds.
32impl std::borrow::Borrow<str> for FeedId {
33    fn borrow(&self) -> &str {
34        self.as_str()
35    }
36}
37
38impl std::fmt::Display for FeedId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        self.0.fmt(f)
41    }
42}
43
44impl AsRef<str> for FeedId {
45    fn as_ref(&self) -> &str {
46        self.as_str()
47    }
48}
49
50/// One Chainlink-compatible dependency used by a derived oracle source.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct OracleDependency {
53    /// Dependency proxy address.
54    pub proxy: Address,
55    /// Dependency current aggregator address.
56    pub aggregator: Address,
57    /// Last known dependency answer.
58    pub answer: I256,
59}
60
61impl OracleDependency {
62    /// Construct a dependency leg from its proxy, current aggregator, and the
63    /// last answer to seed derived-source math with until the next event.
64    pub fn new(proxy: Address, aggregator: Address, answer: I256) -> Self {
65        Self {
66            proxy,
67            aggregator,
68            answer,
69        }
70    }
71}
72
73/// Role of a Chainlink dependency inside `MorphoChainlinkOracleV2`.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum MorphoChainlinkFeedRole {
76    /// First numerator feed.
77    BaseFeed1,
78    /// Second numerator feed.
79    BaseFeed2,
80    /// First denominator feed.
81    QuoteFeed1,
82    /// Second denominator feed.
83    QuoteFeed2,
84}
85
86/// One optional Chainlink feed used by `MorphoChainlinkOracleV2`.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct MorphoChainlinkFeed {
89    /// Feed role in the Morpho formula.
90    pub role: MorphoChainlinkFeedRole,
91    /// Dependency proxy, aggregator, and answer.
92    pub dependency: OracleDependency,
93}
94
95impl MorphoChainlinkFeed {
96    /// Construct a Morpho Chainlink dependency.
97    pub fn new(role: MorphoChainlinkFeedRole, dependency: OracleDependency) -> Self {
98        Self { role, dependency }
99    }
100}
101
102/// One dependency leg inside an Euler quote adapter.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum EulerQuoteLeg {
105    /// Chainlink-backed Euler `ChainlinkOracle` leg.
106    Chainlink {
107        /// Euler leg adapter address.
108        oracle: Address,
109        /// Chainlink feed proxy.
110        feed: Address,
111        /// Chainlink feed current aggregator.
112        aggregator: Address,
113        /// Last known Chainlink answer.
114        answer: I256,
115        /// Chainlink feed decimals.
116        feed_decimals: u8,
117        /// True when the requested leg is the inverse of the adapter's configured pair.
118        inverse: bool,
119        /// Output decimals for this leg.
120        quote_decimals: u8,
121    },
122    /// Euler `FixedRateOracle` leg.
123    FixedRate {
124        /// Euler leg adapter address.
125        oracle: Address,
126        /// Fixed rate scaled to this leg's quote decimals.
127        rate: I256,
128        /// True when the requested leg is the inverse of the adapter's configured pair.
129        inverse: bool,
130        /// Requested base decimals.
131        base_decimals: u8,
132        /// Requested quote decimals.
133        quote_decimals: u8,
134    },
135    /// Euler `RateProviderOracle` leg.
136    RateProvider {
137        /// Euler leg adapter address.
138        oracle: Address,
139        /// Rate provider contract.
140        rate_provider: Address,
141        /// Last known 18-decimal rate.
142        rate: I256,
143        /// True when the requested leg is the inverse of the adapter's configured pair.
144        inverse: bool,
145        /// Output decimals for this leg.
146        quote_decimals: u8,
147    },
148}
149
150impl EulerQuoteLeg {
151    /// Return the event aggregator that drives this leg, if any.
152    pub fn event_aggregator(&self) -> Option<Address> {
153        match self {
154            Self::Chainlink { aggregator, .. } => Some(*aggregator),
155            Self::FixedRate { .. } | Self::RateProvider { .. } => None,
156        }
157    }
158
159    /// Return the leg price scaled to the leg quote decimals.
160    pub fn price(&self) -> I256 {
161        self.price_from_event(None, I256::ZERO)
162    }
163
164    /// Return the leg price with a dependency event answer applied when relevant.
165    pub fn price_from_event(&self, aggregator: Option<Address>, answer: I256) -> I256 {
166        match self {
167            Self::Chainlink {
168                aggregator: dependency_aggregator,
169                answer: stored_answer,
170                feed_decimals,
171                inverse,
172                quote_decimals,
173                ..
174            } => {
175                let answer = if aggregator == Some(*dependency_aggregator) {
176                    answer
177                } else {
178                    *stored_answer
179                };
180                if *inverse {
181                    inverse_positive_price(answer, *feed_decimals, *quote_decimals)
182                } else {
183                    scale_positive_price(answer, *feed_decimals, *quote_decimals)
184                }
185            }
186            Self::FixedRate {
187                rate,
188                inverse,
189                base_decimals,
190                quote_decimals,
191                ..
192            } => {
193                if *inverse {
194                    inverse_fixed_rate(*rate, *base_decimals, *quote_decimals)
195                } else {
196                    *rate
197                }
198            }
199            Self::RateProvider {
200                rate,
201                inverse,
202                quote_decimals,
203                ..
204            } => {
205                if *inverse {
206                    inverse_positive_price(*rate, 18, *quote_decimals)
207                } else {
208                    scale_positive_price(*rate, 18, *quote_decimals)
209                }
210            }
211        }
212    }
213}
214
215/// Network source and transform for a registered feed.
216#[non_exhaustive]
217#[derive(Clone, Debug, Default, PartialEq, Eq)]
218pub enum FeedSource {
219    /// Plain Chainlink-compatible proxy with identity answer transform.
220    #[default]
221    Chainlink,
222    /// Aave `PriceCapAdapterStable` source backed by an underlying Chainlink proxy.
223    AavePriceCapStable {
224        /// User-facing Aave source adapter.
225        source: Address,
226        /// Underlying Chainlink proxy read for rounds and aggregator discovery.
227        underlying_proxy: Address,
228        /// Maximum answer exposed by the Aave adapter.
229        price_cap: I256,
230    },
231    /// Aave ratio-cap/CAPO source backed by a base-to-USD Chainlink proxy.
232    AaveRatioCap {
233        /// User-facing Aave source adapter.
234        source: Address,
235        /// Underlying base-to-USD Chainlink proxy.
236        base_to_usd_proxy: Address,
237        /// Ratio provider used by the Aave source.
238        ratio_provider: Address,
239        /// Current source ratio.
240        current_ratio: I256,
241        /// Maximum ratio exposed by the Aave source.
242        max_ratio: I256,
243        /// Decimals used by the ratio values.
244        ratio_decimals: u8,
245    },
246    /// Aave Chainlink synchronicity adapter composing asset-to-peg and peg-to-base feeds.
247    AaveSynchronicityPegToBase {
248        /// User-facing Aave source adapter.
249        source: Address,
250        /// Asset-to-peg Chainlink proxy.
251        asset_to_peg_proxy: Address,
252        /// Asset-to-peg current aggregator.
253        asset_to_peg_aggregator: Address,
254        /// Seeded asset-to-peg answer used when the other dependency updates.
255        asset_to_peg_answer: I256,
256        /// Asset-to-peg decimals.
257        asset_to_peg_decimals: u8,
258        /// Peg-to-base Chainlink proxy.
259        peg_to_base_proxy: Address,
260        /// Peg-to-base current aggregator.
261        peg_to_base_aggregator: Address,
262        /// Seeded peg-to-base answer used when the other dependency updates.
263        peg_to_base_answer: I256,
264        /// Peg-to-base decimals.
265        peg_to_base_decimals: u8,
266        /// Decimals for the user-facing composed answer.
267        output_decimals: u8,
268    },
269    /// Aave fixed-price source with no Chainlink dependency.
270    AaveFixedPrice {
271        /// User-facing Aave source adapter.
272        source: Address,
273        /// Fixed user-facing answer.
274        price: I256,
275    },
276    /// Morpho Blue `MorphoChainlinkOracleV2` source composed from Chainlink feeds and optional vault conversions.
277    MorphoChainlinkV2 {
278        /// User-facing Morpho oracle.
279        source: Address,
280        /// Latest base-vault conversion factor, or 1 when no base vault is configured.
281        base_vault_assets: I256,
282        /// Latest quote-vault conversion factor, or 1 when no quote vault is configured.
283        quote_vault_assets: I256,
284        /// Immutable Morpho scale factor.
285        scale_factor: I256,
286        /// Non-zero Chainlink feed dependencies.
287        feeds: Vec<MorphoChainlinkFeed>,
288    },
289    /// Euler quote source backed by a single leg.
290    EulerQuote {
291        /// User-facing Euler oracle adapter or router.
292        source: Address,
293        /// Base asset for the quote.
294        base: Address,
295        /// Quote asset for the quote.
296        quote: Address,
297        /// Base token decimals.
298        base_decimals: u8,
299        /// Quote token decimals.
300        quote_decimals: u8,
301        /// Quote leg.
302        leg: EulerQuoteLeg,
303    },
304    /// Euler `CrossAdapter` quote source backed by two quote legs.
305    EulerCross {
306        /// User-facing Euler cross adapter.
307        source: Address,
308        /// Base asset for the final quote.
309        base: Address,
310        /// Intermediate asset.
311        cross: Address,
312        /// Quote asset for the final quote.
313        quote: Address,
314        /// Base token decimals.
315        base_decimals: u8,
316        /// Intermediate token decimals.
317        cross_decimals: u8,
318        /// Quote token decimals.
319        quote_decimals: u8,
320        /// Base-to-cross leg.
321        base_cross: EulerQuoteLeg,
322        /// Cross-to-quote leg.
323        cross_quote: EulerQuoteLeg,
324    },
325    /// Pyth EVM price feed keyed by a shared Pyth contract and per-feed price id.
326    Pyth {
327        /// Shared Pyth contract that emits updates and serves reads.
328        pyth: Address,
329        /// Pyth price feed id.
330        price_id: B256,
331        /// Pyth fixed-point exponent.
332        expo: i32,
333        /// Most recent Pyth confidence interval.
334        conf: u64,
335    },
336    /// RedStone push price feed backed by an on-chain adapter/event source.
337    #[cfg(feature = "redstone")]
338    RedstonePush {
339        /// User-facing RedStone price feed wrapper.
340        price_feed: Address,
341        /// RedStone adapter or merged price-feed event source.
342        adapter: Address,
343        /// RedStone data feed id.
344        data_feed_id: B256,
345    },
346    /// Adapter-owned oracle source.
347    Custom(OracleSourceDescriptor),
348}
349
350impl FeedSource {
351    /// Construct an Aave capped stable source transform.
352    pub fn aave_price_cap_stable(
353        source: Address,
354        underlying_proxy: Address,
355        price_cap: I256,
356    ) -> Self {
357        Self::AavePriceCapStable {
358            source,
359            underlying_proxy,
360            price_cap,
361        }
362    }
363
364    /// Construct an Aave ratio-cap/CAPO source transform.
365    pub fn aave_ratio_cap(
366        source: Address,
367        base_to_usd_proxy: Address,
368        ratio_provider: Address,
369        current_ratio: I256,
370        max_ratio: I256,
371        ratio_decimals: u8,
372    ) -> Self {
373        Self::AaveRatioCap {
374            source,
375            base_to_usd_proxy,
376            ratio_provider,
377            current_ratio,
378            max_ratio,
379            ratio_decimals,
380        }
381    }
382
383    /// Construct an Aave peg-to-base synchronicity source transform.
384    #[allow(clippy::too_many_arguments)]
385    pub fn aave_synchronicity_peg_to_base(
386        source: Address,
387        asset_to_peg_proxy: Address,
388        asset_to_peg_aggregator: Address,
389        asset_to_peg_answer: I256,
390        asset_to_peg_decimals: u8,
391        peg_to_base_proxy: Address,
392        peg_to_base_aggregator: Address,
393        peg_to_base_answer: I256,
394        peg_to_base_decimals: u8,
395        output_decimals: u8,
396    ) -> Self {
397        Self::AaveSynchronicityPegToBase {
398            source,
399            asset_to_peg_proxy,
400            asset_to_peg_aggregator,
401            asset_to_peg_answer,
402            asset_to_peg_decimals,
403            peg_to_base_proxy,
404            peg_to_base_aggregator,
405            peg_to_base_answer,
406            peg_to_base_decimals,
407            output_decimals,
408        }
409    }
410
411    /// Construct an Aave fixed-price source.
412    pub fn aave_fixed_price(source: Address, price: I256) -> Self {
413        Self::AaveFixedPrice { source, price }
414    }
415
416    /// Construct a Morpho Blue Chainlink V2 source transform.
417    pub fn morpho_chainlink_v2(
418        source: Address,
419        base_vault_assets: I256,
420        quote_vault_assets: I256,
421        scale_factor: I256,
422        feeds: Vec<MorphoChainlinkFeed>,
423    ) -> Self {
424        Self::MorphoChainlinkV2 {
425            source,
426            base_vault_assets,
427            quote_vault_assets,
428            scale_factor,
429            feeds,
430        }
431    }
432
433    /// Construct an Euler quote source backed by one quote leg.
434    pub fn euler_quote(
435        source: Address,
436        base: Address,
437        quote: Address,
438        base_decimals: u8,
439        quote_decimals: u8,
440        leg: EulerQuoteLeg,
441    ) -> Self {
442        Self::EulerQuote {
443            source,
444            base,
445            quote,
446            base_decimals,
447            quote_decimals,
448            leg,
449        }
450    }
451
452    /// Construct an Euler cross-adapter source backed by two quote legs.
453    #[allow(clippy::too_many_arguments)]
454    pub fn euler_cross(
455        source: Address,
456        base: Address,
457        cross: Address,
458        quote: Address,
459        base_decimals: u8,
460        cross_decimals: u8,
461        quote_decimals: u8,
462        base_cross: EulerQuoteLeg,
463        cross_quote: EulerQuoteLeg,
464    ) -> Self {
465        Self::EulerCross {
466            source,
467            base,
468            cross,
469            quote,
470            base_decimals,
471            cross_decimals,
472            quote_decimals,
473            base_cross,
474            cross_quote,
475        }
476    }
477
478    /// Rewrite an Euler source's user-facing quote address.
479    ///
480    /// Router-resolved discovery reads the source config through the resolved
481    /// adapter, but the user-facing contract callers quote through (and the
482    /// read overlay serves) is the router itself. Non-Euler sources are
483    /// returned unchanged.
484    #[cfg(feature = "euler")]
485    pub(crate) fn with_euler_user_source(self, user_source: Address) -> Self {
486        match self {
487            Self::EulerQuote {
488                base,
489                quote,
490                base_decimals,
491                quote_decimals,
492                leg,
493                ..
494            } => Self::EulerQuote {
495                source: user_source,
496                base,
497                quote,
498                base_decimals,
499                quote_decimals,
500                leg,
501            },
502            Self::EulerCross {
503                base,
504                cross,
505                quote,
506                base_decimals,
507                cross_decimals,
508                quote_decimals,
509                base_cross,
510                cross_quote,
511                ..
512            } => Self::EulerCross {
513                source: user_source,
514                base,
515                cross,
516                quote,
517                base_decimals,
518                cross_decimals,
519                quote_decimals,
520                base_cross,
521                cross_quote,
522            },
523            other => other,
524        }
525    }
526
527    /// Construct a Pyth EVM source.
528    pub fn pyth(pyth: Address, price_id: B256, expo: i32, conf: u64) -> Self {
529        Self::Pyth {
530            pyth,
531            price_id,
532            expo,
533            conf,
534        }
535    }
536
537    /// Construct a RedStone push source.
538    #[cfg(feature = "redstone")]
539    pub fn redstone_push(price_feed: Address, adapter: Address, data_feed_id: B256) -> Self {
540        Self::RedstonePush {
541            price_feed,
542            adapter,
543            data_feed_id,
544        }
545    }
546
547    /// Construct an adapter-owned custom source.
548    ///
549    /// Custom sources participate in **Chainlink-style proxy reconciliation
550    /// by default**: [`Self::supports_proxy_reconciliation`] returns `true`
551    /// for `Custom`, so reconciliation reads
552    /// [`OracleSourceDescriptor::read_proxy`] with `latestRoundData()` after
553    /// every accepted event (and during [`crate::OracleTracker::reconcile`]).
554    /// The `read_proxy` address therefore **must answer `latestRoundData()`**
555    /// or reconciliation will fail with [`crate::OracleError::Provider`].
556    /// There is currently no per-descriptor flag to opt a custom source out
557    /// of this path — if the source is not Chainlink-shaped, point
558    /// `read_proxy` at a contract that exposes a Chainlink-compatible
559    /// `latestRoundData()` view for it.
560    pub fn custom(descriptor: OracleSourceDescriptor) -> Self {
561        Self::Custom(descriptor)
562    }
563
564    /// Return true when this source is a plain identity Chainlink source.
565    pub fn is_identity(&self) -> bool {
566        matches!(self, Self::Chainlink)
567    }
568
569    /// Return true when the built-in Chainlink reactive handler should route this source.
570    pub fn uses_builtin_chainlink_handler(&self) -> bool {
571        matches!(
572            self,
573            Self::Chainlink
574                | Self::AavePriceCapStable { .. }
575                | Self::AaveRatioCap { .. }
576                | Self::AaveSynchronicityPegToBase { .. }
577                | Self::MorphoChainlinkV2 { .. }
578                | Self::EulerQuote { .. }
579                | Self::EulerCross { .. }
580        )
581    }
582
583    /// Return true when this source can be reconciled by reading a Chainlink proxy.
584    ///
585    /// Sources that fail this predicate are either reconciled through their
586    /// own protocol view (see [`Self::supports_derived_reconciliation`]) or
587    /// not reconciled at all.
588    pub fn supports_proxy_reconciliation(&self) -> bool {
589        !matches!(
590            self,
591            Self::AaveFixedPrice { .. }
592                | Self::MorphoChainlinkV2 { .. }
593                | Self::EulerQuote { .. }
594                | Self::EulerCross { .. }
595                | Self::Pyth { .. }
596        )
597    }
598
599    /// Return true when this source is reconciled by reading its own protocol
600    /// view instead of a Chainlink proxy.
601    ///
602    /// Morpho `MorphoChainlinkOracleV2` sources re-read `price()` and Euler
603    /// quote/cross sources re-read `getQuote(one base unit, base, quote)`;
604    /// both promote event-derived snapshots to `Confirmed`/`Corrected` through
605    /// [`crate::OracleTracker::reconcile_derived_pending_with`]. Each family
606    /// only participates when its adapter feature (`morpho` / `euler`) is
607    /// enabled, so builds without the feature never queue derived requests
608    /// they cannot serve.
609    pub fn supports_derived_reconciliation(&self) -> bool {
610        #[cfg(feature = "morpho")]
611        if matches!(self, Self::MorphoChainlinkV2 { .. }) {
612            return true;
613        }
614        #[cfg(feature = "euler")]
615        if matches!(self, Self::EulerQuote { .. } | Self::EulerCross { .. }) {
616            return true;
617        }
618        false
619    }
620
621    /// Proxy that should be read for authoritative Chainlink round data.
622    pub fn read_proxy(&self, registration_proxy: Address) -> Address {
623        match self {
624            Self::Chainlink => registration_proxy,
625            Self::AavePriceCapStable {
626                underlying_proxy, ..
627            } => *underlying_proxy,
628            Self::AaveRatioCap {
629                base_to_usd_proxy, ..
630            } => *base_to_usd_proxy,
631            Self::AaveSynchronicityPegToBase {
632                asset_to_peg_proxy, ..
633            } => *asset_to_peg_proxy,
634            Self::AaveFixedPrice { source, .. } => *source,
635            Self::MorphoChainlinkV2 { source, .. }
636            | Self::EulerQuote { source, .. }
637            | Self::EulerCross { source, .. } => *source,
638            Self::Pyth { pyth, .. } => *pyth,
639            #[cfg(feature = "redstone")]
640            Self::RedstonePush { price_feed, .. } => *price_feed,
641            Self::Custom(descriptor) => descriptor.read_proxy,
642        }
643    }
644
645    /// Aggregators whose Chainlink events should drive this source.
646    pub fn event_aggregators(&self, current_aggregator: Option<Address>) -> Vec<Address> {
647        match self {
648            Self::AaveSynchronicityPegToBase {
649                asset_to_peg_aggregator,
650                peg_to_base_aggregator,
651                ..
652            } => vec![*asset_to_peg_aggregator, *peg_to_base_aggregator],
653            Self::MorphoChainlinkV2 { feeds, .. } => feeds
654                .iter()
655                .map(|feed| feed.dependency.aggregator)
656                .collect(),
657            Self::EulerQuote { leg, .. } => leg.event_aggregator().into_iter().collect(),
658            Self::EulerCross {
659                base_cross,
660                cross_quote,
661                ..
662            } => base_cross
663                .event_aggregator()
664                .into_iter()
665                .chain(cross_quote.event_aggregator())
666                .collect(),
667            Self::Pyth { pyth, .. } => vec![*pyth],
668            #[cfg(feature = "redstone")]
669            Self::RedstonePush {
670                price_feed,
671                adapter,
672                ..
673            } => {
674                let mut aggregators = vec![*adapter];
675                if price_feed != adapter {
676                    aggregators.push(*price_feed);
677                }
678                aggregators
679            }
680            Self::AaveFixedPrice { .. } => Vec::new(),
681            _ => current_aggregator.into_iter().collect(),
682        }
683    }
684
685    /// Source classification for a value produced from this source's event path.
686    pub fn event_value_source(&self) -> OracleValueSource {
687        match self {
688            Self::AavePriceCapStable { .. }
689            | Self::AaveRatioCap { .. }
690            | Self::AaveSynchronicityPegToBase { .. }
691            | Self::AaveFixedPrice { .. }
692            | Self::MorphoChainlinkV2 { .. }
693            | Self::EulerQuote { .. }
694            | Self::EulerCross { .. } => OracleValueSource::Derived,
695            _ => OracleValueSource::Event,
696        }
697    }
698
699    /// Return true when an event from `aggregator` is acceptable for this source.
700    pub fn accepts_event_from(
701        &self,
702        current_aggregator: Option<Address>,
703        aggregator: Address,
704    ) -> bool {
705        self.event_aggregators(current_aggregator)
706            .into_iter()
707            .any(|event_aggregator| event_aggregator == aggregator)
708    }
709
710    /// Return true when this source should use AnswerUpdated events for `aggregator`.
711    pub fn wants_answer_updated_from(
712        &self,
713        current_aggregator: Option<Address>,
714        aggregator: Address,
715    ) -> bool {
716        match self {
717            Self::AaveRatioCap { .. }
718            | Self::AaveSynchronicityPegToBase { .. }
719            | Self::MorphoChainlinkV2 { .. }
720            | Self::EulerQuote { .. }
721            | Self::EulerCross { .. } => self.accepts_event_from(current_aggregator, aggregator),
722            _ => false,
723        }
724    }
725
726    /// Return true when Chainlink storage adapters can safely mirror this event.
727    pub fn supports_direct_chainlink_storage_effects(&self) -> bool {
728        match self {
729            Self::AaveRatioCap { .. }
730            | Self::AaveSynchronicityPegToBase { .. }
731            | Self::MorphoChainlinkV2 { .. }
732            | Self::EulerQuote { .. }
733            | Self::EulerCross { .. }
734            | Self::Pyth { .. } => false,
735            #[cfg(feature = "redstone")]
736            Self::RedstonePush { .. } => false,
737            _ => true,
738        }
739    }
740
741    /// Apply the source transform to an event or read answer.
742    pub fn normalize_answer(&self, answer: I256) -> I256 {
743        self.normalize_answer_from_event(None, answer)
744    }
745
746    /// Apply the source transform to an answer emitted by a specific dependency aggregator.
747    pub fn normalize_answer_from_event(&self, aggregator: Option<Address>, answer: I256) -> I256 {
748        match self {
749            Self::Chainlink => answer,
750            Self::AavePriceCapStable { price_cap, .. } if answer > *price_cap => *price_cap,
751            Self::AavePriceCapStable { .. } => answer,
752            Self::AaveRatioCap {
753                current_ratio,
754                max_ratio,
755                ratio_decimals,
756                ..
757            } => {
758                if answer <= I256::ZERO || *current_ratio <= I256::ZERO {
759                    return I256::ZERO;
760                }
761                let capped_ratio = if current_ratio < max_ratio {
762                    *current_ratio
763                } else {
764                    *max_ratio
765                };
766                if capped_ratio <= I256::ZERO {
767                    return I256::ZERO;
768                }
769                div_or_zero(
770                    answer.saturating_mul(capped_ratio),
771                    decimal_scale(*ratio_decimals),
772                )
773            }
774            Self::AaveSynchronicityPegToBase {
775                asset_to_peg_aggregator,
776                asset_to_peg_answer,
777                asset_to_peg_decimals,
778                peg_to_base_aggregator,
779                peg_to_base_answer,
780                peg_to_base_decimals,
781                output_decimals,
782                ..
783            } => {
784                let asset_to_peg = if aggregator == Some(*asset_to_peg_aggregator) {
785                    answer
786                } else {
787                    *asset_to_peg_answer
788                };
789                let peg_to_base = if aggregator == Some(*peg_to_base_aggregator) {
790                    answer
791                } else {
792                    *peg_to_base_answer
793                };
794                normalize_synchronicity_answer(
795                    asset_to_peg,
796                    *asset_to_peg_decimals,
797                    peg_to_base,
798                    *peg_to_base_decimals,
799                    *output_decimals,
800                )
801            }
802            Self::AaveFixedPrice { price, .. } => *price,
803            Self::MorphoChainlinkV2 {
804                base_vault_assets,
805                quote_vault_assets,
806                scale_factor,
807                feeds,
808                ..
809            } => normalize_morpho_chainlink_v2_answer(
810                *base_vault_assets,
811                *quote_vault_assets,
812                *scale_factor,
813                feeds,
814                aggregator,
815                answer,
816            ),
817            Self::EulerQuote { leg, .. } => leg.price_from_event(aggregator, answer),
818            Self::EulerCross {
819                cross_decimals,
820                base_cross,
821                cross_quote,
822                ..
823            } => normalize_euler_cross_answer(
824                base_cross.price_from_event(aggregator, answer),
825                cross_quote.price_from_event(aggregator, answer),
826                *cross_decimals,
827            ),
828            Self::Pyth { .. } => answer,
829            #[cfg(feature = "redstone")]
830            Self::RedstonePush { .. } => answer,
831            Self::Custom(descriptor) => match &descriptor.transform {
832                OracleTransformDescriptor::Identity | OracleTransformDescriptor::Custom { .. } => {
833                    answer
834                }
835                OracleTransformDescriptor::PriceCap { cap } if answer > *cap => *cap,
836                OracleTransformDescriptor::PriceCap { .. } => answer,
837            },
838        }
839    }
840
841    /// Apply the source transform to round data.
842    pub fn normalize_round(&self, mut round: RoundData) -> RoundData {
843        round.answer = match self {
844            Self::AaveSynchronicityPegToBase {
845                asset_to_peg_aggregator,
846                ..
847            } => self.normalize_answer_from_event(Some(*asset_to_peg_aggregator), round.answer),
848            Self::MorphoChainlinkV2 { .. }
849            | Self::EulerQuote { .. }
850            | Self::EulerCross { .. }
851            | Self::Pyth { .. } => round.answer,
852            _ => self.normalize_answer(round.answer),
853        };
854        round
855    }
856
857    pub(crate) fn normalize_dependency_answers(
858        &self,
859        asset_to_peg_answer: I256,
860        peg_to_base_answer: I256,
861    ) -> Option<I256> {
862        match self {
863            Self::AaveSynchronicityPegToBase {
864                asset_to_peg_decimals,
865                peg_to_base_decimals,
866                output_decimals,
867                ..
868            } => Some(normalize_synchronicity_answer(
869                asset_to_peg_answer,
870                *asset_to_peg_decimals,
871                peg_to_base_answer,
872                *peg_to_base_decimals,
873                *output_decimals,
874            )),
875            _ => None,
876        }
877    }
878
879    pub(crate) fn with_synchronicity_dependency_state(
880        &self,
881        asset_to_peg_aggregator: Address,
882        asset_to_peg_answer: I256,
883        peg_to_base_aggregator: Address,
884        peg_to_base_answer: I256,
885    ) -> Option<Self> {
886        match self {
887            Self::AaveSynchronicityPegToBase {
888                source,
889                asset_to_peg_proxy,
890                asset_to_peg_decimals,
891                peg_to_base_proxy,
892                peg_to_base_decimals,
893                output_decimals,
894                ..
895            } => Some(Self::AaveSynchronicityPegToBase {
896                source: *source,
897                asset_to_peg_proxy: *asset_to_peg_proxy,
898                asset_to_peg_aggregator,
899                asset_to_peg_answer,
900                asset_to_peg_decimals: *asset_to_peg_decimals,
901                peg_to_base_proxy: *peg_to_base_proxy,
902                peg_to_base_aggregator,
903                peg_to_base_answer,
904                peg_to_base_decimals: *peg_to_base_decimals,
905                output_decimals: *output_decimals,
906            }),
907            _ => None,
908        }
909    }
910
911    /// Return Pyth source fields for Pyth-backed registrations.
912    pub fn pyth_source(&self) -> Option<(Address, B256, i32, u64)> {
913        match self {
914            Self::Pyth {
915                pyth,
916                price_id,
917                expo,
918                conf,
919            } => Some((*pyth, *price_id, *expo, *conf)),
920            _ => None,
921        }
922    }
923
924    /// Return this Pyth source with event-updated metadata.
925    pub fn with_pyth_event_metadata(&self, price_id: B256, expo: i32, conf: u64) -> Option<Self> {
926        match self {
927            Self::Pyth { pyth, .. } => Some(Self::Pyth {
928                pyth: *pyth,
929                price_id,
930                expo,
931                conf,
932            }),
933            _ => None,
934        }
935    }
936}
937
938fn decimal_scale(decimals: u8) -> I256 {
939    let mut scale = I256::unchecked_from(1_i8);
940    for _ in 0..decimals {
941        scale = scale.saturating_mul(I256::unchecked_from(10_i8));
942    }
943    scale
944}
945
946fn div_or_zero(numerator: I256, denominator: I256) -> I256 {
947    if denominator == I256::ZERO {
948        I256::ZERO
949    } else {
950        numerator / denominator
951    }
952}
953
954fn normalize_synchronicity_answer(
955    asset_to_peg: I256,
956    asset_to_peg_decimals: u8,
957    peg_to_base: I256,
958    peg_to_base_decimals: u8,
959    output_decimals: u8,
960) -> I256 {
961    if asset_to_peg <= I256::ZERO || peg_to_base <= I256::ZERO {
962        return I256::ZERO;
963    }
964    let numerator = asset_to_peg
965        .saturating_mul(peg_to_base)
966        .saturating_mul(decimal_scale(output_decimals));
967    let denominator = decimal_scale(asset_to_peg_decimals.saturating_add(peg_to_base_decimals));
968    div_or_zero(numerator, denominator)
969}
970
971fn normalize_morpho_chainlink_v2_answer(
972    base_vault_assets: I256,
973    quote_vault_assets: I256,
974    scale_factor: I256,
975    feeds: &[MorphoChainlinkFeed],
976    aggregator: Option<Address>,
977    answer: I256,
978) -> I256 {
979    if base_vault_assets < I256::ZERO
980        || quote_vault_assets <= I256::ZERO
981        || scale_factor <= I256::ZERO
982    {
983        return I256::ZERO;
984    }
985
986    // Mirror `MorphoChainlinkOracleV2.price()`:
987    // `SCALE_FACTOR.mulDiv(baseFeed1 * baseFeed2 * baseVaultAssets,
988    //                      quoteFeed1 * quoteFeed2 * quoteVaultAssets)`.
989    // The inner products are plain checked uint256 math on-chain (they revert
990    // on overflow; the event path cannot revert, so overflow degrades to zero,
991    // which round classification rejects as an invalid answer). The outer
992    // multiply/divide uses a 512-bit intermediate exactly like OpenZeppelin's
993    // `Math.mulDiv`, so large scale factors composed with multi-feed products
994    // never saturate into a silently wrong price.
995    let mut base_product = i256_magnitude(base_vault_assets);
996    let mut quote_product = i256_magnitude(quote_vault_assets);
997    for feed in feeds {
998        let feed_answer = if aggregator == Some(feed.dependency.aggregator) {
999            answer
1000        } else {
1001            feed.dependency.answer
1002        };
1003        if feed_answer < I256::ZERO {
1004            return I256::ZERO;
1005        }
1006        let magnitude = i256_magnitude(feed_answer);
1007        let product = match feed.role {
1008            MorphoChainlinkFeedRole::BaseFeed1 | MorphoChainlinkFeedRole::BaseFeed2 => {
1009                &mut base_product
1010            }
1011            MorphoChainlinkFeedRole::QuoteFeed1 | MorphoChainlinkFeedRole::QuoteFeed2 => {
1012                &mut quote_product
1013            }
1014        };
1015        *product = match product.checked_mul(magnitude) {
1016            Some(value) => value,
1017            None => return I256::ZERO,
1018        };
1019    }
1020    if quote_product.is_zero() {
1021        return I256::ZERO;
1022    }
1023
1024    // 2^255 * 2^256 = 2^511 fits a U512, so this cannot overflow.
1025    let numerator = u512_from_u256(i256_magnitude(scale_factor)) * u512_from_u256(base_product);
1026    let quotient = numerator / u512_from_u256(quote_product);
1027    let limbs = quotient.into_limbs();
1028    if limbs[4..].iter().any(|limb| *limb != 0) {
1029        return I256::ZERO;
1030    }
1031    let low = U256::from_limbs([limbs[0], limbs[1], limbs[2], limbs[3]]);
1032    I256::try_from(low).unwrap_or(I256::ZERO)
1033}
1034
1035/// Magnitude of a non-negative `I256` (callers guard against negatives).
1036fn i256_magnitude(value: I256) -> U256 {
1037    value.into_raw()
1038}
1039
1040fn u512_from_u256(value: U256) -> U512 {
1041    let limbs = value.into_limbs();
1042    U512::from_limbs([limbs[0], limbs[1], limbs[2], limbs[3], 0, 0, 0, 0])
1043}
1044
1045fn normalize_euler_cross_answer(
1046    base_cross_price: I256,
1047    cross_quote_price: I256,
1048    cross_decimals: u8,
1049) -> I256 {
1050    if base_cross_price <= I256::ZERO || cross_quote_price <= I256::ZERO {
1051        return I256::ZERO;
1052    }
1053    div_or_zero(
1054        base_cross_price.saturating_mul(cross_quote_price),
1055        decimal_scale(cross_decimals),
1056    )
1057}
1058
1059fn scale_positive_price(answer: I256, from_decimals: u8, to_decimals: u8) -> I256 {
1060    if answer <= I256::ZERO {
1061        return I256::ZERO;
1062    }
1063    if from_decimals == to_decimals {
1064        answer
1065    } else if from_decimals < to_decimals {
1066        answer.saturating_mul(decimal_scale(to_decimals - from_decimals))
1067    } else {
1068        div_or_zero(answer, decimal_scale(from_decimals - to_decimals))
1069    }
1070}
1071
1072fn inverse_positive_price(answer: I256, feed_decimals: u8, quote_decimals: u8) -> I256 {
1073    if answer <= I256::ZERO {
1074        return I256::ZERO;
1075    }
1076    div_or_zero(
1077        decimal_scale(feed_decimals.saturating_add(quote_decimals)),
1078        answer,
1079    )
1080}
1081
1082fn inverse_fixed_rate(rate: I256, base_decimals: u8, quote_decimals: u8) -> I256 {
1083    if rate <= I256::ZERO {
1084        return I256::ZERO;
1085    }
1086    div_or_zero(
1087        decimal_scale(base_decimals.saturating_add(quote_decimals)),
1088        rate,
1089    )
1090}
1091
1092/// Registered Chainlink-compatible proxy feed.
1093#[derive(Clone, Debug, PartialEq, Eq)]
1094pub struct FeedRegistration {
1095    /// Stable feed id.
1096    pub id: FeedId,
1097    /// User-facing proxy address.
1098    pub proxy: Address,
1099    /// Optional human-readable label.
1100    pub label: Option<String>,
1101    /// Optional base symbol.
1102    pub base: Option<String>,
1103    /// Optional quote symbol.
1104    pub quote: Option<String>,
1105    /// Freshness and answer validity policy.
1106    pub staleness: crate::StalenessPolicy,
1107    /// Best-known current aggregator.
1108    pub current_aggregator: Option<Address>,
1109    /// Best-known current aggregator layout evidence.
1110    pub aggregator_layout: Option<AggregatorLayoutEvidence>,
1111    /// Metadata loaded from the proxy.
1112    pub metadata: FeedMetadata,
1113    /// Network source and transform used by this registration.
1114    pub source: FeedSource,
1115    /// Feed/runtime readiness state.
1116    pub status: OracleFeedStatus,
1117}
1118
1119/// Feed/runtime readiness surfaced by registry, warmup, build, repair, and health reports.
1120#[derive(Clone, Debug, PartialEq, Eq)]
1121pub struct OracleFeedReadinessReport {
1122    /// Feed id when the feed reached registration or supplied an id before being skipped.
1123    pub id: Option<FeedId>,
1124    /// User-facing proxy/source address.
1125    pub proxy: Address,
1126    /// Feed/runtime readiness state.
1127    pub status: OracleFeedStatus,
1128    /// Optional machine-readable or human-readable diagnostic.
1129    pub reason: Option<String>,
1130}
1131
1132/// Aggregator routing change found during reconciliation.
1133#[derive(Clone, Debug, PartialEq, Eq)]
1134pub struct AggregatorChange {
1135    /// Feed id whose aggregator changed.
1136    pub id: FeedId,
1137    /// Feed proxy.
1138    pub proxy: Address,
1139    /// Previous aggregator.
1140    pub old: Option<Address>,
1141    /// New aggregator.
1142    pub new: Option<Address>,
1143}
1144
1145struct PreparedFeed {
1146    id: FeedId,
1147    config: FeedConfig,
1148    source: FeedSource,
1149    metadata: FeedMetadata,
1150    round: RoundData,
1151    current_aggregator: Option<Address>,
1152}
1153
1154/// Registry plus latest proxy-read snapshots.
1155#[derive(Clone, Debug)]
1156pub struct OracleRegistry {
1157    registrations: BTreeMap<FeedId, FeedRegistration>,
1158    ids_by_proxy: BTreeMap<Address, FeedId>,
1159    snapshots_by_proxy: BTreeMap<Address, OracleSnapshot>,
1160    layouts_by_code_hash: BTreeMap<B256, AggregatorLayout>,
1161    now_timestamp: u64,
1162}
1163
1164impl OracleRegistry {
1165    /// Create an empty registry with a fixed wall-clock timestamp for tests.
1166    pub fn new_at_timestamp(now_timestamp: u64) -> Self {
1167        Self {
1168            registrations: BTreeMap::new(),
1169            ids_by_proxy: BTreeMap::new(),
1170            snapshots_by_proxy: BTreeMap::new(),
1171            layouts_by_code_hash: BTreeMap::new(),
1172            now_timestamp,
1173        }
1174    }
1175
1176    /// Register a Chainlink-compatible proxy and seed state from proxy reads.
1177    pub async fn register_chainlink_feed<P: ChainlinkFeedProvider>(
1178        &mut self,
1179        provider: &P,
1180        config: FeedConfig,
1181    ) -> Result<FeedId, OracleError> {
1182        let id = self.prepare_feed_id(&config)?;
1183        let source = FeedSource::Chainlink;
1184        let read_proxy = source.read_proxy(config.proxy);
1185        let metadata = FeedMetadata {
1186            decimals: provider.decimals(read_proxy).await?,
1187            description: provider.description(read_proxy).await?,
1188            version: provider.version(read_proxy).await?,
1189        };
1190        let round = source.normalize_round(provider.latest_round_data(read_proxy).await?);
1191        let current_aggregator = provider.aggregator(read_proxy).await.unwrap_or(None);
1192        self.insert_prepared_feed(
1193            provider,
1194            PreparedFeed {
1195                id,
1196                config,
1197                source,
1198                metadata,
1199                round,
1200                current_aggregator,
1201            },
1202        )
1203        .await
1204    }
1205
1206    #[allow(dead_code)]
1207    pub(crate) async fn register_discovered_feed<P: ChainlinkFeedProvider>(
1208        &mut self,
1209        provider: &P,
1210        config: FeedConfig,
1211        source: FeedSource,
1212        metadata: FeedMetadata,
1213        round: RoundData,
1214        current_aggregator: Option<Address>,
1215    ) -> Result<FeedId, OracleError> {
1216        let id = self.prepare_feed_id(&config)?;
1217        self.insert_prepared_feed(
1218            provider,
1219            PreparedFeed {
1220                id,
1221                config,
1222                source,
1223                metadata,
1224                round,
1225                current_aggregator,
1226            },
1227        )
1228        .await
1229    }
1230
1231    async fn insert_prepared_feed<P: ChainlinkFeedProvider>(
1232        &mut self,
1233        provider: &P,
1234        prepared: PreparedFeed,
1235    ) -> Result<FeedId, OracleError> {
1236        let PreparedFeed {
1237            id,
1238            config,
1239            source,
1240            metadata,
1241            round,
1242            current_aggregator,
1243        } = prepared;
1244        let aggregator_layout = self
1245            .detect_aggregator_layout(provider, current_aggregator, None)
1246            .await;
1247
1248        let registration = FeedRegistration {
1249            id: id.clone(),
1250            proxy: config.proxy,
1251            label: config.label,
1252            base: config.base,
1253            quote: config.quote,
1254            staleness: config.staleness,
1255            current_aggregator,
1256            aggregator_layout,
1257            metadata: metadata.clone(),
1258            source,
1259            status: OracleFeedStatus::Ready,
1260        };
1261        let snapshot = OracleSnapshot::proxy_read(
1262            id.clone(),
1263            registration.proxy,
1264            current_aggregator,
1265            metadata,
1266            round,
1267            self.now_timestamp,
1268            &registration.staleness,
1269        );
1270
1271        self.ids_by_proxy.insert(registration.proxy, id.clone());
1272        self.snapshots_by_proxy.insert(registration.proxy, snapshot);
1273        self.registrations.insert(id.clone(), registration);
1274        Ok(id)
1275    }
1276
1277    fn prepare_feed_id(&self, config: &FeedConfig) -> Result<FeedId, OracleError> {
1278        if self.ids_by_proxy.contains_key(&config.proxy) {
1279            return Err(OracleError::DuplicateProxy(config.proxy));
1280        }
1281
1282        let id = config
1283            .id
1284            .clone()
1285            .unwrap_or_else(|| derive_feed_id(config.label.as_deref(), config.proxy));
1286        if self.registrations.contains_key(&id) {
1287            return Err(OracleError::DuplicateFeedId(id.to_string()));
1288        }
1289        Ok(id)
1290    }
1291
1292    /// Return a registration by id.
1293    pub fn registration(&self, id: FeedId) -> Option<&FeedRegistration> {
1294        self.registrations.get(&id)
1295    }
1296
1297    /// Return feed/runtime readiness for all registered feeds.
1298    pub fn feed_readiness(&self) -> Vec<OracleFeedReadinessReport> {
1299        self.registrations
1300            .values()
1301            .map(|registration| OracleFeedReadinessReport {
1302                id: Some(registration.id.clone()),
1303                proxy: registration.proxy,
1304                status: registration.status,
1305                reason: None,
1306            })
1307            .collect()
1308    }
1309
1310    /// Return the latest snapshot by proxy.
1311    pub fn latest(&self, proxy: Address) -> Option<&OracleSnapshot> {
1312        self.snapshots_by_proxy.get(&proxy)
1313    }
1314
1315    pub(crate) fn now_timestamp(&self) -> u64 {
1316        self.now_timestamp
1317    }
1318
1319    pub(crate) fn registrations_map(&self) -> &BTreeMap<FeedId, FeedRegistration> {
1320        &self.registrations
1321    }
1322
1323    pub(crate) fn registrations_map_mut(&mut self) -> &mut BTreeMap<FeedId, FeedRegistration> {
1324        &mut self.registrations
1325    }
1326
1327    pub(crate) fn snapshots_by_proxy_mut(&mut self) -> &mut BTreeMap<Address, OracleSnapshot> {
1328        &mut self.snapshots_by_proxy
1329    }
1330
1331    pub(crate) fn id_by_proxy(&self, proxy: Address) -> Option<&FeedId> {
1332        self.ids_by_proxy.get(&proxy)
1333    }
1334
1335    pub(crate) fn record_proxy_id(&mut self, proxy: Address, id: FeedId) {
1336        self.ids_by_proxy.insert(proxy, id);
1337    }
1338
1339    pub(crate) fn replace_snapshot(&mut self, snapshot: OracleSnapshot) {
1340        self.snapshots_by_proxy.insert(snapshot.proxy, snapshot);
1341    }
1342
1343    pub(crate) fn insert_seeded_registration(
1344        &mut self,
1345        registration: FeedRegistration,
1346        round: RoundData,
1347    ) -> Result<(), OracleError> {
1348        if self.ids_by_proxy.contains_key(&registration.proxy) {
1349            return Err(OracleError::DuplicateProxy(registration.proxy));
1350        }
1351        if self.registrations.contains_key(&registration.id) {
1352            return Err(OracleError::DuplicateFeedId(registration.id.to_string()));
1353        }
1354
1355        let snapshot = OracleSnapshot::proxy_read(
1356            registration.id.clone(),
1357            registration.proxy,
1358            registration.current_aggregator,
1359            registration.metadata.clone(),
1360            registration.source.normalize_round(round),
1361            self.now_timestamp,
1362            &registration.staleness,
1363        );
1364        self.ids_by_proxy
1365            .insert(registration.proxy, registration.id.clone());
1366        self.snapshots_by_proxy.insert(registration.proxy, snapshot);
1367        self.registrations
1368            .insert(registration.id.clone(), registration);
1369        Ok(())
1370    }
1371
1372    pub(crate) fn remove_registration_by_id(&mut self, id: FeedId) -> Option<FeedRegistration> {
1373        let registration = self.registrations.remove(&id)?;
1374        self.ids_by_proxy.remove(&registration.proxy);
1375        self.snapshots_by_proxy.remove(&registration.proxy);
1376        Some(registration)
1377    }
1378
1379    pub(crate) fn remove_registration_by_proxy(
1380        &mut self,
1381        proxy: Address,
1382    ) -> Option<FeedRegistration> {
1383        let id = self.ids_by_proxy.get(&proxy)?.clone();
1384        self.remove_registration_by_id(id)
1385    }
1386
1387    pub(crate) async fn detect_aggregator_layout<P: ChainlinkFeedProvider>(
1388        &mut self,
1389        provider: &P,
1390        aggregator: Option<Address>,
1391        block: Option<OracleBlockRef>,
1392    ) -> Option<AggregatorLayoutEvidence> {
1393        let aggregator = aggregator?;
1394        let code_hash = provider
1395            .aggregator_code_hash_at(aggregator, block)
1396            .await
1397            .unwrap_or(None);
1398        let type_and_version = provider
1399            .aggregator_type_and_version_at(aggregator, block)
1400            .await
1401            .unwrap_or(None);
1402
1403        if let Some(type_and_version) = type_and_version {
1404            let layout = classify_type_and_version(&type_and_version);
1405            if let Some(code_hash) = code_hash
1406                && is_cacheable_layout(layout)
1407            {
1408                self.layouts_by_code_hash.insert(code_hash, layout);
1409            }
1410            return Some(AggregatorLayoutEvidence {
1411                aggregator,
1412                type_and_version: Some(type_and_version),
1413                code_hash,
1414                layout,
1415                confidence: AggregatorLayoutConfidence::TypeAndVersion,
1416            });
1417        }
1418
1419        if let Some(code_hash) = code_hash {
1420            if let Some(layout) = self.layouts_by_code_hash.get(&code_hash).copied() {
1421                return Some(AggregatorLayoutEvidence::from_code_hash_cache(
1422                    aggregator, code_hash, layout,
1423                ));
1424            }
1425            return Some(AggregatorLayoutEvidence::unknown(
1426                aggregator,
1427                Some(code_hash),
1428            ));
1429        }
1430
1431        Some(AggregatorLayoutEvidence::unknown(aggregator, None))
1432    }
1433
1434    /// Return cloned registrations for routing rebuilds.
1435    ///
1436    /// Prefer [`Self::registrations_iter`] when read-only access is enough —
1437    /// this iterator clones every registration it yields.
1438    pub fn registrations(&self) -> impl Iterator<Item = FeedRegistration> + '_ {
1439        self.registrations_iter().cloned()
1440    }
1441
1442    /// Iterate over registrations without cloning, in feed-id order.
1443    pub fn registrations_iter(&self) -> impl Iterator<Item = &FeedRegistration> {
1444        self.registrations.values()
1445    }
1446}
1447
1448fn is_cacheable_layout(layout: AggregatorLayout) -> bool {
1449    matches!(
1450        layout,
1451        AggregatorLayout::ChainlinkOcr2V1
1452            | AggregatorLayout::ChainlinkOcr1V2
1453            | AggregatorLayout::ChainlinkOcr1V3
1454            | AggregatorLayout::ChainlinkOcr1V4
1455    )
1456}
1457
1458pub(crate) fn snapshot_from_proxy_read(
1459    registration: &FeedRegistration,
1460    round: RoundData,
1461    aggregator: Option<Address>,
1462    now_timestamp: u64,
1463) -> OracleSnapshot {
1464    OracleSnapshot::proxy_read(
1465        registration.id.clone(),
1466        registration.proxy,
1467        aggregator,
1468        registration.metadata.clone(),
1469        round,
1470        now_timestamp,
1471        &registration.staleness,
1472    )
1473}
1474
1475pub(crate) fn derive_feed_id(label: Option<&str>, proxy: Address) -> FeedId {
1476    if let Some(label) = label {
1477        let slug = label
1478            .chars()
1479            .map(|ch| {
1480                if ch.is_ascii_alphanumeric() {
1481                    ch.to_ascii_lowercase()
1482                } else {
1483                    '-'
1484                }
1485            })
1486            .collect::<String>()
1487            .trim_matches('-')
1488            .to_string();
1489        if !slug.is_empty() {
1490            return FeedId::new(slug);
1491        }
1492    }
1493
1494    FeedId::new(format!("{proxy:?}"))
1495}
1496
1497#[allow(dead_code)]
1498fn _u256_is_part_of_public_metadata(_: U256) {}