mithril_client/
era.rs

1//! A client to retrieve the current Mithril era.
2//!
3//! In order to do so it defines a [MithrilEraClient] which exposes the following features:
4//! - [fetch_current][MithrilEraClient::fetch_current]: fetch the current Mithril era using its [EraFetcher]
5//!
6//! This module defines the following components:
7//!
8//! - [EraFetcher]: defines an interface to retrieve the current Mithril era
9//! - [AggregatorHttpEraFetcher]: an implementation of [EraFetcher] using an HTTP call to the aggregator
10//! - [FetchedEra]: a wrapper around a raw era string that provides a conversion to [SupportedEra]
11//!
12//! # Retrieve the current Mithril era
13//!
14//! To get [SupportedEra] using the [ClientBuilder][crate::client::ClientBuilder].
15//!
16//! ```no_run
17//! # async fn run() -> mithril_client::MithrilResult<()> {
18//! use mithril_client::ClientBuilder;
19//!
20//! let client = ClientBuilder::aggregator("YOUR_AGGREGATOR_ENDPOINT", "YOUR_GENESIS_VERIFICATION_KEY").build()?;
21//!
22//! let fetched_era = client.mithril_era_client().fetch_current().await?;
23//! match fetched_era.to_supported_era() {
24//!     Ok(supported_era) => println!("Current Mithril era: {supported_era}"),
25//!     Err(era) => println!(
26//!         "Warning: Unsupported era '{}', the aggregator might not be compatible with this client version. Consider upgrading.\nFetched era: {era}",
27//!         fetched_era.era
28//!     ),
29//! }
30//! # Ok(())
31//! # }
32//! ```
33
34use std::sync::Arc;
35
36use anyhow::Context;
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39
40use mithril_common::entities::SupportedEra;
41
42use crate::{
43    MithrilResult,
44    aggregator_client::{AggregatorClient, AggregatorRequest},
45};
46
47/// Client for retrieving the current Mithril era.
48pub struct MithrilEraClient {
49    era_fetcher: Arc<dyn EraFetcher>,
50}
51
52impl MithrilEraClient {
53    /// Constructs a new [MithrilEraClient].
54    pub fn new(era_fetcher: Arc<dyn EraFetcher>) -> Self {
55        Self { era_fetcher }
56    }
57
58    /// Fetches the current Mithril era.
59    pub async fn fetch_current(&self) -> MithrilResult<FetchedEra> {
60        self.era_fetcher.fetch_current_era().await
61    }
62}
63
64/// Wrapper around a raw Mithril era string.
65#[derive(Debug, PartialEq, Serialize, Deserialize)]
66pub struct FetchedEra {
67    /// Mithril era.
68    pub era: String,
69}
70
71impl FetchedEra {
72    /// Attempts to convert the internal Mithril era string to a [SupportedEra] enum variant.
73    pub fn to_supported_era(&self) -> MithrilResult<SupportedEra> {
74        self.era
75            .parse::<SupportedEra>()
76            .with_context(|| format!("Unknown supported era: {}", self.era))
77    }
78}
79
80/// Trait for retrieving the current Mithril era.
81#[cfg_attr(target_family = "wasm", async_trait(?Send))]
82#[cfg_attr(not(target_family = "wasm"), async_trait)]
83pub trait EraFetcher: Send + Sync {
84    /// Fetch the current Mithril era.
85    async fn fetch_current_era(&self) -> MithrilResult<FetchedEra>;
86}
87
88/// An implementation of [EraFetcher] that retrieves the current Mithril era
89/// by performing an HTTP request to the aggregator's `/status` endpoint.
90pub struct AggregatorHttpEraFetcher {
91    aggregator_client: Arc<dyn AggregatorClient>,
92}
93
94impl AggregatorHttpEraFetcher {
95    /// Constructs a new [AggregatorHttpEraFetcher].
96    pub fn new(aggregator_client: Arc<dyn AggregatorClient>) -> Self {
97        Self { aggregator_client }
98    }
99}
100
101#[cfg_attr(target_family = "wasm", async_trait(?Send))]
102#[cfg_attr(not(target_family = "wasm"), async_trait)]
103impl EraFetcher for AggregatorHttpEraFetcher {
104    async fn fetch_current_era(&self) -> MithrilResult<FetchedEra> {
105        #[derive(serde::Deserialize)]
106        struct Era {
107            mithril_era: String,
108        }
109
110        let response = self
111            .aggregator_client
112            .get_content(AggregatorRequest::Status)
113            .await
114            .with_context(|| "Failed to fetch the aggregator status route")?;
115
116        let era = serde_json::from_str::<Era>(&response)
117            .with_context(|| "Failed to parse aggregator status message as JSON value")?
118            .mithril_era;
119
120        Ok(FetchedEra { era })
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use mithril_common::{
127        entities::{Epoch, ProtocolParameters, SupportedEra},
128        messages::AggregatorStatusMessage,
129    };
130    use mockall::predicate::eq;
131
132    use crate::aggregator_client::MockAggregatorClient;
133
134    use super::*;
135
136    fn dummy_aggregator_status_message() -> AggregatorStatusMessage {
137        AggregatorStatusMessage {
138            epoch: Epoch(48),
139            cardano_era: "conway".to_string(),
140            cardano_network: "mainnet".to_string(),
141            mithril_era: SupportedEra::Pythagoras,
142            cardano_node_version: "1.2.3".to_string(),
143            aggregator_node_version: "4.5.6".to_string(),
144            protocol_parameters: ProtocolParameters {
145                k: 5,
146                m: 100,
147                phi_f: 0.65,
148            },
149            next_protocol_parameters: ProtocolParameters {
150                k: 50,
151                m: 1000,
152                phi_f: 0.65,
153            },
154            total_signers: 1234,
155            total_next_signers: 56789,
156            total_stakes_signers: 123456789,
157            total_next_stakes_signers: 987654321,
158            total_cardano_spo: 7777,
159            total_cardano_stake: 888888888,
160        }
161    }
162
163    #[tokio::test]
164    async fn fetch_current_era_should_return_mithril_era_from_aggregator_status() {
165        let aggregator_status_message = AggregatorStatusMessage {
166            mithril_era: SupportedEra::Pythagoras,
167            ..dummy_aggregator_status_message()
168        };
169        let mock_aggregator_client = {
170            let mut mock_client = MockAggregatorClient::new();
171            mock_client
172                .expect_get_content()
173                .with(eq(AggregatorRequest::Status))
174                .return_once(move |_| {
175                    Ok(serde_json::to_string(&aggregator_status_message).unwrap())
176                });
177
178            Arc::new(mock_client)
179        };
180        let era_fetcher = AggregatorHttpEraFetcher::new(mock_aggregator_client);
181
182        let fetched_era = era_fetcher.fetch_current_era().await.unwrap();
183
184        assert_eq!(
185            {
186                FetchedEra {
187                    era: SupportedEra::Pythagoras.to_string(),
188                }
189            },
190            fetched_era
191        );
192    }
193
194    #[tokio::test]
195    async fn fetch_current_era_returns_error_if_response_is_invalid_json() {
196        let mock_aggregator_client = {
197            let mut mock_client = MockAggregatorClient::new();
198            mock_client
199                .expect_get_content()
200                .with(eq(AggregatorRequest::Status))
201                .return_once(|_| Ok("invalid_json".to_string()));
202
203            Arc::new(mock_client)
204        };
205        let era_fetcher = AggregatorHttpEraFetcher::new(mock_aggregator_client);
206
207        era_fetcher
208            .fetch_current_era()
209            .await
210            .expect_err("Expected an error due to invalid JSON response");
211    }
212
213    #[tokio::test]
214    async fn fetch_current_era_returns_error_if_mithril_era_field_is_missing() {
215        let mut response_json = serde_json::json!(dummy_aggregator_status_message());
216        response_json.as_object_mut().unwrap().remove("mithril_era");
217        let mock_aggregator_client = {
218            let mut mock_client = MockAggregatorClient::new();
219            mock_client
220                .expect_get_content()
221                .with(eq(AggregatorRequest::Status))
222                .return_once(move |_| Ok(response_json.to_string()));
223
224            Arc::new(mock_client)
225        };
226        let era_fetcher = AggregatorHttpEraFetcher::new(mock_aggregator_client);
227
228        era_fetcher.fetch_current_era().await.expect_err(
229            "Expected an error due to missing 'mithril_era' field in aggregator status",
230        );
231    }
232
233    #[test]
234    fn to_supported_era_should_return_enum_variant_for_known_era() {
235        let fetched_era = FetchedEra {
236            era: SupportedEra::Pythagoras.to_string(),
237        };
238
239        let supported_era = fetched_era.to_supported_era().unwrap();
240
241        assert_eq!(SupportedEra::Pythagoras, supported_era);
242    }
243
244    #[test]
245    fn to_supported_era_returns_error_for_unsupported_era() {
246        let fetched_era = FetchedEra {
247            era: "unsupported_era".to_string(),
248        };
249
250        fetched_era
251            .to_supported_era()
252            .expect_err("Expected an error when converting an unsupported era to 'SupportedEra'");
253    }
254}