Skip to main content

evm_oracle_state/
morpho.rs

1use std::{
2    sync::Arc,
3    time::{SystemTime, UNIX_EPOCH},
4};
5
6use alloy_network::Ethereum;
7use alloy_primitives::{Address, B256, I256, U256};
8use alloy_sol_types::sol;
9use evm_fork_cache::{cache::EvmCache, reactive::ReactiveHandler};
10
11use crate::{
12    AdapterFuture, AssetId, Denomination, EvmCacheChainlinkReader, FeedId, FeedMetadata,
13    FeedRegistration, FeedSource, MorphoChainlinkFeed, MorphoChainlinkFeedRole,
14    OracleAdapterFeedSkip, OracleAdapterId, OracleAdapterPlugin, OracleAdapterSkipReason,
15    OracleDependency, OracleDiscoveredFeed, OracleDiscoveryContext, OracleDiscoveryReport,
16    OracleError, OracleFeedStatus, OracleReactiveHandler, OracleStorageSync, RoundData,
17    StalenessPolicy,
18};
19
20sol! {
21    interface MorphoBlueInterface {
22        function idToMarketParams(bytes32 id) external view returns (
23            address loanToken,
24            address collateralToken,
25            address oracle,
26            address irm,
27            uint256 lltv
28        );
29    }
30
31    interface MorphoChainlinkOracleV2Interface {
32        function BASE_VAULT() external view returns (address);
33        function BASE_VAULT_CONVERSION_SAMPLE() external view returns (uint256);
34        function QUOTE_VAULT() external view returns (address);
35        function QUOTE_VAULT_CONVERSION_SAMPLE() external view returns (uint256);
36        function BASE_FEED_1() external view returns (address);
37        function BASE_FEED_2() external view returns (address);
38        function QUOTE_FEED_1() external view returns (address);
39        function QUOTE_FEED_2() external view returns (address);
40        function SCALE_FACTOR() external view returns (uint256);
41        function price() external view returns (uint256);
42    }
43
44    interface Erc4626Like {
45        function convertToAssets(uint256 shares) external view returns (uint256);
46    }
47}
48
49use Erc4626Like::convertToAssetsCall;
50use MorphoBlueInterface::idToMarketParamsCall;
51use MorphoChainlinkOracleV2Interface::{
52    BASE_FEED_1Call, BASE_FEED_2Call, BASE_VAULT_CONVERSION_SAMPLECall, BASE_VAULTCall,
53    QUOTE_FEED_1Call, QUOTE_FEED_2Call, QUOTE_VAULT_CONVERSION_SAMPLECall, QUOTE_VAULTCall,
54    SCALE_FACTORCall, priceCall,
55};
56
57const ADAPTER_ID: &str = "evm-oracle-state.morpho-blue";
58const MORPHO_PRICE_DECIMALS: u8 = 36;
59
60/// Morpho Blue market parameters.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct MorphoMarketParams {
63    /// Loan token.
64    pub loan_token: Address,
65    /// Collateral token.
66    pub collateral_token: Address,
67    /// Morpho market oracle.
68    pub oracle: Address,
69    /// Interest rate model.
70    pub irm: Address,
71    /// Loan-to-value parameter.
72    pub lltv: U256,
73}
74
75/// Declarative Morpho Blue market oracle registration.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct MorphoBlueMarket {
78    id: Option<B256>,
79    params: Option<MorphoMarketParams>,
80    oracle: Option<Address>,
81    feed_id: Option<FeedId>,
82    label: Option<String>,
83    base: Option<AssetId>,
84    quote: Option<Denomination>,
85    staleness: StalenessPolicy,
86}
87
88impl MorphoBlueMarket {
89    /// Register a market by Morpho Blue market id.
90    pub fn market_id(id: B256) -> Self {
91        Self {
92            id: Some(id),
93            params: None,
94            oracle: None,
95            feed_id: None,
96            label: None,
97            base: None,
98            quote: None,
99            staleness: StalenessPolicy::default(),
100        }
101    }
102
103    /// Register a market by explicit market params.
104    pub fn market_params(params: MorphoMarketParams) -> Self {
105        Self {
106            id: None,
107            oracle: Some(params.oracle),
108            params: Some(params),
109            feed_id: None,
110            label: None,
111            base: None,
112            quote: None,
113            staleness: StalenessPolicy::default(),
114        }
115    }
116
117    /// Register a Morpho oracle address directly.
118    pub fn oracle(oracle: Address) -> Self {
119        Self {
120            id: None,
121            params: None,
122            oracle: Some(oracle),
123            feed_id: None,
124            label: None,
125            base: None,
126            quote: None,
127            staleness: StalenessPolicy::default(),
128        }
129    }
130
131    /// Set a stable feed id.
132    pub fn id(mut self, id: impl Into<String>) -> Self {
133        self.feed_id = Some(FeedId::new(id));
134        self
135    }
136
137    /// Set a stable feed id.
138    pub fn feed_id(mut self, id: FeedId) -> Self {
139        self.feed_id = Some(id);
140        self
141    }
142
143    /// Set a human-readable label.
144    pub fn label(mut self, label: impl Into<String>) -> Self {
145        self.label = Some(label.into());
146        self
147    }
148
149    /// Set the base asset label.
150    pub fn base(mut self, base: AssetId) -> Self {
151        self.base = Some(base);
152        self
153    }
154
155    /// Set the quote denomination label.
156    pub fn quote(mut self, quote: Denomination) -> Self {
157        self.quote = Some(quote);
158        self
159    }
160
161    /// Set a max-age staleness policy.
162    pub fn max_age_secs(mut self, max_age_secs: u64) -> Self {
163        self.staleness = StalenessPolicy::max_age(max_age_secs);
164        self
165    }
166
167    /// Set the full staleness policy.
168    pub fn staleness(mut self, staleness: StalenessPolicy) -> Self {
169        self.staleness = staleness;
170        self
171    }
172}
173
174/// Cache-backed Morpho Blue oracle discovery adapter.
175#[derive(Clone, Debug, Default)]
176pub struct MorphoBlueOracleAdapter {
177    morpho: Option<Address>,
178    markets: Vec<MorphoBlueMarket>,
179    now_timestamp: Option<u64>,
180}
181
182impl MorphoBlueOracleAdapter {
183    /// Create an adapter that can discover market params from a Morpho Blue contract.
184    pub fn new(morpho: Address) -> Self {
185        Self {
186            morpho: Some(morpho),
187            markets: Vec::new(),
188            now_timestamp: None,
189        }
190    }
191
192    /// Create an adapter for a direct Morpho oracle address.
193    pub fn from_oracle(oracle: Address) -> Self {
194        Self {
195            morpho: None,
196            markets: vec![MorphoBlueMarket::oracle(oracle)],
197            now_timestamp: None,
198        }
199    }
200
201    /// Add one market by id.
202    pub fn market_id(self, id: B256) -> Self {
203        self.market(MorphoBlueMarket::market_id(id))
204    }
205
206    /// Add one market registration.
207    pub fn market(mut self, market: MorphoBlueMarket) -> Self {
208        self.markets.push(market);
209        self
210    }
211
212    /// Add multiple market registrations.
213    pub fn markets(mut self, markets: impl IntoIterator<Item = MorphoBlueMarket>) -> Self {
214        self.markets.extend(markets);
215        self
216    }
217
218    /// Set a fixed timestamp for deterministic registration status classification.
219    pub fn now_timestamp(mut self, now_timestamp: u64) -> Self {
220        self.now_timestamp = Some(now_timestamp);
221        self
222    }
223
224    fn timestamp(&self, fallback: Option<u64>) -> Result<u64, OracleError> {
225        if let Some(now_timestamp) = self.now_timestamp.or(fallback) {
226            return Ok(now_timestamp);
227        }
228        Ok(SystemTime::now()
229            .duration_since(UNIX_EPOCH)
230            .map_err(crate::error::clock_error)?
231            .as_secs())
232    }
233
234    fn discover_markets(
235        &self,
236        cache: &mut EvmCache,
237        now_timestamp: u64,
238    ) -> Result<OracleDiscoveryReport, OracleError> {
239        let mut report = OracleDiscoveryReport::new();
240        for market in &self.markets {
241            match self.discover_market(cache, market, now_timestamp) {
242                Ok(feed) => report = report.with_feed(feed),
243                Err(error) => {
244                    report = report.with_skip(OracleAdapterFeedSkip {
245                        feed: crate::Feed::proxy(market.oracle.unwrap_or_default()).build(),
246                        proxy: market.oracle.unwrap_or_default(),
247                        reason: OracleAdapterSkipReason::UnsupportedMorphoSource {
248                            error: format!("unsupported Morpho source ({error})"),
249                        },
250                    });
251                }
252            }
253        }
254        Ok(report)
255    }
256
257    fn discover_market(
258        &self,
259        cache: &mut EvmCache,
260        market: &MorphoBlueMarket,
261        now_timestamp: u64,
262    ) -> Result<OracleDiscoveredFeed, OracleError> {
263        let params = if let Some(params) = &market.params {
264            Some(params.clone())
265        } else if let Some(id) = market.id {
266            Some(self.read_market_params(cache, id)?)
267        } else {
268            None
269        };
270        let oracle = market
271            .oracle
272            .or_else(|| params.as_ref().map(|params| params.oracle))
273            .ok_or_else(|| {
274                OracleError::Config(crate::error::OracleConfigError::Other(
275                    "Morpho market oracle is missing".to_string(),
276                ))
277            })?;
278
279        let probed = self.read_chainlink_v2(cache, oracle)?;
280        let id = market
281            .feed_id
282            .clone()
283            .unwrap_or_else(|| derive_morpho_feed_id(market.id, oracle));
284        let label = market
285            .label
286            .clone()
287            .or_else(|| Some(format!("Morpho {oracle:?}")));
288        let base = market.base.clone().map(String::from).or_else(|| {
289            params
290                .as_ref()
291                .map(|params| format!("{:?}", params.collateral_token))
292        });
293        let quote = market.quote.clone().map(String::from).or_else(|| {
294            params
295                .as_ref()
296                .map(|params| format!("{:?}", params.loan_token))
297        });
298        let metadata = FeedMetadata {
299            decimals: MORPHO_PRICE_DECIMALS,
300            description: label
301                .clone()
302                .unwrap_or_else(|| "MorphoChainlinkOracleV2".to_string()),
303            version: U256::ZERO,
304        };
305        let source = FeedSource::morpho_chainlink_v2(
306            oracle,
307            probed.base_vault_assets,
308            probed.quote_vault_assets,
309            probed.scale_factor,
310            probed.feeds,
311        );
312        let registration = FeedRegistration {
313            id,
314            proxy: oracle,
315            label,
316            base,
317            quote,
318            staleness: market.staleness,
319            current_aggregator: source.event_aggregators(None).first().copied(),
320            aggregator_layout: None,
321            metadata,
322            source,
323            status: OracleFeedStatus::Ready,
324        };
325        let round = RoundData {
326            round_id: U256::ZERO,
327            answer: probed.price,
328            started_at: now_timestamp,
329            updated_at: now_timestamp,
330            answered_in_round: U256::ZERO,
331        };
332
333        Ok(OracleDiscoveredFeed::new(registration, round))
334    }
335
336    fn read_market_params(
337        &self,
338        cache: &mut EvmCache,
339        id: B256,
340    ) -> Result<MorphoMarketParams, OracleError> {
341        let morpho = self.morpho.ok_or_else(|| {
342            OracleError::Config(crate::error::OracleConfigError::Other(
343                "Morpho contract is required for market-id discovery".to_string(),
344            ))
345        })?;
346        let params = cache
347            .call_sol(morpho, idToMarketParamsCall { id })
348            .map_err(provider_error)?;
349        Ok(MorphoMarketParams {
350            loan_token: params.loanToken,
351            collateral_token: params.collateralToken,
352            oracle: params.oracle,
353            irm: params.irm,
354            lltv: params.lltv,
355        })
356    }
357
358    fn read_chainlink_v2(
359        &self,
360        cache: &mut EvmCache,
361        oracle: Address,
362    ) -> Result<MorphoChainlinkV2Read, OracleError> {
363        let base_vault = cache
364            .call_sol(oracle, BASE_VAULTCall {})
365            .map_err(provider_error)?;
366        let base_sample = cache
367            .call_sol(oracle, BASE_VAULT_CONVERSION_SAMPLECall {})
368            .map_err(provider_error)?;
369        let quote_vault = cache
370            .call_sol(oracle, QUOTE_VAULTCall {})
371            .map_err(provider_error)?;
372        let quote_sample = cache
373            .call_sol(oracle, QUOTE_VAULT_CONVERSION_SAMPLECall {})
374            .map_err(provider_error)?;
375        let scale_factor = cache
376            .call_sol(oracle, SCALE_FACTORCall {})
377            .map_err(provider_error)?;
378        let price = cache
379            .call_sol(oracle, priceCall {})
380            .map_err(provider_error)?;
381        let base_vault_assets = read_vault_assets(cache, base_vault, base_sample)?;
382        let quote_vault_assets = read_vault_assets(cache, quote_vault, quote_sample)?;
383        let feed_addresses = [
384            (
385                MorphoChainlinkFeedRole::BaseFeed1,
386                cache
387                    .call_sol(oracle, BASE_FEED_1Call {})
388                    .map_err(provider_error)?,
389            ),
390            (
391                MorphoChainlinkFeedRole::BaseFeed2,
392                cache
393                    .call_sol(oracle, BASE_FEED_2Call {})
394                    .map_err(provider_error)?,
395            ),
396            (
397                MorphoChainlinkFeedRole::QuoteFeed1,
398                cache
399                    .call_sol(oracle, QUOTE_FEED_1Call {})
400                    .map_err(provider_error)?,
401            ),
402            (
403                MorphoChainlinkFeedRole::QuoteFeed2,
404                cache
405                    .call_sol(oracle, QUOTE_FEED_2Call {})
406                    .map_err(provider_error)?,
407            ),
408        ];
409
410        let reader = EvmCacheChainlinkReader::new(cache);
411        let mut feeds = Vec::new();
412        for (role, feed) in feed_addresses {
413            if feed == Address::ZERO {
414                continue;
415            }
416            let round = reader.read_latest_round_data(feed)?;
417            let aggregator = reader.read_aggregator(feed)?.ok_or_else(|| {
418                OracleError::Unsupported("Morpho dependency aggregator() returned none".to_string())
419            })?;
420            feeds.push(MorphoChainlinkFeed::new(
421                role,
422                OracleDependency::new(feed, aggregator, round.answer),
423            ));
424        }
425
426        Ok(MorphoChainlinkV2Read {
427            base_vault_assets: i256_from_u256(base_vault_assets, "base vault assets")?,
428            quote_vault_assets: i256_from_u256(quote_vault_assets, "quote vault assets")?,
429            scale_factor: i256_from_u256(scale_factor, "scale factor")?,
430            price: i256_from_u256(price, "price")?,
431            feeds,
432        })
433    }
434}
435
436impl OracleAdapterPlugin for MorphoBlueOracleAdapter {
437    fn adapter_id(&self) -> OracleAdapterId {
438        OracleAdapterId::new(ADAPTER_ID)
439    }
440
441    fn discover<'a>(
442        &'a self,
443        ctx: OracleDiscoveryContext<'a>,
444    ) -> AdapterFuture<'a, OracleDiscoveryReport> {
445        Box::pin(async move {
446            let now_timestamp = self.timestamp(Some(ctx.now_timestamp))?;
447            self.discover_markets(ctx.cache, now_timestamp)
448        })
449    }
450
451    fn reactive_handler(
452        &self,
453        registrations: Vec<FeedRegistration>,
454        storage_sync: OracleStorageSync,
455    ) -> Arc<dyn ReactiveHandler<Ethereum>> {
456        Arc::new(OracleReactiveHandler::with_storage_sync(
457            registrations,
458            storage_sync,
459        ))
460    }
461}
462
463#[derive(Clone, Debug)]
464struct MorphoChainlinkV2Read {
465    base_vault_assets: I256,
466    quote_vault_assets: I256,
467    scale_factor: I256,
468    price: I256,
469    feeds: Vec<MorphoChainlinkFeed>,
470}
471
472fn read_vault_assets(
473    cache: &mut EvmCache,
474    vault: Address,
475    sample: U256,
476) -> Result<U256, OracleError> {
477    if vault == Address::ZERO {
478        return Ok(U256::from(1_u8));
479    }
480    cache
481        .call_sol(vault, convertToAssetsCall { shares: sample })
482        .map_err(provider_error)
483}
484
485fn derive_morpho_feed_id(id: Option<B256>, oracle: Address) -> FeedId {
486    match id {
487        Some(id) => FeedId::new(format!("morpho-{id:?}")),
488        None => FeedId::new(format!("morpho-{oracle:?}")),
489    }
490}
491
492fn i256_from_u256(value: U256, field: &'static str) -> Result<I256, OracleError> {
493    I256::try_from(value)
494        .map_err(|_| OracleError::Unsupported(format!("{field} does not fit int256")))
495}
496
497fn provider_error(error: impl ToString) -> OracleError {
498    OracleError::Provider(error.to_string())
499}