Skip to main content

evm_oracle_state/
euler.rs

1use std::{
2    sync::Arc,
3    time::{SystemTime, UNIX_EPOCH},
4};
5
6use alloy_network::Ethereum;
7use alloy_primitives::{Address, I256, U256, keccak256};
8use alloy_sol_types::sol;
9use evm_fork_cache::{cache::EvmCache, reactive::ReactiveHandler};
10
11use crate::{
12    AdapterFuture, AssetId, Denomination, EulerQuoteLeg, EvmCacheChainlinkReader, FeedId,
13    FeedMetadata, FeedRegistration, FeedSource, OracleAdapterFeedSkip, OracleAdapterId,
14    OracleAdapterPlugin, OracleAdapterSkipReason, OracleDiscoveredFeed, OracleDiscoveryContext,
15    OracleDiscoveryReport, OracleError, OracleFeedStatus, OracleReactiveHandler, OracleStorageSync,
16    RoundData, StalenessPolicy,
17};
18
19sol! {
20    interface EulerRouterInterface {
21        function getConfiguredOracle(address base, address quote) external view returns (address);
22        function fallbackOracle() external view returns (address);
23    }
24
25    interface EulerPriceOracleInterface {
26        function name() external view returns (string);
27        function getQuote(uint256 inAmount, address base, address quote) external view returns (uint256 outAmount);
28    }
29
30    interface EulerChainlinkOracleInterface {
31        function base() external view returns (address);
32        function quote() external view returns (address);
33        function feed() external view returns (address);
34        function maxStaleness() external view returns (uint256);
35    }
36
37    interface EulerFixedRateOracleInterface {
38        function base() external view returns (address);
39        function quote() external view returns (address);
40        function rate() external view returns (uint256);
41    }
42
43    interface EulerRateProviderOracleInterface {
44        function base() external view returns (address);
45        function quote() external view returns (address);
46        function rateProvider() external view returns (address);
47    }
48
49    interface EulerCrossAdapterInterface {
50        function base() external view returns (address);
51        function cross() external view returns (address);
52        function quote() external view returns (address);
53        function oracleBaseCross() external view returns (address);
54        function oracleCrossQuote() external view returns (address);
55    }
56
57    interface RateProviderInterface {
58        function getRate() external view returns (uint256);
59    }
60
61    interface Erc20Metadata {
62        function decimals() external view returns (uint8);
63    }
64}
65
66use Erc20Metadata::decimalsCall;
67use EulerChainlinkOracleInterface::{
68    baseCall as chainlinkBaseCall, feedCall, maxStalenessCall, quoteCall as chainlinkQuoteCall,
69};
70use EulerCrossAdapterInterface::{
71    baseCall as crossBaseCall, crossCall, oracleBaseCrossCall, oracleCrossQuoteCall,
72    quoteCall as crossQuoteCall,
73};
74use EulerFixedRateOracleInterface::{
75    baseCall as fixedBaseCall, quoteCall as fixedQuoteCall, rateCall,
76};
77use EulerPriceOracleInterface::{getQuoteCall, nameCall};
78use EulerRateProviderOracleInterface::{
79    baseCall as rateBaseCall, quoteCall as rateQuoteCall, rateProviderCall,
80};
81use EulerRouterInterface::{fallbackOracleCall, getConfiguredOracleCall};
82use RateProviderInterface::getRateCall;
83
84const ADAPTER_ID: &str = "evm-oracle-state.euler";
85
86/// Declarative Euler quote-pair registration.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct EulerQuotePair {
89    base: Address,
90    quote: Address,
91    oracle: Option<Address>,
92    state_key: Option<Address>,
93    id: Option<FeedId>,
94    label: Option<String>,
95    base_label: Option<AssetId>,
96    quote_label: Option<Denomination>,
97    staleness: StalenessPolicy,
98}
99
100impl EulerQuotePair {
101    /// Register a base/quote pair to be resolved through an adapter-level router.
102    pub fn new(base: Address, quote: Address) -> Self {
103        Self {
104            base,
105            quote,
106            oracle: None,
107            state_key: None,
108            id: None,
109            label: None,
110            base_label: None,
111            quote_label: None,
112            staleness: StalenessPolicy::default(),
113        }
114    }
115
116    /// Register a base/quote pair with a direct Euler oracle adapter.
117    pub fn with_oracle(base: Address, quote: Address, oracle: Address) -> Self {
118        Self {
119            oracle: Some(oracle),
120            ..Self::new(base, quote)
121        }
122    }
123
124    /// Set a stable feed id.
125    pub fn id(mut self, id: impl Into<String>) -> Self {
126        self.id = Some(FeedId::new(id));
127        self
128    }
129
130    /// Set a stable feed id.
131    pub fn feed_id(mut self, id: FeedId) -> Self {
132        self.id = Some(id);
133        self
134    }
135
136    /// Set a human-readable label.
137    pub fn label(mut self, label: impl Into<String>) -> Self {
138        self.label = Some(label.into());
139        self
140    }
141
142    /// Set the base asset label.
143    pub fn base_label(mut self, base: AssetId) -> Self {
144        self.base_label = Some(base);
145        self
146    }
147
148    /// Set the quote denomination label.
149    pub fn quote_label(mut self, quote: Denomination) -> Self {
150        self.quote_label = Some(quote);
151        self
152    }
153
154    /// Set a max-age staleness policy.
155    pub fn max_age_secs(mut self, max_age_secs: u64) -> Self {
156        self.staleness = StalenessPolicy::max_age(max_age_secs);
157        self
158    }
159
160    /// Set the full staleness policy.
161    pub fn staleness(mut self, staleness: StalenessPolicy) -> Self {
162        self.staleness = staleness;
163        self
164    }
165
166    /// Set an explicit per-feed state key.
167    ///
168    /// By default each discovered pair derives a synthetic state key with
169    /// [`EulerQuotePair::state_key_for`], so multiple pairs resolved through
170    /// one router (or one shared adapter) never collide in the registry.
171    pub fn state_key(mut self, state_key: Address) -> Self {
172        self.state_key = Some(state_key);
173        self
174    }
175
176    /// Deterministically derive a synthetic per-feed state key.
177    ///
178    /// `quote_target` is the user-facing contract the quote is read through:
179    /// the router for router-resolved pairs, or the direct oracle adapter.
180    /// The derived key is the registry/tracker state key (the registration
181    /// `proxy`); the real on-chain addresses stay in [`crate::FeedSource`],
182    /// and [`crate::OracleReadOverlay`] serves `getQuote` at the real
183    /// `quote_target` address.
184    pub fn state_key_for(quote_target: Address, base: Address, quote: Address) -> Address {
185        let mut bytes = Vec::with_capacity(ADAPTER_ID.len() + 20 + 20 + 20);
186        bytes.extend_from_slice(ADAPTER_ID.as_bytes());
187        bytes.extend_from_slice(quote_target.as_slice());
188        bytes.extend_from_slice(base.as_slice());
189        bytes.extend_from_slice(quote.as_slice());
190        let hash = keccak256(bytes);
191        Address::from_slice(&hash.as_slice()[12..])
192    }
193}
194
195/// Cache-backed Euler price oracle discovery adapter.
196#[derive(Clone, Debug, Default)]
197pub struct EulerOracleAdapter {
198    router: Option<Address>,
199    pairs: Vec<EulerQuotePair>,
200    now_timestamp: Option<u64>,
201}
202
203impl EulerOracleAdapter {
204    /// Create an adapter with no default router.
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Create an adapter that resolves quote pairs through `EulerRouter`.
210    pub fn router(router: Address) -> Self {
211        Self {
212            router: Some(router),
213            pairs: Vec::new(),
214            now_timestamp: None,
215        }
216    }
217
218    /// Create an adapter for one direct oracle quote pair.
219    pub fn oracle(oracle: Address, base: Address, quote: Address) -> Self {
220        Self {
221            router: None,
222            pairs: vec![EulerQuotePair::with_oracle(base, quote, oracle)],
223            now_timestamp: None,
224        }
225    }
226
227    /// Add one quote pair.
228    pub fn quote_pair(mut self, pair: EulerQuotePair) -> Self {
229        self.pairs.push(pair);
230        self
231    }
232
233    /// Add multiple quote pairs.
234    pub fn quote_pairs(mut self, pairs: impl IntoIterator<Item = EulerQuotePair>) -> Self {
235        self.pairs.extend(pairs);
236        self
237    }
238
239    /// Set a fixed timestamp for deterministic registration status classification.
240    pub fn now_timestamp(mut self, now_timestamp: u64) -> Self {
241        self.now_timestamp = Some(now_timestamp);
242        self
243    }
244
245    fn timestamp(&self, fallback: Option<u64>) -> Result<u64, OracleError> {
246        if let Some(now_timestamp) = self.now_timestamp.or(fallback) {
247            return Ok(now_timestamp);
248        }
249        Ok(SystemTime::now()
250            .duration_since(UNIX_EPOCH)
251            .map_err(crate::error::clock_error)?
252            .as_secs())
253    }
254
255    fn discover_pairs(
256        &self,
257        cache: &mut EvmCache,
258        now_timestamp: u64,
259    ) -> Result<OracleDiscoveryReport, OracleError> {
260        let mut report = OracleDiscoveryReport::new();
261        for pair in &self.pairs {
262            match self.discover_pair(cache, pair, now_timestamp) {
263                Ok(feed) => report = report.with_feed(feed),
264                Err(error) => {
265                    // Report the user-facing quote target (direct oracle or
266                    // router) so router-pair skips carry a real address.
267                    let skip_target = pair.oracle.or(self.router).unwrap_or_default();
268                    report = report.with_skip(OracleAdapterFeedSkip {
269                        feed: crate::Feed::proxy(skip_target).build(),
270                        proxy: skip_target,
271                        reason: OracleAdapterSkipReason::UnsupportedEulerSource {
272                            error: error.to_string(),
273                        },
274                    });
275                }
276            }
277        }
278        Ok(report)
279    }
280
281    fn discover_pair(
282        &self,
283        cache: &mut EvmCache,
284        pair: &EulerQuotePair,
285        now_timestamp: u64,
286    ) -> Result<OracleDiscoveredFeed, OracleError> {
287        let oracle = match pair.oracle {
288            Some(oracle) => oracle,
289            None => self.resolve_router_oracle(cache, pair.base, pair.quote)?,
290        };
291        // The user-facing contract quotes are read through: the direct oracle
292        // adapter when configured, otherwise the router. Every pair gets its
293        // own synthetic registry state key so multiple pairs resolved through
294        // one router (or one shared adapter serving both directions) never
295        // collide on the registry's unique-proxy invariant.
296        let quote_target = pair.oracle.or(self.router).unwrap_or(oracle);
297        let proxy = pair
298            .state_key
299            .unwrap_or_else(|| EulerQuotePair::state_key_for(quote_target, pair.base, pair.quote));
300        let base_decimals = read_asset_decimals(cache, pair.base)?;
301        let quote_decimals = read_asset_decimals(cache, pair.quote)?;
302        let source = self.read_source(
303            cache,
304            oracle,
305            pair.base,
306            pair.quote,
307            base_decimals,
308            quote_decimals,
309            0,
310        )?;
311        // Rewrite the discovered source's user-facing address to the quote
312        // target: for router-resolved pairs the router is the address callers
313        // (and the read overlay) quote through, while the legs keep the
314        // resolved adapter addresses for event routing.
315        let source = source.with_euler_user_source(quote_target);
316        // Seed the cold-start value from an authoritative on-chain
317        // `getQuote(one base unit, base, quote)` read through the quote
318        // target, so the seeded snapshot honestly carries
319        // `Confirmed`/`Proxy` semantics. Euler adapters enforce their own
320        // `maxStaleness` inside `getQuote`, so a successful read implies the
321        // oracle currently considers its dependencies fresh; a revert (stale,
322        // unsupported, or misconfigured pair) skips the feed with the revert
323        // reason instead of seeding a recomputed value.
324        let in_amount = one_base_unit(base_decimals)?;
325        let quote_out = cache
326            .call_sol(
327                quote_target,
328                getQuoteCall {
329                    inAmount: in_amount,
330                    base: pair.base,
331                    quote: pair.quote,
332                },
333            )
334            .map_err(|error| {
335                OracleError::Provider(format!(
336                    "Euler getQuote({in_amount}, {:?}, {:?}) via {quote_target:?} failed: {error}",
337                    pair.base, pair.quote
338                ))
339            })?;
340        let answer = i256_from_u256(quote_out, "Euler getQuote outAmount")?;
341        let id = pair.id.clone().unwrap_or_else(|| {
342            FeedId::new(format!(
343                "euler-{quote_target:?}-{:?}-{:?}",
344                pair.base, pair.quote
345            ))
346        });
347        let label = pair
348            .label
349            .clone()
350            .or_else(|| Some(format!("Euler {:?}/{:?}", pair.base, pair.quote)));
351        let metadata = FeedMetadata {
352            decimals: quote_decimals,
353            description: label.clone().unwrap_or_else(|| "Euler quote".to_string()),
354            version: U256::ZERO,
355        };
356        let registration = FeedRegistration {
357            id,
358            proxy,
359            label,
360            base: pair
361                .base_label
362                .clone()
363                .map(String::from)
364                .or_else(|| Some(format!("{:?}", pair.base))),
365            quote: pair
366                .quote_label
367                .clone()
368                .map(String::from)
369                .or_else(|| Some(format!("{:?}", pair.quote))),
370            staleness: pair.staleness,
371            current_aggregator: source.event_aggregators(None).first().copied(),
372            aggregator_layout: None,
373            metadata,
374            source,
375            status: OracleFeedStatus::Ready,
376        };
377        let round = RoundData {
378            round_id: U256::ZERO,
379            answer,
380            started_at: now_timestamp,
381            updated_at: now_timestamp,
382            answered_in_round: U256::ZERO,
383        };
384        Ok(OracleDiscoveredFeed::new(registration, round))
385    }
386
387    fn resolve_router_oracle(
388        &self,
389        cache: &mut EvmCache,
390        base: Address,
391        quote: Address,
392    ) -> Result<Address, OracleError> {
393        let router = self.router.ok_or_else(|| {
394            OracleError::Config(crate::error::OracleConfigError::Other(
395                "Euler router or direct oracle is required".to_string(),
396            ))
397        })?;
398        let configured = cache
399            .call_sol(router, getConfiguredOracleCall { base, quote })
400            .map_err(provider_error)?;
401        if configured != Address::ZERO {
402            return Ok(configured);
403        }
404        let fallback = cache
405            .call_sol(router, fallbackOracleCall {})
406            .map_err(provider_error)?;
407        if fallback != Address::ZERO {
408            return Ok(fallback);
409        }
410        Err(OracleError::Unsupported(
411            "Euler router has no configured or fallback oracle for pair".to_string(),
412        ))
413    }
414
415    #[allow(clippy::too_many_arguments)]
416    fn read_source(
417        &self,
418        cache: &mut EvmCache,
419        oracle: Address,
420        requested_base: Address,
421        requested_quote: Address,
422        requested_base_decimals: u8,
423        requested_quote_decimals: u8,
424        depth: usize,
425    ) -> Result<FeedSource, OracleError> {
426        if depth > 4 {
427            return Err(OracleError::Unsupported(
428                "Euler oracle nesting depth exceeded".to_string(),
429            ));
430        }
431        let name = cache
432            .call_sol(oracle, nameCall {})
433            .map_err(provider_error)?;
434        match name.as_str() {
435            "ChainlinkOracle" => {
436                let leg = self.read_chainlink_leg(
437                    cache,
438                    oracle,
439                    requested_base,
440                    requested_quote,
441                    requested_quote_decimals,
442                )?;
443                Ok(FeedSource::euler_quote(
444                    oracle,
445                    requested_base,
446                    requested_quote,
447                    requested_base_decimals,
448                    requested_quote_decimals,
449                    leg,
450                ))
451            }
452            "FixedRateOracle" => {
453                let leg = self.read_fixed_rate_leg(
454                    cache,
455                    oracle,
456                    requested_base,
457                    requested_quote,
458                    requested_base_decimals,
459                    requested_quote_decimals,
460                )?;
461                Ok(FeedSource::euler_quote(
462                    oracle,
463                    requested_base,
464                    requested_quote,
465                    requested_base_decimals,
466                    requested_quote_decimals,
467                    leg,
468                ))
469            }
470            "RateProviderOracle" => {
471                let leg = self.read_rate_provider_leg(
472                    cache,
473                    oracle,
474                    requested_base,
475                    requested_quote,
476                    requested_quote_decimals,
477                )?;
478                Ok(FeedSource::euler_quote(
479                    oracle,
480                    requested_base,
481                    requested_quote,
482                    requested_base_decimals,
483                    requested_quote_decimals,
484                    leg,
485                ))
486            }
487            "CrossAdapter" => self.read_cross_source(
488                cache,
489                oracle,
490                requested_base,
491                requested_quote,
492                requested_base_decimals,
493                requested_quote_decimals,
494                depth,
495            ),
496            other => Err(OracleError::Unsupported(format!(
497                "unsupported Euler oracle adapter {other}"
498            ))),
499        }
500    }
501
502    #[allow(clippy::too_many_arguments)]
503    fn read_cross_source(
504        &self,
505        cache: &mut EvmCache,
506        oracle: Address,
507        requested_base: Address,
508        requested_quote: Address,
509        requested_base_decimals: u8,
510        requested_quote_decimals: u8,
511        depth: usize,
512    ) -> Result<FeedSource, OracleError> {
513        let configured_base = cache
514            .call_sol(oracle, crossBaseCall {})
515            .map_err(provider_error)?;
516        let cross = cache
517            .call_sol(oracle, crossCall {})
518            .map_err(provider_error)?;
519        let configured_quote = cache
520            .call_sol(oracle, crossQuoteCall {})
521            .map_err(provider_error)?;
522        let inverse = direction_or_revert(
523            requested_base,
524            configured_base,
525            requested_quote,
526            configured_quote,
527        )?;
528        let oracle_base_cross = cache
529            .call_sol(oracle, oracleBaseCrossCall {})
530            .map_err(provider_error)?;
531        let oracle_cross_quote = cache
532            .call_sol(oracle, oracleCrossQuoteCall {})
533            .map_err(provider_error)?;
534        let cross_decimals = read_asset_decimals(cache, cross)?;
535        let (first_oracle, second_oracle) = if inverse {
536            (oracle_cross_quote, oracle_base_cross)
537        } else {
538            (oracle_base_cross, oracle_cross_quote)
539        };
540        let base_cross = self.read_leg(
541            cache,
542            first_oracle,
543            requested_base,
544            cross,
545            requested_base_decimals,
546            cross_decimals,
547            depth + 1,
548        )?;
549        let cross_quote = self.read_leg(
550            cache,
551            second_oracle,
552            cross,
553            requested_quote,
554            cross_decimals,
555            requested_quote_decimals,
556            depth + 1,
557        )?;
558
559        Ok(FeedSource::euler_cross(
560            oracle,
561            requested_base,
562            cross,
563            requested_quote,
564            requested_base_decimals,
565            cross_decimals,
566            requested_quote_decimals,
567            base_cross,
568            cross_quote,
569        ))
570    }
571
572    #[allow(clippy::too_many_arguments)]
573    fn read_leg(
574        &self,
575        cache: &mut EvmCache,
576        oracle: Address,
577        requested_base: Address,
578        requested_quote: Address,
579        requested_base_decimals: u8,
580        requested_quote_decimals: u8,
581        depth: usize,
582    ) -> Result<EulerQuoteLeg, OracleError> {
583        let source = self.read_source(
584            cache,
585            oracle,
586            requested_base,
587            requested_quote,
588            requested_base_decimals,
589            requested_quote_decimals,
590            depth,
591        )?;
592        match source {
593            FeedSource::EulerQuote { leg, .. } => Ok(leg),
594            FeedSource::EulerCross { .. } => Err(OracleError::Unsupported(
595                "nested Euler CrossAdapter legs are not supported in this MVP".to_string(),
596            )),
597            _ => Err(OracleError::Unsupported(
598                "Euler child source did not produce a quote leg".to_string(),
599            )),
600        }
601    }
602
603    fn read_chainlink_leg(
604        &self,
605        cache: &mut EvmCache,
606        oracle: Address,
607        requested_base: Address,
608        requested_quote: Address,
609        requested_quote_decimals: u8,
610    ) -> Result<EulerQuoteLeg, OracleError> {
611        let configured_base = cache
612            .call_sol(oracle, chainlinkBaseCall {})
613            .map_err(provider_error)?;
614        let configured_quote = cache
615            .call_sol(oracle, chainlinkQuoteCall {})
616            .map_err(provider_error)?;
617        let inverse = direction_or_revert(
618            requested_base,
619            configured_base,
620            requested_quote,
621            configured_quote,
622        )?;
623        let feed = cache
624            .call_sol(oracle, feedCall {})
625            .map_err(provider_error)?;
626        let _max_staleness = cache
627            .call_sol(oracle, maxStalenessCall {})
628            .map_err(provider_error)?;
629        let reader = EvmCacheChainlinkReader::new(cache);
630        let feed_decimals = reader.read_decimals(feed)?;
631        let round = reader.read_latest_round_data(feed)?;
632        let aggregator = reader.read_aggregator(feed)?.ok_or_else(|| {
633            OracleError::Unsupported(
634                "Euler ChainlinkOracle feed aggregator() returned none".to_string(),
635            )
636        })?;
637        Ok(EulerQuoteLeg::Chainlink {
638            oracle,
639            feed,
640            aggregator,
641            answer: round.answer,
642            feed_decimals,
643            inverse,
644            quote_decimals: requested_quote_decimals,
645        })
646    }
647
648    fn read_fixed_rate_leg(
649        &self,
650        cache: &mut EvmCache,
651        oracle: Address,
652        requested_base: Address,
653        requested_quote: Address,
654        requested_base_decimals: u8,
655        requested_quote_decimals: u8,
656    ) -> Result<EulerQuoteLeg, OracleError> {
657        let configured_base = cache
658            .call_sol(oracle, fixedBaseCall {})
659            .map_err(provider_error)?;
660        let configured_quote = cache
661            .call_sol(oracle, fixedQuoteCall {})
662            .map_err(provider_error)?;
663        let inverse = direction_or_revert(
664            requested_base,
665            configured_base,
666            requested_quote,
667            configured_quote,
668        )?;
669        let rate = cache
670            .call_sol(oracle, rateCall {})
671            .map_err(provider_error)?;
672        Ok(EulerQuoteLeg::FixedRate {
673            oracle,
674            rate: i256_from_u256(rate, "rate")?,
675            inverse,
676            base_decimals: requested_base_decimals,
677            quote_decimals: requested_quote_decimals,
678        })
679    }
680
681    fn read_rate_provider_leg(
682        &self,
683        cache: &mut EvmCache,
684        oracle: Address,
685        requested_base: Address,
686        requested_quote: Address,
687        requested_quote_decimals: u8,
688    ) -> Result<EulerQuoteLeg, OracleError> {
689        let configured_base = cache
690            .call_sol(oracle, rateBaseCall {})
691            .map_err(provider_error)?;
692        let configured_quote = cache
693            .call_sol(oracle, rateQuoteCall {})
694            .map_err(provider_error)?;
695        let inverse = direction_or_revert(
696            requested_base,
697            configured_base,
698            requested_quote,
699            configured_quote,
700        )?;
701        let rate_provider = cache
702            .call_sol(oracle, rateProviderCall {})
703            .map_err(provider_error)?;
704        let rate = cache
705            .call_sol(rate_provider, getRateCall {})
706            .map_err(provider_error)?;
707        Ok(EulerQuoteLeg::RateProvider {
708            oracle,
709            rate_provider,
710            rate: i256_from_u256(rate, "rate")?,
711            inverse,
712            quote_decimals: requested_quote_decimals,
713        })
714    }
715}
716
717impl OracleAdapterPlugin for EulerOracleAdapter {
718    fn adapter_id(&self) -> OracleAdapterId {
719        OracleAdapterId::new(ADAPTER_ID)
720    }
721
722    fn discover<'a>(
723        &'a self,
724        ctx: OracleDiscoveryContext<'a>,
725    ) -> AdapterFuture<'a, OracleDiscoveryReport> {
726        Box::pin(async move {
727            let now_timestamp = self.timestamp(Some(ctx.now_timestamp))?;
728            self.discover_pairs(ctx.cache, now_timestamp)
729        })
730    }
731
732    fn reactive_handler(
733        &self,
734        registrations: Vec<FeedRegistration>,
735        storage_sync: OracleStorageSync,
736    ) -> Arc<dyn ReactiveHandler<Ethereum>> {
737        Arc::new(OracleReactiveHandler::with_storage_sync(
738            registrations,
739            storage_sync,
740        ))
741    }
742}
743
744fn direction_or_revert(
745    requested_base: Address,
746    configured_base: Address,
747    requested_quote: Address,
748    configured_quote: Address,
749) -> Result<bool, OracleError> {
750    if requested_base == configured_base && requested_quote == configured_quote {
751        Ok(false)
752    } else if requested_base == configured_quote && requested_quote == configured_base {
753        Ok(true)
754    } else {
755        Err(OracleError::Unsupported(
756            "Euler oracle does not support requested base/quote pair".to_string(),
757        ))
758    }
759}
760
761fn read_asset_decimals(cache: &mut EvmCache, asset: Address) -> Result<u8, OracleError> {
762    if is_reserved_asset_address(asset) {
763        return Ok(18);
764    }
765    match cache.call_sol(asset, decimalsCall {}) {
766        Ok(decimals) => Ok(decimals),
767        Err(_) => Ok(18),
768    }
769}
770
771fn is_reserved_asset_address(asset: Address) -> bool {
772    // Euler denominates non-token quotes as ISO 4217 numeric currency codes
773    // cast to addresses (e.g. USD = address(840)). Any address whose value
774    // fits the reserved 32-bit range — i.e. whose top 16 bytes are zero — is
775    // treated as such a pseudo-asset and priced with 18 decimals.
776    asset.as_slice()[..16].iter().all(|byte| *byte == 0)
777}
778
779/// One base unit (`10^base_decimals`) for authoritative `getQuote` reads.
780///
781/// Shared with the derived-reconciliation reader so discovery seeding and
782/// post-event protocol reads quote exactly the same in-amount.
783pub(crate) fn one_base_unit(base_decimals: u8) -> Result<U256, OracleError> {
784    U256::from(10_u64)
785        .checked_pow(U256::from(base_decimals))
786        .ok_or_else(|| {
787            OracleError::Unsupported(format!(
788                "Euler base decimals {base_decimals} overflow a uint256 unit amount"
789            ))
790        })
791}
792
793fn i256_from_u256(value: U256, field: &'static str) -> Result<I256, OracleError> {
794    I256::try_from(value)
795        .map_err(|_| OracleError::Unsupported(format!("{field} does not fit int256")))
796}
797
798fn provider_error(error: impl ToString) -> OracleError {
799    OracleError::Provider(error.to_string())
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn state_keys_are_deterministic_and_distinct_per_pair_and_target() {
808        let router = Address::repeat_byte(0x01);
809        let other_router = Address::repeat_byte(0x02);
810        let weth = Address::repeat_byte(0x0a);
811        let usdc = Address::repeat_byte(0x0b);
812        let dai = Address::repeat_byte(0x0c);
813
814        let weth_usdc = EulerQuotePair::state_key_for(router, weth, usdc);
815        assert_eq!(
816            weth_usdc,
817            EulerQuotePair::state_key_for(router, weth, usdc),
818            "state keys must be deterministic"
819        );
820        // Distinct pairs on one router, inverse direction on one router, and
821        // the same pair on another router must never collide.
822        assert_ne!(weth_usdc, EulerQuotePair::state_key_for(router, weth, dai));
823        assert_ne!(weth_usdc, EulerQuotePair::state_key_for(router, usdc, weth));
824        assert_ne!(
825            weth_usdc,
826            EulerQuotePair::state_key_for(other_router, weth, usdc)
827        );
828        // And the key must not shadow the real router address.
829        assert_ne!(weth_usdc, router);
830    }
831
832    #[test]
833    fn reserved_asset_addresses_cover_iso4217_pseudo_assets() {
834        // USD = address(840) per Euler's denomination convention.
835        let usd = Address::from_slice(&{
836            let mut bytes = [0_u8; 20];
837            bytes[18] = 0x03;
838            bytes[19] = 0x48;
839            bytes
840        });
841        assert!(is_reserved_asset_address(usd));
842        assert!(is_reserved_asset_address(Address::ZERO));
843        // The full 32-bit reserved range boundary is still reserved.
844        let boundary = Address::from_slice(&{
845            let mut bytes = [0_u8; 20];
846            bytes[16..].copy_from_slice(&0xffff_ffff_u32.to_be_bytes());
847            bytes
848        });
849        assert!(is_reserved_asset_address(boundary));
850        // A real token address is not.
851        assert!(!is_reserved_asset_address(Address::repeat_byte(0x42)));
852    }
853
854    #[test]
855    fn one_base_unit_scales_by_decimals() {
856        assert_eq!(one_base_unit(0).unwrap(), U256::from(1_u64));
857        assert_eq!(
858            one_base_unit(18).unwrap(),
859            U256::from(1_000_000_000_000_000_000_u128)
860        );
861        assert!(one_base_unit(78).is_err(), "10^78 overflows uint256");
862    }
863}