Skip to main content

evm_oracle_state/
report.rs

1//! Typed per-batch change digest for consumers.
2//!
3//! [`OracleBatchReport`] is the oracle analog of `evm-amm-state`'s
4//! `AmmSyncBatchReport`: alongside the raw per-event hooks it carries a
5//! digested per-feed change list ([`OracleFeedChange`]), typed continuity
6//! incidents ([`OracleIncident`]), and a `requires_full_refresh` flag, so the
7//! two crates express "what changed in this batch, and what should I do about
8//! it" with the same shape:
9//!
10//! | evm-amm-state                 | evm-oracle-state          |
11//! | ----------------------------- | ------------------------- |
12//! | `AmmSyncBatchReport`          | [`OracleBatchReport`]     |
13//! | `AmmSyncPoolChange`           | [`OracleFeedChange`]      |
14//! | `AmmChangeImpact`             | [`OracleChangeImpact`]    |
15//! | `AmmSyncIncident`             | [`OracleIncident`]        |
16//! | `{state, quoteability, topology}` | `{value, actionability, routing}` |
17
18use alloy_primitives::Address;
19
20use crate::{FeedId, OracleHookEvent, OracleValueStatus};
21
22/// Typed digest of one committed reactive batch.
23///
24/// Returned by [`crate::OracleRuntime::apply_batch_report`]. `events` is the
25/// raw per-event hook stream (unchanged from earlier releases); the remaining
26/// fields digest it per feed for consumers that want "what changed" without
27/// re-deriving it from individual hooks.
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct OracleBatchReport {
30    /// Raw typed hook events, in emission order.
31    pub events: Vec<OracleHookEvent>,
32    /// Digested per-feed changes, in event order.
33    pub feed_changes: Vec<OracleFeedChange>,
34    /// Continuity incidents observed while applying the batch.
35    pub incidents: Vec<OracleIncident>,
36    /// True when a routing-impact change occurred (an aggregator changed):
37    /// rebuild reactive handlers/interests (for example through
38    /// [`crate::OracleRuntime::refresh_handlers_live_only`]) before relying on
39    /// event routing again.
40    pub requires_full_refresh: bool,
41}
42
43impl OracleBatchReport {
44    /// Digest a batch's typed hook events into per-feed changes, incidents,
45    /// and the refresh flag.
46    pub fn from_events(events: Vec<OracleHookEvent>) -> Self {
47        let mut feed_changes = Vec::with_capacity(events.len());
48        let mut incidents = Vec::new();
49        let mut requires_full_refresh = false;
50        for event in &events {
51            match event {
52                OracleHookEvent::PriceUpdate(update) => {
53                    let repair_required = matches!(
54                        update.value_status,
55                        OracleValueStatus::RequiresRepair | OracleValueStatus::Unknown
56                    );
57                    if repair_required {
58                        incidents.push(OracleIncident::Reorg {
59                            feed: update.id.clone(),
60                            proxy: update.proxy,
61                        });
62                        feed_changes.push(OracleFeedChange {
63                            feed: update.id.clone(),
64                            proxy: update.proxy,
65                            kind: OracleFeedChangeKind::Degraded,
66                            value_status: Some(update.value_status),
67                            impact: OracleChangeImpact {
68                                value: false,
69                                actionability: true,
70                                routing: false,
71                            },
72                        });
73                    } else {
74                        feed_changes.push(OracleFeedChange {
75                            feed: update.id.clone(),
76                            proxy: update.proxy,
77                            kind: OracleFeedChangeKind::Updated,
78                            value_status: Some(update.value_status),
79                            impact: OracleChangeImpact {
80                                value: true,
81                                actionability: true,
82                                routing: false,
83                            },
84                        });
85                    }
86                }
87                OracleHookEvent::PriceConfirmed(confirmed) => {
88                    feed_changes.push(OracleFeedChange {
89                        feed: confirmed.id.clone(),
90                        proxy: confirmed.proxy,
91                        kind: OracleFeedChangeKind::Confirmed,
92                        value_status: Some(confirmed.value_status),
93                        impact: OracleChangeImpact {
94                            value: false,
95                            actionability: true,
96                            routing: false,
97                        },
98                    });
99                }
100                OracleHookEvent::PriceCorrected(corrected) => {
101                    feed_changes.push(OracleFeedChange {
102                        feed: corrected.id.clone(),
103                        proxy: corrected.proxy,
104                        kind: OracleFeedChangeKind::Corrected,
105                        value_status: Some(corrected.value_status),
106                        impact: OracleChangeImpact {
107                            value: true,
108                            actionability: true,
109                            routing: false,
110                        },
111                    });
112                }
113                OracleHookEvent::PriceStale(stale) => {
114                    feed_changes.push(OracleFeedChange {
115                        feed: stale.id.clone(),
116                        proxy: stale.proxy,
117                        kind: OracleFeedChangeKind::Degraded,
118                        value_status: Some(stale.value_status),
119                        impact: OracleChangeImpact {
120                            value: false,
121                            actionability: true,
122                            routing: false,
123                        },
124                    });
125                }
126                OracleHookEvent::AggregatorChanged(change) => {
127                    requires_full_refresh = true;
128                    feed_changes.push(OracleFeedChange {
129                        feed: change.id.clone(),
130                        proxy: change.proxy,
131                        kind: OracleFeedChangeKind::AggregatorChanged,
132                        value_status: None,
133                        impact: OracleChangeImpact {
134                            value: false,
135                            actionability: false,
136                            routing: true,
137                        },
138                    });
139                }
140            }
141        }
142        Self {
143            events,
144            feed_changes,
145            incidents,
146            requires_full_refresh,
147        }
148    }
149
150    /// True when the batch produced no typed oracle activity at all.
151    pub fn is_empty(&self) -> bool {
152        self.events.is_empty()
153    }
154}
155
156/// One digested per-feed change inside an [`OracleBatchReport`].
157///
158/// Shape-compatible with `evm-amm-state`'s `AmmSyncPoolChange`
159/// (`{pool, kind, impact, source}` there; `{feed, kind, value_status, impact}`
160/// here).
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct OracleFeedChange {
163    /// Stable feed id.
164    pub feed: FeedId,
165    /// Feed state key (registration proxy).
166    pub proxy: Address,
167    /// What happened to the feed in this batch.
168    pub kind: OracleFeedChangeKind,
169    /// Resulting value lifecycle, when the change carries one
170    /// ([`OracleFeedChangeKind::AggregatorChanged`] does not).
171    pub value_status: Option<OracleValueStatus>,
172    /// Downstream consequences of this change.
173    pub impact: OracleChangeImpact,
174}
175
176/// Classified per-feed change kind.
177#[non_exhaustive]
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
179pub enum OracleFeedChangeKind {
180    /// An event updated the feed's typed value (usually `EventPending`).
181    Updated,
182    /// Reconciliation confirmed the event value.
183    Confirmed,
184    /// Reconciliation replaced the event value with the authoritative one.
185    Corrected,
186    /// The feed's value degraded: stale under policy, or marked
187    /// repair-required (for example by a removed log).
188    Degraded,
189    /// The proxy now reports a different aggregator; event routing must be
190    /// rebuilt.
191    AggregatorChanged,
192}
193
194/// Downstream consequences of a feed change, mirroring `evm-amm-state`'s
195/// `AmmChangeImpact { state, quoteability, topology }`.
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
197pub struct OracleChangeImpact {
198    /// The typed value changed (analog of amm `state`).
199    pub value: bool,
200    /// Policy acceptance of the value may have changed (analog of amm
201    /// `quoteability`).
202    pub actionability: bool,
203    /// Event routing/interests must be rebuilt (analog of amm `topology`).
204    pub routing: bool,
205}
206
207/// Continuity incident observed while applying a batch.
208///
209/// Mirrors `evm-amm-state`'s `AmmSyncIncident` vocabulary. The enum is
210/// `#[non_exhaustive]`: gap/coverage-gap incidents are reserved for when the
211/// runtime can observe them.
212#[non_exhaustive]
213#[derive(Clone, Debug, PartialEq, Eq)]
214pub enum OracleIncident {
215    /// A removed (reorged-out) log rolled back event state; the feed's
216    /// snapshot is repair-required until reconciliation.
217    Reorg {
218        /// Affected feed id.
219        feed: FeedId,
220        /// Affected feed state key.
221        proxy: Address,
222    },
223}
224
225#[cfg(test)]
226mod tests {
227    use alloy_primitives::{I256, U256};
228
229    use super::*;
230    use crate::{AggregatorChange, OraclePriceUpdate, OracleRoundStatus, OracleValueSource};
231
232    fn price_update(id: &str, value_status: OracleValueStatus) -> OracleHookEvent {
233        OracleHookEvent::PriceUpdate(OraclePriceUpdate {
234            id: FeedId::new(id),
235            proxy: Address::repeat_byte(0x11),
236            aggregator: Address::repeat_byte(0x22),
237            label: None,
238            base: None,
239            quote: None,
240            raw_answer: I256::unchecked_from(42_i64),
241            decimals: 8,
242            event_round_id: U256::from(7_u64),
243            started_at: 1_700_000_000,
244            updated_at: 1_700_000_000,
245            block_number: Some(1),
246            block_hash: None,
247            log_index: Some(0),
248            round_status: OracleRoundStatus::Fresh,
249            value_status,
250            source: OracleValueSource::Event,
251        })
252    }
253
254    #[test]
255    fn digest_classifies_kinds_incidents_and_refresh() {
256        let events = vec![
257            price_update("fresh", OracleValueStatus::EventPending),
258            price_update("rolled-back", OracleValueStatus::RequiresRepair),
259            OracleHookEvent::AggregatorChanged(AggregatorChange {
260                id: FeedId::new("rotated"),
261                proxy: Address::repeat_byte(0x33),
262                old: Some(Address::repeat_byte(0x44)),
263                new: Some(Address::repeat_byte(0x55)),
264            }),
265        ];
266
267        let report = OracleBatchReport::from_events(events);
268
269        assert_eq!(report.events.len(), 3);
270        assert_eq!(report.feed_changes.len(), 3);
271        assert_eq!(report.feed_changes[0].kind, OracleFeedChangeKind::Updated);
272        assert!(report.feed_changes[0].impact.value);
273        assert!(!report.feed_changes[0].impact.routing);
274        assert_eq!(report.feed_changes[1].kind, OracleFeedChangeKind::Degraded);
275        assert!(!report.feed_changes[1].impact.value);
276        assert_eq!(
277            report.feed_changes[2].kind,
278            OracleFeedChangeKind::AggregatorChanged
279        );
280        assert!(report.feed_changes[2].impact.routing);
281        assert_eq!(report.feed_changes[2].value_status, None);
282        assert_eq!(report.incidents.len(), 1);
283        assert!(matches!(
284            &report.incidents[0],
285            OracleIncident::Reorg { feed, .. } if feed.as_str() == "rolled-back"
286        ));
287        assert!(
288            report.requires_full_refresh,
289            "aggregator changes require handler refresh"
290        );
291        assert!(!report.is_empty());
292        assert!(OracleBatchReport::from_events(Vec::new()).is_empty());
293    }
294}