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                // A removed block can be outside the cache's retained journal.
547                // The typed tracker may still hold its event-derived value, so
548                // the reported dropped anchor is enough to revoke that value.
549                ReactiveReport::Reorg(reorg)
550                    if reorg.dropped.is_some() || !reorg.dropped_blocks.is_empty()
551            )
552        }) {
553            self.mark_event_snapshots_unknown();
554        }
555
556        Ok(())
557    }
558
559    /// Reconcile all registered feeds with authoritative proxy reads.
560    pub async fn reconcile<P: ChainlinkFeedProvider>(
561        &mut self,
562        provider: &P,
563    ) -> Result<ReconcileReport, OracleError> {
564        let registrations: Vec<_> = self.registry.registrations().collect();
565        let mut report = ReconcileReport::default();
566
567        for mut registration in registrations {
568            report.checked_feeds += 1;
569            if !registration.source.supports_proxy_reconciliation() {
570                continue;
571            }
572            let mut new_source = None;
573            let (round, new_aggregator, new_layout) = if matches!(
574                registration.source,
575                FeedSource::AaveSynchronicityPegToBase { .. }
576            ) {
577                let reconciled =
578                    reconcile_current_aave_synchronicity_peg_to_base(self, provider, &registration)
579                        .await?;
580                new_source = reconciled.new_source;
581                (
582                    reconciled.proxy_round,
583                    reconciled.new_aggregator,
584                    reconciled.new_layout,
585                )
586            } else {
587                let read_proxy = registration.source.read_proxy(registration.proxy);
588                let round = registration
589                    .source
590                    .normalize_round(provider.latest_round_data(read_proxy).await?);
591                let new_aggregator = provider
592                    .aggregator(read_proxy)
593                    .await
594                    .unwrap_or(registration.current_aggregator);
595                let new_layout = self
596                    .registry
597                    .detect_aggregator_layout(provider, new_aggregator, None)
598                    .await;
599                (round, new_aggregator, new_layout)
600            };
601            let old_aggregator = registration.current_aggregator;
602            let snapshot = snapshot_from_proxy_read(
603                &registration,
604                round,
605                new_aggregator,
606                self.registry.now_timestamp(),
607            );
608
609            let changed = self
610                .registry
611                .latest(registration.proxy)
612                .is_none_or(|old| old != &snapshot);
613            if changed {
614                report.changed_feeds.push(registration.id.clone());
615            }
616
617            if old_aggregator != new_aggregator {
618                report.aggregator_changes.push(AggregatorChange {
619                    id: registration.id.clone(),
620                    proxy: registration.proxy,
621                    old: old_aggregator,
622                    new: new_aggregator,
623                });
624            }
625
626            registration.current_aggregator = new_aggregator;
627            registration.aggregator_layout = new_layout.clone();
628            if let Some(stored) = self
629                .registry
630                .registrations_map_mut()
631                .get_mut(&registration.id)
632            {
633                stored.current_aggregator = new_aggregator;
634                stored.aggregator_layout = new_layout;
635                if let Some(new_source) = new_source {
636                    stored.source = new_source;
637                }
638            }
639            self.registry.replace_snapshot(snapshot);
640            self.clear_pending_for_proxy(registration.proxy);
641        }
642
643        report.feed_statuses = self
644            .registry
645            .registrations_iter()
646            .map(|registration| OracleFeedReadinessReport {
647                id: Some(registration.id.clone()),
648                proxy: registration.proxy,
649                status: registration.status,
650                reason: None,
651            })
652            .collect();
653
654        Ok(report)
655    }
656
657    // `FeedId: Borrow<str>` makes this an O(log N) map lookup by string id.
658    pub(crate) fn registration_by_id_str(&self, id: &str) -> Option<&FeedRegistration> {
659        self.registry.registrations_map().get(id)
660    }
661
662    fn price_for_registration(
663        &self,
664        registration: &FeedRegistration,
665    ) -> Result<OraclePrice, OracleError> {
666        let snapshot = self
667            .registry
668            .latest(registration.proxy)
669            .ok_or(OracleError::FeedNotFound)?;
670        Ok(OraclePrice::from_snapshot(snapshot, registration))
671    }
672
673    fn apply_update(&mut self, update: OracleUpdate) -> Result<(), OracleError> {
674        let Some(registration) = self.registry.registrations_map().get(&update.id).cloned() else {
675            return Ok(());
676        };
677
678        if !registration
679            .source
680            .accepts_event_from(registration.current_aggregator, update.aggregator)
681        {
682            return Ok(());
683        }
684
685        let snapshot = OracleSnapshot::event(EventSnapshotInput {
686            id: update.id.clone(),
687            proxy: update.proxy,
688            aggregator: Some(update.aggregator),
689            metadata: registration.metadata,
690            round: update.round,
691            now_timestamp: self.registry.now_timestamp(),
692            staleness: &registration.staleness,
693            block_number: update.block_number,
694            block_hash: None,
695            value_status: update.value_status,
696            source: registration.source.event_value_source(),
697        });
698        if let Some(kind) = Self::reconciliation_kind(&registration.source) {
699            self.queue_reconciliation(OracleReconciliationRequest {
700                id: update.id.clone(),
701                proxy: update.proxy,
702                aggregator: Some(update.aggregator),
703                event_round: snapshot.round.clone(),
704                block_number: update.block_number,
705                block_hash: None,
706                log_index: update.log_index,
707                kind,
708            });
709        }
710        self.registry.replace_snapshot(snapshot);
711        Ok(())
712    }
713
714    fn apply_price_update_with_tags(
715        &mut self,
716        update: OraclePriceUpdate,
717        labels: &[ReportTag],
718    ) -> Result<(), OracleError> {
719        let Some(mut registration) = self.registry.registrations_map().get(&update.id).cloned()
720        else {
721            return Ok(());
722        };
723
724        if let Some(new_source) = pyth_source_from_event_tags(&registration.source, labels) {
725            if let Some(stored) = self.registry.registrations_map_mut().get_mut(&update.id) {
726                stored.source = new_source.clone();
727            }
728            registration.source = new_source;
729        }
730
731        if !registration
732            .source
733            .accepts_event_from(registration.current_aggregator, update.aggregator)
734        {
735            return Ok(());
736        }
737
738        let event_round = update.round();
739        if update.value_status == OracleValueStatus::Unknown {
740            self.mark_matching_event_snapshot_unknown(
741                update.proxy,
742                &event_round,
743                update.block_number,
744                update.block_hash,
745            );
746            return Ok(());
747        }
748
749        let snapshot = OracleSnapshot::event(EventSnapshotInput {
750            id: update.id.clone(),
751            proxy: update.proxy,
752            aggregator: Some(update.aggregator),
753            metadata: registration.metadata,
754            round: event_round.clone(),
755            now_timestamp: self.registry.now_timestamp(),
756            staleness: &registration.staleness,
757            block_number: update.block_number,
758            block_hash: update.block_hash,
759            value_status: update.value_status,
760            source: update.source,
761        });
762        if let Some(kind) = Self::reconciliation_kind(&registration.source) {
763            self.queue_reconciliation(OracleReconciliationRequest {
764                id: update.id.clone(),
765                proxy: update.proxy,
766                aggregator: Some(update.aggregator),
767                event_round,
768                block_number: update.block_number,
769                block_hash: update.block_hash,
770                log_index: update.log_index,
771                kind,
772            });
773        }
774        self.registry.replace_snapshot(snapshot);
775        Ok(())
776    }
777
778    fn mark_matching_event_snapshot_unknown(
779        &mut self,
780        proxy: Address,
781        event_round: &RoundData,
782        block_number: Option<u64>,
783        block_hash: Option<B256>,
784    ) {
785        let Some(snapshot) = self.registry.snapshots_by_proxy_mut().get_mut(&proxy) else {
786            return;
787        };
788        if !is_event_originated_source(snapshot.source) {
789            return;
790        }
791        if !proxy_round_matches_event(&snapshot.round, event_round) {
792            return;
793        }
794        if block_number.is_some() && snapshot.block_number != block_number {
795            return;
796        }
797        if block_hash.is_some() && snapshot.block_hash != block_hash {
798            return;
799        }
800
801        snapshot.mark_unknown();
802        self.clear_pending_for_proxy(proxy);
803    }
804
805    fn mark_event_snapshots_unknown(&mut self) {
806        let mut unknown_proxies = Vec::new();
807        for snapshot in self.registry.snapshots_by_proxy_mut().values_mut() {
808            if is_event_originated_source(snapshot.source) {
809                unknown_proxies.push(snapshot.proxy);
810                snapshot.mark_unknown();
811            }
812        }
813        for proxy in unknown_proxies {
814            self.clear_pending_for_proxy(proxy);
815        }
816    }
817
818    /// Reconcile every pending derived-source request through `reader`.
819    ///
820    /// Derived sources (Morpho, Euler) are confirmed by their protocol's own
821    /// view call rather than a Chainlink proxy read: a read equal to the
822    /// event-recomputed value promotes the snapshot to
823    /// [`OracleValueStatus::Confirmed`]; a differing read installs the
824    /// authoritative value as [`OracleValueStatus::Corrected`] with
825    /// read-time timestamps. Either way the resulting snapshot carries
826    /// [`OracleValueSource::Proxy`], because the value now comes from the
827    /// feed's authoritative source path. Failed reads leave their request
828    /// queued and are reported in
829    /// [`DerivedReconcileReport::failed`] instead of failing the pass.
830    ///
831    /// Proxy-kind requests are untouched; drive those through the
832    /// provider-backed [`OracleTracker::reconcile`] / [`OracleReconciler`]
833    /// paths.
834    ///
835    /// Note: correction does not refresh the dependency baselines stored in
836    /// the feed's [`FeedSource`] legs; subsequent dependency events recompute
837    /// from the original discovery baselines until the feed is re-discovered.
838    pub fn reconcile_derived_pending_with<R: crate::OracleDerivedReader>(
839        &mut self,
840        reader: &mut R,
841    ) -> DerivedReconcileReport {
842        let requests: Vec<OracleReconciliationRequest> = self
843            .pending_reconciliations
844            .iter()
845            .filter(|request| request.kind == OracleReconciliationKind::DerivedProtocolRead)
846            .cloned()
847            .collect();
848        let mut report = DerivedReconcileReport::default();
849        for request in requests {
850            let Some(registration) = self.registry.registrations_map().get(&request.id).cloned()
851            else {
852                report.failed.push(DerivedReconcileFailure {
853                    request,
854                    error: OracleError::FeedNotFound,
855                });
856                continue;
857            };
858            let answer = match reader.read_derived_value(&registration) {
859                Ok(answer) => answer,
860                Err(error) => {
861                    report
862                        .failed
863                        .push(DerivedReconcileFailure { request, error });
864                    continue;
865                }
866            };
867            // A matching read confirms the event round verbatim; a differing
868            // read installs the authoritative answer with read-time
869            // timestamps (the classification helper treats any timestamp
870            // difference as a correction, which is exactly right here).
871            let proxy_round = if answer == request.event_round.answer {
872                request.event_round.clone()
873            } else {
874                let now_timestamp = self.registry.now_timestamp();
875                RoundData {
876                    round_id: request.event_round.round_id,
877                    answer,
878                    started_at: now_timestamp,
879                    updated_at: now_timestamp,
880                    answered_in_round: request.event_round.answered_in_round,
881                }
882            };
883            // Preserve dependency routing: derived feeds keep their
884            // dependency aggregator and (absent) layout across protocol-read
885            // reconciliation.
886            let apply = self.apply_reconciled_request(
887                &request,
888                proxy_round,
889                registration.current_aggregator,
890                registration.aggregator_layout.clone(),
891                None,
892                true,
893            );
894            match apply {
895                Ok(hooks) => report
896                    .reconciled
897                    .push(OracleReconciliationResult { request, hooks }),
898                Err(error) => report
899                    .failed
900                    .push(DerivedReconcileFailure { request, error }),
901            }
902        }
903        report
904    }
905
906    fn queue_reconciliation(&mut self, request: OracleReconciliationRequest) {
907        self.clear_pending_for_proxy(request.proxy);
908        self.pending_reconciliations.push(request);
909    }
910
911    /// Read path that can authoritatively confirm an event value from this
912    /// source, or `None` when no reconciliation path exists (for example
913    /// fixed-price sources).
914    fn reconciliation_kind(source: &FeedSource) -> Option<OracleReconciliationKind> {
915        if source.supports_proxy_reconciliation() {
916            Some(OracleReconciliationKind::Proxy)
917        } else if source.supports_derived_reconciliation() {
918            Some(OracleReconciliationKind::DerivedProtocolRead)
919        } else {
920            None
921        }
922    }
923
924    fn clear_pending_for_proxy(&mut self, proxy: Address) {
925        self.pending_reconciliations
926            .retain(|request| request.proxy != proxy);
927    }
928
929    fn complete_pending_reconciliation(&mut self, request: &OracleReconciliationRequest) {
930        self.pending_reconciliations
931            .retain(|pending| pending != request);
932    }
933
934    fn apply_reconciled_request(
935        &mut self,
936        request: &OracleReconciliationRequest,
937        proxy_round: RoundData,
938        new_aggregator: Option<Address>,
939        new_layout: Option<AggregatorLayoutEvidence>,
940        new_source: Option<FeedSource>,
941        round_is_normalized: bool,
942    ) -> Result<Vec<OracleHookEvent>, OracleError> {
943        let Some(registration) = self.registry.registrations_map().get(&request.id).cloned() else {
944            return Err(OracleError::FeedNotFound);
945        };
946
947        let proxy_round = if round_is_normalized {
948            proxy_round
949        } else {
950            registration.source.normalize_round(proxy_round)
951        };
952        let old_aggregator = registration.current_aggregator;
953        let mut snapshot = snapshot_from_proxy_read(
954            &registration,
955            proxy_round.clone(),
956            new_aggregator,
957            self.registry.now_timestamp(),
958        );
959        let value_status =
960            reconciliation_value_status(&snapshot.round_status, request, &proxy_round);
961        snapshot.set_value_status(value_status);
962
963        let mut hooks = Vec::new();
964        if old_aggregator != new_aggregator {
965            hooks.push(OracleHookEvent::AggregatorChanged(AggregatorChange {
966                id: registration.id.clone(),
967                proxy: registration.proxy,
968                old: old_aggregator,
969                new: new_aggregator,
970            }));
971        }
972
973        match value_status {
974            OracleValueStatus::Confirmed => {
975                hooks.push(OracleHookEvent::PriceConfirmed(OraclePriceConfirmed {
976                    id: registration.id.clone(),
977                    proxy: registration.proxy,
978                    aggregator: new_aggregator,
979                    label: registration.label.clone(),
980                    base: registration.base.clone(),
981                    quote: registration.quote.clone(),
982                    raw_answer: proxy_round.answer,
983                    decimals: registration.metadata.decimals,
984                    event_round_id: request.event_round.round_id,
985                    updated_at: request.event_round.updated_at,
986                    block_number: request.block_number,
987                    block_hash: request.block_hash,
988                    log_index: request.log_index,
989                    round_status: snapshot.round_status.clone(),
990                    value_status,
991                    source: OracleValueSource::Proxy,
992                    proxy_round: proxy_round.clone(),
993                }));
994            }
995            OracleValueStatus::Corrected => {
996                hooks.push(OracleHookEvent::PriceCorrected(OraclePriceCorrected {
997                    id: registration.id.clone(),
998                    proxy: registration.proxy,
999                    aggregator: new_aggregator,
1000                    label: registration.label.clone(),
1001                    base: registration.base.clone(),
1002                    quote: registration.quote.clone(),
1003                    event_answer: request.event_round.answer,
1004                    raw_answer: proxy_round.answer,
1005                    decimals: registration.metadata.decimals,
1006                    event_round_id: request.event_round.round_id,
1007                    updated_at: request.event_round.updated_at,
1008                    block_number: request.block_number,
1009                    block_hash: request.block_hash,
1010                    log_index: request.log_index,
1011                    round_status: snapshot.round_status.clone(),
1012                    value_status,
1013                    source: OracleValueSource::Proxy,
1014                    corrected_round: proxy_round.clone(),
1015                }));
1016            }
1017            OracleValueStatus::EventPending
1018            | OracleValueStatus::RequiresRepair
1019            | OracleValueStatus::Unknown => {}
1020        }
1021
1022        if matches!(snapshot.round_status, OracleRoundStatus::Stale { .. }) {
1023            hooks.push(OracleHookEvent::PriceStale(OraclePriceStale {
1024                id: registration.id.clone(),
1025                proxy: registration.proxy,
1026                aggregator: new_aggregator,
1027                label: registration.label.clone(),
1028                base: registration.base.clone(),
1029                quote: registration.quote.clone(),
1030                raw_answer: proxy_round.answer,
1031                decimals: registration.metadata.decimals,
1032                event_round_id: request.event_round.round_id,
1033                updated_at: request.event_round.updated_at,
1034                block_number: request.block_number,
1035                block_hash: request.block_hash,
1036                log_index: request.log_index,
1037                round_status: snapshot.round_status.clone(),
1038                value_status,
1039                source: OracleValueSource::Proxy,
1040                proxy_round: proxy_round.clone(),
1041            }));
1042        }
1043
1044        if let Some(stored) = self
1045            .registry
1046            .registrations_map_mut()
1047            .get_mut(&registration.id)
1048        {
1049            stored.current_aggregator = new_aggregator;
1050            stored.aggregator_layout = new_layout;
1051            if let Some(new_source) = new_source {
1052                stored.source = new_source;
1053            }
1054        }
1055        self.registry.replace_snapshot(snapshot);
1056        self.complete_pending_reconciliation(request);
1057        Ok(hooks)
1058    }
1059}
1060
1061/// Reconciles event-derived oracle state against authoritative proxy reads.
1062#[derive(Clone, Debug, Default)]
1063pub struct OracleReconciler {
1064    queue: std::collections::VecDeque<OracleReconciliationRequest>,
1065}
1066
1067impl OracleReconciler {
1068    /// Enqueue an event reconciliation request.
1069    pub fn enqueue(&mut self, request: OracleReconciliationRequest) {
1070        self.queue.push_back(request);
1071    }
1072
1073    /// Reconcile the next queued event against authoritative proxy reads.
1074    pub async fn reconcile_next<P: ChainlinkFeedProvider>(
1075        &mut self,
1076        tracker: &mut OracleTracker,
1077        provider: &P,
1078    ) -> Result<Option<OracleReconciliationResult>, OracleError> {
1079        let request = loop {
1080            let Some(front) = self.queue.front().cloned() else {
1081                return Ok(None);
1082            };
1083            if front.kind == OracleReconciliationKind::DerivedProtocolRead {
1084                // Derived requests are satisfied by protocol reads, not proxy
1085                // reads: drop this reconciler's copy and leave the tracker's
1086                // pending entry for `reconcile_derived_pending_with`.
1087                self.queue.pop_front();
1088                continue;
1089            }
1090            break front;
1091        };
1092
1093        let Some(registration) = tracker
1094            .registry
1095            .registrations_map()
1096            .get(&request.id)
1097            .cloned()
1098        else {
1099            return Err(OracleError::FeedNotFound);
1100        };
1101        if !registration.source.supports_proxy_reconciliation() {
1102            self.queue.pop_front();
1103            return Ok(Some(OracleReconciliationResult {
1104                request,
1105                hooks: Vec::new(),
1106            }));
1107        }
1108        let block_ref = request.block_ref();
1109        let reconciled = if matches!(
1110            registration.source,
1111            FeedSource::AaveSynchronicityPegToBase { .. }
1112        ) {
1113            reconcile_aave_synchronicity_peg_to_base(
1114                tracker,
1115                provider,
1116                &registration,
1117                &request,
1118                block_ref,
1119            )
1120            .await?
1121        } else {
1122            reconcile_single_proxy_source(tracker, provider, &registration, &request, block_ref)
1123                .await?
1124        };
1125        let hooks = tracker.apply_reconciled_request(
1126            &request,
1127            reconciled.proxy_round,
1128            reconciled.new_aggregator,
1129            reconciled.new_layout,
1130            reconciled.new_source,
1131            reconciled.round_is_normalized,
1132        )?;
1133        self.queue.pop_front();
1134
1135        Ok(Some(OracleReconciliationResult { request, hooks }))
1136    }
1137}
1138
1139struct ReconciledSourceRead {
1140    proxy_round: RoundData,
1141    new_aggregator: Option<Address>,
1142    new_layout: Option<AggregatorLayoutEvidence>,
1143    new_source: Option<FeedSource>,
1144    round_is_normalized: bool,
1145}
1146
1147async fn reconcile_single_proxy_source<P: ChainlinkFeedProvider>(
1148    tracker: &mut OracleTracker,
1149    provider: &P,
1150    registration: &FeedRegistration,
1151    request: &OracleReconciliationRequest,
1152    block_ref: Option<OracleBlockRef>,
1153) -> Result<ReconciledSourceRead, OracleError> {
1154    let read_proxy = registration.source.read_proxy(registration.proxy);
1155    let proxy_round = provider.latest_round_data_at(read_proxy, block_ref).await?;
1156    let new_aggregator = provider
1157        .aggregator_at(read_proxy, block_ref)
1158        .await
1159        .unwrap_or_else(|_| {
1160            tracker
1161                .current_aggregator(&request.id)
1162                .or(request.aggregator)
1163        });
1164    let new_layout = tracker
1165        .detect_aggregator_layout(provider, new_aggregator, block_ref)
1166        .await;
1167    Ok(ReconciledSourceRead {
1168        proxy_round,
1169        new_aggregator,
1170        new_layout,
1171        new_source: None,
1172        round_is_normalized: false,
1173    })
1174}
1175
1176async fn reconcile_aave_synchronicity_peg_to_base<P: ChainlinkFeedProvider>(
1177    tracker: &mut OracleTracker,
1178    provider: &P,
1179    registration: &FeedRegistration,
1180    request: &OracleReconciliationRequest,
1181    block_ref: Option<OracleBlockRef>,
1182) -> Result<ReconciledSourceRead, OracleError> {
1183    let FeedSource::AaveSynchronicityPegToBase {
1184        asset_to_peg_proxy,
1185        asset_to_peg_aggregator,
1186        peg_to_base_proxy,
1187        peg_to_base_aggregator,
1188        ..
1189    } = registration.source.clone()
1190    else {
1191        unreachable!("caller checked source family")
1192    };
1193
1194    let asset_round = provider
1195        .latest_round_data_at(asset_to_peg_proxy, block_ref)
1196        .await?;
1197    let peg_round = provider
1198        .latest_round_data_at(peg_to_base_proxy, block_ref)
1199        .await?;
1200    let new_asset_aggregator = provider
1201        .aggregator_at(asset_to_peg_proxy, block_ref)
1202        .await
1203        .unwrap_or(Some(asset_to_peg_aggregator));
1204    let new_peg_aggregator = provider
1205        .aggregator_at(peg_to_base_proxy, block_ref)
1206        .await
1207        .unwrap_or(Some(peg_to_base_aggregator));
1208    let changed_dependency_round = if new_peg_aggregator == request.aggregator {
1209        &peg_round
1210    } else {
1211        &asset_round
1212    };
1213    let derived_answer = registration
1214        .source
1215        .normalize_dependency_answers(asset_round.answer, peg_round.answer)
1216        .ok_or_else(|| {
1217            OracleError::Config(crate::error::OracleConfigError::Other(
1218                "source is not an Aave peg-to-base synchronicity source".to_string(),
1219            ))
1220        })?;
1221    let proxy_round = RoundData {
1222        round_id: changed_dependency_round.round_id,
1223        answer: derived_answer,
1224        started_at: changed_dependency_round.started_at,
1225        updated_at: changed_dependency_round.updated_at,
1226        answered_in_round: changed_dependency_round.answered_in_round,
1227    };
1228    let new_source = match (new_asset_aggregator, new_peg_aggregator) {
1229        (Some(asset_aggregator), Some(peg_aggregator)) => {
1230            registration.source.with_synchronicity_dependency_state(
1231                asset_aggregator,
1232                asset_round.answer,
1233                peg_aggregator,
1234                peg_round.answer,
1235            )
1236        }
1237        _ => None,
1238    };
1239    let new_layout = tracker
1240        .detect_aggregator_layout(provider, new_asset_aggregator, block_ref)
1241        .await;
1242
1243    Ok(ReconciledSourceRead {
1244        proxy_round,
1245        new_aggregator: new_asset_aggregator,
1246        new_layout,
1247        new_source,
1248        round_is_normalized: true,
1249    })
1250}
1251
1252async fn reconcile_current_aave_synchronicity_peg_to_base<P: ChainlinkFeedProvider>(
1253    tracker: &mut OracleTracker,
1254    provider: &P,
1255    registration: &FeedRegistration,
1256) -> Result<ReconciledSourceRead, OracleError> {
1257    let FeedSource::AaveSynchronicityPegToBase {
1258        asset_to_peg_proxy,
1259        asset_to_peg_aggregator,
1260        peg_to_base_proxy,
1261        peg_to_base_aggregator,
1262        ..
1263    } = registration.source.clone()
1264    else {
1265        unreachable!("caller checked source family")
1266    };
1267
1268    let asset_round = provider.latest_round_data(asset_to_peg_proxy).await?;
1269    let peg_round = provider.latest_round_data(peg_to_base_proxy).await?;
1270    let new_asset_aggregator = provider
1271        .aggregator(asset_to_peg_proxy)
1272        .await
1273        .unwrap_or(Some(asset_to_peg_aggregator));
1274    let new_peg_aggregator = provider
1275        .aggregator(peg_to_base_proxy)
1276        .await
1277        .unwrap_or(Some(peg_to_base_aggregator));
1278    let representative_round = if peg_round.updated_at >= asset_round.updated_at {
1279        &peg_round
1280    } else {
1281        &asset_round
1282    };
1283    let derived_answer = registration
1284        .source
1285        .normalize_dependency_answers(asset_round.answer, peg_round.answer)
1286        .ok_or_else(|| {
1287            OracleError::Config(crate::error::OracleConfigError::Other(
1288                "source is not an Aave peg-to-base synchronicity source".to_string(),
1289            ))
1290        })?;
1291    let proxy_round = RoundData {
1292        round_id: representative_round.round_id,
1293        answer: derived_answer,
1294        started_at: representative_round.started_at,
1295        updated_at: representative_round.updated_at,
1296        answered_in_round: representative_round.answered_in_round,
1297    };
1298    let new_source = match (new_asset_aggregator, new_peg_aggregator) {
1299        (Some(asset_aggregator), Some(peg_aggregator)) => {
1300            registration.source.with_synchronicity_dependency_state(
1301                asset_aggregator,
1302                asset_round.answer,
1303                peg_aggregator,
1304                peg_round.answer,
1305            )
1306        }
1307        _ => None,
1308    };
1309    let new_layout = tracker
1310        .detect_aggregator_layout(provider, new_asset_aggregator, None)
1311        .await;
1312
1313    Ok(ReconciledSourceRead {
1314        proxy_round,
1315        new_aggregator: new_asset_aggregator,
1316        new_layout,
1317        new_source,
1318        round_is_normalized: true,
1319    })
1320}
1321
1322impl OracleTracker {
1323    async fn detect_aggregator_layout<P: ChainlinkFeedProvider>(
1324        &mut self,
1325        provider: &P,
1326        aggregator: Option<Address>,
1327        block: Option<OracleBlockRef>,
1328    ) -> Option<AggregatorLayoutEvidence> {
1329        self.registry
1330            .detect_aggregator_layout(provider, aggregator, block)
1331            .await
1332    }
1333
1334    fn current_aggregator(&self, id: &FeedId) -> Option<Address> {
1335        self.registry
1336            .registrations_map()
1337            .get(id)
1338            .and_then(|registration| registration.current_aggregator)
1339    }
1340}
1341
1342fn reconciliation_value_status(
1343    round_status: &OracleRoundStatus,
1344    request: &OracleReconciliationRequest,
1345    proxy_round: &RoundData,
1346) -> OracleValueStatus {
1347    match round_status {
1348        OracleRoundStatus::Unknown => OracleValueStatus::RequiresRepair,
1349        OracleRoundStatus::Fresh
1350        | OracleRoundStatus::Stale { .. }
1351        | OracleRoundStatus::IncompleteRound
1352        | OracleRoundStatus::InvalidAnswer
1353            if proxy_round_matches_event(proxy_round, &request.event_round) =>
1354        {
1355            OracleValueStatus::Confirmed
1356        }
1357        OracleRoundStatus::Fresh
1358        | OracleRoundStatus::Stale { .. }
1359        | OracleRoundStatus::IncompleteRound
1360        | OracleRoundStatus::InvalidAnswer => OracleValueStatus::Corrected,
1361    }
1362}
1363
1364fn is_event_originated_source(source: OracleValueSource) -> bool {
1365    matches!(
1366        source,
1367        OracleValueSource::Event | OracleValueSource::Derived
1368    )
1369}
1370
1371fn proxy_round_matches_event(proxy_round: &RoundData, event_round: &RoundData) -> bool {
1372    proxy_round.round_id == event_round.round_id
1373        && proxy_round.answer == event_round.answer
1374        && proxy_round.updated_at == event_round.updated_at
1375}
1376
1377fn pyth_source_from_event_tags(source: &FeedSource, labels: &[ReportTag]) -> Option<FeedSource> {
1378    let (_pyth, price_id, current_expo, current_conf) = source.pyth_source()?;
1379    let mut expo = current_expo;
1380    let mut conf = current_conf;
1381    let mut saw_pyth_label = false;
1382
1383    for label in labels {
1384        match label.key.as_str() {
1385            "pyth_expo" => {
1386                if let Ok(value) = label.value.parse::<i32>() {
1387                    expo = value;
1388                    saw_pyth_label = true;
1389                }
1390            }
1391            "pyth_conf" => {
1392                if let Ok(value) = label.value.parse::<u64>() {
1393                    conf = value;
1394                    saw_pyth_label = true;
1395                }
1396            }
1397            _ => {}
1398        }
1399    }
1400
1401    saw_pyth_label.then(|| {
1402        source
1403            .with_pyth_event_metadata(price_id, expo, conf)
1404            .expect("pyth_source returned Some for Pyth source")
1405    })
1406}
1407
1408#[allow(dead_code)]
1409fn _ethereum_batch_report_type_is_supported(_: &ReactiveBatchReport<Ethereum>) {}