diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index f4cc411a4..17ca2892c 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -44,7 +44,12 @@ CiphernodeSelected event arrives at ThresholdKeyshare │ │ → These collectors start immediately so early peer keys/shares can │ │ be buffered while this node is still finishing earlier DKG phases │ │ -│ └─ Each collector has a timeout (60s for keys, 120s for shares) +│ └─ Collector timeouts are derived from the DKG stage budget: +│ ├─ shared base window from `E3_DKG_WINDOW_SECS` (default 7200s, +│ │ matching current production `Enclave` deployment config) +│ ├─ EncryptionKeyCollector cutoff at 10% of the DKG window +│ ├─ ThresholdShareCollector cutoff at 60% of the DKG window +│ └─ per-collector env vars still override these derived defaults ``` ### Step 2: C0 Proof Generation → EncryptionKeyCreated @@ -106,9 +111,14 @@ EncryptionKeyCollector waits for EncryptionKeyCreated from ALL N parties │ ├─ On each arrival: store (party_id → bfv_public_key) │ -├─ On TIMEOUT (60s): -│ └─ Publish EncryptionKeyCollectionFailed -│ └─ ThresholdKeyshare actor stops +├─ On TIMEOUT (derived DKG-phase cutoff): +│ └─ Send EncryptionKeyCollectionFailed to parent ThresholdKeyshare +│ ├─ ThresholdKeyshare republishes EncryptionKeyCollectionFailed for telemetry +│ ├─ ThresholdKeyshare emits E3Failed { +│ │ failed_at_stage: CommitteeFinalized, +│ │ reason: InsufficientCommitteeMembers +│ │ } +│ └─ ThresholdKeyshare actor stops │ └─ When ALL N collected: └─ Send AllEncryptionKeysCollected to parent ThresholdKeyshare @@ -294,9 +304,14 @@ ThresholdShareCollector waits for ThresholdShareCreated from ALL N parties │ │ → This node only extracts what's encrypted for it │ └─ Forwards filtered share to ThresholdShareCollector │ -├─ On TIMEOUT (120s): -│ └─ Publish ThresholdShareCollectionFailed -│ └─ ThresholdKeyshare actor stops +├─ On TIMEOUT (derived DKG-phase cutoff): +│ └─ Send ThresholdShareCollectionFailed to parent ThresholdKeyshare +│ ├─ ThresholdKeyshare republishes ThresholdShareCollectionFailed for telemetry +│ ├─ ThresholdKeyshare emits E3Failed { +│ │ failed_at_stage: CommitteeFinalized, +│ │ reason: InsufficientCommitteeMembers +│ │ } +│ └─ ThresholdKeyshare actor stops │ └─ When ALL N shares collected: ├─ Send AllThresholdSharesCollected to ThresholdKeyshare diff --git a/crates/keyshare/src/decryption_key_shared_collector.rs b/crates/keyshare/src/decryption_key_shared_collector.rs index ee294fe58..6c3e589a2 100644 --- a/crates/keyshare/src/decryption_key_shared_collector.rs +++ b/crates/keyshare/src/decryption_key_shared_collector.rs @@ -16,19 +16,6 @@ use tracing::{info, warn}; use crate::ThresholdKeyshare; -const DEFAULT_COLLECTION_TIMEOUT: Duration = Duration::from_secs(3600); -const COLLECTION_TIMEOUT_ENV: &str = "E3_DECRYPTION_KEY_SHARED_COLLECTION_TIMEOUT_SECS"; - -fn collection_timeout() -> Duration { - match std::env::var(COLLECTION_TIMEOUT_ENV) - .ok() - .and_then(|v| v.parse::().ok()) - { - Some(0) | None => DEFAULT_COLLECTION_TIMEOUT, - Some(secs) => Duration::from_secs(secs), - } -} - enum CollectorState { Collecting, Finished, @@ -76,6 +63,7 @@ pub struct DecryptionKeySharedCollector { parent: Addr, state: CollectorState, shares: HashMap, + timeout: Duration, timeout_handle: Option, } @@ -84,6 +72,7 @@ impl DecryptionKeySharedCollector { parent: Addr, expected_parties: HashSet, e3_id: E3id, + timeout: Duration, ) -> Addr { let collector = Self { e3_id, @@ -91,6 +80,7 @@ impl DecryptionKeySharedCollector { parent, state: CollectorState::Collecting, shares: HashMap::new(), + timeout, timeout_handle: None, }; collector.start() @@ -102,14 +92,13 @@ impl Actor for DecryptionKeySharedCollector { fn started(&mut self, ctx: &mut Self::Context) { ctx.set_mailbox_capacity(MAILBOX_LIMIT); - let timeout = collection_timeout(); info!( e3_id = %self.e3_id, "DecryptionKeySharedCollector started, expecting {} parties, timeout {:?}", self.expected.len(), - timeout + self.timeout ); - let handle = ctx.notify_later(DecryptionKeySharedCollectionTimeout, timeout); + let handle = ctx.notify_later(DecryptionKeySharedCollectionTimeout, self.timeout); self.timeout_handle = Some(handle); } } diff --git a/crates/keyshare/src/encryption_key_collector.rs b/crates/keyshare/src/encryption_key_collector.rs index f0843c837..ebee880a2 100644 --- a/crates/keyshare/src/encryption_key_collector.rs +++ b/crates/keyshare/src/encryption_key_collector.rs @@ -19,19 +19,6 @@ use e3_trbfv::PartyId; use e3_utils::MAILBOX_LIMIT; use tracing::{info, warn}; -const DEFAULT_COLLECTION_TIMEOUT: Duration = Duration::from_secs(600); -const COLLECTION_TIMEOUT_ENV: &str = "E3_ENCRYPTION_KEY_COLLECTION_TIMEOUT_SECS"; - -fn collection_timeout() -> Duration { - match std::env::var(COLLECTION_TIMEOUT_ENV) - .ok() - .and_then(|v| v.parse::().ok()) - { - Some(0) | None => DEFAULT_COLLECTION_TIMEOUT, - Some(secs) => Duration::from_secs(secs), - } -} - use crate::ThresholdKeyshare; /// State of the collector @@ -90,17 +77,24 @@ pub struct EncryptionKeyCollector { parent: Addr, state: CollectorState, keys: HashMap>, + timeout: Duration, timeout_handle: Option, } impl EncryptionKeyCollector { - pub fn setup(parent: Addr, total: u64, e3_id: E3id) -> Addr { + pub fn setup( + parent: Addr, + total: u64, + e3_id: E3id, + timeout: Duration, + ) -> Addr { let collector = Self { e3_id, todo: (0..total).collect(), parent, state: CollectorState::Collecting, keys: HashMap::new(), + timeout, timeout_handle: None, }; collector.start() @@ -112,14 +106,13 @@ impl Actor for EncryptionKeyCollector { fn started(&mut self, ctx: &mut Self::Context) { ctx.set_mailbox_capacity(MAILBOX_LIMIT); - let timeout = collection_timeout(); info!( e3_id = %self.e3_id, "EncryptionKeyCollector started, scheduling timeout in {:?}", - timeout + self.timeout ); - let handle = ctx.notify_later(EncryptionKeyCollectionTimeout, timeout); + let handle = ctx.notify_later(EncryptionKeyCollectionTimeout, self.timeout); self.timeout_handle = Some(handle); } } diff --git a/crates/keyshare/src/lib.rs b/crates/keyshare/src/lib.rs index 6f0975f9c..9e46e9ee0 100644 --- a/crates/keyshare/src/lib.rs +++ b/crates/keyshare/src/lib.rs @@ -10,6 +10,7 @@ pub mod ext; mod repo; mod threshold_keyshare; mod threshold_share_collector; +mod timeout_policy; pub use encryption_key_collector::{ AllEncryptionKeysCollected, EncryptionKeyCollector, ExpelPartyFromKeyCollection, }; diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 99afe0fd6..6efe35d12 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -59,6 +59,7 @@ use crate::encryption_key_collector::{ use crate::threshold_share_collector::{ ExpelPartyFromShareCollection, ReceivedShareProofs, ThresholdShareCollector, }; +use crate::timeout_policy::{now_unix_secs, resolve_timeout, DkgTimeoutPhase}; #[derive(Message, Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[rtype(result = "()")] @@ -262,6 +263,8 @@ pub struct ThresholdKeyshareState { /// Honest party IDs in deterministic ascending order (`BTreeSet` guarantees this). /// Downstream proof circuits index parties by position in this sorted set. pub honest_parties: Option>, + #[serde(default)] + pub dkg_started_at_unix_secs: Option, #[serde(default = "default_proof_agg")] pub proof_aggregation_enabled: bool, } @@ -292,6 +295,7 @@ impl ThresholdKeyshareState { aggregated_pk: None, expelled_parties: HashSet::new(), honest_parties: None, + dkg_started_at_unix_secs: Some(now_unix_secs()), proof_aggregation_enabled, } } @@ -463,9 +467,19 @@ impl ThresholdKeyshare { ); let e3_id = state.e3_id.clone(); let threshold_n = state.threshold_n; - let addr = self - .decryption_key_collector - .get_or_insert_with(|| ThresholdShareCollector::setup(self_addr, threshold_n, e3_id)); + let timeout = resolve_timeout( + DkgTimeoutPhase::ThresholdShareCollection, + state.dkg_started_at_unix_secs, + ); + info!( + e3_id = %e3_id, + timeout = ?timeout.duration, + "{}", + timeout.description + ); + let addr = self.decryption_key_collector.get_or_insert_with(|| { + ThresholdShareCollector::setup(self_addr, threshold_n, e3_id, timeout.duration) + }); Ok(addr.clone()) } @@ -483,9 +497,19 @@ impl ThresholdKeyshare { ); let e3_id = state.e3_id.clone(); let threshold_n = state.threshold_n; - let addr = self - .encryption_key_collector - .get_or_insert_with(|| EncryptionKeyCollector::setup(self_addr, threshold_n, e3_id)); + let timeout = resolve_timeout( + DkgTimeoutPhase::EncryptionKeyCollection, + state.dkg_started_at_unix_secs, + ); + info!( + e3_id = %e3_id, + timeout = ?timeout.duration, + "{}", + timeout.description + ); + let addr = self.encryption_key_collector.get_or_insert_with(|| { + EncryptionKeyCollector::setup(self_addr, threshold_n, e3_id, timeout.duration) + }); Ok(addr.clone()) } @@ -510,9 +534,19 @@ impl ThresholdKeyshare { .collect(); let e3_id = state.e3_id.clone(); - let addr = self - .decryption_key_shared_collector - .get_or_insert_with(|| DecryptionKeySharedCollector::setup(self_addr, expected, e3_id)); + let timeout = resolve_timeout( + DkgTimeoutPhase::DecryptionKeySharedCollection, + state.dkg_started_at_unix_secs, + ); + info!( + e3_id = %e3_id, + timeout = ?timeout.duration, + "{}", + timeout.description + ); + let addr = self.decryption_key_shared_collector.get_or_insert_with(|| { + DecryptionKeySharedCollector::setup(self_addr, expected, e3_id, timeout.duration) + }); Ok(addr.clone()) } @@ -2487,7 +2521,13 @@ impl Handler for ThresholdKeyshare { self.encryption_key_collector = None; // Publish failure event to event bus for sync tracking - self.bus.publish_without_context(msg)?; + self.bus.publish_without_context(msg.clone())?; + + self.bus.publish_without_context(E3Failed { + e3_id: msg.e3_id, + failed_at_stage: E3Stage::CommitteeFinalized, + reason: FailureReason::InsufficientCommitteeMembers, + })?; // Stop this actor since we can't proceed without all encryption keys ctx.stop(); @@ -2515,7 +2555,13 @@ impl Handler for ThresholdKeyshare { self.decryption_key_collector = None; // Publish failure event to event bus for sync tracking - self.bus.publish_without_context(msg)?; + self.bus.publish_without_context(msg.clone())?; + + self.bus.publish_without_context(E3Failed { + e3_id: msg.e3_id, + failed_at_stage: E3Stage::CommitteeFinalized, + reason: FailureReason::InsufficientCommitteeMembers, + })?; ctx.stop(); Ok(()) @@ -2591,3 +2637,173 @@ impl Handler for ThresholdKeyshare { ctx.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::decryption_key_shared_collector::DecryptionKeySharedCollectionFailed; + use actix::{Actor, Addr, Handler}; + use anyhow::Result; + use e3_crypto::Cipher; + use e3_data::{AutoPersist, DataStore, InMemStore, Persistable, Repository}; + use e3_events::{ + hlc_factory::HlcFactory, BusHandle, E3Stage, E3id, EnclaveEvent, EnclaveEventData, + EventBus, EventBusConfig, FailureReason, HistoryCollector, Sequencer, StoreEventRequested, + StoreEventResponse, TakeEvents, + }; + use e3_fhe_params::DEFAULT_BFV_PRESET; + use std::sync::Arc; + + #[derive(Default)] + struct TestEventStore { + next_seq: u64, + } + + impl Actor for TestEventStore { + type Context = actix::Context; + } + + impl Handler for TestEventStore { + type Result = (); + + fn handle(&mut self, msg: StoreEventRequested, _: &mut Self::Context) -> Self::Result { + let StoreEventRequested { event, sender } = msg; + let seq = self.next_seq; + self.next_seq += 1; + sender.do_send(StoreEventResponse(event.into_sequenced(seq))); + } + } + + fn test_bus() -> (BusHandle, Addr>) { + let event_bus = EventBus::::new(EventBusConfig { deduplicate: true }).start(); + let store = TestEventStore::default().start(); + let sequencer = Sequencer::new(&event_bus, store.recipient()).start(); + let bus = BusHandle::new(event_bus, sequencer, HlcFactory::new()).enable("test-keyshare"); + let history = bus.history(); + (bus, history) + } + + fn test_state() -> Persistable { + let store = InMemStore::new(false).start(); + let repo = Repository::::new(DataStore::from_in_mem(&store)); + repo.send(None) + } + + async fn start_actor() -> Result<( + Addr, + Addr>, + E3id, + )> { + let (bus, history) = test_bus(); + let actor = ThresholdKeyshare::new(ThresholdKeyshareParams { + bus, + cipher: Arc::new(Cipher::from_password("test-password").await?), + state: test_state(), + share_enc_preset: DEFAULT_BFV_PRESET, + }) + .start(); + + Ok((actor, history, E3id::new("42", 1))) + } + + async fn next_event(history: &Addr>) -> Result { + let mut result = history.send(TakeEvents::::new(1)).await?; + assert!(!result.timed_out, "timed out waiting for an event"); + Ok(result.events.pop().expect("expected one event")) + } + + async fn next_events( + history: &Addr>, + count: usize, + ) -> Result> { + let result = history.send(TakeEvents::::new(count)).await?; + assert!(!result.timed_out, "timed out waiting for events"); + assert_eq!(result.events.len(), count, "expected {count} events"); + Ok(result.events) + } + + #[actix::test] + async fn encryption_key_collection_failure_preserves_telemetry_and_emits_e3_failed( + ) -> Result<()> { + let (actor, history, e3_id) = start_actor().await?; + let failure = EncryptionKeyCollectionFailed { + e3_id, + reason: "missing encryption keys".to_string(), + missing_parties: vec![2, 3], + }; + + actor.send(failure.clone()).await?; + + let mut events = next_events(&history, 2).await?; + let event = events.remove(0); + assert!(matches!( + event.into_data(), + EnclaveEventData::EncryptionKeyCollectionFailed(data) if data == failure + )); + + let event = events.remove(0); + assert!(matches!( + event.into_data(), + EnclaveEventData::E3Failed(data) + if data.e3_id == failure.e3_id + && data.failed_at_stage == E3Stage::CommitteeFinalized + && data.reason == FailureReason::InsufficientCommitteeMembers + )); + + Ok(()) + } + + #[actix::test] + async fn threshold_share_collection_failure_preserves_telemetry_and_emits_e3_failed( + ) -> Result<()> { + let (actor, history, e3_id) = start_actor().await?; + let failure = ThresholdShareCollectionFailed { + e3_id, + reason: "missing threshold shares".to_string(), + missing_parties: vec![4, 5], + }; + + actor.send(failure.clone()).await?; + + let mut events = next_events(&history, 2).await?; + let event = events.remove(0); + assert!(matches!( + event.into_data(), + EnclaveEventData::ThresholdShareCollectionFailed(data) if data == failure + )); + + let event = events.remove(0); + assert!(matches!( + event.into_data(), + EnclaveEventData::E3Failed(data) + if data.e3_id == failure.e3_id + && data.failed_at_stage == E3Stage::CommitteeFinalized + && data.reason == FailureReason::InsufficientCommitteeMembers + )); + + Ok(()) + } + + #[actix::test] + async fn decryption_key_shared_collection_failure_emits_e3_failed() -> Result<()> { + let (actor, history, e3_id) = start_actor().await?; + let failure = DecryptionKeySharedCollectionFailed { + e3_id, + reason: "missing decryption key shares".to_string(), + missing_parties: vec![6, 7], + }; + + actor.send(failure.clone()).await?; + + let event = next_event(&history).await?; + assert!(matches!( + event.into_data(), + EnclaveEventData::E3Failed(data) + if data.e3_id == failure.e3_id + && data.failed_at_stage == E3Stage::CommitteeFinalized + && data.reason == FailureReason::InsufficientCommitteeMembers + )); + + Ok(()) + } +} diff --git a/crates/keyshare/src/threshold_share_collector.rs b/crates/keyshare/src/threshold_share_collector.rs index c3085c594..c36d261f6 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -34,19 +34,6 @@ pub struct ReceivedShareProofs { pub signed_c3b_proofs: Vec, } -const DEFAULT_COLLECTION_TIMEOUT: Duration = Duration::from_secs(3600); -const COLLECTION_TIMEOUT_ENV: &str = "E3_THRESHOLD_SHARE_COLLECTION_TIMEOUT_SECS"; - -fn collection_timeout() -> Duration { - match std::env::var(COLLECTION_TIMEOUT_ENV) - .ok() - .and_then(|v| v.parse::().ok()) - { - Some(0) | None => DEFAULT_COLLECTION_TIMEOUT, - Some(secs) => Duration::from_secs(secs), - } -} - pub(crate) enum CollectorState { Collecting, Finished, @@ -80,12 +67,18 @@ pub struct ThresholdShareCollector { shares: HashMap>, /// Proofs received alongside each party's shares share_proofs: HashMap, + timeout: Duration, /// A timeout handle for when this collector will report failure timeout_handle: Option, } impl ThresholdShareCollector { - pub fn setup(parent: Addr, total: u64, e3_id: E3id) -> Addr { + pub fn setup( + parent: Addr, + total: u64, + e3_id: E3id, + timeout: Duration, + ) -> Addr { let collector = Self { e3_id, todo: (0..total).collect(), @@ -93,6 +86,7 @@ impl ThresholdShareCollector { state: CollectorState::Collecting, shares: HashMap::new(), share_proofs: HashMap::new(), + timeout, timeout_handle: None, }; collector.start() @@ -104,14 +98,13 @@ impl Actor for ThresholdShareCollector { fn started(&mut self, ctx: &mut Self::Context) { ctx.set_mailbox_capacity(MAILBOX_LIMIT); - let timeout = collection_timeout(); info!( e3_id = %self.e3_id, "ThresholdShareCollector started, scheduling timeout in {:?}", - timeout + self.timeout ); // Schedule timeout - let handle = ctx.notify_later(ThresholdShareCollectionTimeout, timeout); + let handle = ctx.notify_later(ThresholdShareCollectionTimeout, self.timeout); self.timeout_handle = Some(handle); } } diff --git a/crates/keyshare/src/timeout_policy.rs b/crates/keyshare/src/timeout_policy.rs new file mode 100644 index 000000000..a50730e7d --- /dev/null +++ b/crates/keyshare/src/timeout_policy.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub(crate) const DKG_WINDOW_ENV: &str = "E3_DKG_WINDOW_SECS"; +pub(crate) const DEFAULT_DKG_WINDOW_SECS: u64 = 7200; + +const ENCRYPTION_KEY_CUTOFF_BPS: u64 = 1000; +const THRESHOLD_SHARE_CUTOFF_BPS: u64 = 6000; +const DECRYPTION_KEY_SHARED_CUTOFF_BPS: u64 = 10000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DkgTimeoutPhase { + EncryptionKeyCollection, + ThresholdShareCollection, + DecryptionKeySharedCollection, +} + +impl DkgTimeoutPhase { + pub(crate) fn label(self) -> &'static str { + match self { + Self::EncryptionKeyCollection => "encryption-key collection", + Self::ThresholdShareCollection => "threshold-share collection", + Self::DecryptionKeySharedCollection => "decryption-key-shared collection", + } + } + + pub(crate) fn override_env(self) -> &'static str { + match self { + Self::EncryptionKeyCollection => "E3_ENCRYPTION_KEY_COLLECTION_TIMEOUT_SECS", + Self::ThresholdShareCollection => "E3_THRESHOLD_SHARE_COLLECTION_TIMEOUT_SECS", + Self::DecryptionKeySharedCollection => { + "E3_DECRYPTION_KEY_SHARED_COLLECTION_TIMEOUT_SECS" + } + } + } + + fn cutoff_bps(self) -> u64 { + match self { + Self::EncryptionKeyCollection => ENCRYPTION_KEY_CUTOFF_BPS, + Self::ThresholdShareCollection => THRESHOLD_SHARE_CUTOFF_BPS, + Self::DecryptionKeySharedCollection => DECRYPTION_KEY_SHARED_CUTOFF_BPS, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DerivedTimeout { + pub duration: Duration, + pub description: String, +} + +pub(crate) fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(crate) fn resolve_timeout( + phase: DkgTimeoutPhase, + dkg_started_at_unix_secs: Option, +) -> DerivedTimeout { + let collector_override = parse_env_secs(phase.override_env()); + let dkg_window_secs = parse_env_secs(DKG_WINDOW_ENV).unwrap_or(DEFAULT_DKG_WINDOW_SECS); + + resolve_timeout_from_inputs( + phase, + collector_override, + dkg_window_secs, + dkg_started_at_unix_secs, + now_unix_secs(), + ) +} + +pub(crate) fn resolve_timeout_from_inputs( + phase: DkgTimeoutPhase, + collector_override_secs: Option, + dkg_window_secs: u64, + dkg_started_at_unix_secs: Option, + now_unix_secs: u64, +) -> DerivedTimeout { + if let Some(override_secs) = collector_override_secs { + return DerivedTimeout { + duration: Duration::from_secs(override_secs), + description: format!( + "{} timeout override from {}={}s", + phase.label(), + phase.override_env(), + override_secs + ), + }; + } + + let cutoff_secs = phase_cutoff_secs(dkg_window_secs, phase.cutoff_bps()); + let remaining_secs = match dkg_started_at_unix_secs { + Some(started_at) => cutoff_secs.saturating_sub(now_unix_secs.saturating_sub(started_at)), + None => cutoff_secs, + }; + + let description = match dkg_started_at_unix_secs { + Some(started_at) => format!( + "{} timeout derived from {}={}s, DKG start {}, cutoff {}% of DKG window", + phase.label(), + DKG_WINDOW_ENV, + dkg_window_secs, + started_at, + phase.cutoff_bps() / 100 + ), + None => format!( + "{} timeout derived from {}={}s with missing DKG start, using full cutoff budget of {}%", + phase.label(), + DKG_WINDOW_ENV, + dkg_window_secs, + phase.cutoff_bps() / 100 + ), + }; + + DerivedTimeout { + duration: Duration::from_secs(remaining_secs), + description, + } +} + +fn parse_env_secs(name: &str) -> Option { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|secs| *secs > 0) +} + +fn phase_cutoff_secs(dkg_window_secs: u64, cutoff_bps: u64) -> u64 { + let scaled = dkg_window_secs.saturating_mul(cutoff_bps); + let secs = scaled / 10_000; + secs.max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encryption_timeout_uses_remaining_dkg_budget() { + let timeout = resolve_timeout_from_inputs( + DkgTimeoutPhase::EncryptionKeyCollection, + None, + 7200, + Some(1_000), + 1_600, + ); + + assert_eq!(timeout.duration, Duration::from_secs(120)); + assert!(timeout.description.contains(DKG_WINDOW_ENV)); + } + + #[test] + fn threshold_share_timeout_uses_cumulative_cutoff() { + let timeout = resolve_timeout_from_inputs( + DkgTimeoutPhase::ThresholdShareCollection, + None, + 7200, + Some(1_000), + 2_000, + ); + + assert_eq!(timeout.duration, Duration::from_secs(3320)); + } + + #[test] + fn collector_override_wins_over_dkg_window() { + let timeout = resolve_timeout_from_inputs( + DkgTimeoutPhase::DecryptionKeySharedCollection, + Some(45), + 7200, + Some(1_000), + 8_000, + ); + + assert_eq!(timeout.duration, Duration::from_secs(45)); + assert!(timeout + .description + .contains(DkgTimeoutPhase::DecryptionKeySharedCollection.override_env())); + } +}