From b947c7d15e23c1904ba7eac2cdc71bc2e749c313 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Thu, 18 Jun 2026 12:12:59 +0200 Subject: [PATCH 1/6] feat: accelerate startup by one epoch Clamp the signer retrieval epoch to epoch zero when computing the network configuration, so the aggregator can start as soon as the devnet is at epoch zero. --- internal/mithril-protocol-config/src/http.rs | 5 +--- .../src/services/epoch_service.rs | 5 +--- .../network_configuration_provider.rs | 6 +---- .../src/store/epoch_settings_storer.rs | 4 ++- mithril-common/src/entities/epoch.rs | 26 +++++++++++++++++++ .../src/mithril/infrastructure.rs | 8 ++++++ 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/internal/mithril-protocol-config/src/http.rs b/internal/mithril-protocol-config/src/http.rs index ac29d6fe764..36a1bbb3e67 100644 --- a/internal/mithril-protocol-config/src/http.rs +++ b/internal/mithril-protocol-config/src/http.rs @@ -64,10 +64,7 @@ impl MithrilNetworkConfigurationProvider for HttpMithrilNetworkConfigurationProv &self, epoch: Epoch, ) -> StdResult { - let aggregation_epoch = - epoch.offset_to_signer_retrieval_epoch().with_context(|| { - format!("MithrilNetworkConfigurationProvider could not compute aggregation epoch from epoch: {epoch}") - })?; + let aggregation_epoch = epoch.offset_to_signer_retrieval_epoch_saturating(); let next_aggregation_epoch = epoch.offset_to_next_signer_retrieval_epoch(); let registration_epoch = epoch.offset_to_next_signer_retrieval_epoch().next(); diff --git a/mithril-aggregator/src/services/epoch_service.rs b/mithril-aggregator/src/services/epoch_service.rs index 4edc7a761b6..3b49bd22f54 100644 --- a/mithril-aggregator/src/services/epoch_service.rs +++ b/mithril-aggregator/src/services/epoch_service.rs @@ -296,10 +296,7 @@ impl EpochService for MithrilEpochService { let mithril_era = self.era_checker.current_era(); - let signer_retrieval_epoch = - epoch.offset_to_signer_retrieval_epoch().with_context(|| { - format!("EpochService could not compute signer retrieval epoch from epoch: {epoch}") - })?; + let signer_retrieval_epoch = epoch.offset_to_signer_retrieval_epoch_saturating(); let next_signer_retrieval_epoch = epoch.offset_to_next_signer_retrieval_epoch(); let signer_registration_epoch = epoch.offset_to_recording_epoch(); diff --git a/mithril-aggregator/src/services/network_configuration_provider.rs b/mithril-aggregator/src/services/network_configuration_provider.rs index da25fad59cd..6a16cf91c34 100644 --- a/mithril-aggregator/src/services/network_configuration_provider.rs +++ b/mithril-aggregator/src/services/network_configuration_provider.rs @@ -1,7 +1,6 @@ use std::collections::BTreeSet; use std::sync::Arc; -use anyhow::Context; use async_trait::async_trait; use slog::{Logger, warn}; @@ -96,10 +95,7 @@ impl MithrilNetworkConfigurationProvider for LocalMithrilNetworkConfigurationPro &self, epoch: Epoch, ) -> StdResult { - let aggregation_epoch = - epoch.offset_to_signer_retrieval_epoch().with_context(|| { - format!("MithrilNetworkConfigurationProvider could not compute aggregation epoch from epoch: {epoch}") - })?; + let aggregation_epoch = epoch.offset_to_signer_retrieval_epoch_saturating(); let next_aggregation_epoch = epoch.offset_to_next_signer_retrieval_epoch(); let registration_epoch = epoch.offset_to_next_signer_retrieval_epoch().next(); diff --git a/mithril-aggregator/src/store/epoch_settings_storer.rs b/mithril-aggregator/src/store/epoch_settings_storer.rs index 2a6f37f9b86..2ea55f77045 100644 --- a/mithril-aggregator/src/store/epoch_settings_storer.rs +++ b/mithril-aggregator/src/store/epoch_settings_storer.rs @@ -47,7 +47,9 @@ pub trait EpochSettingsStorer: ) -> StdResult<()> { for (epoch, epoch_configuration) in [ ( - network_configuration.epoch.offset_to_signer_retrieval_epoch()?, + network_configuration + .epoch + .offset_to_signer_retrieval_epoch_saturating(), &network_configuration.configuration_for_aggregation, ), ( diff --git a/mithril-common/src/entities/epoch.rs b/mithril-common/src/entities/epoch.rs index 60aef7a6320..f3690fadbdd 100644 --- a/mithril-common/src/entities/epoch.rs +++ b/mithril-common/src/entities/epoch.rs @@ -62,6 +62,16 @@ impl Epoch { self.offset_by(Self::SIGNER_RETRIEVAL_OFFSET) } + /// Apply the [retrieval offset][Self::SIGNER_RETRIEVAL_OFFSET] to this epoch, saturating at + /// epoch zero. + /// + /// Unlike [offset_to_signer_retrieval_epoch][Self::offset_to_signer_retrieval_epoch], this + /// returns epoch zero instead of failing when the offset would yield a negative epoch + /// (i.e. at epoch zero itself). + pub fn offset_to_signer_retrieval_epoch_saturating(&self) -> Self { + self.offset_to_signer_retrieval_epoch().unwrap_or(Epoch(0)) + } + /// Apply the [next signer retrieval offset][Self::NEXT_SIGNER_RETRIEVAL_OFFSET] to this epoch pub fn offset_to_next_signer_retrieval_epoch(&self) -> Self { *self + Self::NEXT_SIGNER_RETRIEVAL_OFFSET @@ -308,6 +318,22 @@ mod tests { assert!(Epoch(0).previous().is_err()); } + #[test] + fn offset_to_signer_retrieval_epoch_saturating_clamps_at_epoch_zero() { + assert_eq!( + Epoch(2), + Epoch(3).offset_to_signer_retrieval_epoch_saturating() + ); + assert_eq!( + Epoch(0), + Epoch(1).offset_to_signer_retrieval_epoch_saturating() + ); + assert_eq!( + Epoch(0), + Epoch(0).offset_to_signer_retrieval_epoch_saturating() + ); + } + #[test] fn test_next() { assert_eq!(Epoch(4), Epoch(3).next()); 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 9aab6571419..9ccabdc8b82 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 @@ -123,6 +123,14 @@ impl MithrilInfrastructure { .await?; Self::register_startup_era(&toolkit, &leader_aggregator, config).await?; + toolkit + .wait + .for_aggregator_at_target_epoch( + &leader_aggregator, + Epoch(1), + "minimal epoch for the aggregator to handle startup discrepancies".to_string(), + ) + .await?; leader_aggregator.serve().await?; let follower_aggregator_endpoints = follower_aggregators From 7682c5880102cc06ce104354c6d84d535596c9eb Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Mon, 27 Jul 2026 13:15:48 +0200 Subject: [PATCH 2/6] fix(common): saturate the signer retrieval epoch with direct arithmetic Compute the clamped epoch on the underlying integer instead of discarding the error, so unrelated offset failures are no longer reported as epoch zero. --- mithril-common/src/entities/epoch.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mithril-common/src/entities/epoch.rs b/mithril-common/src/entities/epoch.rs index f3690fadbdd..29533f61e40 100644 --- a/mithril-common/src/entities/epoch.rs +++ b/mithril-common/src/entities/epoch.rs @@ -69,7 +69,7 @@ impl Epoch { /// returns epoch zero instead of failing when the offset would yield a negative epoch /// (i.e. at epoch zero itself). pub fn offset_to_signer_retrieval_epoch_saturating(&self) -> Self { - self.offset_to_signer_retrieval_epoch().unwrap_or(Epoch(0)) + Epoch(self.0.saturating_sub(Self::SIGNER_RETRIEVAL_OFFSET.unsigned_abs())) } /// Apply the [next signer retrieval offset][Self::NEXT_SIGNER_RETRIEVAL_OFFSET] to this epoch @@ -332,6 +332,10 @@ mod tests { Epoch(0), Epoch(0).offset_to_signer_retrieval_epoch_saturating() ); + assert_eq!( + Epoch(u64::MAX - 1), + Epoch(u64::MAX).offset_to_signer_retrieval_epoch_saturating() + ); } #[test] From 57004a860a1d29fcb08e7c93e0bae8113403d7fe Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Mon, 27 Jul 2026 15:24:12 +0200 Subject: [PATCH 3/6] fix(common): compute the epoch offset over the whole epoch range Replace the i64 cast in 'offset_by' with checked signed arithmetic, which no longer truncates above i64::MAX nor wraps above u64::MAX. --- mithril-common/src/entities/epoch.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/mithril-common/src/entities/epoch.rs b/mithril-common/src/entities/epoch.rs index 29533f61e40..7b10bc7d8f5 100644 --- a/mithril-common/src/entities/epoch.rs +++ b/mithril-common/src/entities/epoch.rs @@ -48,13 +48,12 @@ impl Epoch { /// Computes a new Epoch by applying an epoch offset. /// - /// Will fail if the computed epoch is negative. + /// Will fail if the computed epoch is out of the [Epoch] range. pub fn offset_by(&self, epoch_offset: i64) -> Result { - let epoch_new = self.0 as i64 + epoch_offset; - if epoch_new < 0 { - return Err(EpochError::EpochOffset(self.0, epoch_offset)); - } - Ok(Epoch(epoch_new as u64)) + self.0 + .checked_add_signed(epoch_offset) + .map(Epoch) + .ok_or(EpochError::EpochOffset(self.0, epoch_offset)) } /// Apply the [retrieval offset][Self::SIGNER_RETRIEVAL_OFFSET] to this epoch @@ -69,7 +68,7 @@ impl Epoch { /// returns epoch zero instead of failing when the offset would yield a negative epoch /// (i.e. at epoch zero itself). pub fn offset_to_signer_retrieval_epoch_saturating(&self) -> Self { - Epoch(self.0.saturating_sub(Self::SIGNER_RETRIEVAL_OFFSET.unsigned_abs())) + Epoch(self.0.saturating_add_signed(Self::SIGNER_RETRIEVAL_OFFSET)) } /// Apply the [next signer retrieval offset][Self::NEXT_SIGNER_RETRIEVAL_OFFSET] to this epoch @@ -312,6 +311,19 @@ mod tests { assert_eq!(Epoch(0), Epoch(1) - 5_u64); } + #[test] + fn offset_by_covers_the_whole_epoch_range() { + assert_eq!(Epoch(2), Epoch(3).offset_by(-1).unwrap()); + assert_eq!(Epoch(4), Epoch(3).offset_by(1).unwrap()); + assert_eq!(Epoch(u64::MAX - 1), Epoch(u64::MAX).offset_by(-1).unwrap()); + } + + #[test] + fn offset_by_fails_when_the_computed_epoch_is_out_of_range() { + assert!(Epoch(0).offset_by(-1).is_err()); + assert!(Epoch(u64::MAX).offset_by(1).is_err()); + } + #[test] fn test_previous() { assert_eq!(Epoch(2), Epoch(3).previous().unwrap()); From 1a8c0f366cd3ce80d3126a0c9a2e1b77be37f76a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Mon, 27 Jul 2026 16:51:29 +0200 Subject: [PATCH 4/6] fixup! fix(common): saturate the signer retrieval epoch with direct arithmetic --- mithril-common/src/entities/epoch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mithril-common/src/entities/epoch.rs b/mithril-common/src/entities/epoch.rs index 7b10bc7d8f5..3943c43b795 100644 --- a/mithril-common/src/entities/epoch.rs +++ b/mithril-common/src/entities/epoch.rs @@ -48,7 +48,7 @@ impl Epoch { /// Computes a new Epoch by applying an epoch offset. /// - /// Will fail if the computed epoch is out of the [Epoch] range. + /// Will fail if overflow occurred (see [u64] for bounds). pub fn offset_by(&self, epoch_offset: i64) -> Result { self.0 .checked_add_signed(epoch_offset) @@ -319,7 +319,7 @@ mod tests { } #[test] - fn offset_by_fails_when_the_computed_epoch_is_out_of_range() { + fn offset_by_fails_when_overflow_occurred() { assert!(Epoch(0).offset_by(-1).is_err()); assert!(Epoch(u64::MAX).offset_by(1).is_err()); } From 9ff24932a7e8f23802966a9d61265c2dd013e582 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Mon, 27 Jul 2026 16:57:15 +0200 Subject: [PATCH 5/6] test(aggregator): cover the aggregator startup at epoch zero Check that `inform_epoch` and the epoch settings discrepancy handling succeed at epoch zero with a clamped signer retrieval epoch. --- .../src/services/epoch_service.rs | 25 +++++++++++- .../src/store/epoch_settings_storer.rs | 39 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mithril-aggregator/src/services/epoch_service.rs b/mithril-aggregator/src/services/epoch_service.rs index 3b49bd22f54..474f682fcdc 100644 --- a/mithril-aggregator/src/services/epoch_service.rs +++ b/mithril-aggregator/src/services/epoch_service.rs @@ -954,7 +954,7 @@ mod tests { async fn build(self) -> MithrilEpochService { let signer_retrieval_epoch = - self.current_epoch.offset_to_signer_retrieval_epoch().unwrap(); + self.current_epoch.offset_to_signer_retrieval_epoch_saturating(); let next_signer_retrieval_epoch = self.current_epoch.offset_to_next_signer_retrieval_epoch(); @@ -1095,6 +1095,29 @@ mod tests { ); } + #[tokio::test] + async fn inform_epoch_at_epoch_zero_retrieves_signers_at_epoch_zero() { + let epoch = Epoch(0); + let epoch_fixture = MithrilFixtureBuilder::default().with_signers(3).build(); + + let mut service = EpochServiceBuilder::new(epoch, epoch_fixture.clone()).build().await; + + service + .inform_epoch(epoch) + .await + .expect("inform_epoch should not fail at epoch zero"); + + assert_eq!(epoch, service.epoch_of_current_data().unwrap()); + assert_eq!( + epoch_fixture.signers(), + service.current_signers().unwrap().clone() + ); + assert_eq!( + epoch_fixture.signers(), + service.next_signers().unwrap().clone() + ); + } + #[tokio::test] async fn inform_epoch_get_signed_entity_config_from_its_dependencies_and_store() { let epoch = Epoch(5); diff --git a/mithril-aggregator/src/store/epoch_settings_storer.rs b/mithril-aggregator/src/store/epoch_settings_storer.rs index 2ea55f77045..e1b96dd44e9 100644 --- a/mithril-aggregator/src/store/epoch_settings_storer.rs +++ b/mithril-aggregator/src/store/epoch_settings_storer.rs @@ -244,4 +244,43 @@ mod tests { let epoch_settings_stored = store.get_epoch_settings(epoch + 2).await.unwrap(); assert!(epoch_settings_stored.is_none()); } + + #[tokio::test] + async fn test_handle_discrepancies_at_startup_at_epoch_zero_clamps_the_aggregation_epoch() { + let epoch_settings = AggregatorEpochSettings::dummy(); + let mut aggregation_epoch_settings = epoch_settings.clone(); + aggregation_epoch_settings.protocol_parameters.k += 15; + + let mut next_aggregation_epoch_settings = epoch_settings.clone(); + next_aggregation_epoch_settings.protocol_parameters.k += 26; + + let mut registration_epoch_settings = epoch_settings.clone(); + registration_epoch_settings.protocol_parameters.k += 37; + + let epoch = Epoch(0); + let store = FakeEpochSettingsStorer::new(vec![]); + store + .handle_discrepancies_at_startup(&MithrilNetworkConfiguration { + epoch, + configuration_for_aggregation: aggregation_epoch_settings + .clone() + .into_network_configuration_for_epoch(BTreeSet::new()), + configuration_for_next_aggregation: next_aggregation_epoch_settings + .into_network_configuration_for_epoch(BTreeSet::new()), + configuration_for_registration: registration_epoch_settings + .clone() + .into_network_configuration_for_epoch(BTreeSet::new()), + }) + .await + .unwrap(); + + let epoch_settings_stored = store.get_epoch_settings(Epoch(0)).await.unwrap(); + assert_eq!(Some(aggregation_epoch_settings), epoch_settings_stored); + + let epoch_settings_stored = store.get_epoch_settings(Epoch(1)).await.unwrap(); + assert_eq!(Some(registration_epoch_settings), epoch_settings_stored); + + let epoch_settings_stored = store.get_epoch_settings(Epoch(2)).await.unwrap(); + assert!(epoch_settings_stored.is_none()); + } } From d86100fa0e269775b867ab616c670aa5cb9b807f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Raynaud Date: Mon, 27 Jul 2026 17:04:15 +0200 Subject: [PATCH 6/6] chore: upgrade crate versions * mithril-protocol-config from `0.1.11` to `0.1.12` * mithril-aggregator from `0.9.17` to `0.9.18` * mithril-common from `0.7.13` to `0.7.14` * mithril-end-to-end from `0.5.6` to `0.5.7` --- Cargo.lock | 8 ++++---- .../mithril-cardano-node-internal-database/Cargo.toml | 2 +- internal/mithril-aggregator-client/Cargo.toml | 2 +- internal/mithril-aggregator-discovery/Cargo.toml | 2 +- internal/mithril-protocol-config/Cargo.toml | 2 +- mithril-aggregator/Cargo.toml | 2 +- mithril-client/Cargo.toml | 2 +- mithril-common/Cargo.toml | 2 +- mithril-test-lab/mithril-end-to-end/Cargo.toml | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7df7cfcc7e..9191642a757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,7 +4214,7 @@ dependencies = [ [[package]] name = "mithril-aggregator" -version = "0.9.17" +version = "0.9.18" dependencies = [ "anyhow", "async-trait", @@ -4518,7 +4518,7 @@ dependencies = [ [[package]] name = "mithril-common" -version = "0.7.13" +version = "0.7.14" dependencies = [ "anyhow", "async-trait", @@ -4603,7 +4603,7 @@ dependencies = [ [[package]] name = "mithril-end-to-end" -version = "0.5.6" +version = "0.5.7" dependencies = [ "anyhow", "async-recursion", @@ -4693,7 +4693,7 @@ dependencies = [ [[package]] name = "mithril-protocol-config" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "async-trait", diff --git a/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml index 6f727f6b100..5e6d372fb81 100644 --- a/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml +++ b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml @@ -15,7 +15,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } digest = { workspace = true } hex = { workspace = true } -mithril-common = { path = "../../../mithril-common", version = "0.7.13" } +mithril-common = { path = "../../../mithril-common", version = "0.7.14" } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10.9" diff --git a/internal/mithril-aggregator-client/Cargo.toml b/internal/mithril-aggregator-client/Cargo.toml index cf1917920e4..34c36bf8518 100644 --- a/internal/mithril-aggregator-client/Cargo.toml +++ b/internal/mithril-aggregator-client/Cargo.toml @@ -13,7 +13,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -mithril-common = { path = "../../mithril-common", version = "0.7.13" } +mithril-common = { path = "../../mithril-common", version = "0.7.14" } reqwest = { workspace = true } semver = { workspace = true } serde = { workspace = true } diff --git a/internal/mithril-aggregator-discovery/Cargo.toml b/internal/mithril-aggregator-discovery/Cargo.toml index aed94a49991..1e9d98543ff 100644 --- a/internal/mithril-aggregator-discovery/Cargo.toml +++ b/internal/mithril-aggregator-discovery/Cargo.toml @@ -14,7 +14,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md", ".gitignore"] anyhow = { workspace = true } async-trait = { workspace = true } mithril-aggregator-client = { path = "../mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../../mithril-common", version = "0.7.13" } +mithril-common = { path = "../../mithril-common", version = "0.7.14" } rand = { version = "0.10.2" } reqwest = { workspace = true } serde = { workspace = true } diff --git a/internal/mithril-protocol-config/Cargo.toml b/internal/mithril-protocol-config/Cargo.toml index d987c9f3437..f9e452d860e 100644 --- a/internal/mithril-protocol-config/Cargo.toml +++ b/internal/mithril-protocol-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-protocol-config" -version = "0.1.11" +version = "0.1.12" description = "Configuraton parameters for Mithril network" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-aggregator/Cargo.toml b/mithril-aggregator/Cargo.toml index 88c95854bfd..03f5f8e41f8 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-aggregator" -version = "0.9.17" +version = "0.9.18" description = "A Mithril Aggregator server" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-client/Cargo.toml b/mithril-client/Cargo.toml index 1709964c9bc..9e201093230 100644 --- a/mithril-client/Cargo.toml +++ b/mithril-client/Cargo.toml @@ -64,7 +64,7 @@ flate2 = { version = "1.1.9", optional = true } flume = { version = "0.12.0", optional = true } futures = "0.3.32" mithril-aggregator-client = { path = "../internal/mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../mithril-common", version = "0.7.13", default-features = false } +mithril-common = { path = "../mithril-common", version = "0.7.14", default-features = false } reqwest = { workspace = true, default-features = false, features = ["charset", "http2", "stream", "system-proxy"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/mithril-common/Cargo.toml b/mithril-common/Cargo.toml index e22693786df..33971c5708b 100644 --- a/mithril-common/Cargo.toml +++ b/mithril-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-common" -version = "0.7.13" +version = "0.7.14" description = "Common types, interfaces, and utilities for Mithril nodes." authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-test-lab/mithril-end-to-end/Cargo.toml b/mithril-test-lab/mithril-end-to-end/Cargo.toml index e4a53abef4b..b465356bcc5 100644 --- a/mithril-test-lab/mithril-end-to-end/Cargo.toml +++ b/mithril-test-lab/mithril-end-to-end/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-end-to-end" -version = "0.5.6" +version = "0.5.7" authors = { workspace = true } edition = { workspace = true } documentation = { workspace = true }