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}