diff --git a/CHANGELOG.md b/CHANGELOG.md index 1822ee933ae..18076aef6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ As a minor extension, we have adopted a slightly different versioning convention - **UNSTABLE** Cardano stake distribution certification: - - Implement the signable and artifact builders for the signed entity type `CardanoStakeDistribution` + - Implement the signable and artifact builders for the signed entity type `CardanoStakeDistribution`. + - Implement the HTTP routes related to the signed entity type `CardanoStakeDistribution` on the aggregator REST API. - Crates versions: diff --git a/Cargo.lock b/Cargo.lock index 2c75c03f25d..cf322886c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3539,7 +3539,7 @@ dependencies = [ [[package]] name = "mithril-aggregator" -version = "0.5.53" +version = "0.5.54" dependencies = [ "anyhow", "async-trait", @@ -3695,7 +3695,7 @@ dependencies = [ [[package]] name = "mithril-common" -version = "0.4.42" +version = "0.4.43" dependencies = [ "anyhow", "async-trait", @@ -3767,7 +3767,7 @@ dependencies = [ [[package]] name = "mithril-end-to-end" -version = "0.4.27" +version = "0.4.28" dependencies = [ "anyhow", "async-recursion", diff --git a/mithril-aggregator/Cargo.toml b/mithril-aggregator/Cargo.toml index 9ee89452071..bd7b60aab7f 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-aggregator" -version = "0.5.53" +version = "0.5.54" description = "A Mithril Aggregator server" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-aggregator/src/database/migration.rs b/mithril-aggregator/src/database/migration.rs index bc9a1624d09..3e17a951ee1 100644 --- a/mithril-aggregator/src/database/migration.rs +++ b/mithril-aggregator/src/database/migration.rs @@ -752,5 +752,13 @@ pragma foreign_keys=true; SignedEntityTypeDiscriminants::CardanoTransactions.index() ), ), + // Migration 26 + // Alter `signed_entity` table, add a unique index on `signed_entity_type_id` and `beacon` + SqlMigration::new( + 26, + r#" +create unique index signed_entity_unique_index on signed_entity(signed_entity_type_id, beacon); +"#, + ), ] } diff --git a/mithril-aggregator/src/database/query/signed_entity/get_signed_entity.rs b/mithril-aggregator/src/database/query/signed_entity/get_signed_entity.rs index 15dcaa03734..ee68de31868 100644 --- a/mithril-aggregator/src/database/query/signed_entity/get_signed_entity.rs +++ b/mithril-aggregator/src/database/query/signed_entity/get_signed_entity.rs @@ -1,6 +1,6 @@ use sqlite::Value; -use mithril_common::entities::SignedEntityTypeDiscriminants; +use mithril_common::entities::{Epoch, SignedEntityTypeDiscriminants}; use mithril_common::StdResult; use mithril_persistence::sqlite::{Query, SourceAlias, SqLiteEntity, WhereCondition}; @@ -60,6 +60,19 @@ impl GetSignedEntityRecordQuery { ), }) } + + pub fn cardano_stake_distribution_by_epoch(epoch: Epoch) -> Self { + let signed_entity_type_id = + SignedEntityTypeDiscriminants::CardanoStakeDistribution.index() as i64; + let epoch = *epoch as i64; + + Self { + condition: WhereCondition::new( + "signed_entity_type_id = ?* and beacon = ?*", + vec![Value::Integer(signed_entity_type_id), Value::Integer(epoch)], + ), + } + } } impl Query for GetSignedEntityRecordQuery { @@ -80,13 +93,96 @@ impl Query for GetSignedEntityRecordQuery { #[cfg(test)] mod tests { - use mithril_common::entities::{CardanoDbBeacon, SignedEntityType}; + use chrono::DateTime; + use mithril_common::{ + entities::{CardanoDbBeacon, SignedEntityType}, + test_utils::fake_data, + }; use mithril_persistence::sqlite::ConnectionExtensions; + use sqlite::ConnectionThreadSafe; use crate::database::test_helper::{insert_signed_entities, main_db_connection}; use super::*; + fn create_database_with_cardano_stake_distributions>( + cardano_stake_distributions: Vec, + ) -> (ConnectionThreadSafe, Vec) { + let records = cardano_stake_distributions + .into_iter() + .map(|cardano_stake_distribution| cardano_stake_distribution.into()) + .collect::>(); + + let connection = create_database(&records); + + (connection, records) + } + + fn create_database(records: &[SignedEntityRecord]) -> ConnectionThreadSafe { + let connection = main_db_connection().unwrap(); + insert_signed_entities(&connection, records.to_vec()).unwrap(); + connection + } + + #[test] + fn cardano_stake_distribution_by_epoch_returns_records_filtered_by_epoch() { + let mut cardano_stake_distributions = fake_data::cardano_stake_distributions(3); + cardano_stake_distributions[0].epoch = Epoch(3); + cardano_stake_distributions[1].epoch = Epoch(4); + cardano_stake_distributions[2].epoch = Epoch(5); + + let (connection, records) = + create_database_with_cardano_stake_distributions(cardano_stake_distributions); + + let records_retrieved: Vec = connection + .fetch_collect( + GetSignedEntityRecordQuery::cardano_stake_distribution_by_epoch(Epoch(4)), + ) + .unwrap(); + + assert_eq!(vec![records[1].clone()], records_retrieved); + } + + #[test] + fn cardano_stake_distribution_by_epoch_returns_records_returns_only_cardano_stake_distribution_records( + ) { + let cardano_stake_distributions_record: SignedEntityRecord = { + let mut cardano_stake_distribution = fake_data::cardano_stake_distribution(Epoch(4)); + cardano_stake_distribution.hash = "hash-123".to_string(); + cardano_stake_distribution.into() + }; + + let snapshots_record = { + let mut snapshot = fake_data::snapshots(1)[0].clone(); + snapshot.beacon.epoch = Epoch(4); + SignedEntityRecord::from_snapshot(snapshot, "whatever".to_string(), DateTime::default()) + }; + + let mithril_stake_distribution_record: SignedEntityRecord = { + let mithril_stake_distributions = fake_data::mithril_stake_distributions(1); + let mut mithril_stake_distribution = mithril_stake_distributions[0].clone(); + mithril_stake_distribution.epoch = Epoch(4); + mithril_stake_distribution.into() + }; + + let connection = create_database(&[ + cardano_stake_distributions_record.clone(), + snapshots_record, + mithril_stake_distribution_record, + ]); + + let records_retrieved: Vec = connection + .fetch_collect( + GetSignedEntityRecordQuery::cardano_stake_distribution_by_epoch(Epoch(4)), + ) + .unwrap(); + + assert_eq!( + vec![cardano_stake_distributions_record.clone()], + records_retrieved, + ); + } + #[test] fn test_get_signed_entity_records() { let signed_entity_records = SignedEntityRecord::fake_records(5); diff --git a/mithril-aggregator/src/database/record/signed_entity.rs b/mithril-aggregator/src/database/record/signed_entity.rs index 1b4773bc193..ef15c2cd17d 100644 --- a/mithril-aggregator/src/database/record/signed_entity.rs +++ b/mithril-aggregator/src/database/record/signed_entity.rs @@ -2,8 +2,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use mithril_common::crypto_helper::ProtocolParameters; -use mithril_common::entities::{BlockNumber, Epoch, SignedEntity, SignedEntityType, Snapshot}; +use mithril_common::entities::{ + BlockNumber, Epoch, SignedEntity, SignedEntityType, Snapshot, StakeDistribution, +}; +#[cfg(test)] +use mithril_common::entities::{CardanoStakeDistribution, MithrilStakeDistribution}; use mithril_common::messages::{ + CardanoStakeDistributionListItemMessage, CardanoStakeDistributionMessage, CardanoTransactionSnapshotListItemMessage, CardanoTransactionSnapshotMessage, MithrilStakeDistributionListItemMessage, MithrilStakeDistributionMessage, SignerWithStakeMessagePart, SnapshotListItemMessage, SnapshotMessage, @@ -32,6 +37,32 @@ pub struct SignedEntityRecord { pub created_at: DateTime, } +#[cfg(test)] +impl From for SignedEntityRecord { + fn from(cardano_stake_distribution: CardanoStakeDistribution) -> Self { + SignedEntityRecord::from_cardano_stake_distribution(cardano_stake_distribution) + } +} + +#[cfg(test)] +impl From for SignedEntityRecord { + fn from(mithril_stake_distribution: MithrilStakeDistribution) -> Self { + let entity = serde_json::to_string(&mithril_stake_distribution).unwrap(); + + SignedEntityRecord { + signed_entity_id: mithril_stake_distribution.hash.clone(), + signed_entity_type: SignedEntityType::MithrilStakeDistribution( + mithril_stake_distribution.epoch, + ), + certificate_id: format!("certificate-{}", mithril_stake_distribution.hash), + artifact: entity, + created_at: DateTime::parse_from_rfc3339("2023-01-19T13:43:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } +} + #[cfg(test)] impl SignedEntityRecord { pub(crate) fn from_snapshot( @@ -50,6 +81,24 @@ impl SignedEntityRecord { } } + pub(crate) fn from_cardano_stake_distribution( + cardano_stake_distribution: CardanoStakeDistribution, + ) -> Self { + let entity = serde_json::to_string(&cardano_stake_distribution).unwrap(); + + SignedEntityRecord { + signed_entity_id: cardano_stake_distribution.hash.clone(), + signed_entity_type: SignedEntityType::CardanoStakeDistribution( + cardano_stake_distribution.epoch, + ), + certificate_id: format!("certificate-{}", cardano_stake_distribution.hash), + artifact: entity, + created_at: DateTime::parse_from_rfc3339("2023-01-19T13:43:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } + pub(crate) fn fake_records(number_if_records: usize) -> Vec { use mithril_common::test_utils::fake_data; @@ -233,6 +282,52 @@ impl TryFrom for SnapshotListItemMessage { } } +impl TryFrom for CardanoStakeDistributionMessage { + type Error = StdError; + + fn try_from(value: SignedEntityRecord) -> Result { + #[derive(Deserialize)] + struct TmpCardanoStakeDistribution { + hash: String, + stake_distribution: StakeDistribution, + } + let artifact = serde_json::from_str::(&value.artifact)?; + let cardano_stake_distribution_message = CardanoStakeDistributionMessage { + // The epoch stored in the signed entity type beacon corresponds to epoch + // at the end of which the Cardano stake distribution is computed by the Cardano node. + epoch: value.signed_entity_type.get_epoch(), + stake_distribution: artifact.stake_distribution, + hash: artifact.hash, + certificate_hash: value.certificate_id, + created_at: value.created_at, + }; + + Ok(cardano_stake_distribution_message) + } +} + +impl TryFrom for CardanoStakeDistributionListItemMessage { + type Error = StdError; + + fn try_from(value: SignedEntityRecord) -> Result { + #[derive(Deserialize)] + struct TmpCardanoStakeDistribution { + hash: String, + } + let artifact = serde_json::from_str::(&value.artifact)?; + let message = CardanoStakeDistributionListItemMessage { + // The epoch stored in the signed entity type beacon corresponds to epoch + // at the end of which the Cardano stake distribution is computed by the Cardano node. + epoch: value.signed_entity_type.get_epoch(), + hash: artifact.hash, + certificate_hash: value.certificate_id, + created_at: value.created_at, + }; + + Ok(message) + } +} + impl SqLiteEntity for SignedEntityRecord { fn hydrate(row: sqlite::Row) -> Result where diff --git a/mithril-aggregator/src/database/repository/signed_entity_store.rs b/mithril-aggregator/src/database/repository/signed_entity_store.rs index 777e444dcf2..84b64a0d938 100644 --- a/mithril-aggregator/src/database/repository/signed_entity_store.rs +++ b/mithril-aggregator/src/database/repository/signed_entity_store.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Context; use async_trait::async_trait; -use mithril_common::entities::SignedEntityTypeDiscriminants; +use mithril_common::entities::{Epoch, SignedEntityTypeDiscriminants}; use mithril_common::StdResult; use mithril_persistence::sqlite::{ConnectionExtensions, SqliteConnection}; @@ -44,6 +44,12 @@ pub trait SignedEntityStorer: Sync + Send { total: usize, ) -> StdResult>; + /// Get Cardano stake distribution signed entity by epoch + async fn get_cardano_stake_distribution_signed_entity_by_epoch( + &self, + epoch: Epoch, + ) -> StdResult>; + /// Perform an update for all the given signed entities. async fn update_signed_entities( &self, @@ -127,6 +133,14 @@ impl SignedEntityStorer for SignedEntityStore { Ok(signed_entities) } + async fn get_cardano_stake_distribution_signed_entity_by_epoch( + &self, + epoch: Epoch, + ) -> StdResult> { + self.connection + .fetch_first(GetSignedEntityRecordQuery::cardano_stake_distribution_by_epoch(epoch)) + } + async fn update_signed_entities( &self, signed_entities: Vec, @@ -150,7 +164,10 @@ impl SignedEntityStorer for SignedEntityStore { #[cfg(test)] mod tests { - use mithril_common::entities::{MithrilStakeDistribution, SignedEntity, Snapshot}; + use mithril_common::{ + entities::{Epoch, MithrilStakeDistribution, SignedEntity, Snapshot}, + test_utils::fake_data, + }; use crate::database::test_helper::{insert_signed_entities, main_db_connection}; @@ -310,4 +327,37 @@ mod tests { assert_eq!(records_to_update, updated_records); assert_eq!(expected_records, stored_records); } + + #[tokio::test] + async fn get_cardano_stake_distribution_signed_entity_by_epoch_when_nothing_found() { + let epoch_to_retrieve = Epoch(4); + let connection = main_db_connection().unwrap(); + let store = SignedEntityStore::new(Arc::new(connection)); + + let record = store + .get_cardano_stake_distribution_signed_entity_by_epoch(epoch_to_retrieve) + .await + .unwrap(); + + assert_eq!(None, record); + } + + #[tokio::test] + async fn get_cardano_stake_distribution_signed_entity_by_epoch_when_signed_entity_found_for_epoch( + ) { + let cardano_stake_distribution = fake_data::cardano_stake_distribution(Epoch(4)); + + let expected_record: SignedEntityRecord = cardano_stake_distribution.into(); + + let connection = main_db_connection().unwrap(); + insert_signed_entities(&connection, vec![expected_record.clone()]).unwrap(); + let store = SignedEntityStore::new(Arc::new(connection)); + + let record = store + .get_cardano_stake_distribution_signed_entity_by_epoch(Epoch(4)) + .await + .unwrap(); + + assert_eq!(Some(expected_record), record); + } } diff --git a/mithril-aggregator/src/http_server/routes/artifact_routes/cardano_stake_distribution.rs b/mithril-aggregator/src/http_server/routes/artifact_routes/cardano_stake_distribution.rs new file mode 100644 index 00000000000..de03fa2d466 --- /dev/null +++ b/mithril-aggregator/src/http_server/routes/artifact_routes/cardano_stake_distribution.rs @@ -0,0 +1,446 @@ +use crate::http_server::routes::middlewares; +use crate::DependencyContainer; +use std::sync::Arc; +use warp::Filter; + +pub fn routes( + dependency_manager: Arc, +) -> impl Filter + Clone { + artifact_cardano_stake_distributions(dependency_manager.clone()) + .or(artifact_cardano_stake_distribution_by_id( + dependency_manager.clone(), + )) + .or(artifact_cardano_stake_distribution_by_epoch( + dependency_manager, + )) +} + +/// GET /artifact/cardano-stake-distributions +fn artifact_cardano_stake_distributions( + dependency_manager: Arc, +) -> impl Filter + Clone { + warp::path!("artifact" / "cardano-stake-distributions") + .and(warp::get()) + .and(middlewares::with_http_message_service(dependency_manager)) + .and_then(handlers::list_artifacts) +} + +/// GET /artifact/cardano-stake-distribution/:id +fn artifact_cardano_stake_distribution_by_id( + dependency_manager: Arc, +) -> impl Filter + Clone { + warp::path!("artifact" / "cardano-stake-distribution" / String) + .and(warp::get()) + .and(middlewares::with_http_message_service(dependency_manager)) + .and_then(handlers::get_artifact_by_signed_entity_id) +} + +/// GET /artifact/cardano-stake-distribution/epoch/:epoch +fn artifact_cardano_stake_distribution_by_epoch( + dependency_manager: Arc, +) -> impl Filter + Clone { + warp::path!("artifact" / "cardano-stake-distribution" / "epoch" / String) + .and(warp::get()) + .and(middlewares::with_http_message_service(dependency_manager)) + .and_then(handlers::get_artifact_by_epoch) +} + +pub mod handlers { + use crate::http_server::routes::reply; + use crate::services::MessageService; + + use mithril_common::entities::Epoch; + use slog_scope::{debug, warn}; + use std::convert::Infallible; + use std::sync::Arc; + use warp::http::StatusCode; + + pub const LIST_MAX_ITEMS: usize = 20; + + /// List CardanoStakeDistribution artifacts + pub async fn list_artifacts( + http_message_service: Arc, + ) -> Result { + debug!("⇄ HTTP SERVER: artifacts"); + + match http_message_service + .get_cardano_stake_distribution_list_message(LIST_MAX_ITEMS) + .await + { + Ok(message) => Ok(reply::json(&message, StatusCode::OK)), + Err(err) => { + warn!("list_artifacts_cardano_stake_distribution"; "error" => ?err); + Ok(reply::server_error(err)) + } + } + } + + /// Get Artifact by signed entity id + pub async fn get_artifact_by_signed_entity_id( + signed_entity_id: String, + http_message_service: Arc, + ) -> Result { + debug!("⇄ HTTP SERVER: artifact/{signed_entity_id}"); + + match http_message_service + .get_cardano_stake_distribution_message(&signed_entity_id) + .await + { + Ok(Some(message)) => Ok(reply::json(&message, StatusCode::OK)), + Ok(None) => { + warn!("get_cardano_stake_distribution_details::not_found"); + Ok(reply::empty(StatusCode::NOT_FOUND)) + } + Err(err) => { + warn!("get_cardano_stake_distribution_details::error"; "error" => ?err); + Ok(reply::server_error(err)) + } + } + } + + /// Get Artifact by epoch + pub async fn get_artifact_by_epoch( + epoch: String, + http_message_service: Arc, + ) -> Result { + debug!("⇄ HTTP SERVER: artifact/epoch/{epoch}"); + + let artifact_epoch = match epoch.parse::() { + Ok(epoch) => Epoch(epoch), + Err(err) => { + warn!("get_artifact_by_epoch::invalid_epoch"; "error" => ?err); + return Ok(reply::bad_request( + "invalid_epoch".to_string(), + err.to_string(), + )); + } + }; + + match http_message_service + .get_cardano_stake_distribution_message_by_epoch(artifact_epoch) + .await + { + Ok(Some(message)) => Ok(reply::json(&message, StatusCode::OK)), + Ok(None) => { + warn!("get_cardano_stake_distribution_details_by_epoch::not_found"); + Ok(reply::empty(StatusCode::NOT_FOUND)) + } + Err(err) => { + warn!("get_cardano_stake_distribution_details_by_epoch::error"; "error" => ?err); + Ok(reply::server_error(err)) + } + } + } +} + +#[cfg(test)] +pub mod tests { + use anyhow::anyhow; + use serde_json::Value::Null; + use warp::{ + http::{Method, StatusCode}, + test::request, + }; + + use mithril_common::{ + messages::{CardanoStakeDistributionListItemMessage, CardanoStakeDistributionMessage}, + test_utils::apispec::APISpec, + }; + + use crate::{ + http_server::SERVER_BASE_PATH, initialize_dependencies, services::MockMessageService, + }; + + use super::*; + + fn setup_router( + dependency_manager: Arc, + ) -> impl Filter + Clone { + let cors = warp::cors() + .allow_any_origin() + .allow_headers(vec!["content-type"]) + .allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS]); + + warp::any() + .and(warp::path(SERVER_BASE_PATH)) + .and(routes(dependency_manager).with(cors)) + } + + #[tokio::test] + async fn test_cardano_stake_distributions_returns_ok() { + let message = vec![CardanoStakeDistributionListItemMessage::dummy()]; + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_list_message() + .return_once(|_| Ok(message)) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let path = "/artifact/cardano-stake-distributions"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{path}")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + path, + "application/json", + &Null, + &response, + &StatusCode::OK, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distributions_returns_ko_500_when_error() { + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_list_message() + .return_once(|_| Err(anyhow!("an error occured"))) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let path = "/artifact/cardano-stake-distributions"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{path}")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + path, + "application/json", + &Null, + &response, + &StatusCode::INTERNAL_SERVER_ERROR, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_returns_ok() { + let message = CardanoStakeDistributionMessage::dummy(); + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message() + .return_once(|_| Ok(Some(message))) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let path = "/artifact/cardano-stake-distribution/{hash}"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{path}")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + path, + "application/json", + &Null, + &response, + &StatusCode::OK, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_returns_404_not_found_when_no_record() { + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message() + .return_once(|_| Ok(None)) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let path = "/artifact/cardano-stake-distribution/{hash}"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{path}")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + path, + "application/json", + &Null, + &response, + &StatusCode::NOT_FOUND, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_returns_ko_500_when_error() { + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message() + .return_once(|_| Err(anyhow!("an error occured"))) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let path = "/artifact/cardano-stake-distribution/{hash}"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{path}")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + path, + "application/json", + &Null, + &response, + &StatusCode::INTERNAL_SERVER_ERROR, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_by_epoch_returns_ok() { + let message = CardanoStakeDistributionMessage::dummy(); + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message_by_epoch() + .return_once(|_| Ok(Some(message))) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let base_path = "/artifact/cardano-stake-distribution/epoch"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{base_path}/123")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + &format!("{base_path}/{{epoch}}"), + "application/json", + &Null, + &response, + &StatusCode::OK, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_by_epoch_returns_400_bad_request_when_invalid_epoch() { + let mock_http_message_service = MockMessageService::new(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let base_path = "/artifact/cardano-stake-distribution/epoch"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{base_path}/invalid-epoch")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + &format!("{base_path}/{{epoch}}"), + "application/json", + &Null, + &response, + &StatusCode::BAD_REQUEST, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_by_epoch_returns_404_not_found_when_no_record() { + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message_by_epoch() + .return_once(|_| Ok(None)) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let base_path = "/artifact/cardano-stake-distribution/epoch"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{base_path}/123")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + &format!("{base_path}/{{epoch}}"), + "application/json", + &Null, + &response, + &StatusCode::NOT_FOUND, + ) + .unwrap(); + } + + #[tokio::test] + async fn test_cardano_stake_distribution_by_epoch_returns_ko_500_when_error() { + let mut mock_http_message_service = MockMessageService::new(); + mock_http_message_service + .expect_get_cardano_stake_distribution_message_by_epoch() + .return_once(|_| Err(anyhow!("an error occured"))) + .once(); + let mut dependency_manager = initialize_dependencies().await; + dependency_manager.message_service = Arc::new(mock_http_message_service); + + let method = Method::GET.as_str(); + let base_path = "/artifact/cardano-stake-distribution/epoch"; + + let response = request() + .method(method) + .path(&format!("/{SERVER_BASE_PATH}{base_path}/123")) + .reply(&setup_router(Arc::new(dependency_manager))) + .await; + + APISpec::verify_conformity( + APISpec::get_all_spec_files(), + method, + &format!("{base_path}/{{epoch}}"), + "application/json", + &Null, + &response, + &StatusCode::INTERNAL_SERVER_ERROR, + ) + .unwrap(); + } +} diff --git a/mithril-aggregator/src/http_server/routes/artifact_routes/mod.rs b/mithril-aggregator/src/http_server/routes/artifact_routes/mod.rs index 198ff6d1757..2d6b7b8a3ce 100644 --- a/mithril-aggregator/src/http_server/routes/artifact_routes/mod.rs +++ b/mithril-aggregator/src/http_server/routes/artifact_routes/mod.rs @@ -1,3 +1,4 @@ +pub mod cardano_stake_distribution; pub mod cardano_transaction; pub mod mithril_stake_distribution; pub mod snapshot; diff --git a/mithril-aggregator/src/http_server/routes/router.rs b/mithril-aggregator/src/http_server/routes/router.rs index 0854eb164f2..fcedfb2a06e 100644 --- a/mithril-aggregator/src/http_server/routes/router.rs +++ b/mithril-aggregator/src/http_server/routes/router.rs @@ -49,6 +49,9 @@ pub fn routes( .or(artifact_routes::mithril_stake_distribution::routes( dependency_manager.clone(), )) + .or(artifact_routes::cardano_stake_distribution::routes( + dependency_manager.clone(), + )) .or(artifact_routes::cardano_transaction::routes( dependency_manager.clone(), )) diff --git a/mithril-aggregator/src/message_adapters/mod.rs b/mithril-aggregator/src/message_adapters/mod.rs index 754055c9d46..4f6be088c5e 100644 --- a/mithril-aggregator/src/message_adapters/mod.rs +++ b/mithril-aggregator/src/message_adapters/mod.rs @@ -1,5 +1,7 @@ mod from_register_signature; mod from_register_signer; +mod to_cardano_stake_distribution_list_message; +mod to_cardano_stake_distribution_message; mod to_cardano_transaction_list_message; mod to_cardano_transaction_message; mod to_cardano_transactions_proof_message; @@ -13,6 +15,10 @@ mod to_snapshot_message; pub use from_register_signature::FromRegisterSingleSignatureAdapter; pub use from_register_signer::FromRegisterSignerAdapter; #[cfg(test)] +pub use to_cardano_stake_distribution_list_message::ToCardanoStakeDistributionListMessageAdapter; +#[cfg(test)] +pub use to_cardano_stake_distribution_message::ToCardanoStakeDistributionMessageAdapter; +#[cfg(test)] pub use to_cardano_transaction_list_message::ToCardanoTransactionListMessageAdapter; #[cfg(test)] pub use to_cardano_transaction_message::ToCardanoTransactionMessageAdapter; diff --git a/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_list_message.rs b/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_list_message.rs new file mode 100644 index 00000000000..08278d491a5 --- /dev/null +++ b/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_list_message.rs @@ -0,0 +1,57 @@ +use mithril_common::entities::{CardanoStakeDistribution, SignedEntity}; +use mithril_common::messages::{ + CardanoStakeDistributionListItemMessage, CardanoStakeDistributionListMessage, ToMessageAdapter, +}; + +/// Adapter to convert a list of [CardanoStakeDistribution] to [CardanoStakeDistributionListMessage] instances +#[allow(dead_code)] +pub struct ToCardanoStakeDistributionListMessageAdapter; + +impl + ToMessageAdapter< + Vec>, + CardanoStakeDistributionListMessage, + > for ToCardanoStakeDistributionListMessageAdapter +{ + /// Method to trigger the conversion + fn adapt( + snapshots: Vec>, + ) -> CardanoStakeDistributionListMessage { + snapshots + .into_iter() + .map(|entity| CardanoStakeDistributionListItemMessage { + // The epoch stored in the signed entity type beacon corresponds to epoch + // at the end of which the Cardano stake distribution is computed by the Cardano node. + epoch: entity.signed_entity_type.get_epoch(), + hash: entity.artifact.hash, + certificate_hash: entity.certificate_id, + created_at: entity.created_at, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapt_ok() { + let signed_entity = SignedEntity::::dummy(); + let cardano_stake_distribution_list_message_expected = + vec![CardanoStakeDistributionListItemMessage { + epoch: signed_entity.artifact.epoch, + hash: signed_entity.artifact.hash.clone(), + certificate_hash: signed_entity.certificate_id.clone(), + created_at: signed_entity.created_at, + }]; + + let cardano_stake_distribution_list_message = + ToCardanoStakeDistributionListMessageAdapter::adapt(vec![signed_entity]); + + assert_eq!( + cardano_stake_distribution_list_message_expected, + cardano_stake_distribution_list_message + ); + } +} diff --git a/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_message.rs b/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_message.rs new file mode 100644 index 00000000000..d0e0dd838a2 --- /dev/null +++ b/mithril-aggregator/src/message_adapters/to_cardano_stake_distribution_message.rs @@ -0,0 +1,48 @@ +use mithril_common::entities::{CardanoStakeDistribution, SignedEntity}; +use mithril_common::messages::{CardanoStakeDistributionMessage, ToMessageAdapter}; + +/// Adapter to convert [CardanoStakeDistribution] to [CardanoStakeDistributionMessage] instances +#[allow(dead_code)] +pub struct ToCardanoStakeDistributionMessageAdapter; + +impl ToMessageAdapter, CardanoStakeDistributionMessage> + for ToCardanoStakeDistributionMessageAdapter +{ + /// Method to trigger the conversion + fn adapt(from: SignedEntity) -> CardanoStakeDistributionMessage { + CardanoStakeDistributionMessage { + // The epoch stored in the signed entity type beacon corresponds to epoch + // at the end of which the Cardano stake distribution is computed by the Cardano node. + epoch: from.signed_entity_type.get_epoch(), + hash: from.artifact.hash, + certificate_hash: from.certificate_id, + stake_distribution: from.artifact.stake_distribution, + created_at: from.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapt_ok() { + let signed_entity = SignedEntity::::dummy(); + let cardano_stake_distribution_message_expected = CardanoStakeDistributionMessage { + epoch: signed_entity.artifact.epoch, + hash: signed_entity.artifact.hash.clone(), + certificate_hash: signed_entity.certificate_id.clone(), + stake_distribution: signed_entity.artifact.stake_distribution.clone(), + created_at: signed_entity.created_at, + }; + + let cardano_stake_distribution_message = + ToCardanoStakeDistributionMessageAdapter::adapt(signed_entity); + + assert_eq!( + cardano_stake_distribution_message_expected, + cardano_stake_distribution_message + ); + } +} diff --git a/mithril-aggregator/src/services/message.rs b/mithril-aggregator/src/services/message.rs index 231aeab0810..e40c1aac014 100644 --- a/mithril-aggregator/src/services/message.rs +++ b/mithril-aggregator/src/services/message.rs @@ -6,8 +6,9 @@ use async_trait::async_trait; use thiserror::Error; use mithril_common::{ - entities::SignedEntityTypeDiscriminants, + entities::{Epoch, SignedEntityTypeDiscriminants}, messages::{ + CardanoStakeDistributionListMessage, CardanoStakeDistributionMessage, CardanoTransactionSnapshotListMessage, CardanoTransactionSnapshotMessage, CertificateListMessage, CertificateMessage, MithrilStakeDistributionListMessage, MithrilStakeDistributionMessage, SnapshotListMessage, SnapshotMessage, @@ -74,6 +75,24 @@ pub trait MessageService: Sync + Send { &self, limit: usize, ) -> StdResult; + + /// Return the information regarding the Cardano stake distribution for the given identifier. + async fn get_cardano_stake_distribution_message( + &self, + signed_entity_id: &str, + ) -> StdResult>; + + /// Return the information regarding the Cardano stake distribution for the given epoch. + async fn get_cardano_stake_distribution_message_by_epoch( + &self, + epoch: Epoch, + ) -> StdResult>; + + /// Return the list of the last Cardano stake distributions message + async fn get_cardano_stake_distribution_list_message( + &self, + limit: usize, + ) -> StdResult; } /// Implementation of the [MessageService] @@ -186,6 +205,43 @@ impl MessageService for MithrilMessageService { entities.into_iter().map(|i| i.try_into()).collect() } + + async fn get_cardano_stake_distribution_message( + &self, + signed_entity_id: &str, + ) -> StdResult> { + let signed_entity = self + .signed_entity_storer + .get_signed_entity(signed_entity_id) + .await?; + + signed_entity.map(|v| v.try_into()).transpose() + } + + async fn get_cardano_stake_distribution_message_by_epoch( + &self, + epoch: Epoch, + ) -> StdResult> { + let signed_entity = self + .signed_entity_storer + .get_cardano_stake_distribution_signed_entity_by_epoch(epoch) + .await?; + + signed_entity.map(|v| v.try_into()).transpose() + } + + async fn get_cardano_stake_distribution_list_message( + &self, + limit: usize, + ) -> StdResult { + let signed_entity_type_id = SignedEntityTypeDiscriminants::CardanoStakeDistribution; + let entities = self + .signed_entity_storer + .get_last_signed_entities_by_type(&signed_entity_type_id, limit) + .await?; + + entities.into_iter().map(|i| i.try_into()).collect() + } } #[cfg(test)] @@ -193,8 +249,8 @@ mod tests { use std::sync::Arc; use mithril_common::entities::{ - CardanoTransactionsSnapshot, Certificate, Epoch, MithrilStakeDistribution, SignedEntity, - SignedEntityType, Snapshot, + CardanoStakeDistribution, CardanoTransactionsSnapshot, Certificate, Epoch, + MithrilStakeDistribution, SignedEntity, SignedEntityType, Snapshot, }; use mithril_common::messages::ToMessageAdapter; use mithril_common::test_utils::MithrilFixtureBuilder; @@ -203,6 +259,7 @@ mod tests { use crate::database::repository::MockSignedEntityStorer; use crate::dependency_injection::DependenciesBuilder; use crate::message_adapters::{ + ToCardanoStakeDistributionListMessageAdapter, ToCardanoStakeDistributionMessageAdapter, ToCardanoTransactionListMessageAdapter, ToCardanoTransactionMessageAdapter, ToMithrilStakeDistributionListMessageAdapter, ToMithrilStakeDistributionMessageAdapter, ToSnapshotListMessageAdapter, ToSnapshotMessageAdapter, @@ -499,4 +556,128 @@ mod tests { assert_eq!(message, response); } + + #[tokio::test] + async fn get_cardano_stake_distribution() { + let entity = SignedEntity::::dummy(); + let record = SignedEntityRecord { + signed_entity_id: entity.signed_entity_id.clone(), + signed_entity_type: SignedEntityType::CardanoStakeDistribution(entity.artifact.epoch), + certificate_id: entity.certificate_id.clone(), + artifact: serde_json::to_string(&entity.artifact).unwrap(), + created_at: entity.created_at, + }; + let message = ToCardanoStakeDistributionMessageAdapter::adapt(entity); + let configuration = Configuration::new_sample(); + let mut dep_builder = DependenciesBuilder::new(configuration); + let mut storer = MockSignedEntityStorer::new(); + storer + .expect_get_signed_entity() + .return_once(|_| Ok(Some(record))) + .once(); + dep_builder.signed_entity_storer = Some(Arc::new(storer)); + let service = dep_builder.get_message_service().await.unwrap(); + let response = service + .get_cardano_stake_distribution_message("whatever") + .await + .unwrap() + .expect("A CardanoStakeDistributionMessage was expected."); + + assert_eq!(message, response); + } + + #[tokio::test] + async fn get_cardano_stake_distribution_not_exist() { + let configuration = Configuration::new_sample(); + let mut dep_builder = DependenciesBuilder::new(configuration); + let mut storer = MockSignedEntityStorer::new(); + storer + .expect_get_signed_entity() + .return_once(|_| Ok(None)) + .once(); + dep_builder.signed_entity_storer = Some(Arc::new(storer)); + let service = dep_builder.get_message_service().await.unwrap(); + let response = service + .get_cardano_stake_distribution_message("whatever") + .await + .unwrap(); + + assert!(response.is_none()); + } + + #[tokio::test] + async fn get_cardano_stake_distribution_by_epoch() { + let entity = SignedEntity::::dummy(); + let record = SignedEntityRecord { + signed_entity_id: entity.signed_entity_id.clone(), + signed_entity_type: SignedEntityType::CardanoStakeDistribution(entity.artifact.epoch), + certificate_id: entity.certificate_id.clone(), + artifact: serde_json::to_string(&entity.artifact).unwrap(), + created_at: entity.created_at, + }; + let message = ToCardanoStakeDistributionMessageAdapter::adapt(entity.clone()); + let configuration = Configuration::new_sample(); + let mut dep_builder = DependenciesBuilder::new(configuration); + let mut storer = MockSignedEntityStorer::new(); + storer + .expect_get_cardano_stake_distribution_signed_entity_by_epoch() + .return_once(|_| Ok(Some(record))) + .once(); + dep_builder.signed_entity_storer = Some(Arc::new(storer)); + let service = dep_builder.get_message_service().await.unwrap(); + let response = service + .get_cardano_stake_distribution_message_by_epoch(Epoch(999)) + .await + .unwrap() + .expect("A CardanoStakeDistributionMessage was expected."); + + assert_eq!(message, response); + } + + #[tokio::test] + async fn get_cardano_stake_distribution_by_epoch_not_exist() { + let configuration = Configuration::new_sample(); + let mut dep_builder = DependenciesBuilder::new(configuration); + let mut storer = MockSignedEntityStorer::new(); + storer + .expect_get_cardano_stake_distribution_signed_entity_by_epoch() + .return_once(|_| Ok(None)) + .once(); + dep_builder.signed_entity_storer = Some(Arc::new(storer)); + let service = dep_builder.get_message_service().await.unwrap(); + let response = service + .get_cardano_stake_distribution_message_by_epoch(Epoch(999)) + .await + .unwrap(); + + assert!(response.is_none()); + } + + #[tokio::test] + async fn get_cardano_stake_distribution_list_message() { + let entity = SignedEntity::::dummy(); + let records = vec![SignedEntityRecord { + signed_entity_id: entity.signed_entity_id.clone(), + signed_entity_type: SignedEntityType::CardanoStakeDistribution(entity.artifact.epoch), + certificate_id: entity.certificate_id.clone(), + artifact: serde_json::to_string(&entity.artifact).unwrap(), + created_at: entity.created_at, + }]; + let message = ToCardanoStakeDistributionListMessageAdapter::adapt(vec![entity]); + let configuration = Configuration::new_sample(); + let mut dep_builder = DependenciesBuilder::new(configuration); + let mut storer = MockSignedEntityStorer::new(); + storer + .expect_get_last_signed_entities_by_type() + .return_once(|_, _| Ok(records)) + .once(); + dep_builder.signed_entity_storer = Some(Arc::new(storer)); + let service = dep_builder.get_message_service().await.unwrap(); + let response = service + .get_cardano_stake_distribution_list_message(10) + .await + .unwrap(); + + assert_eq!(message, response); + } } diff --git a/mithril-aggregator/src/services/signed_entity.rs b/mithril-aggregator/src/services/signed_entity.rs index dba456a0c4d..2482be8c592 100644 --- a/mithril-aggregator/src/services/signed_entity.rs +++ b/mithril-aggregator/src/services/signed_entity.rs @@ -68,6 +68,13 @@ pub trait SignedEntityService: Send + Sync { &self, signed_entity_id: &str, ) -> StdResult>>; + + /// Return a list of signed Cardano stake distribution order by creation + /// date descending. + async fn get_last_signed_cardano_stake_distributions( + &self, + total: usize, + ) -> StdResult>>; } /// Mithril ArtifactBuilder Service @@ -358,6 +365,25 @@ impl SignedEntityService for MithrilSignedEntityService { Ok(entity) } + + async fn get_last_signed_cardano_stake_distributions( + &self, + total: usize, + ) -> StdResult>> { + let signed_entities_records = self + .get_last_signed_entities( + total, + &SignedEntityTypeDiscriminants::CardanoStakeDistribution, + ) + .await?; + let mut signed_entities: Vec> = Vec::new(); + + for record in signed_entities_records { + signed_entities.push(record.try_into()?); + } + + Ok(signed_entities) + } } #[cfg(test)] diff --git a/mithril-aggregator/tests/cardano_stake_distribution_verify_stakes.rs b/mithril-aggregator/tests/cardano_stake_distribution_verify_stakes.rs new file mode 100644 index 00000000000..6d991f93ba0 --- /dev/null +++ b/mithril-aggregator/tests/cardano_stake_distribution_verify_stakes.rs @@ -0,0 +1,184 @@ +mod test_extensions; + +use mithril_aggregator::Configuration; +use mithril_common::entities::SignerWithStake; +use mithril_common::{ + entities::{ + BlockNumber, CardanoDbBeacon, ChainPoint, Epoch, ProtocolParameters, SignedEntityType, + SignedEntityTypeDiscriminants, SlotNumber, StakeDistribution, StakeDistributionParty, + TimePoint, + }, + test_utils::MithrilFixtureBuilder, +}; +use test_extensions::{utilities::get_test_dir, ExpectedCertificate, RuntimeTester}; + +#[tokio::test] +async fn cardano_stake_distribution_verify_stakes() { + let protocol_parameters = ProtocolParameters { + k: 5, + m: 150, + phi_f: 0.95, + }; + let configuration = Configuration { + protocol_parameters: protocol_parameters.clone(), + signed_entity_types: Some( + SignedEntityTypeDiscriminants::CardanoStakeDistribution.to_string(), + ), + data_stores_directory: get_test_dir("cardano_stake_distribution_verify_stakes"), + ..Configuration::new_sample() + }; + let mut tester = RuntimeTester::build( + TimePoint::new( + 2, + 1, + ChainPoint::new(SlotNumber(10), BlockNumber(1), "block_hash-1"), + ), + configuration, + ) + .await; + + comment!("create signers & declare the initial stake distribution"); + let fixture = MithrilFixtureBuilder::default() + .with_signers(5) + .with_protocol_parameters(protocol_parameters.clone()) + .build(); + let signers = &fixture.signers_fixture(); + + tester.init_state_from_fixture(&fixture).await.unwrap(); + + comment!("Bootstrap the genesis certificate"); + tester.register_genesis_certificate(&fixture).await.unwrap(); + + assert_last_certificate_eq!( + tester, + ExpectedCertificate::new_genesis( + CardanoDbBeacon::new("devnet".to_string(), 2, 1), + fixture.compute_and_encode_avk() + ) + ); + + comment!("Start the runtime state machine and register signers"); + cycle!(tester, "ready"); + tester.register_signers(signers).await.unwrap(); + + comment!("Increase epoch and register signers with a different stake distribution"); + tester.increase_epoch().await.unwrap(); + let signers_with_updated_stake_distribution = fixture + .signers_with_stake() + .iter() + .map(|signer_with_stake| { + SignerWithStake::from_signer( + signer_with_stake.to_owned().into(), + signer_with_stake.stake + 999, + ) + }) + .collect::>(); + let updated_stake_distribution: StakeDistribution = signers_with_updated_stake_distribution + .iter() + .map(|s| (s.party_id.clone(), s.stake)) + .collect(); + tester + .chain_observer + .set_signers(signers_with_updated_stake_distribution) + .await; + cycle!(tester, "idle"); + cycle!(tester, "ready"); + cycle!(tester, "signing"); + tester.register_signers(signers).await.unwrap(); + tester + .send_single_signatures( + SignedEntityTypeDiscriminants::MithrilStakeDistribution, + signers, + ) + .await + .unwrap(); + + comment!("The state machine should issue a certificate for the MithrilStakeDistribution"); + cycle!(tester, "ready"); + assert_last_certificate_eq!( + tester, + ExpectedCertificate::new( + CardanoDbBeacon::new("devnet".to_string(), 3, 1), + StakeDistributionParty::from_signers(fixture.signers_with_stake()).as_slice(), + fixture.compute_and_encode_avk(), + SignedEntityType::MithrilStakeDistribution(Epoch(3)), + ExpectedCertificate::genesis_identifier(&CardanoDbBeacon::new( + "devnet".to_string(), + 2, + 1 + )), + ) + ); + + comment!("Increase epoch and register signers with a different stake distribution"); + let signers_with_updated_stake_distribution = fixture + .signers_with_stake() + .iter() + .map(|signer_with_stake| { + SignerWithStake::from_signer( + signer_with_stake.to_owned().into(), + signer_with_stake.stake + 9999, + ) + }) + .collect::>(); + tester + .chain_observer + .set_signers(signers_with_updated_stake_distribution) + .await; + tester.increase_epoch().await.unwrap(); + cycle!(tester, "idle"); + cycle!(tester, "ready"); + cycle!(tester, "signing"); + tester + .send_single_signatures( + SignedEntityTypeDiscriminants::MithrilStakeDistribution, + signers, + ) + .await + .unwrap(); + + comment!("The state machine should issue a certificate for the MithrilStakeDistribution"); + cycle!(tester, "ready"); + assert_last_certificate_eq!( + tester, + ExpectedCertificate::new( + CardanoDbBeacon::new("devnet".to_string(), 4, 1), + StakeDistributionParty::from_signers(fixture.signers_with_stake()).as_slice(), + fixture.compute_and_encode_avk(), + SignedEntityType::MithrilStakeDistribution(Epoch(4)), + ExpectedCertificate::identifier(&SignedEntityType::MithrilStakeDistribution(Epoch(3))), + ) + ); + + cycle!(tester, "signing"); + tester + .send_single_signatures( + SignedEntityTypeDiscriminants::CardanoStakeDistribution, + signers, + ) + .await + .unwrap(); + + comment!("The state machine should issue a certificate for the CardanoStakeDistribution"); + cycle!(tester, "ready"); + assert_last_certificate_eq!( + tester, + ExpectedCertificate::new( + CardanoDbBeacon::new("devnet".to_string(), 4, 1), + StakeDistributionParty::from_signers(fixture.signers_with_stake()).as_slice(), + fixture.compute_and_encode_avk(), + SignedEntityType::CardanoStakeDistribution(Epoch(3)), + ExpectedCertificate::identifier(&SignedEntityType::MithrilStakeDistribution(Epoch(4))), + ) + ); + + comment!("The message service should return the expected stake distribution"); + let message = tester + .dependencies + .message_service + .get_cardano_stake_distribution_message_by_epoch(Epoch(3)) + .await + .unwrap() + .unwrap(); + assert_eq!(updated_stake_distribution, message.stake_distribution); +} diff --git a/mithril-aggregator/tests/test_extensions/aggregator_observer.rs b/mithril-aggregator/tests/test_extensions/aggregator_observer.rs index def6e353b66..af879a3e031 100644 --- a/mithril-aggregator/tests/test_extensions/aggregator_observer.rs +++ b/mithril-aggregator/tests/test_extensions/aggregator_observer.rs @@ -140,7 +140,13 @@ impl AggregatorObserver { .await? .map(|s| s.signed_entity_type) .as_ref()), - _ => Ok(false), + SignedEntityType::CardanoStakeDistribution(_) => Ok(Some(signed_entity_type_expected) + == self + .signed_entity_service + .get_last_signed_cardano_stake_distributions(1) + .await? + .first() + .map(|s| &s.signed_entity_type)), } } } diff --git a/mithril-common/Cargo.toml b/mithril-common/Cargo.toml index 4286c22068f..3bb12275ae4 100644 --- a/mithril-common/Cargo.toml +++ b/mithril-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-common" -version = "0.4.42" +version = "0.4.43" description = "Common types, interfaces, and utilities for Mithril nodes." authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-common/src/entities/signed_entity.rs b/mithril-common/src/entities/signed_entity.rs index de499ee8c31..bcd96c83d48 100644 --- a/mithril-common/src/entities/signed_entity.rs +++ b/mithril-common/src/entities/signed_entity.rs @@ -7,6 +7,7 @@ use crate::signable_builder::Artifact; #[cfg(any(test, feature = "test_tools"))] use crate::test_utils::fake_data; +use super::CardanoStakeDistribution; #[cfg(any(test, feature = "test_tools"))] use super::{CardanoDbBeacon, Epoch}; @@ -83,3 +84,20 @@ impl SignedEntity { } } } + +impl SignedEntity { + cfg_test_tools! { + /// Create a dummy [SignedEntity] for [CardanoStakeDistribution] entity + pub fn dummy() -> Self { + SignedEntity { + signed_entity_id: "cardano-stake-distribution-id-123".to_string(), + signed_entity_type: SignedEntityType::CardanoStakeDistribution(Epoch(1)), + certificate_id: "certificate-hash-123".to_string(), + artifact: fake_data::cardano_stake_distributions(1)[0].to_owned(), + created_at: DateTime::parse_from_rfc3339("2024-07-29T16:15:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } + } +} diff --git a/mithril-common/src/messages/cardano_stake_distribution.rs b/mithril-common/src/messages/cardano_stake_distribution.rs new file mode 100644 index 00000000000..0059b8a910e --- /dev/null +++ b/mithril-common/src/messages/cardano_stake_distribution.rs @@ -0,0 +1,81 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::entities::Epoch; +use crate::entities::StakeDistribution; + +/// Message structure of a Cardano Stake Distribution +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct CardanoStakeDistributionMessage { + /// Epoch at the end of which the Cardano stake distribution is computed by the Cardano node + pub epoch: Epoch, + + /// Hash of the Cardano Stake Distribution + pub hash: String, + + /// Hash of the associated certificate + pub certificate_hash: String, + + /// Represents the list of participants in the Cardano chain with their associated stake + pub stake_distribution: StakeDistribution, + + /// DateTime of creation + pub created_at: DateTime, +} + +impl CardanoStakeDistributionMessage { + cfg_test_tools! { + /// Return a dummy test entity (test-only). + pub fn dummy() -> Self { + Self { + epoch: Epoch(1), + hash: "hash-123".to_string(), + certificate_hash: "cert-hash-123".to_string(), + stake_distribution: StakeDistribution::from([ + ("pool-123".to_string(), 1000), + ]), + created_at: DateTime::parse_from_rfc3339("2024-07-29T16:15:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn golden_message() -> CardanoStakeDistributionMessage { + CardanoStakeDistributionMessage { + epoch: Epoch(1), + hash: "hash-123".to_string(), + certificate_hash: "cert-hash-123".to_string(), + stake_distribution: StakeDistribution::from([ + ("pool-123".to_string(), 1000), + ("pool-456".to_string(), 2000), + ]), + created_at: DateTime::parse_from_rfc3339("2024-07-29T16:15:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } + + // Test the backward compatibility with possible future upgrades. + #[test] + fn test_v1() { + let json = r#"{ + "epoch": 1, + "hash": "hash-123", + "certificate_hash": "cert-hash-123", + "stake_distribution": { "pool-123": 1000, "pool-456": 2000 }, + "created_at": "2024-07-29T16:15:05.618857482Z" + }"#; + let message: CardanoStakeDistributionMessage = serde_json::from_str(json).expect( + "This JSON is expected to be successfully parsed into a CardanoStakeDistributionMessage instance.", + ); + + assert_eq!(golden_message(), message); + } +} diff --git a/mithril-common/src/messages/cardano_stake_distribution_list.rs b/mithril-common/src/messages/cardano_stake_distribution_list.rs new file mode 100644 index 00000000000..55ea28b1252 --- /dev/null +++ b/mithril-common/src/messages/cardano_stake_distribution_list.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::entities::Epoch; + +/// Message structure of a Cardano Stake Distribution list +pub type CardanoStakeDistributionListMessage = Vec; + +/// Message structure of a Cardano Stake Distribution list item +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CardanoStakeDistributionListItemMessage { + /// Epoch at the end of which the Cardano stake distribution is computed by the Cardano node + pub epoch: Epoch, + + /// Hash of the Cardano Stake Distribution + pub hash: String, + + /// Hash of the associated certificate + pub certificate_hash: String, + + /// Date and time at which the Cardano Stake Distribution was created + pub created_at: DateTime, +} + +impl CardanoStakeDistributionListItemMessage { + /// Return a dummy test entity (test-only). + pub fn dummy() -> Self { + Self { + epoch: Epoch(1), + hash: "hash-123".to_string(), + certificate_hash: "certificate-hash-123".to_string(), + created_at: DateTime::parse_from_rfc3339("2024-07-29T16:15:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn golden_message() -> CardanoStakeDistributionListMessage { + vec![CardanoStakeDistributionListItemMessage { + epoch: Epoch(1), + hash: "hash-123".to_string(), + certificate_hash: "cert-hash-123".to_string(), + created_at: DateTime::parse_from_rfc3339("2024-07-29T16:15:05.618857482Z") + .unwrap() + .with_timezone(&Utc), + }] + } + + // Test the backward compatibility with possible future upgrades. + #[test] + fn test_v1() { + let json = r#"[{ + "epoch": 1, + "hash": "hash-123", + "certificate_hash": "cert-hash-123", + "created_at": "2024-07-29T16:15:05.618857482Z" + }]"#; + let message: CardanoStakeDistributionListMessage = serde_json::from_str(json).expect( + "This JSON is expected to be successfully parsed into a CardanoStakeDistributionListMessage instance.", + ); + + assert_eq!(golden_message(), message); + } +} diff --git a/mithril-common/src/messages/mod.rs b/mithril-common/src/messages/mod.rs index 5346fa2daaa..31a0f8ac1ac 100644 --- a/mithril-common/src/messages/mod.rs +++ b/mithril-common/src/messages/mod.rs @@ -1,6 +1,8 @@ //! Messages module //! This module aims at providing shared structures for API communications. mod aggregator_features; +mod cardano_stake_distribution; +mod cardano_stake_distribution_list; mod cardano_transaction_snapshot; mod cardano_transaction_snapshot_list; mod cardano_transactions_proof; @@ -21,6 +23,10 @@ mod snapshot_list; pub use aggregator_features::{ AggregatorCapabilities, AggregatorFeaturesMessage, CardanoTransactionsProverCapabilities, }; +pub use cardano_stake_distribution::CardanoStakeDistributionMessage; +pub use cardano_stake_distribution_list::{ + CardanoStakeDistributionListItemMessage, CardanoStakeDistributionListMessage, +}; pub use cardano_transaction_snapshot::CardanoTransactionSnapshotMessage; pub use cardano_transaction_snapshot_list::{ CardanoTransactionSnapshotListItemMessage, CardanoTransactionSnapshotListMessage, diff --git a/mithril-common/src/test_utils/fake_data.rs b/mithril-common/src/test_utils/fake_data.rs index ff28346d34a..262a9e5da67 100644 --- a/mithril-common/src/test_utils/fake_data.rs +++ b/mithril-common/src/test_utils/fake_data.rs @@ -7,7 +7,7 @@ use crate::crypto_helper::{self, ProtocolMultiSignature}; use crate::entities::{ self, BlockNumber, CertificateMetadata, CertificateSignature, CompressionAlgorithm, Epoch, LotteryIndex, ProtocolMessage, ProtocolMessagePartKey, SignedEntityType, SingleSignatures, - SlotNumber, StakeDistributionParty, + SlotNumber, StakeDistribution, StakeDistributionParty, }; use crate::test_utils::MithrilFixtureBuilder; @@ -202,7 +202,8 @@ pub fn snapshots(total: u64) -> Vec { (1..total + 1) .map(|snapshot_id| { let digest = format!("1{snapshot_id}").repeat(20); - let beacon = beacon(); + let mut beacon = beacon(); + beacon.immutable_file_number += snapshot_id; let certificate_hash = "123".to_string(); let size = snapshot_id * 100000; let cardano_node_version = Version::parse("1.0.0").unwrap(); @@ -258,3 +259,20 @@ pub const fn transaction_hashes<'a>() -> [&'a str; 5] { "f4fd91dccc25fd63f2caebab3d3452bc4b2944fcc11652214a3e8f1d32b09713", ] } + +/// Fake Cardano Stake Distributions +pub fn cardano_stake_distributions(total: u64) -> Vec { + (1..total + 1) + .map(|epoch_idx| cardano_stake_distribution(Epoch(epoch_idx))) + .collect::>() +} + +/// Fake Cardano Stake Distribution +pub fn cardano_stake_distribution(epoch: Epoch) -> entities::CardanoStakeDistribution { + let stake_distribution = StakeDistribution::from([("pool-1".to_string(), 100)]); + entities::CardanoStakeDistribution { + hash: format!("hash-epoch-{epoch}"), + epoch, + stake_distribution, + } +} diff --git a/mithril-test-lab/mithril-devnet/mkfiles/mkfiles-mithril-delegation.sh b/mithril-test-lab/mithril-devnet/mkfiles/mkfiles-mithril-delegation.sh index cc91916fd3d..c8756f705e0 100644 --- a/mithril-test-lab/mithril-devnet/mkfiles/mkfiles-mithril-delegation.sh +++ b/mithril-test-lab/mithril-devnet/mkfiles/mkfiles-mithril-delegation.sh @@ -85,7 +85,7 @@ done # Prepare transactions for delegating to stake pools for N in ${POOL_NODES_N}; do cat >> delegate.sh < StdResult { + let url = format!("{aggregator_endpoint}/artifact/cardano-stake-distributions"); + info!("Waiting for the aggregator to produce a Cardano stake distribution"); + + match attempt!(45, Duration::from_millis(2000), { + match reqwest::get(url.clone()).await { + Ok(response) => match response.status() { + StatusCode::OK => match response.json::().await.as_deref() { + Ok([stake_distribution, ..]) => Ok(Some(stake_distribution.hash.clone())), + Ok(&[]) => Ok(None), + Err(err) => Err(anyhow!("Invalid Cardano stake distribution body : {err}",)), + }, + s => Err(anyhow!("Unexpected status code from Aggregator: {s}")), + }, + Err(err) => Err(anyhow!(err).context(format!("Request to `{url}` failed"))), + } + }) { + AttemptResult::Ok(hash) => { + info!("Aggregator produced a Cardano stake distribution"; "hash" => &hash); + Ok(hash) + } + AttemptResult::Err(error) => Err(error), + AttemptResult::Timeout() => Err(anyhow!( + "Timeout exhausted assert_node_producing_cardano_stake_distribution, no response from `{url}`" + )), + } +} + +pub async fn assert_signer_is_signing_cardano_stake_distribution( + aggregator_endpoint: &str, + hash: &str, + expected_epoch_min: Epoch, +) -> StdResult { + let url = format!("{aggregator_endpoint}/artifact/cardano-stake-distribution/{hash}"); + info!( + "Asserting the aggregator is signing the Cardano stake distribution message `{}` with an expected min epoch of `{}`", + hash, + expected_epoch_min + ); + + match attempt!(10, Duration::from_millis(1000), { + match reqwest::get(url.clone()).await { + Ok(response) => match response.status() { + StatusCode::OK => match response.json::().await { + Ok(stake_distribution) => match stake_distribution.epoch { + epoch if epoch >= expected_epoch_min => Ok(Some(stake_distribution)), + epoch => Err(anyhow!( + "Minimum expected Cardano stake distribution epoch not reached : {epoch} < {expected_epoch_min}" + )), + }, + Err(err) => Err(anyhow!(err).context("Invalid Cardano stake distribution body",)), + }, + StatusCode::NOT_FOUND => Ok(None), + s => Err(anyhow!("Unexpected status code from Aggregator: {s}")), + }, + Err(err) => Err(anyhow!(err).context(format!("Request to `{url}` failed"))), + } + }) { + AttemptResult::Ok(cardano_stake_distribution) => { + info!("Signer signed a Cardano stake distribution"; "certificate_hash" => &cardano_stake_distribution.certificate_hash); + Ok(cardano_stake_distribution.certificate_hash) + } + AttemptResult::Err(error) => Err(error), + AttemptResult::Timeout() => Err(anyhow!( + "Timeout exhausted assert_signer_is_signing_cardano_stake_distribution, no response from `{url}`" + )), + } +} + pub async fn assert_is_creating_certificate_with_enough_signers( aggregator_endpoint: &str, certificate_hash: &str, diff --git a/mithril-test-lab/mithril-end-to-end/src/end_to_end_spec.rs b/mithril-test-lab/mithril-end-to-end/src/end_to_end_spec.rs index 4397db3f630..81c52ea0481 100644 --- a/mithril-test-lab/mithril-end-to-end/src/end_to_end_spec.rs +++ b/mithril-test-lab/mithril-end-to-end/src/end_to_end_spec.rs @@ -71,6 +71,7 @@ impl<'a> Spec<'a> { ) .await?; + let expected_epoch_min = target_epoch - 3; // Verify that mithril stake distribution artifacts are produced and signed correctly { let hash = @@ -79,7 +80,7 @@ impl<'a> Spec<'a> { let certificate_hash = assertions::assert_signer_is_signing_mithril_stake_distribution( &aggregator_endpoint, &hash, - target_epoch - 3, + expected_epoch_min, ) .await?; assertions::assert_is_creating_certificate_with_enough_signers( @@ -99,7 +100,7 @@ impl<'a> Spec<'a> { let certificate_hash = assertions::assert_signer_is_signing_snapshot( &aggregator_endpoint, &digest, - target_epoch - 3, + expected_epoch_min, ) .await?; @@ -121,7 +122,7 @@ impl<'a> Spec<'a> { let certificate_hash = assertions::assert_signer_is_signing_cardano_transactions( &aggregator_endpoint, &hash, - target_epoch - 3, + expected_epoch_min, ) .await?; @@ -141,6 +142,29 @@ impl<'a> Spec<'a> { .await?; } + // Verify that Cardano stake distribution artifacts are produced and signed correctly + if self.infrastructure.is_signing_cardano_stake_distribution() { + { + let hash = assertions::assert_node_producing_cardano_stake_distribution( + &aggregator_endpoint, + ) + .await?; + let certificate_hash = + assertions::assert_signer_is_signing_cardano_stake_distribution( + &aggregator_endpoint, + &hash, + expected_epoch_min, + ) + .await?; + assertions::assert_is_creating_certificate_with_enough_signers( + &aggregator_endpoint, + &certificate_hash, + self.infrastructure.signers().len(), + ) + .await?; + } + } + Ok(()) } } diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs index 1a6bea1875c..8e1e67fad5b 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs @@ -41,6 +41,7 @@ pub struct MithrilInfrastructure { cardano_chain_observer: Arc, run_only_mode: bool, is_signing_cardano_transactions: bool, + is_signing_cardano_stake_distribution: bool, } impl MithrilInfrastructure { @@ -90,6 +91,11 @@ impl MithrilInfrastructure { .as_ref() .to_string(), ), + is_signing_cardano_stake_distribution: config.signed_entity_types.contains( + &SignedEntityTypeDiscriminants::CardanoStakeDistribution + .as_ref() + .to_string(), + ), }) } @@ -285,6 +291,10 @@ impl MithrilInfrastructure { self.is_signing_cardano_transactions } + pub fn is_signing_cardano_stake_distribution(&self) -> bool { + self.is_signing_cardano_stake_distribution + } + pub async fn tail_logs(&self, number_of_line: u64) -> StdResult<()> { self.aggregator().tail_logs(number_of_line).await?; for signer in self.signers() { diff --git a/openapi.yaml b/openapi.yaml index 52093d0d2c7..b0903a574ea 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -290,6 +290,91 @@ paths: schema: $ref: "#/components/schemas/Error" + /artifact/cardano-stake-distributions: + get: + summary: Get most recent Cardano stake distributions + description: | + Returns the list of the most recent Cardano stake distributions + responses: + "200": + description: Cardano stake distribution found + content: + application/json: + schema: + $ref: "#/components/schemas/CardanoStakeDistributionListMessage" + "412": + description: API version mismatch + default: + description: Cardano stake distribution retrieval error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /artifact/cardano-stake-distribution/{hash}: + get: + summary: Get Cardano stake distribution information + description: | + Returns the information of a Cardano stake distribution + parameters: + - name: hash + in: path + description: Hash of the Cardano stake distribution to retrieve + required: true + schema: + type: string + format: bytes + example: "6da2b104ed68481ef829d72d72c2f6a20142916d17985e01774b14ed49f0fea1" + responses: + "200": + description: Cardano stake distribution found + content: + application/json: + schema: + $ref: "#/components/schemas/CardanoStakeDistributionMessage" + "404": + description: Cardano stake distribution not found + "412": + description: API version mismatch + default: + description: Cardano stake distribution retrieval error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /artifact/cardano-stake-distribution/epoch/{epoch}: + get: + summary: Get Cardano stake distribution information for a specific epoch + description: | + Returns the information of a Cardano stake distribution at a given epoch + parameters: + - name: epoch + in: path + description: Epoch of the Cardano stake distribution to retrieve + required: true + schema: + type: integer + format: int64 + example: 419 + responses: + "200": + description: Cardano stake distribution found + content: + application/json: + schema: + $ref: "#/components/schemas/CardanoStakeDistributionMessage" + "404": + description: Cardano stake distribution not found + "412": + description: API version mismatch + default: + description: Cardano stake distribution retrieval error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /artifact/cardano-transactions: get: summary: Get most recent Cardano transactions set snapshots @@ -1590,6 +1675,98 @@ components: "protocol_parameters": { "k": 5, "m": 100, "phi_f": 0.65 } } + StakeDistribution: + description: The list of Stake Pool Operator pool identifiers with their associated stake in the Cardano chain + properties: + code: + type: string + text: + type: integer + example: + { + "pool15ka28a4a3qxgcgh60wavkylku4vqjg385jezsrqxlafyrhahf02": 1192520901428, + "pool1aymf474uv528zafxlpfg3yr55zp267wj5mpu4qt557z5k5frn9p": 1009503382720 + } + + CardanoStakeDistributionListMessage: + description: CardanoStakeDistributionListMessage represents a list of Cardano stake distribution + type: array + items: + type: object + additionalProperties: false + required: + - epoch + - hash + - certificate_hash + - created_at + properties: + epoch: + description: Epoch at the end of which the Cardano stake distribution is computed by the Cardano node + $ref: "#/components/schemas/Epoch" + hash: + description: Hash of the Cardano stake distribution + type: string + format: bytes + certificate_hash: + description: Hash of the associated certificate + type: string + format: bytes + created_at: + description: Date and time at which the Cardano stake distribution was created + type: string + format: date-time, + example: + { + "epoch": 123, + "hash": "6367ee65d0d1272e6e70736a1ea2cae34015874517f6328364f6b73930966732", + "certificate_hash": "7905e83ab5d7bc082c1bbc3033bfd19c539078830d19080d1f241c70aa532572", + "created_at": "2022-06-14T10:52:31Z" + } + + CardanoStakeDistributionMessage: + description: This message represents a Cardano stake distribution. + type: object + additionalProperties: false + required: + - epoch + - hash + - certificate_hash + - stake_distribution + - created_at + properties: + epoch: + description: Epoch at the end of which the Cardano stake distribution is computed by the Cardano node + $ref: "#/components/schemas/Epoch" + hash: + description: Hash of the Cardano stake distribution + type: string + format: bytes + certificate_hash: + description: Hash of the associated certificate + type: string + format: bytes + stake_distribution: + description: The list of Stake Pool Operator pool identifiers with their associated stake in the Cardano chain + type: object + additionalProperties: + $ref: "#/components/schemas/StakeDistribution" + created_at: + description: Date and time of the entity creation + type: string + format: date-time, + example: + { + "epoch": 123, + "hash": "6367ee65d0d1272e6e70736a1ea2cae34015874517f6328364f6b73930966732", + "certificate_hash": "7905e83ab5d7bc082c1bbc3033bfd19c539078830d19080d1f241c70aa532572", + "stake_distribution": + { + "pool15ka28a4a3qxgcgh60wavkylku4vqjg385jezsrqxlafyrhahf02": 1192520901428, + "pool1aymf474uv528zafxlpfg3yr55zp267wj5mpu4qt557z5k5frn9p": 1009503382720 + }, + "created_at": "2022-06-14T10:52:31Z" + } + CardanoTransactionSnapshotListMessage: description: CardanoTransactionSnapshotListMessage represents a list of Cardano transactions set snapshots type: array