Skip to main content

evm_oracle_state/
state.rs

1use alloy_primitives::{Address, B256, I256, U256};
2
3use crate::{FeedId, StalenessPolicy};
4
5/// Static metadata read from a Chainlink-compatible proxy.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct FeedMetadata {
8    /// Number of decimals used by the answer.
9    pub decimals: u8,
10    /// Human-readable feed description.
11    pub description: String,
12    /// Feed contract version.
13    pub version: U256,
14}
15
16/// Round tuple returned by `latestRoundData()`.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct RoundData {
19    /// Proxy or aggregator round id, depending on the source.
20    pub round_id: U256,
21    /// Signed answer.
22    pub answer: I256,
23    /// `startedAt` timestamp.
24    pub started_at: u64,
25    /// `updatedAt` timestamp.
26    pub updated_at: u64,
27    /// `answeredInRound`.
28    pub answered_in_round: U256,
29}
30
31/// Lifecycle readiness of a registered oracle feed.
32///
33/// This is a feed/runtime readiness signal, not a price freshness signal. A
34/// feed can be [`Ready`](Self::Ready) while its current round is
35/// [`Stale`](OracleRoundStatus::Stale).
36#[non_exhaustive]
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
38pub enum OracleFeedStatus {
39    /// Registration was requested but has not been initialized.
40    #[default]
41    Pending,
42    /// Metadata or registration exists, but usable state/warmup is incomplete.
43    Cold,
44    /// Feed is structurally usable.
45    Ready,
46    /// Feed remains registered, but a repair, reconciliation, source, layout, or warmup path failed.
47    Degraded,
48    /// Feed was explicitly disabled by the caller/runtime.
49    Disabled,
50    /// Feed source/layout/protocol is not supported.
51    Unsupported,
52}
53
54/// Classified usability of an oracle round under the feed policy.
55#[non_exhaustive]
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum OracleRoundStatus {
58    /// Round is usable under its configured policy.
59    Fresh,
60    /// Round is older than the configured max age.
61    Stale {
62        /// Observed age in seconds.
63        age_secs: u64,
64        /// Configured max age in seconds.
65        max_age_secs: u64,
66    },
67    /// Round data is incomplete.
68    IncompleteRound,
69    /// Answer is disallowed by policy.
70    InvalidAnswer,
71    /// Round validity is not currently known.
72    Unknown,
73}
74
75/// Reconciliation lifecycle for an oracle value.
76#[non_exhaustive]
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum OracleValueStatus {
79    /// Snapshot was derived from an event and still needs proxy reconciliation.
80    EventPending,
81    /// Value came from the authoritative source path without needing event correction.
82    ///
83    /// This does not imply the round is fresh; inspect [`OracleRoundStatus`].
84    Confirmed,
85    /// Event-derived value was superseded by an authoritative source read.
86    ///
87    /// This does not imply the corrected round is fresh; inspect [`OracleRoundStatus`].
88    Corrected,
89    /// Value/cache path is not trusted as final and needs authoritative repair.
90    ///
91    /// Payloads may still carry a best-available diagnostic value, but
92    /// [`crate::PricePolicy`] rejects this status by default.
93    RequiresRepair,
94    /// Value lifecycle is not currently known.
95    Unknown,
96}
97
98/// Source that produced an oracle value.
99#[non_exhaustive]
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum OracleValueSource {
102    /// Value came from the authoritative proxy/source path.
103    Proxy,
104    /// Value came from an oracle event and must be reconciled before final trust.
105    Event,
106    /// Value was derived from one or more dependency feeds.
107    Derived,
108    /// Value came from a caller-controlled mock override.
109    Mock,
110    /// Value source is not currently trusted or known.
111    Unknown,
112}
113
114/// Typed current oracle state for one registered proxy feed.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct OracleSnapshot {
117    /// Registered feed id.
118    pub id: FeedId,
119    /// User-facing proxy address.
120    pub proxy: Address,
121    /// Best-known current aggregator address.
122    pub aggregator: Option<Address>,
123    /// Feed metadata read from the proxy.
124    pub metadata: FeedMetadata,
125    /// Current round data.
126    pub round: RoundData,
127    /// Current round validity status.
128    pub round_status: OracleRoundStatus,
129    /// Current value reconciliation lifecycle.
130    pub value_status: OracleValueStatus,
131    /// State source.
132    pub source: OracleValueSource,
133    /// Block number associated with the observation, when known.
134    pub block_number: Option<u64>,
135    /// Block hash associated with the observation, when known.
136    pub block_hash: Option<B256>,
137    requires_reconciliation: bool,
138}
139
140impl OracleSnapshot {
141    pub(crate) fn proxy_read(
142        id: FeedId,
143        proxy: Address,
144        aggregator: Option<Address>,
145        metadata: FeedMetadata,
146        round: RoundData,
147        now_timestamp: u64,
148        staleness: &StalenessPolicy,
149    ) -> Self {
150        let round_status = classify_round(&round, now_timestamp, staleness);
151        Self {
152            id,
153            proxy,
154            aggregator,
155            metadata,
156            round,
157            round_status,
158            value_status: OracleValueStatus::Confirmed,
159            source: OracleValueSource::Proxy,
160            block_number: None,
161            block_hash: None,
162            requires_reconciliation: false,
163        }
164    }
165
166    pub(crate) fn event(input: EventSnapshotInput<'_>) -> Self {
167        let round_status = classify_round(&input.round, input.now_timestamp, input.staleness);
168        Self {
169            id: input.id,
170            proxy: input.proxy,
171            aggregator: input.aggregator,
172            metadata: input.metadata,
173            round: input.round,
174            round_status,
175            value_status: input.value_status,
176            source: input.source,
177            block_number: input.block_number,
178            block_hash: input.block_hash,
179            requires_reconciliation: matches!(
180                input.value_status,
181                OracleValueStatus::EventPending
182                    | OracleValueStatus::RequiresRepair
183                    | OracleValueStatus::Unknown
184            ),
185        }
186    }
187
188    pub(crate) fn mark_unknown(&mut self) {
189        self.round_status = OracleRoundStatus::Unknown;
190        self.value_status = OracleValueStatus::RequiresRepair;
191        self.source = OracleValueSource::Unknown;
192        self.requires_reconciliation = true;
193    }
194
195    pub(crate) fn set_value_status(&mut self, value_status: OracleValueStatus) {
196        self.value_status = value_status;
197        self.requires_reconciliation = matches!(
198            value_status,
199            OracleValueStatus::EventPending
200                | OracleValueStatus::RequiresRepair
201                | OracleValueStatus::Unknown
202        );
203    }
204
205    /// Return true when callers should repair this snapshot via proxy read.
206    pub fn requires_reconciliation(&self) -> bool {
207        self.requires_reconciliation
208    }
209}
210
211pub(crate) struct EventSnapshotInput<'a> {
212    pub(crate) id: FeedId,
213    pub(crate) proxy: Address,
214    pub(crate) aggregator: Option<Address>,
215    pub(crate) metadata: FeedMetadata,
216    pub(crate) round: RoundData,
217    pub(crate) now_timestamp: u64,
218    pub(crate) staleness: &'a StalenessPolicy,
219    pub(crate) block_number: Option<u64>,
220    pub(crate) block_hash: Option<B256>,
221    pub(crate) value_status: OracleValueStatus,
222    pub(crate) source: OracleValueSource,
223}
224
225pub(crate) fn classify_round(
226    round: &RoundData,
227    now_timestamp: u64,
228    staleness: &StalenessPolicy,
229) -> OracleRoundStatus {
230    if round.updated_at == 0 || round.answered_in_round < round.round_id {
231        return OracleRoundStatus::IncompleteRound;
232    }
233
234    if !staleness.allows_zero_or_negative_answer() && round.answer <= I256::ZERO {
235        return OracleRoundStatus::InvalidAnswer;
236    }
237
238    if let Some(max_age_secs) = staleness.max_age_secs() {
239        let age_secs = now_timestamp.saturating_sub(round.updated_at);
240        if age_secs > max_age_secs {
241            return OracleRoundStatus::Stale {
242                age_secs,
243                max_age_secs,
244            };
245        }
246    }
247
248    OracleRoundStatus::Fresh
249}