Skip to main content

evm_oracle_state/
price.rs

1use alloy_primitives::{Address, I256, U256};
2use thiserror::Error;
3
4use crate::{
5    AssetId, Denomination, FeedRegistration, OracleError, OracleRoundStatus, OracleSnapshot,
6    OracleValueSource, OracleValueStatus, RoundData, TokenAmount, ValuedAmount,
7};
8
9/// Structured reason a [`PricePolicy`] check or valuation was rejected.
10///
11/// Carried by [`OracleError::Policy`]. Each variant names one distinct
12/// policy or valuation failure; the enum is `#[non_exhaustive]` because new
13/// checks may be added.
14#[non_exhaustive]
15#[derive(Clone, Debug, Error, PartialEq, Eq)]
16pub enum PricePolicyViolation {
17    /// The price does not carry a base asset symbol.
18    #[error("price base asset is unknown")]
19    MissingBase,
20    /// The price does not carry a quote denomination.
21    #[error("price quote denomination is unknown")]
22    MissingQuote,
23    /// The price's quote denomination does not match the quote required by
24    /// the policy.
25    #[error("price quote {actual} does not match required quote {required}")]
26    QuoteMismatch {
27        /// Quote denomination required by the policy.
28        required: Denomination,
29        /// Quote denomination carried by the price.
30        actual: Denomination,
31    },
32    /// The round is older than its configured max age.
33    #[error("price is stale: age {age_secs}s exceeds max age {max_age_secs}s")]
34    Stale {
35        /// Observed age in seconds.
36        age_secs: u64,
37        /// Configured max age in seconds.
38        max_age_secs: u64,
39    },
40    /// The round data is incomplete.
41    #[error("price round is incomplete")]
42    IncompleteRound,
43    /// The answer is disallowed by the feed's validity classification.
44    #[error("price answer is invalid")]
45    InvalidAnswer,
46    /// The round validity is not currently known.
47    #[error("price round status is unknown")]
48    UnknownRoundStatus,
49    /// The value's reconciliation lifecycle is not allowed by the policy
50    /// (for example event-pending without
51    /// [`PricePolicy::allow_event_pending`]).
52    #[error("{}", value_status_violation_message(.0))]
53    ValueStatusNotAllowed(OracleValueStatus),
54    /// The source that produced the value is not allowed by the policy (for
55    /// example a mock source without [`PricePolicy::allow_mock_source`]).
56    #[error("{}", source_violation_message(.0))]
57    SourceNotAllowed(OracleValueSource),
58    /// The policy requires a strictly positive market price.
59    #[error("liquidation price requires a positive market price")]
60    NonPositivePrice,
61    /// An amount cannot be valued with a negative market price.
62    #[error("cannot value an amount with a negative market price")]
63    NegativePrice,
64    /// The token amount's asset does not match the price's base asset.
65    #[error("token amount base asset {actual} does not match price base {expected}")]
66    BaseAssetMismatch {
67        /// Base asset priced by the checked price.
68        expected: AssetId,
69        /// Asset carried by the token amount.
70        actual: AssetId,
71    },
72    /// A [`ValuedAmount`] cannot be negative.
73    #[error("valued amount cannot be negative")]
74    NegativeValuedAmount,
75    /// A valuation or scaling computation left the representable range.
76    ///
77    /// The payload is a static description of the failed step (for example
78    /// `"valuation multiplication overflowed"`).
79    #[error("{0}")]
80    ValueOutOfRange(&'static str),
81}
82
83fn value_status_violation_message(status: &OracleValueStatus) -> &'static str {
84    match status {
85        OracleValueStatus::EventPending => "price is pending authoritative proxy reconciliation",
86        OracleValueStatus::RequiresRepair => "price requires authoritative repair",
87        OracleValueStatus::Unknown => "price value status is unknown",
88        _ => "price value status is not allowed",
89    }
90}
91
92fn source_violation_message(source: &OracleValueSource) -> &'static str {
93    match source {
94        OracleValueSource::Event => "event-derived price source is not allowed",
95        OracleValueSource::Mock => "mock price source is not allowed",
96        OracleValueSource::Derived => "derived price source is not allowed",
97        OracleValueSource::Unknown => "price source is unknown",
98        _ => "price source is not allowed",
99    }
100}
101
102/// Consumer-facing typed oracle price.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct OraclePrice {
105    /// Stable feed id.
106    pub id: crate::FeedId,
107    /// User-facing proxy address.
108    pub proxy: Address,
109    /// Best-known aggregator.
110    pub aggregator: Option<Address>,
111    /// Optional human-readable label.
112    pub label: Option<String>,
113    /// Optional base symbol.
114    pub base: Option<String>,
115    /// Optional quote symbol.
116    pub quote: Option<String>,
117    /// Raw signed answer.
118    pub raw_answer: I256,
119    /// Feed decimals.
120    pub decimals: u8,
121    /// Current round id.
122    pub round_id: U256,
123    /// Current `updatedAt` timestamp.
124    pub updated_at: u64,
125    /// Full current round data.
126    pub round: RoundData,
127    /// Round validity classification.
128    pub round_status: OracleRoundStatus,
129    /// Value reconciliation lifecycle.
130    pub value_status: OracleValueStatus,
131    /// Source that produced the current snapshot.
132    pub source: OracleValueSource,
133}
134
135impl OraclePrice {
136    pub(crate) fn from_snapshot(
137        snapshot: &OracleSnapshot,
138        registration: &FeedRegistration,
139    ) -> Self {
140        Self {
141            id: snapshot.id.clone(),
142            proxy: snapshot.proxy,
143            aggregator: snapshot.aggregator,
144            label: registration.label.clone(),
145            base: registration.base.clone(),
146            quote: registration.quote.clone(),
147            raw_answer: snapshot.round.answer,
148            decimals: snapshot.metadata.decimals,
149            round_id: snapshot.round.round_id,
150            updated_at: snapshot.round.updated_at,
151            round: snapshot.round.clone(),
152            round_status: snapshot.round_status.clone(),
153            value_status: snapshot.value_status,
154            source: snapshot.source,
155        }
156    }
157
158    /// Return true when the price is fresh and allowed for immediate decisions.
159    pub fn is_actionable(&self) -> bool {
160        self.round_status == OracleRoundStatus::Fresh
161            && matches!(
162                self.value_status,
163                OracleValueStatus::EventPending
164                    | OracleValueStatus::Confirmed
165                    | OracleValueStatus::Corrected
166            )
167    }
168
169    /// Return true when the price came from an event awaiting reconciliation.
170    pub fn is_event_pending(&self) -> bool {
171        self.value_status == OracleValueStatus::EventPending
172    }
173
174    /// Return true when the price was confirmed by a proxy read.
175    pub fn is_confirmed(&self) -> bool {
176        self.value_status == OracleValueStatus::Confirmed
177    }
178
179    /// Return true when the price was corrected by a proxy read.
180    pub fn is_corrected(&self) -> bool {
181        self.value_status == OracleValueStatus::Corrected
182    }
183
184    /// Return the corresponding latest round tuple.
185    pub fn round_data(&self) -> RoundData {
186        self.round.clone()
187    }
188
189    /// Scale `raw_answer` to `target_decimals`.
190    pub fn scaled_to(&self, target_decimals: u8) -> Result<I256, OracleError> {
191        scale_i256(self.raw_answer, self.decimals, target_decimals)
192    }
193
194    /// Promote this price to a checked price under the supplied policy.
195    pub fn require(&self, policy: PricePolicy) -> Result<CheckedPrice, OracleError> {
196        policy.check(self)
197    }
198}
199
200/// Policy for promoting raw oracle prices into checked prices.
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct PricePolicy {
203    kind: PricePolicyKind,
204    quote: Option<Denomination>,
205    allow_event_pending: bool,
206    allow_mock_source: bool,
207    allow_derived_source: bool,
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
211enum PricePolicyKind {
212    Liquidation,
213}
214
215impl PricePolicy {
216    /// Policy suitable for liquidation and other market-valuation decisions.
217    pub fn liquidation() -> Self {
218        Self {
219            kind: PricePolicyKind::Liquidation,
220            quote: None,
221            allow_event_pending: false,
222            allow_mock_source: false,
223            allow_derived_source: false,
224        }
225    }
226
227    /// Require a specific quote denomination.
228    pub fn quote(mut self, quote: Denomination) -> Self {
229        self.quote = Some(quote);
230        self
231    }
232
233    /// Allow fresh event-derived prices before authoritative proxy reconciliation.
234    pub fn allow_event_pending(mut self) -> Self {
235        self.allow_event_pending = true;
236        self
237    }
238
239    /// Allow caller-controlled mock prices. Intended for tests and simulations.
240    pub fn allow_mock_source(mut self) -> Self {
241        self.allow_mock_source = true;
242        self
243    }
244
245    /// Allow prices derived from dependency feeds.
246    pub fn allow_derived_source(mut self) -> Self {
247        self.allow_derived_source = true;
248        self
249    }
250
251    fn check(&self, price: &OraclePrice) -> Result<CheckedPrice, OracleError> {
252        let base = price
253            .base
254            .as_deref()
255            .map(AssetId::symbol)
256            .ok_or(OracleError::Policy(PricePolicyViolation::MissingBase))?;
257        let quote = price
258            .quote
259            .as_deref()
260            .map(Denomination::from)
261            .ok_or(OracleError::Policy(PricePolicyViolation::MissingQuote))?;
262
263        if let Some(required_quote) = &self.quote
264            && &quote != required_quote
265        {
266            return Err(OracleError::Policy(PricePolicyViolation::QuoteMismatch {
267                required: required_quote.clone(),
268                actual: quote.clone(),
269            }));
270        }
271
272        match &price.round_status {
273            OracleRoundStatus::Fresh => {}
274            OracleRoundStatus::Stale {
275                age_secs,
276                max_age_secs,
277            } => {
278                return Err(OracleError::Policy(PricePolicyViolation::Stale {
279                    age_secs: *age_secs,
280                    max_age_secs: *max_age_secs,
281                }));
282            }
283            OracleRoundStatus::IncompleteRound => {
284                return Err(OracleError::Policy(PricePolicyViolation::IncompleteRound));
285            }
286            OracleRoundStatus::InvalidAnswer => {
287                return Err(OracleError::Policy(PricePolicyViolation::InvalidAnswer));
288            }
289            OracleRoundStatus::Unknown => {
290                return Err(OracleError::Policy(
291                    PricePolicyViolation::UnknownRoundStatus,
292                ));
293            }
294        }
295
296        match price.value_status {
297            OracleValueStatus::Confirmed | OracleValueStatus::Corrected => {}
298            OracleValueStatus::EventPending if self.allow_event_pending => {}
299            OracleValueStatus::EventPending => {
300                return Err(OracleError::Policy(
301                    PricePolicyViolation::ValueStatusNotAllowed(OracleValueStatus::EventPending),
302                ));
303            }
304            OracleValueStatus::RequiresRepair => {
305                return Err(OracleError::Policy(
306                    PricePolicyViolation::ValueStatusNotAllowed(OracleValueStatus::RequiresRepair),
307                ));
308            }
309            OracleValueStatus::Unknown => {
310                return Err(OracleError::Policy(
311                    PricePolicyViolation::ValueStatusNotAllowed(OracleValueStatus::Unknown),
312                ));
313            }
314        }
315
316        match price.source {
317            OracleValueSource::Proxy => {}
318            OracleValueSource::Event if self.allow_event_pending => {}
319            OracleValueSource::Event => {
320                return Err(OracleError::Policy(PricePolicyViolation::SourceNotAllowed(
321                    OracleValueSource::Event,
322                )));
323            }
324            OracleValueSource::Mock if self.allow_mock_source => {}
325            OracleValueSource::Mock => {
326                return Err(OracleError::Policy(PricePolicyViolation::SourceNotAllowed(
327                    OracleValueSource::Mock,
328                )));
329            }
330            OracleValueSource::Derived if self.allow_derived_source => {}
331            OracleValueSource::Derived => {
332                return Err(OracleError::Policy(PricePolicyViolation::SourceNotAllowed(
333                    OracleValueSource::Derived,
334                )));
335            }
336            OracleValueSource::Unknown => {
337                return Err(OracleError::Policy(PricePolicyViolation::SourceNotAllowed(
338                    OracleValueSource::Unknown,
339                )));
340            }
341        }
342
343        match self.kind {
344            PricePolicyKind::Liquidation if price.raw_answer <= I256::ZERO => {
345                return Err(OracleError::Policy(PricePolicyViolation::NonPositivePrice));
346            }
347            PricePolicyKind::Liquidation => {}
348        }
349
350        Ok(CheckedPrice {
351            base,
352            quote,
353            raw_answer: price.raw_answer,
354            decimals: price.decimals,
355        })
356    }
357}
358
359/// Oracle price that has satisfied a caller-selected policy.
360#[derive(Clone, Debug, PartialEq, Eq)]
361pub struct CheckedPrice {
362    base: AssetId,
363    quote: Denomination,
364    raw_answer: I256,
365    decimals: u8,
366}
367
368impl CheckedPrice {
369    /// Base asset priced by this oracle.
370    pub fn base(&self) -> &AssetId {
371        &self.base
372    }
373
374    /// Quote denomination of this oracle price.
375    pub fn quote(&self) -> &Denomination {
376        &self.quote
377    }
378
379    /// Raw signed oracle answer.
380    pub fn raw_answer(&self) -> I256 {
381        self.raw_answer
382    }
383
384    /// Value a token amount in this price's quote denomination.
385    pub fn value_of(
386        &self,
387        amount: TokenAmount,
388        output_decimals: u8,
389    ) -> Result<ValuedAmount, OracleError> {
390        if amount.asset() != &self.base {
391            return Err(OracleError::Policy(
392                PricePolicyViolation::BaseAssetMismatch {
393                    expected: self.base.clone(),
394                    actual: amount.asset().clone(),
395                },
396            ));
397        }
398        if self.raw_answer.is_negative() {
399            return Err(OracleError::Policy(PricePolicyViolation::NegativePrice));
400        }
401
402        let amount_raw = I256::try_from(amount.raw()).map_err(|_| {
403            OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
404                "token amount does not fit in int256",
405            ))
406        })?;
407        let product = amount_raw
408            .checked_mul(self.raw_answer)
409            .ok_or(OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
410                "valuation multiplication overflowed",
411            )))?;
412
413        let input_decimals = u16::from(amount.decimals()) + u16::from(self.decimals);
414        let raw = if input_decimals >= u16::from(output_decimals) {
415            let scale = pow10_i256(input_decimals - u16::from(output_decimals))?;
416            product.checked_div(scale).ok_or(OracleError::Policy(
417                PricePolicyViolation::ValueOutOfRange("valuation division failed"),
418            ))?
419        } else {
420            let scale = pow10_i256(u16::from(output_decimals) - input_decimals)?;
421            product.checked_mul(scale).ok_or(OracleError::Policy(
422                PricePolicyViolation::ValueOutOfRange("valuation scale-up overflowed"),
423            ))?
424        };
425
426        ValuedAmount::checked_new(self.quote.clone(), raw, output_decimals)
427    }
428}
429
430fn scale_i256(value: I256, from_decimals: u8, to_decimals: u8) -> Result<I256, OracleError> {
431    if from_decimals == to_decimals {
432        return Ok(value);
433    }
434
435    let diff = from_decimals.abs_diff(to_decimals);
436    let factor = I256::unchecked_from(10_i64)
437        .checked_pow(U256::from(diff))
438        .ok_or(OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
439            "decimal scale factor overflowed",
440        )))?;
441
442    if to_decimals > from_decimals {
443        value
444            .checked_mul(factor)
445            .ok_or(OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
446                "scaled oracle price overflowed",
447            )))
448    } else {
449        value
450            .checked_div(factor)
451            .ok_or(OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
452                "scaled oracle price division failed",
453            )))
454    }
455}
456
457fn pow10_i256(exp: u16) -> Result<I256, OracleError> {
458    I256::unchecked_from(10_i64)
459        .checked_pow(U256::from(exp))
460        .ok_or(OracleError::Policy(PricePolicyViolation::ValueOutOfRange(
461            "decimal scale factor overflowed",
462        )))
463}