Skip to main content

evm_oracle_state/
tracker.rs

1use alloy_network::{Ethereum, Network};
2use alloy_primitives::{Address, B256, I256, U256};
3use evm_fork_cache::reactive::{ReactiveBatchReport, ReactiveReport, ReportTag};
4
5use crate::{
6    AggregatorChange, AggregatorLayoutEvidence, ChainlinkFeedProvider, FeedId, FeedRegistration,
7    FeedSource, ORACLE_LEGACY_ANSWER_UPDATED_KIND, ORACLE_SIGNAL_NAMESPACE, OracleBlockRef,
8    OracleError, OracleFeedReadinessReport, OraclePrice, OracleRegistry, OracleRoundStatus,
9    OracleSignalKind, OracleSnapshot, OracleValueSource, OracleValueStatus, RoundData,
10    registry::snapshot_from_proxy_read, state::EventSnapshotInput,
11};
12
13/// Event-derived oracle update carried in reactive hook payloads.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct OracleUpdate {
16    /// Feed id.
17    pub id: FeedId,
18    /// Proxy address.
19    pub proxy: Address,
20    /// Emitting aggregator.
21    pub aggregator: Address,
22    /// Event-derived round.
23    pub round: RoundData,
24    /// Event block number, when known.
25    pub block_number: Option<u64>,
26    /// Event log index, when known.
27    pub log_index: Option<u64>,
28    /// Event-derived value lifecycle.
29    pub value_status: OracleValueStatus,
30}
31
32/// Rich event-derived oracle price update emitted by the reactive handler.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct OraclePriceUpdate {
35    /// Feed id.
36    pub id: FeedId,
37    /// Proxy address.
38    pub proxy: Address,
39    /// Emitting aggregator.
40    pub aggregator: Address,
41    /// Optional human-readable feed label.
42    pub label: Option<String>,
43    /// Optional base symbol.
44    pub base: Option<String>,
45    /// Optional quote symbol.
46    pub quote: Option<String>,
47    /// Raw signed event answer.
48    pub raw_answer: I256,
49    /// Feed decimals.
50    pub decimals: u8,
51    /// Event round id.
52    pub event_round_id: U256,
53    /// Event-derived `startedAt` timestamp.
54    pub started_at: u64,
55    /// Event `updatedAt` timestamp.
56    pub updated_at: u64,
57    /// Event block number, when known.
58    pub block_number: Option<u64>,
59    /// Event block hash, when known.
60    pub block_hash: Option<B256>,
61    /// Event log index, when known.
62    pub log_index: Option<u64>,
63    /// Round validity classification.
64    pub round_status: OracleRoundStatus,
65    /// Value reconciliation lifecycle.
66    pub value_status: OracleValueStatus,
67    /// Source that produced this value.
68    pub source: OracleValueSource,
69}
70
71impl OraclePriceUpdate {
72    fn round(&self) -> RoundData {
73        RoundData {
74            round_id: self.event_round_id,
75            answer: self.raw_answer,
76            started_at: self.started_at,
77            updated_at: self.updated_at,
78            answered_in_round: self.event_round_id,
79        }
80    }
81}
82
83/// How a queued [`OracleReconciliationRequest`] is satisfied.
84#[non_exhaustive]
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
86pub enum OracleReconciliationKind {
87    /// Authoritative Chainlink-shaped proxy read (`latestRoundData()`),
88    /// served by the provider-backed [`OracleTracker::reconcile`] /
89    /// [`OracleReconciler`] paths.
90    #[default]
91    Proxy,
92    /// Authoritative derived-source protocol read (Morpho `price()`, Euler
93    /// `getQuote`), served by
94    /// [`OracleTracker::reconcile_derived_pending_with`] through a
95    /// [`crate::OracleDerivedReader`].
96    DerivedProtocolRead,
97}
98
99/// Event-specific reconciliation request queued after an event update.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct OracleReconciliationRequest {
102    /// Feed id.
103    pub id: FeedId,
104    /// Proxy address to read authoritatively.
105    pub proxy: Address,
106    /// Aggregator that emitted the event.
107    pub aggregator: Option<Address>,
108    /// Event-derived round that needs confirmation.
109    pub event_round: RoundData,
110    /// Event block number, when known.
111    pub block_number: Option<u64>,
112    /// Event block hash, when known.
113    pub block_hash: Option<B256>,
114    /// Event log index, when known.
115    pub log_index: Option<u64>,
116    /// Read path that satisfies this request.
117    pub kind: OracleReconciliationKind,
118}
119
120impl OracleReconciliationRequest {
121    /// Block identity for providers that support hash-pinned reads.
122    pub fn block_ref(&self) -> Option<OracleBlockRef> {
123        Some(OracleBlockRef {
124            number: self.block_number?,
125            hash: self.block_hash?,
126        })
127    }
128}
129
130/// Hook emitted when an event price is confirmed by the proxy.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct OraclePriceConfirmed {
133    /// Feed id.
134    pub id: FeedId,
135    /// Proxy address.
136    pub proxy: Address,
137    /// Best-known current aggregator.
138    pub aggregator: Option<Address>,
139    /// Optional human-readable feed label.
140    pub label: Option<String>,
141    /// Optional base symbol.
142    pub base: Option<String>,
143    /// Optional quote symbol.
144    pub quote: Option<String>,
145    /// Confirmed raw answer.
146    pub raw_answer: I256,
147    /// Feed decimals.
148    pub decimals: u8,
149    /// Event round id.
150    pub event_round_id: U256,
151    /// Event `updatedAt` timestamp.
152    pub updated_at: u64,
153    /// Event block number, when known.
154    pub block_number: Option<u64>,
155    /// Event block hash, when known.
156    pub block_hash: Option<B256>,
157    /// Event log index, when known.
158    pub log_index: Option<u64>,
159    /// Round validity classification.
160    pub round_status: OracleRoundStatus,
161    /// Resulting value lifecycle.
162    pub value_status: OracleValueStatus,
163    /// Source that produced this value.
164    pub source: OracleValueSource,
165    /// Authoritative proxy round.
166    pub proxy_round: RoundData,
167}
168
169/// Hook emitted when a proxy read corrects an event price.
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct OraclePriceCorrected {
172    /// Feed id.
173    pub id: FeedId,
174    /// Proxy address.
175    pub proxy: Address,
176    /// Best-known current aggregator.
177    pub aggregator: Option<Address>,
178    /// Optional human-readable feed label.
179    pub label: Option<String>,
180    /// Optional base symbol.
181    pub base: Option<String>,
182    /// Optional quote symbol.
183    pub quote: Option<String>,
184    /// Raw event answer that was corrected.
185    pub event_answer: I256,
186    /// Corrected raw answer from the proxy.
187    pub raw_answer: I256,
188    /// Feed decimals.
189    pub decimals: u8,
190    /// Event round id.
191    pub event_round_id: U256,
192    /// Event `updatedAt` timestamp.
193    pub updated_at: u64,
194    /// Event block number, when known.
195    pub block_number: Option<u64>,
196    /// Event block hash, when known.
197    pub block_hash: Option<B256>,
198    /// Event log index, when known.
199    pub log_index: Option<u64>,
200    /// Round validity classification.
201    pub round_status: OracleRoundStatus,
202    /// Resulting value lifecycle.
203    pub value_status: OracleValueStatus,
204    /// Source that produced this value.
205    pub source: OracleValueSource,
206    /// Corrected authoritative proxy round.
207    pub corrected_round: RoundData,
208}
209
210/// Hook emitted when the proxy read is stale under policy.
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub struct OraclePriceStale {
213    /// Feed id.
214    pub id: FeedId,
215    /// Proxy address.
216    pub proxy: Address,
217    /// Best-known current aggregator.
218    pub aggregator: Option<Address>,
219    /// Optional human-readable feed label.
220    pub label: Option<String>,
221    /// Optional base symbol.
222    pub base: Option<String>,
223    /// Optional quote symbol.
224    pub quote: Option<String>,
225    /// Raw stale answer.
226    pub raw_answer: I256,
227    /// Feed decimals.
228    pub decimals: u8,
229    /// Event round id.
230    pub event_round_id: U256,
231    /// Event `updatedAt` timestamp.
232    pub updated_at: u64,
233    /// Event block number, when known.
234    pub block_number: Option<u64>,
235    /// Event block hash, when known.
236    pub block_hash: Option<B256>,
237    /// Event log index, when known.
238    pub log_index: Option<u64>,
239    /// Round validity classification.
240    pub round_status: OracleRoundStatus,
241    /// Resulting value lifecycle.
242    pub value_status: OracleValueStatus,
243    /// Source that produced this value.
244    pub source: OracleValueSource,
245    /// Authoritative proxy round.
246    pub proxy_round: RoundData,
247}
248
249/// Rich oracle hook events emitted by reconciliation.
250#[non_exhaustive]
251#[derive(Clone, Debug, PartialEq, Eq)]
252pub enum OracleHookEvent {
253    /// Immediate event-derived price update.
254    PriceUpdate(OraclePriceUpdate),
255    /// Event price matched the authoritative proxy read.
256    PriceConfirmed(OraclePriceConfirmed),
257    /// Event price was corrected by the authoritative proxy read.
258    PriceCorrected(OraclePriceCorrected),
259    /// Authoritative proxy read was stale under policy.
260    PriceStale(OraclePriceStale),
261    /// Proxy now points at a different aggregator.
262    AggregatorChanged(AggregatorChange),
263}
264
265/// Hook emitted when proxy reconciliation detects an aggregator change.
266pub type OracleAggregatorChanged = AggregatorChange;
267
268/// One completed event reconciliation.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct OracleReconciliationResult {
271    /// Request that was reconciled.
272    pub request: OracleReconciliationRequest,
273    /// Lifecycle hooks emitted by this reconciliation.
274    pub hooks: Vec<OracleHookEvent>,
275}
276
277/// Reconciliation summary.
278#[derive(Clone, Debug, Default, PartialEq, Eq)]
279pub struct ReconcileReport {
280    /// Number of feeds checked.
281    pub checked_feeds: usize,
282    /// Feed/runtime readiness after reconciliation.
283    pub feed_statuses: Vec<OracleFeedReadinessReport>,
284    /// Feeds whose snapshot changed.
285    pub changed_feeds: Vec<FeedId>,
286    /// Aggregator changes detected while reading proxies.
287    pub aggregator_changes: Vec<AggregatorChange>,
288}
289
290/// One derived-source protocol read that failed during a
291/// [`OracleTracker::reconcile_derived_pending_with`] pass.
292#[derive(Clone, Debug, PartialEq, Eq)]
293pub struct DerivedReconcileFailure {
294    /// Request whose protocol read failed; it remains queued.
295    pub request: OracleReconciliationRequest,
296    /// Read error.
297    pub error: OracleError,
298}
299
300/// Summary of one [`OracleTracker::reconcile_derived_pending_with`] pass.
301#[derive(Clone, Debug, Default, PartialEq, Eq)]
302pub struct DerivedReconcileReport {
303    /// Requests whose protocol read completed, with the hooks each produced.
304    pub reconciled: Vec<OracleReconciliationResult>,
305    /// Requests whose protocol read failed; each remains queued.
306    pub failed: Vec<DerivedReconcileFailure>,
307}
308
309/// Mutable typed oracle state.
310#[derive(Clone, Debug)]
311pub struct OracleTracker {
312    registry: OracleRegistry,
313    pending_reconciliations: Vec<OracleReconciliationRequest>,
314}
315
316impl OracleTracker {
317    /// Create a tracker from an existing registry.
318    pub fn new(registry: OracleRegistry) -> Self {
319        Self {
320            registry,
321            pending_reconciliations: Vec::new(),
322        }
323    }
324
325    /// Seed a tracker from registrations and round data.
326    pub fn from_registrations_at_timestamp(
327        feeds: Vec<(FeedRegistration, RoundData)>,
328        now_timestamp: u64,
329    ) -> Result<Self, OracleError> {
330        let mut registry = OracleRegistry::new_at_timestamp(now_timestamp);
331        for (registration, round) in feeds {
332            let id = registration.id.clone();
333            if registry.registrations_map().contains_key(&id) {
334                return Err(OracleError::DuplicateFeedId(id.to_string()));
335            }
336            if registry.id_by_proxy(registration.proxy).is_some() {
337                return Err(OracleError::DuplicateProxy(registration.proxy));
338            }
339
340            let snapshot = OracleSnapshot::proxy_read(
341                id.clone(),
342                registration.proxy,
343                registration.current_aggregator,
344                registration.metadata.clone(),
345                round,
346                now_timestamp,
347                &registration.staleness,
348            );
349            registry
350                .registrations_map_mut()
351                .insert(id.clone(), registration.clone());
352            registry
353                .snapshots_by_proxy_mut()
354                .insert(registration.proxy, snapshot);
355            registry.record_proxy_id(registration.proxy, id);
356        }
357        Ok(Self {
358            registry,
359            pending_reconciliations: Vec::new(),
360        })
361    }
362
363    /// Return cloned registrations for rebuilding reactive routing.
364    ///
365    /// Prefer [`Self::registrations_iter`] when read-only access is enough —
366    /// this iterator clones every registration it yields.
367    pub fn registrations(&self) -> impl Iterator<Item = FeedRegistration> + '_ {
368        self.registry.registrations()
369    }
370
371    /// Iterate over registrations without cloning, in feed-id order.
372    pub fn registrations_iter(&self) -> impl Iterator<Item = &FeedRegistration> {
373        self.registry.registrations_iter()
374    }
375
376    /// Return feed/runtime readiness for all registered feeds.
377    pub fn feed_readiness(&self) -> Vec<OracleFeedReadinessReport> {
378        self.registry.feed_readiness()
379    }
380
381    /// Insert a pre-discovered feed and seed its latest proxy-read snapshot.
382    pub fn insert_seeded_registration(
383        &mut self,
384        registration: FeedRegistration,
385        round: RoundData,
386    ) -> Result<(), OracleError> {
387        self.registry
388            .insert_seeded_registration(registration, round)
389    }
390
391    /// Remove a feed by id, including its latest snapshot and pending reconciliation.
392    pub fn remove_by_id(&mut self, id: FeedId) -> Option<FeedRegistration> {
393        let registration = self.registry.remove_registration_by_id(id)?;
394        self.clear_pending_for_proxy(registration.proxy);
395        Some(registration)
396    }
397
398    /// Remove a feed by proxy, including its latest snapshot and pending reconciliation.
399    pub fn remove_by_proxy(&mut self, proxy: Address) -> Option<FeedRegistration> {
400        let registration = self.registry.remove_registration_by_proxy(proxy)?;
401        self.clear_pending_for_proxy(registration.proxy);
402        Some(registration)
403    }
404
405    /// Return the timestamp used for snapshot freshness classification.
406    pub fn now_timestamp(&self) -> u64 {
407        self.registry.now_timestamp()
408    }
409
410    /// Return the latest snapshot by proxy.
411    pub fn latest(&self, proxy: Address) -> Option<&OracleSnapshot> {
412        self.registry.latest(proxy)
413    }
414
415    /// Return the feed registration for a proxy.
416    pub fn registration_for_proxy(&self, proxy: Address) -> Option<&FeedRegistration> {
417        let id = self.registry.id_by_proxy(proxy)?;
418        self.registry.registrations_map().get(id)
419    }
420
421    /// Return the latest actionable-facing price by feed id string.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`OracleError::FeedNotFound`] when no feed is registered under
426    /// `id` or the feed has no current snapshot.
427    pub fn price(&self, id: impl AsRef<str>) -> Result<OraclePrice, OracleError> {
428        let registration = self
429            .registration_by_id_str(id.as_ref())
430            .ok_or(OracleError::FeedNotFound)?;
431        self.price_for_registration(registration)
432    }
433
434    /// Return the latest actionable-facing price by feed id.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`OracleError::FeedNotFound`] when no feed is registered under
439    /// `id` or the feed has no current snapshot.
440    pub fn price_by_id(&self, id: FeedId) -> Result<OraclePrice, OracleError> {
441        let registration = self
442            .registry
443            .registrations_map()
444            .get(&id)
445            .ok_or(OracleError::FeedNotFound)?;
446        self.price_for_registration(registration)
447    }
448
449    /// Return the latest actionable-facing price by proxy address.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`OracleError::FeedNotFound`] when no feed is registered under
454    /// `proxy` or the feed has no current snapshot.
455    pub fn price_by_proxy(&self, proxy: Address) -> Result<OraclePrice, OracleError> {
456        let id = self
457            .registry
458            .id_by_proxy(proxy)
459            .cloned()
460            .ok_or(OracleError::FeedNotFound)?;
461        self.price_by_id(id)
462    }
463
464    /// Return the latest round by feed id string.
465    ///
466    /// # Errors
467    ///
468    /// Returns [`OracleError::FeedNotFound`] when no feed is registered under
469    /// `id` or the feed has no current snapshot.
470    pub fn latest_round(&self, id: impl AsRef<str>) -> Result<RoundData, OracleError> {
471        Ok(self.price(id)?.round_data())
472    }
473
474    /// Return the latest round by proxy address.
475    pub fn latest_round_by_proxy(&self, proxy: Address) -> Result<RoundData, OracleError> {
476        Ok(self.price_by_proxy(proxy)?.round_data())
477    }
478
479    /// Return event-specific proxy reconciliations waiting to be processed.
480    pub fn pending_reconciliations(&self) -> &[OracleReconciliationRequest] {
481        &self.pending_reconciliations
482    }
483
484    /// Apply committed reactive reports to typed oracle state.
485    ///
486    /// Built-in handlers emit each update twice for compatibility: as a rich
487    /// `oracle.price_update` signal and as a legacy `oracle.answer_updated`
488    /// signal. A legacy signal is skipped only when the same batch carries a
489    /// rich update for the same proxy and event round — legacy-only signals
490    /// from custom handlers still apply even when unrelated rich updates
491    /// share the batch. Legacy signals update typed state but are not decoded
492    /// into [`OracleHookEvent`]s, so runtime `on_event`/`on_price_update`
493    /// callbacks do not fire for them.
494    pub fn apply_batch_report<N: Network>(
495        &mut self,
496        report: &ReactiveBatchReport<N>,
497    ) -> Result<(), OracleError> {
498        // Keys (proxy, event round id) of rich updates in this batch. Legacy
499        // `oracle.answer_updated` duplicates of these are skipped so one event
500        // is not applied twice; legacy signals for other state keys or rounds
501        // are still applied.
502        let rich_price_update_keys = report
503            .applied
504            .iter()
505            .flat_map(|applied| applied.hook_signals.iter())
506            .filter(|signal| {
507                signal.namespace.as_ref() == ORACLE_SIGNAL_NAMESPACE
508                    && signal.kind.as_ref() == OracleSignalKind::PriceUpdate.as_str()
509            })
510            .filter_map(|signal| signal.payload.as_ref()?.downcast_ref::<OraclePriceUpdate>())
511            .map(|update| (update.proxy, update.event_round_id))
512            .collect::<std::collections::BTreeSet<_>>();
513
514        for applied in &report.applied {
515            for signal in &applied.hook_signals {
516                if signal.namespace.as_ref() != ORACLE_SIGNAL_NAMESPACE {
517                    continue;
518                }
519
520                let Some(payload) = signal.payload.as_ref() else {
521                    continue;
522                };
523
524                match signal.kind.as_ref() {
525                    kind if kind == OracleSignalKind::PriceUpdate.as_str() => {
526                        if let Some(update) = payload.downcast_ref::<OraclePriceUpdate>() {
527                            self.apply_price_update_with_tags(update.clone(), &signal.labels)?;
528                        }
529                    }
530                    kind if kind == ORACLE_LEGACY_ANSWER_UPDATED_KIND => {
531                        if let Some(update) = payload.downcast_ref::<OracleUpdate>()
532                            && !rich_price_update_keys
533                                .contains(&(update.proxy, update.round.round_id))
534                        {
535                            self.apply_update(update.clone())?;
536                        }
537                    }
538                    _ => {}
539                }
540            }
541        }
542
543        if report.reports.iter().any(|report| {
544            matches!(
545                report.as_ref(),
546                ReactiveReport::Reorg(reorg) if !reorg.dropped_blocks.is_empty()
547            )
548        }) {
549            self.mark_event_snapshots_unknown();
550        }
551
552        Ok(())
553    }
554
555    /// Reconcile all registered feeds with authoritative proxy reads.
556    pub async fn reconcile<P: ChainlinkFeedProvider>(
557        &mut self,
558        provider: &P,
559    ) -> Result<ReconcileReport, OracleError> {
560        let registrations: Vec<_> = self.registry.registrations().collect();
561        let mut report = ReconcileReport::default();
562
563        for mut registration in registrations {
564            report.checked_feeds += 1;
565            if !registration.source.supports_proxy_reconciliation() {
566                continue;
567            }
568            let mut new_source = None;
569            let (round, new_aggregator, new_layout) = if matches!(
570                registration.source,
571                FeedSource::AaveSynchronicityPegToBase { .. }
572            ) {
573                let reconciled =
574                    reconcile_current_aave_synchronicity_peg_to_base(self, provider, &registration)
575                        .await?;
576                new_source = reconciled.new_source;
577                (
578                    reconciled.proxy_round,
579                    reconciled.new_aggregator,
580                    reconciled.new_layout,
581                )
582            } else {
583                let read_proxy = registration.source.read_proxy(registration.proxy);
584                let round = registration
585                    .source
586                    .normalize_round(provider.latest_round_data(read_proxy).await?);
587                let new_aggregator = provider
588                    .aggregator(read_proxy)
589                    .await
590                    .unwrap_or(registration.current_aggregator);
591                let new_layout = self
592                    .registry
593                    .detect_aggregator_layout(provider, new_aggregator, None)
594                    .await;
595                (round, new_aggregator, new_layout)
596            };
597            let old_aggregator = registration.current_aggregator;
598            let snapshot = snapshot_from_proxy_read(
599                &registration,
600                round,
601                new_aggregator,
602                self.registry.now_timestamp(),
603            );
604
605            let changed = self
606                .registry
607                .latest(registration.proxy)
608                .is_none_or(|old| old != &snapshot);
609            if changed {
610                report.changed_feeds.push(registration.id.clone());
611            }
612
613            if old_aggregator != new_aggregator {
614                report.aggregator_changes.push(AggregatorChange {
615                    id: registration.id.clone(),
616                    proxy: registration.proxy,
617                    old: old_aggregator,
618                    new: new_aggregator,
619                });
620            }
621
622            registration.current_aggregator = new_aggregator;
623            registration.aggregator_layout = new_layout.clone();
624            if let Some(stored) = self
625                .registry
626                .registrations_map_mut()
627                .get_mut(&registration.id)
628            {
629                stored.current_aggregator = new_aggregator;
630                stored.aggregator_layout = new_layout;
631                if let Some(new_source) = new_source {
632                    stored.source = new_source;
633                }
634            }
635            self.registry.replace_snapshot(snapshot);
636            self.clear_pending_for_proxy(registration.proxy);
637        }
638
639        report.feed_statuses = self
640            .registry
641            .registrations_iter()
642            .map(|registration| OracleFeedReadinessReport {
643                id: Some(registration.id.clone()),
644                proxy: registration.proxy,
645                status: registration.status,
646                reason: None,
647            })
648            .collect();
649
650        Ok(report)
651    }
652
653    // `FeedId: Borrow<str>` makes this an O(log N) map lookup by string id.
654    pub(crate) fn registration_by_id_str(&self, id: &str) -> Option<&FeedRegistration> {
655        self.registry.registrations_map().get(id)
656    }
657
658    fn price_for_registration(
659        &self,
660        registration: &FeedRegistration,
661    ) -> Result<OraclePrice, OracleError> {
662        let snapshot = self
663            .registry
664            .latest(registration.proxy)
665            .ok_or(OracleError::FeedNotFound)?;
666        Ok(OraclePrice::from_snapshot(snapshot, registration))
667    }
668
669    fn apply_update(&mut self, update: OracleUpdate) -> Result<(), OracleError> {
670        let Some(registration) = self.registry.registrations_map().get(&update.id).cloned() else {
671            return Ok(());
672        };
673
674        if !registration
675            .source
676            .accepts_event_from(registration.current_aggregator, update.aggregator)
677        {
678            return Ok(());
679        }
680
681        let snapshot = OracleSnapshot::event(EventSnapshotInput {
682            id: update.id.clone(),
683            proxy: update.proxy,
684            aggregator: Some(update.aggregator),
685            metadata: registration.metadata,
686            round: update.round,
687            now_timestamp: self.registry.now_timestamp(),
688            staleness: &registration.staleness,
689            block_number: update.block_number,
690            block_hash: None,
691            value_status: update.value_status,
692            source: registration.source.event_value_source(),
693        });
694        if let Some(kind) = Self::reconciliation_kind(&registration.source) {
695            self.queue_reconciliation(OracleReconciliationRequest {
696                id: update.id.clone(),
697                proxy: update.proxy,
698                aggregator: Some(update.aggregator),
699                event_round: snapshot.round.clone(),
700                block_number: update.block_number,
701                block_hash: None,
702                log_index: update.log_index,
703                kind,
704            });
705        }
706        self.registry.replace_snapshot(snapshot);
707        Ok(())
708    }
709
710    fn apply_price_update_with_tags(
711        &mut self,
712        update: OraclePriceUpdate,
713        labels: &[ReportTag],
714    ) -> Result<(), OracleError> {
715        let Some(mut registration) = self.registry.registrations_map().get(&update.id).cloned()
716        else {
717            return Ok(());
718        };
719
720        if let Some(new_source) = pyth_source_from_event_tags(&registration.source, labels) {
721            if let Some(stored) = self.registry.registrations_map_mut().get_mut(&update.id) {
722                stored.source = new_source.clone();
723            }
724            registration.source = new_source;
725        }
726
727        if !registration
728            .source
729            .accepts_event_from(registration.current_aggregator, update.aggregator)
730        {
731            return Ok(());
732        }
733
734        let event_round = update.round();
735        if update.value_status == OracleValueStatus::Unknown {
736            self.mark_matching_event_snapshot_unknown(
737                update.proxy,
738                &event_round,
739                update.block_number,
740                update.block_hash,
741            );
742            return Ok(());
743        }
744
745        let snapshot = OracleSnapshot::event(EventSnapshotInput {
746            id: update.id.clone(),
747            proxy: update.proxy,
748            aggregator: Some(update.aggregator),
749            metadata: registration.metadata,
750            round: event_round.clone(),
751            now_timestamp: self.registry.now_timestamp(),
752            staleness: &registration.staleness,
753            block_number: update.block_number,
754            block_hash: update.block_hash,
755            value_status: update.value_status,
756            source: update.source,
757        });
758        if let Some(kind) = Self::reconciliation_kind(&registration.source) {
759            self.queue_reconciliation(OracleReconciliationRequest {
760                id: update.id.clone(),
761                proxy: update.proxy,
762                aggregator: Some(update.aggregator),
763                event_round,
764                block_number: update.block_number,
765                block_hash: update.block_hash,
766                log_index: update.log_index,
767                kind,
768            });
769        }
770        self.registry.replace_snapshot(snapshot);
771        Ok(())
772    }
773
774    fn mark_matching_event_snapshot_unknown(
775        &mut self,
776        proxy: Address,
777        event_round: &RoundData,
778        block_number: Option<u64>,
779        block_hash: Option<B256>,
780    ) {
781        let Some(snapshot) = self.registry.snapshots_by_proxy_mut().get_mut(&proxy) else {
782            return;
783        };
784        if !is_event_originated_source(snapshot.source) {
785            return;
786        }
787        if !proxy_round_matches_event(&snapshot.round, event_round) {
788            return;
789        }
790        if block_number.is_some() && snapshot.block_number != block_number {
791            return;
792        }
793        if block_hash.is_some() && snapshot.block_hash != block_hash {
794            return;
795        }
796
797        snapshot.mark_unknown();
798        self.clear_pending_for_proxy(proxy);
799    }
800
801    fn mark_event_snapshots_unknown(&mut self) {
802        let mut unknown_proxies = Vec::new();
803        for snapshot in self.registry.snapshots_by_proxy_mut().values_mut() {
804            if is_event_originated_source(snapshot.source) {
805                unknown_proxies.push(snapshot.proxy);
806                snapshot.mark_unknown();
807            }
808        }
809        for proxy in unknown_proxies {
810            self.clear_pending_for_proxy(proxy);
811        }
812    }
813
814    /// Reconcile every pending derived-source request through `reader`.
815    ///
816    /// Derived sources (Morpho, Euler) are confirmed by their protocol's own
817    /// view call rather than a Chainlink proxy read: a read equal to the
818    /// event-recomputed value promotes the snapshot to
819    /// [`OracleValueStatus::Confirmed`]; a differing read installs the
820    /// authoritative value as [`OracleValueStatus::Corrected`] with
821    /// read-time timestamps. Either way the resulting snapshot carries
822    /// [`OracleValueSource::Proxy`], because the value now comes from the
823    /// feed's authoritative source path. Failed reads leave their request
824    /// queued and are reported in
825    /// [`DerivedReconcileReport::failed`] instead of failing the pass.
826    ///
827    /// Proxy-kind requests are untouched; drive those through the
828    /// provider-backed [`OracleTracker::reconcile`] / [`OracleReconciler`]
829    /// paths.
830    ///
831    /// Note: correction does not refresh the dependency baselines stored in
832    /// the feed's [`FeedSource`] legs; subsequent dependency events recompute
833    /// from the original discovery baselines until the feed is re-discovered.
834    pub fn reconcile_derived_pending_with<R: crate::OracleDerivedReader>(
835        &mut self,
836        reader: &mut R,
837    ) -> DerivedReconcileReport {
838        let requests: Vec<OracleReconciliationRequest> = self
839            .pending_reconciliations
840            .iter()
841            .filter(|request| request.kind == OracleReconciliationKind::DerivedProtocolRead)
842            .cloned()
843            .collect();
844        let mut report = DerivedReconcileReport::default();
845        for request in requests {
846            let Some(registration) = self.registry.registrations_map().get(&request.id).cloned()
847            else {
848                report.failed.push(DerivedReconcileFailure {
849                    request,
850                    error: OracleError::FeedNotFound,
851                });
852                continue;
853            };
854            let answer = match reader.read_derived_value(&registration) {
855                Ok(answer) => answer,
856                Err(error) => {
857                    report
858                        .failed
859                        .push(DerivedReconcileFailure { request, error });
860                    continue;
861                }
862            };
863            // A matching read confirms the event round verbatim; a differing
864            // read installs the authoritative answer with read-time
865            // timestamps (the classification helper treats any timestamp
866            // difference as a correction, which is exactly right here).
867            let proxy_round = if answer == request.event_round.answer {
868                request.event_round.clone()
869            } else {
870                let now_timestamp = self.registry.now_timestamp();
871                RoundData {
872                    round_id: request.event_round.round_id,
873                    answer,
874                    started_at: now_timestamp,
875                    updated_at: now_timestamp,
876                    answered_in_round: request.event_round.answered_in_round,
877                }
878            };
879            // Preserve dependency routing: derived feeds keep their
880            // dependency aggregator and (absent) layout across protocol-read
881            // reconciliation.
882            let apply = self.apply_reconciled_request(
883                &request,
884                proxy_round,
885                registration.current_aggregator,
886                registration.aggregator_layout.clone(),
887                None,
888                true,
889            );
890            match apply {
891                Ok(hooks) => report
892                    .reconciled
893                    .push(OracleReconciliationResult { request, hooks }),
894                Err(error) => report
895                    .failed
896                    .push(DerivedReconcileFailure { request, error }),
897            }
898        }
899        report
900    }
901
902    fn queue_reconciliation(&mut self, request: OracleReconciliationRequest) {
903        self.clear_pending_for_proxy(request.proxy);
904        self.pending_reconciliations.push(request);
905    }
906
907    /// Read path that can authoritatively confirm an event value from this
908    /// source, or `None` when no reconciliation path exists (for example
909    /// fixed-price sources).
910    fn reconciliation_kind(source: &FeedSource) -> Option<OracleReconciliationKind> {
911        if source.supports_proxy_reconciliation() {
912            Some(OracleReconciliationKind::Proxy)
913        } else if source.supports_derived_reconciliation() {
914            Some(OracleReconciliationKind::DerivedProtocolRead)
915        } else {
916            None
917        }
918    }
919
920    fn clear_pending_for_proxy(&mut self, proxy: Address) {
921        self.pending_reconciliations
922            .retain(|request| request.proxy != proxy);
923    }
924
925    fn complete_pending_reconciliation(&mut self, request: &OracleReconciliationRequest) {
926        self.pending_reconciliations
927            .retain(|pending| pending != request);
928    }
929
930    fn apply_reconciled_request(
931        &mut self,
932        request: &OracleReconciliationRequest,
933        proxy_round: RoundData,
934        new_aggregator: Option<Address>,
935        new_layout: Option<AggregatorLayoutEvidence>,
936        new_source: Option<FeedSource>,
937        round_is_normalized: bool,
938    ) -> Result<Vec<OracleHookEvent>, OracleError> {
939        let Some(registration) = self.registry.registrations_map().get(&request.id).cloned() else {
940            return Err(OracleError::FeedNotFound);
941        };
942
943        let proxy_round = if round_is_normalized {
944            proxy_round
945        } else {
946            registration.source.normalize_round(proxy_round)
947        };
948        let old_aggregator = registration.current_aggregator;
949        let mut snapshot = snapshot_from_proxy_read(
950            &registration,
951            proxy_round.clone(),
952            new_aggregator,
953            self.registry.now_timestamp(),
954        );
955        let value_status =
956            reconciliation_value_status(&snapshot.round_status, request, &proxy_round);
957        snapshot.set_value_status(value_status);
958
959        let mut hooks = Vec::new();
960        if old_aggregator != new_aggregator {
961            hooks.push(OracleHookEvent::AggregatorChanged(AggregatorChange {
962                id: registration.id.clone(),
963                proxy: registration.proxy,
964                old: old_aggregator,
965                new: new_aggregator,
966            }));
967        }
968
969        match value_status {
970            OracleValueStatus::Confirmed => {
971                hooks.push(OracleHookEvent::PriceConfirmed(OraclePriceConfirmed {
972                    id: registration.id.clone(),
973                    proxy: registration.proxy,
974                    aggregator: new_aggregator,
975                    label: registration.label.clone(),
976                    base: registration.base.clone(),
977                    quote: registration.quote.clone(),
978                    raw_answer: proxy_round.answer,
979                    decimals: registration.metadata.decimals,
980                    event_round_id: request.event_round.round_id,
981                    updated_at: request.event_round.updated_at,
982                    block_number: request.block_number,
983                    block_hash: request.block_hash,
984                    log_index: request.log_index,
985                    round_status: snapshot.round_status.clone(),
986                    value_status,
987                    source: OracleValueSource::Proxy,
988                    proxy_round: proxy_round.clone(),
989                }));
990            }
991            OracleValueStatus::Corrected => {
992                hooks.push(OracleHookEvent::PriceCorrected(OraclePriceCorrected {
993                    id: registration.id.clone(),
994                    proxy: registration.proxy,
995                    aggregator: new_aggregator,
996                    label: registration.label.clone(),
997                    base: registration.base.clone(),
998                    quote: registration.quote.clone(),
999                    event_answer: request.event_round.answer,
1000                    raw_answer: proxy_round.answer,
1001                    decimals: registration.metadata.decimals,
1002                    event_round_id: request.event_round.round_id,
1003                    updated_at: request.event_round.updated_at,
1004                    block_number: request.block_number,
1005                    block_hash: request.block_hash,
1006                    log_index: request.log_index,
1007                    round_status: snapshot.round_status.clone(),
1008                    value_status,
1009                    source: OracleValueSource::Proxy,
1010                    corrected_round: proxy_round.clone(),
1011                }));
1012            }
1013            OracleValueStatus::EventPending
1014            | OracleValueStatus::RequiresRepair
1015            | OracleValueStatus::Unknown => {}
1016        }
1017
1018        if matches!(snapshot.round_status, OracleRoundStatus::Stale { .. }) {
1019            hooks.push(OracleHookEvent::PriceStale(OraclePriceStale {
1020                id: registration.id.clone(),
1021                proxy: registration.proxy,
1022                aggregator: new_aggregator,
1023                label: registration.label.clone(),
1024                base: registration.base.clone(),
1025                quote: registration.quote.clone(),
1026                raw_answer: proxy_round.answer,
1027                decimals: registration.metadata.decimals,
1028                event_round_id: request.event_round.round_id,
1029                updated_at: request.event_round.updated_at,
1030                block_number: request.block_number,
1031                block_hash: request.block_hash,
1032                log_index: request.log_index,
1033                round_status: snapshot.round_status.clone(),
1034                value_status,
1035                source: OracleValueSource::Proxy,
1036                proxy_round: proxy_round.clone(),
1037            }));
1038        }
1039
1040        if let Some(stored) = self
1041            .registry
1042            .registrations_map_mut()
1043            .get_mut(&registration.id)
1044        {
1045            stored.current_aggregator = new_aggregator;
1046            stored.aggregator_layout = new_layout;
1047            if let Some(new_source) = new_source {
1048                stored.source = new_source;
1049            }
1050        }
1051        self.registry.replace_snapshot(snapshot);
1052        self.complete_pending_reconciliation(request);
1053        Ok(hooks)
1054    }
1055}
1056
1057/// Reconciles event-derived oracle state against authoritative proxy reads.
1058#[derive(Clone, Debug, Default)]
1059pub struct OracleReconciler {
1060    queue: std::collections::VecDeque<OracleReconciliationRequest>,
1061}
1062
1063impl OracleReconciler {
1064    /// Enqueue an event reconciliation request.
1065    pub fn enqueue(&mut self, request: OracleReconciliationRequest) {
1066        self.queue.push_back(request);
1067    }
1068
1069    /// Reconcile the next queued event against authoritative proxy reads.
1070    pub async fn reconcile_next<P: ChainlinkFeedProvider>(
1071        &mut self,
1072        tracker: &mut OracleTracker,
1073        provider: &P,
1074    ) -> Result<Option<OracleReconciliationResult>, OracleError> {
1075        let request = loop {
1076            let Some(front) = self.queue.front().cloned() else {
1077                return Ok(None);
1078            };
1079            if front.kind == OracleReconciliationKind::DerivedProtocolRead {
1080                // Derived requests are satisfied by protocol reads, not proxy
1081                // reads: drop this reconciler's copy and leave the tracker's
1082                // pending entry for `reconcile_derived_pending_with`.
1083                self.queue.pop_front();
1084                continue;
1085            }
1086            break front;
1087        };
1088
1089        let Some(registration) = tracker
1090            .registry
1091            .registrations_map()
1092            .get(&request.id)
1093            .cloned()
1094        else {
1095            return Err(OracleError::FeedNotFound);
1096        };
1097        if !registration.source.supports_proxy_reconciliation() {
1098            self.queue.pop_front();
1099            return Ok(Some(OracleReconciliationResult {
1100                request,
1101                hooks: Vec::new(),
1102            }));
1103        }
1104        let block_ref = request.block_ref();
1105        let reconciled = if matches!(
1106            registration.source,
1107            FeedSource::AaveSynchronicityPegToBase { .. }
1108        ) {
1109            reconcile_aave_synchronicity_peg_to_base(
1110                tracker,
1111                provider,
1112                &registration,
1113                &request,
1114                block_ref,
1115            )
1116            .await?
1117        } else {
1118            reconcile_single_proxy_source(tracker, provider, &registration, &request, block_ref)
1119                .await?
1120        };
1121        let hooks = tracker.apply_reconciled_request(
1122            &request,
1123            reconciled.proxy_round,
1124            reconciled.new_aggregator,
1125            reconciled.new_layout,
1126            reconciled.new_source,
1127            reconciled.round_is_normalized,
1128        )?;
1129        self.queue.pop_front();
1130
1131        Ok(Some(OracleReconciliationResult { request, hooks }))
1132    }
1133}
1134
1135struct ReconciledSourceRead {
1136    proxy_round: RoundData,
1137    new_aggregator: Option<Address>,
1138    new_layout: Option<AggregatorLayoutEvidence>,
1139    new_source: Option<FeedSource>,
1140    round_is_normalized: bool,
1141}
1142
1143async fn reconcile_single_proxy_source<P: ChainlinkFeedProvider>(
1144    tracker: &mut OracleTracker,
1145    provider: &P,
1146    registration: &FeedRegistration,
1147    request: &OracleReconciliationRequest,
1148    block_ref: Option<OracleBlockRef>,
1149) -> Result<ReconciledSourceRead, OracleError> {
1150    let read_proxy = registration.source.read_proxy(registration.proxy);
1151    let proxy_round = provider.latest_round_data_at(read_proxy, block_ref).await?;
1152    let new_aggregator = provider
1153        .aggregator_at(read_proxy, block_ref)
1154        .await
1155        .unwrap_or_else(|_| {
1156            tracker
1157                .current_aggregator(&request.id)
1158                .or(request.aggregator)
1159        });
1160    let new_layout = tracker
1161        .detect_aggregator_layout(provider, new_aggregator, block_ref)
1162        .await;
1163    Ok(ReconciledSourceRead {
1164        proxy_round,
1165        new_aggregator,
1166        new_layout,
1167        new_source: None,
1168        round_is_normalized: false,
1169    })
1170}
1171
1172async fn reconcile_aave_synchronicity_peg_to_base<P: ChainlinkFeedProvider>(
1173    tracker: &mut OracleTracker,
1174    provider: &P,
1175    registration: &FeedRegistration,
1176    request: &OracleReconciliationRequest,
1177    block_ref: Option<OracleBlockRef>,
1178) -> Result<ReconciledSourceRead, OracleError> {
1179    let FeedSource::AaveSynchronicityPegToBase {
1180        asset_to_peg_proxy,
1181        asset_to_peg_aggregator,
1182        peg_to_base_proxy,
1183        peg_to_base_aggregator,
1184        ..
1185    } = registration.source.clone()
1186    else {
1187        unreachable!("caller checked source family")
1188    };
1189
1190    let asset_round = provider
1191        .latest_round_data_at(asset_to_peg_proxy, block_ref)
1192        .await?;
1193    let peg_round = provider
1194        .latest_round_data_at(peg_to_base_proxy, block_ref)
1195        .await?;
1196    let new_asset_aggregator = provider
1197        .aggregator_at(asset_to_peg_proxy, block_ref)
1198        .await
1199        .unwrap_or(Some(asset_to_peg_aggregator));
1200    let new_peg_aggregator = provider
1201        .aggregator_at(peg_to_base_proxy, block_ref)
1202        .await
1203        .unwrap_or(Some(peg_to_base_aggregator));
1204    let changed_dependency_round = if new_peg_aggregator == request.aggregator {
1205        &peg_round
1206    } else {
1207        &asset_round
1208    };
1209    let derived_answer = registration
1210        .source
1211        .normalize_dependency_answers(asset_round.answer, peg_round.answer)
1212        .ok_or_else(|| {
1213            OracleError::Config(crate::error::OracleConfigError::Other(
1214                "source is not an Aave peg-to-base synchronicity source".to_string(),
1215            ))
1216        })?;
1217    let proxy_round = RoundData {
1218        round_id: changed_dependency_round.round_id,
1219        answer: derived_answer,
1220        started_at: changed_dependency_round.started_at,
1221        updated_at: changed_dependency_round.updated_at,
1222        answered_in_round: changed_dependency_round.answered_in_round,
1223    };
1224    let new_source = match (new_asset_aggregator, new_peg_aggregator) {
1225        (Some(asset_aggregator), Some(peg_aggregator)) => {
1226            registration.source.with_synchronicity_dependency_state(
1227                asset_aggregator,
1228                asset_round.answer,
1229                peg_aggregator,
1230                peg_round.answer,
1231            )
1232        }
1233        _ => None,
1234    };
1235    let new_layout = tracker
1236        .detect_aggregator_layout(provider, new_asset_aggregator, block_ref)
1237        .await;
1238
1239    Ok(ReconciledSourceRead {
1240        proxy_round,
1241        new_aggregator: new_asset_aggregator,
1242        new_layout,
1243        new_source,
1244        round_is_normalized: true,
1245    })
1246}
1247
1248async fn reconcile_current_aave_synchronicity_peg_to_base<P: ChainlinkFeedProvider>(
1249    tracker: &mut OracleTracker,
1250    provider: &P,
1251    registration: &FeedRegistration,
1252) -> Result<ReconciledSourceRead, OracleError> {
1253    let FeedSource::AaveSynchronicityPegToBase {
1254        asset_to_peg_proxy,
1255        asset_to_peg_aggregator,
1256        peg_to_base_proxy,
1257        peg_to_base_aggregator,
1258        ..
1259    } = registration.source.clone()
1260    else {
1261        unreachable!("caller checked source family")
1262    };
1263
1264    let asset_round = provider.latest_round_data(asset_to_peg_proxy).await?;
1265    let peg_round = provider.latest_round_data(peg_to_base_proxy).await?;
1266    let new_asset_aggregator = provider
1267        .aggregator(asset_to_peg_proxy)
1268        .await
1269        .unwrap_or(Some(asset_to_peg_aggregator));
1270    let new_peg_aggregator = provider
1271        .aggregator(peg_to_base_proxy)
1272        .await
1273        .unwrap_or(Some(peg_to_base_aggregator));
1274    let representative_round = if peg_round.updated_at >= asset_round.updated_at {
1275        &peg_round
1276    } else {
1277        &asset_round
1278    };
1279    let derived_answer = registration
1280        .source
1281        .normalize_dependency_answers(asset_round.answer, peg_round.answer)
1282        .ok_or_else(|| {
1283            OracleError::Config(crate::error::OracleConfigError::Other(
1284                "source is not an Aave peg-to-base synchronicity source".to_string(),
1285            ))
1286        })?;
1287    let proxy_round = RoundData {
1288        round_id: representative_round.round_id,
1289        answer: derived_answer,
1290        started_at: representative_round.started_at,
1291        updated_at: representative_round.updated_at,
1292        answered_in_round: representative_round.answered_in_round,
1293    };
1294    let new_source = match (new_asset_aggregator, new_peg_aggregator) {
1295        (Some(asset_aggregator), Some(peg_aggregator)) => {
1296            registration.source.with_synchronicity_dependency_state(
1297                asset_aggregator,
1298                asset_round.answer,
1299                peg_aggregator,
1300                peg_round.answer,
1301            )
1302        }
1303        _ => None,
1304    };
1305    let new_layout = tracker
1306        .detect_aggregator_layout(provider, new_asset_aggregator, None)
1307        .await;
1308
1309    Ok(ReconciledSourceRead {
1310        proxy_round,
1311        new_aggregator: new_asset_aggregator,
1312        new_layout,
1313        new_source,
1314        round_is_normalized: true,
1315    })
1316}
1317
1318impl OracleTracker {
1319    async fn detect_aggregator_layout<P: ChainlinkFeedProvider>(
1320        &mut self,
1321        provider: &P,
1322        aggregator: Option<Address>,
1323        block: Option<OracleBlockRef>,
1324    ) -> Option<AggregatorLayoutEvidence> {
1325        self.registry
1326            .detect_aggregator_layout(provider, aggregator, block)
1327            .await
1328    }
1329
1330    fn current_aggregator(&self, id: &FeedId) -> Option<Address> {
1331        self.registry
1332            .registrations_map()
1333            .get(id)
1334            .and_then(|registration| registration.current_aggregator)
1335    }
1336}
1337
1338fn reconciliation_value_status(
1339    round_status: &OracleRoundStatus,
1340    request: &OracleReconciliationRequest,
1341    proxy_round: &RoundData,
1342) -> OracleValueStatus {
1343    match round_status {
1344        OracleRoundStatus::Unknown => OracleValueStatus::RequiresRepair,
1345        OracleRoundStatus::Fresh
1346        | OracleRoundStatus::Stale { .. }
1347        | OracleRoundStatus::IncompleteRound
1348        | OracleRoundStatus::InvalidAnswer
1349            if proxy_round_matches_event(proxy_round, &request.event_round) =>
1350        {
1351            OracleValueStatus::Confirmed
1352        }
1353        OracleRoundStatus::Fresh
1354        | OracleRoundStatus::Stale { .. }
1355        | OracleRoundStatus::IncompleteRound
1356        | OracleRoundStatus::InvalidAnswer => OracleValueStatus::Corrected,
1357    }
1358}
1359
1360fn is_event_originated_source(source: OracleValueSource) -> bool {
1361    matches!(
1362        source,
1363        OracleValueSource::Event | OracleValueSource::Derived
1364    )
1365}
1366
1367fn proxy_round_matches_event(proxy_round: &RoundData, event_round: &RoundData) -> bool {
1368    proxy_round.round_id == event_round.round_id
1369        && proxy_round.answer == event_round.answer
1370        && proxy_round.updated_at == event_round.updated_at
1371}
1372
1373fn pyth_source_from_event_tags(source: &FeedSource, labels: &[ReportTag]) -> Option<FeedSource> {
1374    let (_pyth, price_id, current_expo, current_conf) = source.pyth_source()?;
1375    let mut expo = current_expo;
1376    let mut conf = current_conf;
1377    let mut saw_pyth_label = false;
1378
1379    for label in labels {
1380        match label.key.as_str() {
1381            "pyth_expo" => {
1382                if let Ok(value) = label.value.parse::<i32>() {
1383                    expo = value;
1384                    saw_pyth_label = true;
1385                }
1386            }
1387            "pyth_conf" => {
1388                if let Ok(value) = label.value.parse::<u64>() {
1389                    conf = value;
1390                    saw_pyth_label = true;
1391                }
1392            }
1393            _ => {}
1394        }
1395    }
1396
1397    saw_pyth_label.then(|| {
1398        source
1399            .with_pyth_event_metadata(price_id, expo, conf)
1400            .expect("pyth_source returned Some for Pyth source")
1401    })
1402}
1403
1404#[allow(dead_code)]
1405fn _ethereum_batch_report_type_is_supported(_: &ReactiveBatchReport<Ethereum>) {}