diff --git a/.gitleaksignore b/.gitleaksignore index f42a9dc2..9266d55c 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -3,8 +3,8 @@ # (e.g. slashing db → interchange.rs). Not related to DSR-3.1 digest pins in docs. # Same attester/proposer duty pubkeys. Lines moved 928 → 940 and 1391 → 1403 # when BlockHeaderSummary landed in types.rs (#641). -crates/beacon/src/types.rs:generic-api-key:1403 -crates/beacon/src/types.rs:generic-api-key:940 +crates/beacon/src/types.rs:generic-api-key:1464 +crates/beacon/src/types.rs:generic-api-key:978 crates/crypto/examples/log_sample.rs:generic-api-key:27 # Same test keystore pubkeys. Lines moved 452 → 522, 704 → 774, and 994 → 1064 # when the password-map key and load report were added (#644). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7bf8854a..d30301dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -529,7 +529,7 @@ Central coordination crate. Contains: - **`DutyOrchestrator`** — Main loop with 3-phase slot processing: t=0 block proposals, t=slot/3 attestations + sync messages, t=2*slot/3 aggregations + contributions. Generic over `SlotClock`, `AttestationSubmitter`, and `BeaconBlockClient` for testability. - **`Config`** / **`Network`** — Configuration types with network presets (Mainnet, Hoodi, Custom). - **`OrchestratorConfig`** — Fork schedule, genesis root, shutdown timeout. -- **Adapter modules** — `beacon_adapter`, `doppelganger_adapter`, `keymanager_adapters` bridge domain traits to concrete services. +- **Adapter modules** — `beacon_adapter`, `keymanager_adapters` bridge domain traits to concrete services. - **gRPC DutyTracker service** — Exposes a `Healthz` RPC via tonic. ### `crates/bn-manager` — Multi-BN Management diff --git a/Cargo.lock b/Cargo.lock index 2b909c17..fbc42524 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4421,7 +4421,6 @@ dependencies = [ name = "rvc-doppelganger" version = "0.8.0" dependencies = [ - "async-trait", "hex", "parking_lot", "rvc-crypto", diff --git a/bin/rvc/src/commands/signed_exit.rs b/bin/rvc/src/commands/signed_exit.rs index 8d2a15e6..c291a562 100644 --- a/bin/rvc/src/commands/signed_exit.rs +++ b/bin/rvc/src/commands/signed_exit.rs @@ -85,6 +85,73 @@ pub fn require_exit_confirm( bail!("Voluntary exit aborted: --confirm flag not provided"); } +/// Epoch from genesis and the beacon node's slot duration. +/// +/// Reads `/eth/v1/config/spec`. A missing, zero, or conflicting duration is an +/// error. The 12 s constant is not a fallback. +async fn default_exit_epoch( + beacon_client: &BeaconClient, + genesis_time: u64, + network: rvc::config::Network, +) -> anyhow::Result { + // One beacon URL. The daemon cross-checks every configured BN at startup; + // this path still fail-closes on the bound, a key conflict, SLOTS_PER_EPOCH, + // a named-network duration preset, and a named-network genesis preset. + let spec = beacon_client + .get_config_spec() + .await + .context("Failed to read slot duration from beacon node spec")?; + let slot_duration_ms = beacon::parse_slot_duration_ms(&spec.data) + .map_err(|e| anyhow::anyhow!("failed to parse slot duration from beacon node spec: {e}"))?; + let slots_per_epoch = beacon::parse_slots_per_epoch(&spec.data).map_err(|e| { + anyhow::anyhow!("failed to parse SLOTS_PER_EPOCH from beacon node spec: {e}") + })?; + let expected_slots = network.slots_per_epoch(); + if slots_per_epoch != expected_slots { + bail!("SLOTS_PER_EPOCH {slots_per_epoch} does not match preset {expected_slots}"); + } + if !matches!(network, rvc::config::Network::Custom) { + let preset = network.slot_duration_ms(); + if slot_duration_ms != preset { + bail!( + "slot duration {slot_duration_ms} ms does not match {network} preset {preset} ms" + ); + } + if let Some(preset_genesis) = network.genesis_time() { + if genesis_time != preset_genesis { + bail!( + "beacon genesis_time {genesis_time} does not match {network} preset {preset_genesis}" + ); + } + } + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| anyhow::anyhow!("system time before UNIX epoch"))? + .as_secs(); + current_exit_epoch(now, genesis_time, slot_duration_ms) +} + +/// Current epoch at `now_unix_secs`. Zero duration is an error, not epoch 0. +fn current_exit_epoch( + now_unix_secs: u64, + genesis_time: u64, + slot_duration_ms: u64, +) -> anyhow::Result { + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&slot_duration_ms) + { + bail!( + "slot duration {slot_duration_ms} ms is outside {}..={}", + eth_types::MIN_SLOT_DURATION_MS, + eth_types::MAX_SLOT_DURATION_MS + ); + } + let current_slot = + now_unix_secs.saturating_sub(genesis_time).saturating_mul(1000) / slot_duration_ms; + Ok(current_slot / SLOTS_PER_EPOCH) +} + /// Resolve validator index, epoch, load keys / slashing DB, and sign a voluntary exit. /// /// Callers differ only in the final step: submit to the BN or write to a file. @@ -110,6 +177,11 @@ pub async fn build_signed_exit(args: ExitCommonArgs) -> anyhow::Result name.parse().map_err(|e: String| anyhow::anyhow!("{e}"))?, + None => rvc::config::Network::default(), + }; + let epoch = match args.epoch { Some(e) => e, None => { @@ -121,14 +193,7 @@ pub async fn build_signed_exit(args: ExitCommonArgs) -> anyhow::Result anyhow::Result (BeaconClient, wiremock::MockServer) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "genesis_time": genesis_time.to_string(), + "genesis_validators_root": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "genesis_fork_version": "0x00000000" + } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": spec })), + ) + .mount(&server) + .await; + let client = BeaconClient::new(beacon::BeaconClientConfig::new(server.uri())) + .expect("beacon client"); + (client, server) + } + + #[tokio::test] + async fn test_cli_default_exit_epoch_uses_bn_slot_duration() { + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let genesis = now - 10 * 192 - 96; + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + genesis, + ) + .await; + let epoch = default_exit_epoch(&client, genesis, rvc::config::Network::Custom) + .await + .expect("epoch"); + assert_eq!(epoch, 10); + } + + #[tokio::test] + async fn test_cli_default_exit_epoch_fails_when_spec_has_no_duration() { + let (client, _server) = + mock_client(serde_json::json!({ "SLOTS_PER_EPOCH": "32" }), 1).await; + let err = default_exit_epoch(&client, 1, rvc::config::Network::Mainnet) + .await + .expect_err("missing duration"); + let msg = err.to_string(); + assert!(msg.contains("SLOT_DURATION_MS") || msg.contains("SECONDS_PER_SLOT"), "{msg}"); + } + + #[tokio::test] + async fn test_cli_default_exit_epoch_fails_when_spec_keys_conflict() { + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SECONDS_PER_SLOT": "12" }), + 1, + ) + .await; + let err = default_exit_epoch(&client, 1, rvc::config::Network::Custom) + .await + .expect_err("conflict"); + assert!(err.to_string().contains("conflicting"), "{err}"); + } + + #[tokio::test] + async fn test_cli_default_exit_epoch_rejects_huge_duration() { + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "86400000", "SLOTS_PER_EPOCH": "32" }), + 1, + ) + .await; + let err = + default_exit_epoch(&client, 1, rvc::config::Network::Custom).await.expect_err("huge"); + assert!(err.to_string().contains("outside"), "{err}"); + } + + #[tokio::test] + async fn test_cli_named_network_rejects_non_preset_duration() { + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + 1, + ) + .await; + let err = default_exit_epoch(&client, 1, rvc::config::Network::Mainnet) + .await + .expect_err("preset"); + assert!(err.to_string().contains("preset"), "{err}"); + } + + #[tokio::test] + async fn test_cli_named_network_rejects_genesis_time_that_is_not_the_preset() { + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }), + 1, + ) + .await; + let err = default_exit_epoch(&client, 1, rvc::config::Network::Mainnet) + .await + .expect_err("genesis"); + assert!(err.to_string().contains("genesis_time"), "{err}"); + assert!(err.to_string().contains("preset"), "{err}"); + } + + #[tokio::test] + async fn test_cli_rejects_slots_per_epoch_that_is_not_the_preset() { + let (client, _server) = mock_client( + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "16" }), + 1, + ) + .await; + let err = + default_exit_epoch(&client, 1, rvc::config::Network::Mainnet).await.expect_err("slots"); + assert!(err.to_string().contains("SLOTS_PER_EPOCH"), "{err}"); + } } diff --git a/crates/architecture-tests/tests/dead_liveness_surface.rs b/crates/architecture-tests/tests/dead_liveness_surface.rs new file mode 100644 index 00000000..c4c3cf88 --- /dev/null +++ b/crates/architecture-tests/tests/dead_liveness_surface.rs @@ -0,0 +1,100 @@ +//! RV-1.5: the unused liveness-checker surface stays deleted. +//! +//! `ValidatorLivenessData` is the live sample type and is not part of this ban. + +use std::path::{Path, PathBuf}; + +const BANNED: &[&str] = + &["LivenessChecker", "DoppelgangerStatus", "DoppelgangerResult", "BeaconLivenessAdapter"]; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap().to_path_buf() +} + +fn collect_rs(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name == "target" || name.starts_with('.') { + continue; + } + collect_rs(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(path); + } + } +} + +fn is_ident_char(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' +} + +fn contains_word(haystack: &str, word: &str) -> bool { + let bytes = haystack.as_bytes(); + let needle = word.as_bytes(); + if needle.is_empty() || needle.len() > bytes.len() { + return false; + } + let mut i = 0; + while i + needle.len() <= bytes.len() { + if &bytes[i..i + needle.len()] == needle { + let before_ok = i == 0 || !is_ident_char(bytes[i - 1]); + let after = i + needle.len(); + let after_ok = after == bytes.len() || !is_ident_char(bytes[after]); + if before_ok && after_ok { + return true; + } + } + i += 1; + } + false +} + +#[test] +fn dead_liveness_names_are_absent_from_rust_sources() { + let root = workspace_root(); + let mut files = Vec::new(); + collect_rs(&root.join("crates"), &mut files); + collect_rs(&root.join("bin"), &mut files); + let mut hits = Vec::new(); + for path in files { + let rel = path.strip_prefix(&root).unwrap_or(&path); + let rel = rel.to_string_lossy().replace('\\', "/"); + if rel.ends_with("dead_liveness_surface.rs") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + for word in BANNED { + if contains_word(&text, word) { + hits.push(format!("{rel}: {word}")); + } + } + } + assert!(hits.is_empty(), "dead liveness surface returned:\n{}", hits.join("\n")); +} + +#[test] +fn doppelganger_crate_does_not_depend_on_async_trait() { + let path = workspace_root().join("crates/doppelganger/Cargo.toml"); + let text = std::fs::read_to_string(&path).expect("doppelganger Cargo.toml"); + assert!( + !text.lines().any(|line| line.contains("async-trait")), + "async-trait must not return to the doppelganger crate" + ); +} + +#[test] +fn validator_liveness_data_remains() { + let path = workspace_root().join("crates/doppelganger/src/traits.rs"); + let text = std::fs::read_to_string(&path).expect("traits.rs"); + assert!( + contains_word(&text, "ValidatorLivenessData"), + "ValidatorLivenessData is the live sample type" + ); +} diff --git a/crates/architecture-tests/tests/fork_hazard_inventory.rs b/crates/architecture-tests/tests/fork_hazard_inventory.rs index 6c2bde2f..0a4a6272 100644 --- a/crates/architecture-tests/tests/fork_hazard_inventory.rs +++ b/crates/architecture-tests/tests/fork_hazard_inventory.rs @@ -135,7 +135,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/beacon/src/client.rs", - 540, + 547, Class::GeForkName, false, ">= ForkName::Gloas", @@ -144,7 +144,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 305, + 331, Class::GeForkName, false, ">= ForkName::Gloas", @@ -153,7 +153,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 395, + 421, Class::GeForkName, false, ">= ForkName::Gloas", @@ -162,7 +162,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 417, + 443, Class::GeForkName, false, ">= ForkName::Gloas", @@ -171,7 +171,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 1139, + 1165, Class::GeForkName, false, ">= ForkName::Gloas", @@ -180,7 +180,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 1151, + 1177, Class::GeForkName, false, ">= ForkName::Gloas", @@ -189,7 +189,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 1155, + 1181, Class::GeForkName, false, ">= ForkName::Gloas", @@ -252,7 +252,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/rvc/benches/deadline_rebenchmark.rs", - 75, + 77, Class::GeForkName, false, ">= ForkName::Gloas", @@ -270,7 +270,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/rvc/src/orchestrator/coordinator/mod.rs", - 1591, + 1592, Class::GeForkName, false, ">= ForkName::Gloas", @@ -443,7 +443,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/beacon/src/client.rs", - 1239, + 1246, Class::MatchForkName, false, "match fork {", @@ -452,7 +452,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/beacon/src/client.rs", - 2097, + 2123, Class::MatchForkName, false, "match requested {", @@ -461,7 +461,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/block-service/src/service/mod.rs", - 1117, + 1143, Class::MatchForkName, false, "match fork {", @@ -579,7 +579,7 @@ const INVENTORY: &[Inv] = &[ ), inv( "crates/bn-manager/src/manager.rs", - 2306, + 2359, Class::StringDispatch, true, "match consensus_version {", diff --git a/crates/beacon/src/client.rs b/crates/beacon/src/client.rs index 69ee545f..0db1e16a 100644 --- a/crates/beacon/src/client.rs +++ b/crates/beacon/src/client.rs @@ -210,10 +210,9 @@ pub fn ensure_genesis_validators_root( if hex_ids_equal(&genesis.data.genesis_validators_root, expected_root_hex) { Ok(genesis) } else { - Err(BeaconError::HttpError(format!( - "genesis_validators_root {} does not match configured root", - genesis.data.genesis_validators_root - ))) + Err(BeaconError::GenesisValidatorsRootMismatch { + root: genesis.data.genesis_validators_root, + }) } } @@ -495,6 +494,14 @@ impl BeaconClient { ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) } + /// One client is the only responder. A different validators root is an error. + pub async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError> { + self.get_genesis_matching_validators_root(expected_root_hex).await.map(|_| ()) + } + /// Fetches fork information for the given state. /// /// Returns the previous and current fork versions along with the fork epoch. @@ -1919,6 +1926,10 @@ impl BeaconClient { let status = response.status(); span.record("http.status_code", status.as_u16()); + // 429 is proxy backpressure, same class as 502/503/504 below. + // The final error stays `ApiError` so callers can match the + // status. BnManager treats that status as unreachable at boot. + // Block production does not fail over on 429. if status.as_u16() == 429 { last_error = Some(BeaconError::ApiError { status: 429, @@ -1937,6 +1948,21 @@ impl BeaconClient { continue; } + // 502/503/504 are proxy-unavailable, grouped with 429: retried, + // returned as `ApiError`, and treated as unreachable at boot. + if matches!(status.as_u16(), 502..=504) { + let message = read_body_capped_lossy(response, 16 * 1024).await; + last_error = + Some(BeaconError::ApiError { status: status.as_u16(), message }); + warn!( + attempt = attempt, + status = status.as_u16(), + "Proxy unavailable, will retry" + ); + continue; + } + + // 500 and other 5xx are retried here and stay boot-fatal. if status.is_server_error() { let message = read_body_capped_lossy(response, 16 * 1024).await; last_error = diff --git a/crates/beacon/src/error.rs b/crates/beacon/src/error.rs index 5c746459..300f5988 100644 --- a/crates/beacon/src/error.rs +++ b/crates/beacon/src/error.rs @@ -31,6 +31,10 @@ pub enum BeaconError { /// No BN matched the requested role and no `All`-role fallback exists. #[error("no beacon nodes eligible for {operation} (role {role})")] NoEligibleBn { operation: String, role: String }, + + /// The beacon node returned a genesis validators root other than the configured one. + #[error("genesis validators root {root} does not match configured root")] + GenesisValidatorsRootMismatch { root: String }, } #[cfg(test)] diff --git a/crates/beacon/src/lib.rs b/crates/beacon/src/lib.rs index b68642a1..bb39cacc 100644 --- a/crates/beacon/src/lib.rs +++ b/crates/beacon/src/lib.rs @@ -15,21 +15,21 @@ pub use error::BeaconError; pub use http_caps::ResponseCaps; pub use retry::RetryPolicy; pub use types::{ - parse_fork_schedule, parse_slot_duration_ms, AttestationData, AttestationDataResponse, - AttesterDutiesResponse, AttesterDuty, BeaconBlockHeader, BeaconCommitteeSubscription, - BlockHeaderSummary, BlockRootData, BlockRootResponse, Checkpoint, ClientVersionV1, - ConfigSpecResponse, DataResponse, DependentRootResponse, ExecutionOptimisticResponse, - GenesisData, GenesisResponse, GloasBlockContents, IndexedAttestationError, IndexedErrorMessage, - IndexedFailure, LegacyAttestation, NodeVersionData, NodeVersionResponse, NodeVersionV2Data, - NodeVersionV2Response, PayloadAttestationDataResponse, ProduceBlockResponse, - ProduceBlockV4Body, ProposerDutiesResponse, ProposerDuty, ProposerPreparation, - PtcDutiesResponse, PtcDuty, SignedAggregateAndProof, SignedContributionAndProof, - SingleAttestation, StateFork, StateForkResponse, StateResponse, SubmitAttestationResult, - SubmitBuilderPreferencesResult, SyncCommitteeContributionResponse, SyncCommitteeDutiesResponse, - SyncCommitteeMessage, SyncingData, SyncingResponse, ValidatorData, ValidatorInfo, - ValidatorLiveness, ValidatorLivenessResponse, ValidatorsResponse, - VersionedAggregateAttestation, VersionedAttestation, VersionedSignedAggregateAndProof, - WireBody, + parse_fork_schedule, parse_slot_duration_ms, parse_slots_per_epoch, AttestationData, + AttestationDataResponse, AttesterDutiesResponse, AttesterDuty, BeaconBlockHeader, + BeaconCommitteeSubscription, BlockHeaderSummary, BlockRootData, BlockRootResponse, Checkpoint, + ClientVersionV1, ConfigSpecResponse, DataResponse, DependentRootResponse, + ExecutionOptimisticResponse, GenesisData, GenesisResponse, GloasBlockContents, + IndexedAttestationError, IndexedErrorMessage, IndexedFailure, LegacyAttestation, + NodeVersionData, NodeVersionResponse, NodeVersionV2Data, NodeVersionV2Response, + PayloadAttestationDataResponse, ProduceBlockResponse, ProduceBlockV4Body, + ProposerDutiesResponse, ProposerDuty, ProposerPreparation, PtcDutiesResponse, PtcDuty, + SignedAggregateAndProof, SignedContributionAndProof, SingleAttestation, StateFork, + StateForkResponse, StateResponse, SubmitAttestationResult, SubmitBuilderPreferencesResult, + SyncCommitteeContributionResponse, SyncCommitteeDutiesResponse, SyncCommitteeMessage, + SyncingData, SyncingResponse, ValidatorData, ValidatorInfo, ValidatorLiveness, + ValidatorLivenessResponse, ValidatorsResponse, VersionedAggregateAttestation, + VersionedAttestation, VersionedSignedAggregateAndProof, WireBody, }; pub use v4_wire::{ BuilderConfig, BuilderEntry, BuilderPreferencesEntry, BuilderRequestAuth, diff --git a/crates/beacon/src/types.rs b/crates/beacon/src/types.rs index 9c0d2afe..b9fcc807 100644 --- a/crates/beacon/src/types.rs +++ b/crates/beacon/src/types.rs @@ -541,13 +541,17 @@ pub fn parse_fork_schedule( /// window and are accepted when they agree exactly. Neither key, or both /// present with unequal values, is a parse error. Extra keys such as /// `INTERVALS_PER_SLOT` are ignored. +/// +/// The parsed length must sit in +/// [`eth_types::MIN_SLOT_DURATION_MS`]..=[`eth_types::MAX_SLOT_DURATION_MS`]. +/// Zero and an unbounded value are parse errors. There is no 12 s fallback. pub fn parse_slot_duration_ms( spec: &HashMap, ) -> Result { const SLOT_DURATION_MS_KEY: &str = "SLOT_DURATION_MS"; const SECONDS_PER_SLOT_KEY: &str = "SECONDS_PER_SLOT"; - match (spec.get(SLOT_DURATION_MS_KEY), spec.get(SECONDS_PER_SLOT_KEY)) { + let ms = match (spec.get(SLOT_DURATION_MS_KEY), spec.get(SECONDS_PER_SLOT_KEY)) { (None, None) => Err(BeaconError::ParseError(format!( "missing config keys: {SLOT_DURATION_MS_KEY} and {SECONDS_PER_SLOT_KEY}" ))), @@ -566,6 +570,40 @@ pub fn parse_slot_duration_ms( ))) } } + }?; + bound_slot_duration_ms(ms) +} + +/// `SLOTS_PER_EPOCH` from a config spec. Missing or zero is an error. +/// +/// Callers compare the value to the network preset. This function does not +/// substitute 32 when the key is absent. +pub fn parse_slots_per_epoch( + spec: &HashMap, +) -> Result { + const KEY: &str = "SLOTS_PER_EPOCH"; + let value = spec + .get(KEY) + .ok_or_else(|| BeaconError::ParseError(format!("missing config key: {KEY}")))?; + let parsed = value_to_string(value, KEY)?; + let n = parsed + .parse::() + .map_err(|e| BeaconError::ParseError(format!("invalid {KEY}: {e}")))?; + if n == 0 { + return Err(BeaconError::ParseError(format!("{KEY} must be greater than zero"))); + } + Ok(n) +} + +fn bound_slot_duration_ms(ms: u64) -> Result { + if (eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS).contains(&ms) { + Ok(ms) + } else { + Err(BeaconError::ParseError(format!( + "slot duration {ms} ms is outside {}..={}", + eth_types::MIN_SLOT_DURATION_MS, + eth_types::MAX_SLOT_DURATION_MS + ))) } } @@ -1302,6 +1340,29 @@ mod tests { assert_parse_error_contains(parse_slot_duration_ms(&spec), &["SECONDS_PER_SLOT"]); } + #[test] + fn test_parse_slot_duration_ms_above_bound() { + let mut spec = HashMap::new(); + spec.insert("SLOT_DURATION_MS".to_string(), json!("86400000")); + assert_parse_error_contains(parse_slot_duration_ms(&spec), &["outside", "86400000"]); + + let mut spec = HashMap::new(); + spec.insert("SECONDS_PER_SLOT".to_string(), json!("100")); + assert_parse_error_contains(parse_slot_duration_ms(&spec), &["outside"]); + } + + #[test] + fn test_parse_slots_per_epoch_missing_and_zero() { + let spec = HashMap::new(); + assert_parse_error_contains(parse_slots_per_epoch(&spec), &["SLOTS_PER_EPOCH"]); + let mut spec = HashMap::new(); + spec.insert("SLOTS_PER_EPOCH".to_string(), json!("0")); + assert_parse_error_contains(parse_slots_per_epoch(&spec), &["greater than zero"]); + let mut spec = HashMap::new(); + spec.insert("SLOTS_PER_EPOCH".to_string(), json!("32")); + assert_eq!(parse_slots_per_epoch(&spec).unwrap(), 32); + } + #[test] fn test_parse_slot_duration_ms_non_numeric() { let mut spec = HashMap::new(); diff --git a/crates/block-service/src/service/mod.rs b/crates/block-service/src/service/mod.rs index 23bd49bc..a231155a 100644 --- a/crates/block-service/src/service/mod.rs +++ b/crates/block-service/src/service/mod.rs @@ -45,6 +45,10 @@ pub struct BlockService { circuit_breaker: Arc, builder_config_provider: Option>, deadline_schedule: DeadlineSchedule, + /// Beacon-node slot length in milliseconds for the Gloas payload deadline. + /// + /// [`Self::new`] keeps 12 s. Production passes the resolved duration. + slot_duration_ms: u64, #[cfg(test)] envelope_deadline_note: Mutex>, } @@ -64,10 +68,15 @@ pub(crate) struct PayloadDeadline { } impl PayloadDeadline { - fn new(fork: ForkName, schedule: &DeadlineSchedule, start: Instant) -> Self { + fn new( + fork: ForkName, + schedule: &DeadlineSchedule, + start: Instant, + slot_duration_ms: u64, + ) -> Self { Self { start, - deadline_ms: due_ms(schedule.for_fork(fork).payload, SLOT_DURATION_MS), + deadline_ms: due_ms(schedule.for_fork(fork).payload, slot_duration_ms), first_overrun_stage: None, } } @@ -132,6 +141,7 @@ impl BlockService { circuit_breaker, builder_config_provider: None, deadline_schedule: DeadlineSchedule::uniform(DeadlineBps::default()), + slot_duration_ms: SLOT_DURATION_MS, #[cfg(test)] envelope_deadline_note: Mutex::new(None), } @@ -152,6 +162,23 @@ impl BlockService { self } + /// Slot length used by the Gloas payload deadline. + /// + /// Production passes the duration resolved from the beacon node. + pub fn with_slot_duration_ms(mut self, slot_duration_ms: u64) -> Self { + self.slot_duration_ms = slot_duration_ms; + self + } + + /// Slot length in milliseconds stored for [`PayloadDeadline`]. + pub fn slot_duration_ms(&self) -> u64 { + self.slot_duration_ms + } + + fn payload_deadline(&self, fork: ForkName, start: Instant) -> PayloadDeadline { + PayloadDeadline::new(fork, &self.deadline_schedule, start, self.slot_duration_ms) + } + #[cfg(test)] pub(crate) fn last_envelope_deadline_note(&self) -> Option { self.envelope_deadline_note.lock().ok().and_then(|g| *g) @@ -228,8 +255,7 @@ impl BlockService { let epoch = slot / SLOTS_PER_EPOCH; let fork = ForkName::from_epoch(epoch, &self.fork_schedule); - let mut payload_deadline = - PayloadDeadline::new(fork, &self.deadline_schedule, proposal_start); + let mut payload_deadline = self.payload_deadline(fork, proposal_start); // 1. Sign RANDAO reveal let randao_start = std::time::Instant::now(); diff --git a/crates/block-service/src/service/tests/deadline.rs b/crates/block-service/src/service/tests/deadline.rs new file mode 100644 index 00000000..c2bcaea3 --- /dev/null +++ b/crates/block-service/src/service/tests/deadline.rs @@ -0,0 +1,80 @@ +//! Gloas payload deadline follows the beacon-node slot length. + +use super::*; +use eth_types::{ForkName, SLOT_DURATION_MS}; +use std::sync::Arc; +use std::time::Instant; +use timing::{DeadlineBps, DeadlineSchedule}; + +fn gloas_schedule() -> DeadlineSchedule { + DeadlineSchedule { + pre_gloas: DeadlineBps { payload: 1_000, ..DeadlineBps::default() }, + gloas: DeadlineBps { payload: 5_000, ..DeadlineBps::default() }, + } +} + +fn service(slot_duration_ms: u64) -> BlockService { + let pubkey = test_pubkey(); + BlockService::new( + Arc::new(MockSigner::new()), + Arc::new(MockBeaconClient::from_response(ProduceBlockResponse { + data: serde_json::json!({}), + is_blinded: false, + consensus_version: "gloas".to_string(), + execution_payload_value: None, + is_ssz: false, + ssz_bytes: None, + payload_included: false, + builder_url: None, + consensus_block_value: None, + })), + Arc::new(test_validator_store(&pubkey)), + Arc::new(test_fork_schedule()), + [0xaa; 32], + ) + .with_slot_duration_ms(slot_duration_ms) + .with_deadline_schedule(gloas_schedule()) +} + +#[test] +fn gloas_payload_deadline_is_half_the_slot() { + let start = Instant::now(); + let schedule = gloas_schedule(); + let six = PayloadDeadline::new(ForkName::Gloas, &schedule, start, 6_000); + assert_eq!(six.deadline_ms, 3_000); + let twelve = PayloadDeadline::new(ForkName::Gloas, &schedule, start, 12_000); + assert_eq!(twelve.deadline_ms, 6_000); + // Pre-Gloas uses its own payload basis points, not the Gloas 5000. + let pre = PayloadDeadline::new(ForkName::Electra, &schedule, start, 6_000); + assert_eq!(pre.deadline_ms, 600); +} + +#[test] +fn block_service_payload_deadline_uses_stored_slot_duration() { + let untouched = BlockService::new( + Arc::new(MockSigner::new()), + Arc::new(MockBeaconClient::from_response(ProduceBlockResponse { + data: serde_json::json!({}), + is_blinded: false, + consensus_version: "gloas".to_string(), + execution_payload_value: None, + is_ssz: false, + ssz_bytes: None, + payload_included: false, + builder_url: None, + consensus_block_value: None, + })), + Arc::new(test_validator_store(&test_pubkey())), + Arc::new(test_fork_schedule()), + [0xaa; 32], + ); + assert_eq!(untouched.slot_duration_ms(), SLOT_DURATION_MS); + assert_eq!(untouched.slot_duration_ms(), 12_000); + + let start = Instant::now(); + assert_eq!(service(6_000).payload_deadline(ForkName::Gloas, start).deadline_ms, 3_000); + assert_eq!(service(12_000).payload_deadline(ForkName::Gloas, start).deadline_ms, 6_000); + assert_eq!(service(6_000).slot_duration_ms(), 6_000); + // Zero is not rewritten to 12 s. A zero deadline overruns immediately. + assert_eq!(service(0).payload_deadline(ForkName::Gloas, start).deadline_ms, 0); +} diff --git a/crates/block-service/src/service/tests/mod.rs b/crates/block-service/src/service/tests/mod.rs index 27043f3e..4451fb67 100644 --- a/crates/block-service/src/service/tests/mod.rs +++ b/crates/block-service/src/service/tests/mod.rs @@ -23,6 +23,7 @@ mod mocks; pub(crate) use mocks::*; mod boost; +mod deadline; mod envelope; mod json; mod propose; diff --git a/crates/bn-manager/src/lib.rs b/crates/bn-manager/src/lib.rs index 0b4a0ace..a2a1ba27 100644 --- a/crates/bn-manager/src/lib.rs +++ b/crates/bn-manager/src/lib.rs @@ -23,7 +23,7 @@ mod traits; pub mod types; pub use error::BnManagerError; -pub use manager::{BnManager, ATTEMPT_TIMEOUT_FLOOR}; +pub use manager::{BnManager, SpecAgreement, ATTEMPT_TIMEOUT_FLOOR}; #[cfg(any(test, feature = "test-utils"))] pub use mock::{MockBeaconNodeClient, MockCallStamp, MockMethod}; pub use sse::{ diff --git a/crates/bn-manager/src/manager.rs b/crates/bn-manager/src/manager.rs index f7019714..6def5b0f 100644 --- a/crates/bn-manager/src/manager.rs +++ b/crates/bn-manager/src/manager.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -33,7 +34,8 @@ use crate::fan_out::{self, BnAttempt}; use crate::health::{new_shared_health_trackers, BnHealthTracker, SharedHealthTrackers}; use crate::sse::{self, SseConfig, SseEvent}; use crate::sync_status::{ - check_all_sync_statuses, new_shared_sync_statuses, start_sync_monitor, SharedSyncStatuses, + check_all_sync_statuses, new_shared_sync_statuses, start_sync_monitor, AfterSyncPoll, + SharedSyncStatuses, }; use crate::traits::{ AttestationApi, BeaconNodeClient, BlockProducer, BnHealthScore, BnManagerConfig, @@ -318,6 +320,11 @@ pub struct BnManager { roles: Vec>, tier_thresholds: TierThresholds, fee_recipient_policy: FeeRecipientPolicy, + /// `true` until boot skips the node. Duties and liveness ignore `false` + /// until the node matches the boot chain. + admitted: Arc>, + /// Identity from the beacon nodes that completed spec and genesis. + chain_identity: Arc>>, } /// Apply health-tracker updates. Each call takes the write lock once. @@ -492,6 +499,7 @@ impl BnManager { publish_capability(ep, capability, true); } } + let admitted = Arc::new((0..clients.len()).map(|_| AtomicBool::new(true)).collect()); Ok(Self { clients, sync_statuses, @@ -501,6 +509,8 @@ impl BnManager { roles, tier_thresholds, fee_recipient_policy: config.fee_recipient_policy, + admitted, + chain_identity: Arc::new(tokio::sync::RwLock::new(None)), }) } @@ -630,6 +640,15 @@ impl BnManager { #[tracing::instrument(name = "bn_manager.check_sync_status", skip_all)] pub async fn check_sync_status(&self) { check_all_sync_statuses(&self.clients, &self.sync_statuses, &self.tier_thresholds).await; + // A syncing recovery does not admit a node boot skipped. The identity + // check does, and only after this poll shows the node is reachable. + admit_reachable_clients( + &self.clients, + &self.sync_statuses, + &self.admitted, + &self.chain_identity, + ) + .await; } /// Starts a background task that periodically polls sync status. @@ -642,12 +661,26 @@ impl BnManager { shutdown: tokio::sync::watch::Receiver, ) -> tokio::task::JoinHandle<()> { let interval = interval.unwrap_or(DEFAULT_SYNC_CHECK_INTERVAL); + let clients = self.clients.clone(); + let statuses = Arc::clone(&self.sync_statuses); + let admitted = Arc::clone(&self.admitted); + let identity = Arc::clone(&self.chain_identity); + let after_poll: AfterSyncPoll = Arc::new(move || { + let clients = clients.clone(); + let statuses = Arc::clone(&statuses); + let admitted = Arc::clone(&admitted); + let identity = Arc::clone(&identity); + Box::pin(async move { + admit_reachable_clients(&clients, &statuses, &admitted, &identity).await; + }) as std::pin::Pin + Send>> + }); start_sync_monitor( self.clients.clone(), self.sync_statuses.clone(), self.tier_thresholds.clone(), interval, shutdown, + Some(after_poll), ) } @@ -657,8 +690,10 @@ impl BnManager { self.clients[0].endpoint() } - /// Starts SSE event subscription on the primary beacon node. + /// Starts SSE event subscription on admitted beacon nodes. /// + /// An unadmitted node is not connected, so its head and reorg events are + /// not delivered. Once it is admitted, a later pass may connect. /// The returned `JoinHandle` runs the SSE loop in a background task. /// Send `true` on `shutdown` to stop the subscription. pub fn start_sse( @@ -671,24 +706,41 @@ impl BnManager { { let configs: Vec = self.clients.iter().map(|c| SseConfig::new(c.endpoint().to_string())).collect(); + let admitted = Arc::clone(&self.admitted); tokio::spawn(async move { - sse::subscribe_events(configs, callback, shutdown).await; + sse::subscribe_events_admitted(configs, admitted, callback, shutdown).await; }) } /// Role match, then `All`-role fallback. Does **not** fall back to every client. /// Shared by query (`synced_indices`) and broadcast so All-fallback is one path. + fn is_admitted(&self, index: usize) -> bool { + self.admitted.get(index).is_some_and(|flag| flag.load(Ordering::Acquire)) + } + + fn mark_unadmitted(&self, index: usize) { + if let Some(flag) = self.admitted.get(index) { + flag.store(false, Ordering::Release); + } + } + + /// Role match among admitted beacon nodes, then `All`-role fallback. + /// + /// Does not fall back to an unadmitted node. An empty result fails closed. fn role_matching_indices(&self, role: BnRole) -> Vec { - let matched: Vec = - (0..self.clients.len()).filter(|&i| BnRole::matches(&self.roles[i], role)).collect(); + let matched: Vec = (0..self.clients.len()) + .filter(|&i| self.is_admitted(i) && BnRole::matches(&self.roles[i], role)) + .collect(); if !matched.is_empty() { return matched; } warn!( role = %role, - "no BNs assigned for role, falling back to all-role BNs" + "no admitted BNs assigned for role, falling back to admitted all-role BNs" ); - (0..self.clients.len()).filter(|&i| self.roles[i].contains(&BnRole::All)).collect() + (0..self.clients.len()) + .filter(|&i| self.is_admitted(i) && self.roles[i].contains(&BnRole::All)) + .collect() } /// Returns indices of BNs matching the given role and meeting the minimum health tier, @@ -699,7 +751,7 @@ impl BnManager { /// Fallback chain: /// 1. If no BNs match the role, fall back to `All`-role BNs with WARN /// 2. If no BNs meet the tier, try the next lower tier with WARN - /// 3. If still empty, fall back to all BNs (query path only) + /// 3. If still empty, fall back to every admitted BN (query path only) /// 4. Capability is a hard exclude for one selection when a capable peer /// remains; the next selection re-probes flagged BNs first (bounded /// schedule). If every remaining BN is incapable of `op_name`, those BNs @@ -719,10 +771,11 @@ impl BnManager { let role_indices = self.role_matching_indices(role); - // If still empty after role fallback, use all BNs + // If still empty after role fallback, use every admitted BN. + // Unadmitted nodes stay out: a syncing recovery is not chain identity. let role_indices = if role_indices.is_empty() { - warn!("no BNs with All role either, falling back to all BNs"); - (0..self.clients.len()).collect() + warn!("no admitted BNs with All role either, falling back to all admitted BNs"); + (0..self.clients.len()).filter(|&i| self.is_admitted(i)).collect() } else { role_indices }; @@ -2484,6 +2537,230 @@ fn merge_builder_preferences_broadcast( Ok(SubmitBuilderPreferencesResult::PartialFailure { failures }) } +/// Which configured beacon nodes must answer before a spec is accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecAgreement { + /// Custom networks. Every configured BN must respond and the values must agree. + All, + /// Named networks. The preset already pins duration and genesis, so a BN + /// that does not respond is skipped. At least one must respond. Responders + /// that disagree fail closed. + Responding, +} + +impl BnManager { + /// Slot duration, slots per epoch, and genesis time. + /// + /// A missing key, a value outside the slot-duration bound, or any + /// disagreement among responders fails closed. [`SpecAgreement::All`] + /// also fails when any configured BN does not respond. + /// [`SpecAgreement::Responding`] skips a connect failure, a timeout, or a + /// proxy status (429, 502, 503, 504) and fails when nobody returned both + /// spec and genesis. A spec is compared before genesis is fetched, so a + /// genesis timeout cannot hide a disagreeing spec. `configured_root` seeds + /// the validators-root check: a responder that differs is not admitted and + /// is not the chain identity. A skipped node stays unadmitted until + /// [`Self::admit_skipped_beacon`]. Endpoints are not included in the error. + pub async fn agreed_spec_and_genesis( + &self, + agreement: SpecAgreement, + configured_root: &str, + ) -> Result<(u64, u64, u64), BeaconError> { + if self.clients.is_empty() { + return Err(BeaconError::HttpError("no beacon nodes configured".into())); + } + let mut duration_ms = None; + let mut slots_per_epoch = None; + let mut genesis_time = None; + let mut responded = 0u32; + let mut saw_root_mismatch = false; + for (index, client) in self.clients.iter().enumerate() { + let spec = match client.get_config_spec().await { + Ok(spec) => spec, + Err(e) if agreement == SpecAgreement::Responding && is_unreachable(&e) => { + self.mark_unadmitted(index); + continue; + } + Err(e) => return Err(e), + }; + let ms = beacon::parse_slot_duration_ms(&spec.data)?; + let slots = beacon::parse_slots_per_epoch(&spec.data)?; + // Compare the spec before fetching genesis. A timeout on genesis + // must not discard a duration the other nodes disagree with. + duration_ms = agree(duration_ms, ms, "slot duration")?; + slots_per_epoch = agree(slots_per_epoch, slots, "SLOTS_PER_EPOCH")?; + let genesis = match client.get_genesis().await { + Ok(genesis) => genesis, + Err(e) if agreement == SpecAgreement::Responding && is_unreachable(&e) => { + self.mark_unadmitted(index); + continue; + } + Err(e) => return Err(e), + }; + let parsed_genesis = genesis + .data + .genesis_time + .parse::() + .map_err(|e| BeaconError::ParseError(format!("invalid genesis_time: {e}")))?; + genesis_time = agree(genesis_time, parsed_genesis, "genesis_time")?; + if !beacon::hex_ids_equal(configured_root, &genesis.data.genesis_validators_root) { + saw_root_mismatch = true; + self.mark_unadmitted(index); + if agreement == SpecAgreement::All { + return Err(BeaconError::ParseError( + "beacon nodes disagree on genesis_validators_root".into(), + )); + } + continue; + } + responded += 1; + } + if responded == 0 { + if saw_root_mismatch { + return Err(BeaconError::ParseError( + "beacon nodes disagree on genesis_validators_root".into(), + )); + } + return Err(BeaconError::HttpError( + "no beacon node responded with spec and genesis".into(), + )); + } + match (duration_ms, slots_per_epoch, genesis_time) { + (Some(duration_ms), Some(slots_per_epoch), Some(genesis_time)) => { + *self.chain_identity.write().await = Some(AgreedChain { + slot_duration_ms: duration_ms, + slots_per_epoch, + genesis_time, + genesis_validators_root: configured_root.to_string(), + }); + Ok((duration_ms, slots_per_epoch, genesis_time)) + } + _ => { + Err(BeaconError::HttpError("no beacon node responded with spec and genesis".into())) + } + } + } + + /// Admit one boot-skipped beacon node after its spec and genesis match + /// the chain the responding nodes agreed on. + /// + /// `/eth/v1/node/syncing` does not admit the node. A mismatch leaves it + /// unadmitted. + pub async fn admit_skipped_beacon(&self, index: usize) -> Result<(), BeaconError> { + admit_client(&self.clients, &self.admitted, &self.chain_identity, index).await + } +} + +/// Chain identity the boot responders agreed on. +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgreedChain { + slot_duration_ms: u64, + slots_per_epoch: u64, + genesis_time: u64, + genesis_validators_root: String, +} + +fn is_unreachable(err: &BeaconError) -> bool { + match err { + BeaconError::HttpError(_) | BeaconError::Timeout | BeaconError::OperationTimeout { .. } => { + true + } + // Proxy unavailable. 500 stays boot-fatal. Production failover does + // not treat 429 as a produce failover. + BeaconError::ApiError { status, .. } if matches!(*status, 429 | 502..=504) => true, + _ => false, + } +} + +fn agree(prev: Option, next: u64, field: &str) -> Result, BeaconError> { + match prev { + Some(prev) if prev != next => Err(BeaconError::ParseError(format!( + "beacon nodes disagree on {field}: {prev} vs {next}" + ))), + _ => Ok(Some(next)), + } +} + +async fn admit_reachable_clients( + clients: &[BeaconClient], + statuses: &SharedSyncStatuses, + admitted: &Arc>, + identity: &tokio::sync::RwLock>, +) { + let snapshot = statuses.read().await.clone(); + for (index, detail) in snapshot.iter().enumerate() { + if admitted.get(index).is_some_and(|flag| flag.load(Ordering::Acquire)) { + continue; + } + if matches!(detail.status, BnSyncStatus::Unreachable | BnSyncStatus::Unknown) { + continue; + } + if let Err(err) = admit_client(clients, admitted, identity, index).await { + warn!( + bn_index = index, + error = %err, + "skipped beacon node did not match the boot chain" + ); + } + } +} + +async fn admit_client( + clients: &[BeaconClient], + admitted: &[AtomicBool], + identity: &tokio::sync::RwLock>, + index: usize, +) -> Result<(), BeaconError> { + let Some(client) = clients.get(index) else { + return Err(BeaconError::HttpError("beacon node index out of range".into())); + }; + // The stored root is the configured genesis validators root, not whichever + // responder answered first. A recovered node must match that root. + let agreed = + identity.read().await.clone().ok_or_else(|| { + BeaconError::HttpError("beacon chain identity is not established".into()) + })?; + let spec = client.get_config_spec().await?; + let duration_ms = beacon::parse_slot_duration_ms(&spec.data)?; + let slots = beacon::parse_slots_per_epoch(&spec.data)?; + if duration_ms != agreed.slot_duration_ms { + return Err(BeaconError::ParseError(format!( + "beacon nodes disagree on slot duration: {} vs {duration_ms}", + agreed.slot_duration_ms + ))); + } + if slots != agreed.slots_per_epoch { + return Err(BeaconError::ParseError(format!( + "beacon nodes disagree on SLOTS_PER_EPOCH: {} vs {slots}", + agreed.slots_per_epoch + ))); + } + let genesis = client.get_genesis().await?; + let parsed_genesis = genesis + .data + .genesis_time + .parse::() + .map_err(|e| BeaconError::ParseError(format!("invalid genesis_time: {e}")))?; + if parsed_genesis != agreed.genesis_time { + return Err(BeaconError::ParseError(format!( + "beacon nodes disagree on genesis_time: {} vs {parsed_genesis}", + agreed.genesis_time + ))); + } + if !beacon::hex_ids_equal( + &agreed.genesis_validators_root, + &genesis.data.genesis_validators_root, + ) { + return Err(BeaconError::ParseError( + "beacon nodes disagree on genesis_validators_root".into(), + )); + } + if let Some(flag) = admitted.get(index) { + flag.store(true, Ordering::Release); + } + Ok(()) +} + #[async_trait] impl NodeStatusApi for BnManager { // -- State / Config: query(First), any role, accept SmallLag -- @@ -2529,6 +2806,38 @@ impl NodeStatusApi for BnManager { }) } + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError> { + let mut matched = 0u32; + let mut mismatch = None; + for (index, client) in self.clients.iter().enumerate() { + match client.get_genesis().await { + Ok(genesis) => { + if beacon::hex_ids_equal( + &genesis.data.genesis_validators_root, + expected_root_hex, + ) { + matched += 1; + } else { + self.mark_unadmitted(index); + mismatch = Some(genesis.data.genesis_validators_root); + } + } + Err(err) if is_unreachable(&err) => self.mark_unadmitted(index), + Err(err) => return Err(err), + } + } + if matched == 0 { + return Err(match mismatch { + Some(root) => BeaconError::GenesisValidatorsRootMismatch { root }, + None => BeaconError::HttpError("no beacon node responded with genesis".into()), + }); + } + Ok(()) + } + async fn get_config_spec(&self) -> Result { // no budget: startup spec read; OperationTimeouts has no field for it self.query_first("get_config_spec", BnRole::All, HealthTier::SmallLag, None, |c| { @@ -3365,6 +3674,10 @@ impl_beacon_client_passthrough! { &self, expected_root_hex: &str, ) -> Result; + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError>; async fn get_config_spec(&self) -> Result; async fn get_fork_schedule(&self) -> Result; async fn get_fork(&self, state_id: &str) -> Result; @@ -3544,6 +3857,7 @@ mod tests { use super::*; use crate::broadcast::BnOutcome; + use crate::traits::{LivenessApi, NodeStatusApi}; use crate::types::bn_capability; fn testing_select( @@ -3578,10 +3892,10 @@ mod tests { fn _assert_full_client() {} _assert_full_client::(); - // 37 methods across the seven role traits (see impl_beacon_client_passthrough!). + // 38 methods across the seven role traits (see impl_beacon_client_passthrough!). assert_eq!( BEACON_CLIENT_PASSTHROUGH_METHODS.len(), - 37, + 38, "update impl_beacon_client_passthrough! when adding a role-trait method" ); @@ -6429,4 +6743,521 @@ mod tests { budget + epsilon ); } + + fn spec_body(slot_ms: &str) -> String { + format!(r#"{{"data":{{"SLOT_DURATION_MS":"{slot_ms}","SLOTS_PER_EPOCH":"32"}}}}"#) + } + + fn genesis_body(genesis_time: &str) -> String { + format!( + r#"{{"data":{{"genesis_time":"{genesis_time}","genesis_validators_root":"0xabc","genesis_fork_version":"0x00000000"}}}}"# + ) + } + + async fn mount_spec_and_genesis( + server: &wiremock::MockServer, + slot_ms: &str, + genesis_time: &str, + ) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_string(spec_body(slot_ms))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_string(genesis_body(genesis_time))) + .mount(server) + .await; + } + + #[tokio::test] + async fn agreed_spec_on_named_network_skips_an_unreachable_bn() { + use wiremock::MockServer; + let live = MockServer::start().await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + let manager = BnManager::new(BnManagerConfig::new(vec![ + "http://127.0.0.1:1".to_string(), + live.uri(), + ])) + .expect("manager"); + let (duration, slots, genesis) = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect("unreachable peer is skipped"); + assert_eq!((duration, slots, genesis), (12_000, 32, 1_606_824_023)); + } + + #[tokio::test] + async fn agreed_spec_fails_when_no_bn_responds() { + let manager = BnManager::new(BnManagerConfig::new(vec!["http://127.0.0.1:1".to_string()])) + .expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect_err("at least one response"); + assert!(err.to_string().contains("no beacon node responded"), "{err}"); + } + + #[tokio::test] + async fn agreed_spec_on_custom_requires_every_bn() { + use wiremock::MockServer; + let live = MockServer::start().await; + mount_spec_and_genesis(&live, "6000", "1606824023").await; + let manager = BnManager::new(BnManagerConfig::new(vec![ + "http://127.0.0.1:1".to_string(), + live.uri(), + ])) + .expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::All, "0xabc") + .await + .expect_err("custom requires every BN"); + assert!(err.to_string().contains("HTTP request failed"), "{err}"); + } + + #[tokio::test] + async fn agreed_spec_fails_closed_when_responders_disagree() { + use wiremock::MockServer; + let first = MockServer::start().await; + let second = MockServer::start().await; + mount_spec_and_genesis(&first, "12000", "1606824023").await; + mount_spec_and_genesis(&second, "6000", "1606824023").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![first.uri(), second.uri()])).expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect_err("disagreement"); + assert!(err.to_string().contains("disagree"), "{err}"); + } + + fn manager_with_timeout(endpoints: Vec, timeout: Duration) -> BnManager { + let mut config = BnManagerConfig::new(endpoints); + config.timeout = timeout; + BnManager::new(config).expect("manager") + } + + async fn mount_spec(server: &wiremock::MockServer, slot_ms: &str) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_string(spec_body(slot_ms))) + .mount(server) + .await; + } + + async fn mount_genesis_delayed(server: &wiremock::MockServer, delay: Duration) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(genesis_body("1606824023")) + .set_delay(delay), + ) + .mount(server) + .await; + } + + async fn mount_status(server: &wiremock::MockServer, route: &str, status: u16) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(status).set_body_string("unavailable")) + .mount(server) + .await; + } + + async fn mount_synced(server: &wiremock::MockServer) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/node/syncing")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"data":{"head_slot":"100","sync_distance":"0","is_syncing":false,"is_optimistic":false,"el_offline":false}}"#, + )) + .mount(server) + .await; + } + + async fn mount_liveness(server: &wiremock::MockServer, status: u16) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + let template = if status == 200 { + ResponseTemplate::new(200).set_body_string(r#"{"data":[{"index":"1","is_live":true}]}"#) + } else { + ResponseTemplate::new(status).set_body_string("down") + }; + Mock::given(method("POST")) + .and(path("/eth/v1/validator/liveness/1")) + .respond_with(template) + .mount(server) + .await; + } + + async fn liveness_hits(server: &wiremock::MockServer) -> usize { + server + .received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|request| request.url.path().contains("/eth/v1/validator/liveness/")) + .count() + } + + #[test] + fn proxy_statuses_count_as_unreachable_and_500_does_not() { + for status in [429_u16, 502, 503, 504] { + assert!(is_unreachable(&BeaconError::ApiError { status, message: "proxy".into() })); + } + assert!(!is_unreachable(&BeaconError::ApiError { status: 500, message: "fault".into() })); + } + + #[tokio::test] + async fn agreed_spec_keeps_a_spec_when_genesis_times_out() { + use wiremock::MockServer; + let slow = MockServer::start().await; + let peer = MockServer::start().await; + mount_spec(&slow, "12000").await; + mount_genesis_delayed(&slow, Duration::from_millis(300)).await; + mount_spec_and_genesis(&peer, "6000", "1606824023").await; + let manager = manager_with_timeout(vec![slow.uri(), peer.uri()], Duration::from_millis(50)); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect_err("the timed-out spec must still disagree"); + let message = err.to_string(); + assert!(message.contains("disagree on slot duration"), "{message}"); + assert!(message.contains("12000"), "{message}"); + assert!(message.contains("6000"), "{message}"); + } + + #[tokio::test] + async fn unvalidated_bn_is_not_used_for_liveness_after_sync_recovers() { + use wiremock::MockServer; + let skipped = MockServer::start().await; + let live = MockServer::start().await; + mount_spec(&skipped, "12000").await; + mount_genesis_delayed(&skipped, Duration::from_millis(300)).await; + mount_synced(&skipped).await; + mount_liveness(&skipped, 200).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + mount_liveness(&live, 200).await; + let manager = + manager_with_timeout(vec![skipped.uri(), live.uri()], Duration::from_millis(50)); + manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect("the live beacon node completes agreement"); + assert!(!manager.is_admitted(0), "a genesis timeout must leave the node unadmitted"); + manager.check_sync_status().await; + assert!(!manager.is_admitted(0), "syncing recovery must not admit the node"); + let indices = vec!["1".to_string()]; + manager.post_validator_liveness(1, &indices).await.expect("live node answers"); + manager + .post_validator_liveness_merged(1, &indices) + .await + .expect("merged liveness uses admitted nodes"); + assert_eq!(liveness_hits(&skipped).await, 0, "the unvalidated node must not be queried"); + assert!(liveness_hits(&live).await > 0); + } + + #[tokio::test] + async fn skipped_bn_is_admitted_for_liveness_after_identity_matches() { + use wiremock::MockServer; + let skipped = MockServer::start().await; + let live = MockServer::start().await; + mount_spec(&skipped, "12000").await; + mount_genesis_delayed(&skipped, Duration::from_millis(300)).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + mount_liveness(&live, 500).await; + let manager = + manager_with_timeout(vec![skipped.uri(), live.uri()], Duration::from_millis(50)); + manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect("the live beacon node completes agreement"); + mount_liveness(&skipped, 200).await; + let indices = vec!["1".to_string()]; + let blocked = manager.post_validator_liveness(1, &indices).await; + assert!(blocked.is_err(), "the unadmitted node must not fail over: {blocked:?}"); + assert_eq!(liveness_hits(&skipped).await, 0); + + skipped.reset().await; + mount_spec_and_genesis(&skipped, "12000", "1606824023").await; + mount_liveness(&skipped, 200).await; + manager.admit_skipped_beacon(0).await.expect("identity matches"); + assert!(manager.is_admitted(0)); + manager.post_validator_liveness(1, &indices).await.expect("admitted node answers"); + assert!(liveness_hits(&skipped).await > 0); + } + + #[tokio::test] + async fn admit_skipped_beacon_rejects_mismatched_genesis_validators() { + use wiremock::MockServer; + let skipped = MockServer::start().await; + let live = MockServer::start().await; + mount_status(&skipped, "/eth/v1/config/spec", 503).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![skipped.uri(), live.uri()])).expect("manager"); + manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect("503 is skipped"); + skipped.reset().await; + mount_spec(&skipped, "12000").await; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"data":{"genesis_time":"1606824023","genesis_validators_root":"0xdef","genesis_fork_version":"0x00000000"}}"#, + )) + .mount(&skipped) + .await; + let err = manager.admit_skipped_beacon(0).await.expect_err("root mismatch"); + assert!(err.to_string().contains("genesis_validators_root"), "{err}"); + assert!(!manager.is_admitted(0)); + } + + #[tokio::test] + async fn responding_agreement_skips_proxy_unavailable() { + use wiremock::MockServer; + for status in [502_u16, 503, 504, 429] { + let down = MockServer::start().await; + let live = MockServer::start().await; + mount_status(&down, "/eth/v1/config/spec", status).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + let manager = BnManager::new(BnManagerConfig::new(vec![down.uri(), live.uri()])) + .expect("manager"); + let agreed = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .unwrap_or_else(|err| panic!("status {status} must be skipped, got {err}")); + assert_eq!(agreed, (12_000, 32, 1_606_824_023)); + assert!(!manager.is_admitted(0)); + } + } + + #[tokio::test] + async fn responding_agreement_treats_500_as_boot_fatal() { + use wiremock::MockServer; + let down = MockServer::start().await; + let live = MockServer::start().await; + mount_status(&down, "/eth/v1/config/spec", 500).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![down.uri(), live.uri()])).expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect_err("500 stays boot-fatal"); + assert!(matches!(err, BeaconError::ApiError { status: 500, .. }), "{err:?}"); + } + + #[tokio::test] + async fn custom_agreement_treats_503_as_boot_fatal() { + use wiremock::MockServer; + let down = MockServer::start().await; + let live = MockServer::start().await; + mount_status(&down, "/eth/v1/config/spec", 503).await; + mount_spec_and_genesis(&live, "12000", "1606824023").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![down.uri(), live.uri()])).expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::All, "0xabc") + .await + .expect_err("custom requires every BN"); + assert!(matches!(err, BeaconError::ApiError { status: 503, .. }), "{err:?}"); + } + + async fn mount_genesis_root(server: &wiremock::MockServer, genesis_time: &str, root: &str) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"data":{{"genesis_time":"{genesis_time}","genesis_validators_root":"{root}","genesis_fork_version":"0x00000000"}}}}"# + ))) + .mount(server) + .await; + } + + #[tokio::test] + async fn confirm_genesis_root_checks_every_responder() { + use wiremock::MockServer; + let good = MockServer::start().await; + let wrong = MockServer::start().await; + mount_genesis_root(&good, "1606824023", "0xabc").await; + mount_genesis_root(&wrong, "1606824023", "0xdef").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![good.uri(), wrong.uri()])).expect("manager"); + manager.confirm_genesis_validators_root("0xabc").await.expect("the good node matches"); + assert!(manager.is_admitted(0)); + assert!(!manager.is_admitted(1), "a later wrong root must not stay admitted"); + } + + #[tokio::test] + async fn single_wrong_root_node_is_rejected_at_boot() { + use wiremock::MockServer; + let server = MockServer::start().await; + mount_spec(&server, "12000").await; + mount_genesis_root(&server, "1606824023", "0xdef").await; + mount_liveness(&server, 200).await; + let manager = BnManager::new(BnManagerConfig::new(vec![server.uri()])).expect("manager"); + let err = manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect_err("configured root rejects the node"); + assert!(err.to_string().contains("genesis_validators_root"), "{err}"); + assert!(!manager.is_admitted(0)); + let liveness = manager.post_validator_liveness(1, &["1".to_string()]).await; + assert!(liveness.is_err(), "an unadmitted node is not a liveness source: {liveness:?}"); + assert_eq!(liveness_hits(&server).await, 0); + } + + #[tokio::test] + async fn wrong_chain_nodes_stay_unadmitted_after_the_good_node_drops() { + use wiremock::MockServer; + let good = MockServer::start().await; + let wrong_a = MockServer::start().await; + let wrong_b = MockServer::start().await; + mount_spec_and_genesis(&good, "12000", "1606824023").await; + for wrong in [&wrong_a, &wrong_b] { + mount_spec(wrong, "12000").await; + mount_genesis_root(wrong, "1606824023", "0xdef").await; + mount_synced(wrong).await; + mount_liveness(wrong, 200).await; + } + let manager = + BnManager::new(BnManagerConfig::new(vec![good.uri(), wrong_a.uri(), wrong_b.uri()])) + .expect("manager"); + manager + .agreed_spec_and_genesis(SpecAgreement::Responding, "0xabc") + .await + .expect("the good node seeds the configured root"); + assert!(manager.is_admitted(0)); + assert!(!manager.is_admitted(1)); + assert!(!manager.is_admitted(2)); + + good.reset().await; + mount_status(&good, "/eth/v1/config/spec", 503).await; + mount_status(&good, "/eth/v1/beacon/genesis", 503).await; + manager.check_sync_status().await; + let err = manager.admit_skipped_beacon(1).await.expect_err("wrong root"); + assert!(err.to_string().contains("genesis_validators_root"), "{err}"); + assert!(!manager.is_admitted(1)); + assert!(!manager.is_admitted(2)); + let liveness = manager.post_validator_liveness(1, &["1".to_string()]).await; + assert!(liveness.is_err(), "{liveness:?}"); + assert_eq!(liveness_hits(&wrong_a).await, 0); + assert_eq!(liveness_hits(&wrong_b).await, 0); + } + + async fn sse_head_server( + slot: &str, + ) -> (String, std::sync::Arc, tokio::task::JoinHandle<()>) { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let accepts = Arc::new(AtomicUsize::new(0)); + let accepts_task = Arc::clone(&accepts); + let body = format!( + "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0xabc\",\"state\":\"0xdef\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x11\",\"current_duty_dependent_root\":\"0x22\",\"execution_optimistic\":false}}\n\nevent: chain_reorg\ndata: {{\"slot\":\"{slot}\",\"depth\":\"1\",\"old_head_block\":\"0x01\",\"new_head_block\":\"0x02\",\"old_head_state\":\"0x03\",\"new_head_state\":\"0x04\",\"epoch\":\"1\",\"execution_optimistic\":false}}\n\n" + ); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + accepts_task.fetch_add(1, Ordering::SeqCst); + let body = body.clone(); + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await; + let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n"; + let _ = stream.write_all(headers.as_bytes()).await; + let chunk = format!("{:x}\r\n{}\r\n", body.len(), body); + let _ = stream.write_all(chunk.as_bytes()).await; + let _ = stream.flush().await; + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + }); + } + }); + (format!("http://127.0.0.1:{port}"), accepts, handle) + } + + #[tokio::test] + async fn sse_does_not_connect_an_unadmitted_bn_until_it_is_admitted() { + use std::sync::atomic::Ordering; + use std::sync::{Arc, Mutex}; + + let (skipped_url, skipped_hits, skipped_server) = sse_head_server("1").await; + let (live_url, live_hits, live_server) = sse_head_server("2").await; + let manager = + BnManager::new(BnManagerConfig::new(vec![skipped_url, live_url])).expect("manager"); + manager.mark_unadmitted(0); + let events = Arc::new(Mutex::new(Vec::new())); + let events_cb = Arc::clone(&events); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let handle = manager.start_sse( + move |event| { + events_cb.lock().expect("events").push(event); + }, + shutdown_rx, + ); + + for _ in 0..40 { + tokio::time::sleep(Duration::from_millis(50)).await; + if live_hits.load(Ordering::SeqCst) > 0 && !events.lock().expect("events").is_empty() { + break; + } + } + assert_eq!( + skipped_hits.load(Ordering::SeqCst), + 0, + "an unadmitted node must not be connected" + ); + assert!(live_hits.load(Ordering::SeqCst) > 0, "the admitted node is the stream"); + let delivered = events.lock().expect("events").clone(); + assert!( + delivered.iter().any(|event| matches!(event, SseEvent::Head(head) if head.slot == "2")), + "admitted head must be delivered: {delivered:?}" + ); + assert!( + delivered.iter().all(|event| match event { + SseEvent::Head(head) => head.slot != "1", + SseEvent::ChainReorg(reorg) => reorg.slot != "1", + SseEvent::Block(_) | SseEvent::FinalizedCheckpoint(_) => true, + }), + "unadmitted head and reorg events must be dropped: {delivered:?}" + ); + + manager.admitted[0].store(true, Ordering::Release); + live_server.abort(); + for _ in 0..80 { + tokio::time::sleep(Duration::from_millis(100)).await; + if skipped_hits.load(Ordering::SeqCst) > 0 { + break; + } + } + assert!( + skipped_hits.load(Ordering::SeqCst) > 0, + "the node may be connected after admission" + ); + shutdown_tx.send(true).expect("shutdown"); + let _ = tokio::time::timeout(Duration::from_secs(2), handle).await; + skipped_server.abort(); + } } diff --git a/crates/bn-manager/src/mock.rs b/crates/bn-manager/src/mock.rs index dfeb498c..53d33509 100644 --- a/crates/bn-manager/src/mock.rs +++ b/crates/bn-manager/src/mock.rs @@ -907,6 +907,14 @@ impl NodeStatusApi for MockBeaconNodeClient { ) } + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError> { + beacon::ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) + .map(|_| ()) + } + async fn get_config_spec(&self) -> Result { self.charge(MockMethod::GetConfigSpec).await; self.get_config_spec.invoke("get_config_spec", ()) diff --git a/crates/bn-manager/src/sse.rs b/crates/bn-manager/src/sse.rs index 991ffc55..552cfd85 100644 --- a/crates/bn-manager/src/sse.rs +++ b/crates/bn-manager/src/sse.rs @@ -1,3 +1,6 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + use serde::Deserialize; mod frame; @@ -243,6 +246,23 @@ fn content_type_is_event_stream(value: &reqwest::header::HeaderValue) -> bool { pub async fn subscribe_events( configs: Vec, callback: F, + shutdown: tokio::sync::watch::Receiver, +) where + F: Fn(SseEvent) + Send + Sync + 'static, +{ + let admitted = Arc::new((0..configs.len()).map(|_| AtomicBool::new(true)).collect::>()); + subscribe_events_admitted(configs, admitted, callback, shutdown).await +} + +/// [`subscribe_events`], skipping any endpoint whose flag is `false`. +/// +/// The flag is read before each connection and per chunk. +/// A node that is unadmitted is not connected. Once the flag becomes `true`, +/// a later pass may connect. +pub async fn subscribe_events_admitted( + configs: Vec, + admitted: Arc>, + callback: F, mut shutdown: tokio::sync::watch::Receiver, ) where F: Fn(SseEvent) + Send + Sync + 'static, @@ -279,12 +299,40 @@ pub async fn subscribe_events( let mut consecutive_failures: u32 = 0; let mut events_since_reconnect: u64; + let endpoint_admitted = + |index: usize| admitted.get(index).is_some_and(|flag| flag.load(Ordering::Acquire)); + loop { if *shutdown.borrow() { info!("SSE subscriber shutting down"); return; } + if !endpoint_admitted(current_idx) { + // Start after the current index. A scan from 0 returns to a node + // that is already failing and never reaches a later admitted peer. + let len = configs.len(); + match (1..len) + .map(|offset| (current_idx + offset) % len) + .find(|&index| endpoint_admitted(index)) + { + Some(index) => { + current_idx = index; + consecutive_failures = 0; + } + None => { + tokio::select! { + _ = shutdown.changed() => { + info!("SSE subscriber shutting down"); + return; + } + _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {} + } + continue; + } + } + } + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { // Try failover to next endpoint if available if configs.len() > 1 { @@ -403,6 +451,9 @@ pub async fn subscribe_events( let mut framer = SseFramer::new(max_event_bytes); loop { + if !endpoint_admitted(current_idx) { + break; + } let chunk = tokio::select! { _ = shutdown.changed() => { info!("SSE subscriber shutting down"); @@ -1487,4 +1538,208 @@ mod tests { let connects = connects.load(Ordering::SeqCst); assert!(connects >= 2, "oversize id field must close and reconnect, connects={connects}"); } + + fn head_frame(slot: &str) -> String { + format!( + "event: head\ndata: {{\"slot\":\"{slot}\",\"block\":\"0xabc\",\"state\":\"0xdef\",\"epoch_transition\":false,\"previous_duty_dependent_root\":\"0x11\",\"current_duty_dependent_root\":\"0x22\",\"execution_optimistic\":false}}\n\n" + ) + } + + fn reorg_frame(slot: &str) -> String { + format!( + "event: chain_reorg\ndata: {{\"slot\":\"{slot}\",\"depth\":\"1\",\"old_head_block\":\"0x01\",\"new_head_block\":\"0x02\",\"old_head_state\":\"0x03\",\"new_head_state\":\"0x04\",\"epoch\":\"1\",\"execution_optimistic\":false}}\n\n" + ) + } + + /// Chunked SSE server. Each body is one HTTP chunk. `release` gates every body after the first. + async fn gated_sse_server( + bodies: Vec, + release: Arc, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + use std::sync::atomic::AtomicUsize; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepts = Arc::new(AtomicUsize::new(0)); + let accepts_task = Arc::clone(&accepts); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + accepts_task.fetch_add(1, Ordering::SeqCst); + let bodies = bodies.clone(); + let release = Arc::clone(&release); + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await; + let headers = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n"; + let _ = stream.write_all(headers.as_bytes()).await; + for (index, body) in bodies.iter().enumerate() { + if index > 0 { + while !release.load(Ordering::Acquire) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + // Let the subscriber observe the admission flag between chunks. + if index > 1 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + let chunk = format!("{:x}\r\n{body}\r\n", body.len()); + let _ = stream.write_all(chunk.as_bytes()).await; + let _ = stream.flush().await; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + } + }); + (format!("http://127.0.0.1:{port}"), accepts, handle) + } + + #[tokio::test] + async fn sse_failover_reaches_a_later_admitted_node() { + use std::sync::Mutex; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let failing_port = listener.local_addr().unwrap().port(); + let failing_server = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + drop(stream); + } + }); + + let hold = Arc::new(AtomicBool::new(true)); + let (skipped_url, skipped_hits, skipped_server) = + gated_sse_server(vec![head_frame("1")], Arc::clone(&hold)).await; + let (healthy_url, healthy_hits, healthy_server) = + gated_sse_server(vec![head_frame("2")], Arc::clone(&hold)).await; + let admitted = + Arc::new(vec![AtomicBool::new(true), AtomicBool::new(false), AtomicBool::new(true)]); + let events = Arc::new(Mutex::new(Vec::new())); + let events_cb = Arc::clone(&events); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let handle = tokio::spawn({ + let admitted = Arc::clone(&admitted); + async move { + subscribe_events_admitted( + vec![ + SseConfig::new(format!("http://127.0.0.1:{failing_port}")), + SseConfig::new(skipped_url), + SseConfig::new(healthy_url), + ], + admitted, + move |event| { + events_cb.lock().unwrap().push(event); + }, + shutdown_rx, + ) + .await; + } + }); + + for _ in 0..80 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if healthy_hits.load(Ordering::SeqCst) > 0 && !events.lock().unwrap().is_empty() { + break; + } + } + assert_eq!( + skipped_hits.load(Ordering::SeqCst), + 0, + "failover must not connect the unadmitted middle node" + ); + assert!( + healthy_hits.load(Ordering::SeqCst) > 0, + "the later admitted node must be used after the first node fails" + ); + let delivered = events.lock().unwrap().clone(); + assert!( + delivered.iter().any(|event| matches!(event, SseEvent::Head(head) if head.slot == "2")), + "head from the later admitted node must be delivered: {delivered:?}" + ); + assert!( + delivered.iter().all(|event| match event { + SseEvent::Head(head) => head.slot != "1", + SseEvent::ChainReorg(reorg) => reorg.slot != "1", + SseEvent::Block(_) | SseEvent::FinalizedCheckpoint(_) => true, + }), + "the unadmitted node must not deliver events: {delivered:?}" + ); + shutdown_tx.send(true).unwrap(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; + failing_server.abort(); + skipped_server.abort(); + healthy_server.abort(); + } + + #[tokio::test] + async fn sse_drops_chunks_after_admission_is_revoked() { + use std::sync::Mutex; + + let release = Arc::new(AtomicBool::new(false)); + let (url, _hits, server) = gated_sse_server( + vec![ + head_frame("1"), + head_frame("2"), + format!("{}{}", head_frame("3"), reorg_frame("3")), + ], + Arc::clone(&release), + ) + .await; + let admitted = Arc::new(vec![AtomicBool::new(true)]); + let events = Arc::new(Mutex::new(Vec::new())); + let events_cb = Arc::clone(&events); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let handle = tokio::spawn({ + let admitted = Arc::clone(&admitted); + async move { + subscribe_events_admitted( + vec![SseConfig::new(url)], + admitted, + move |event| { + events_cb.lock().unwrap().push(event); + }, + shutdown_rx, + ) + .await; + } + }); + + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if events + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, SseEvent::Head(head) if head.slot == "1")) + { + break; + } + } + admitted[0].store(false, Ordering::Release); + release.store(true, Ordering::Release); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + + let delivered = events.lock().unwrap().clone(); + assert!( + delivered.iter().any(|event| matches!(event, SseEvent::Head(head) if head.slot == "1")), + "the chunk read while admitted must be delivered: {delivered:?}" + ); + assert!( + delivered.iter().all(|event| match event { + SseEvent::Head(head) => head.slot != "3", + SseEvent::ChainReorg(reorg) => reorg.slot != "3", + SseEvent::Block(_) | SseEvent::FinalizedCheckpoint(_) => true, + }), + "the chunk after admission is revoked must be dropped: {delivered:?}" + ); + shutdown_tx.send(true).unwrap(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; + server.abort(); + } } diff --git a/crates/bn-manager/src/sync_status.rs b/crates/bn-manager/src/sync_status.rs index 56d5a0e9..7bbcb52f 100644 --- a/crates/bn-manager/src/sync_status.rs +++ b/crates/bn-manager/src/sync_status.rs @@ -1,3 +1,5 @@ +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -195,20 +197,28 @@ async fn check_single_sync_status(client: &BeaconClient) -> BnSyncDetail { } } +/// Runs after each sync poll. Production uses it to admit a skipped beacon node. +pub type AfterSyncPoll = Arc Pin + Send>> + Send + Sync>; + /// Starts a background task that periodically polls sync status of all BNs. /// /// The task runs until the shutdown signal fires. Each poll updates -/// `rvc_bn_health_tier` via [`check_all_sync_statuses`]. +/// `rvc_bn_health_tier` via [`check_all_sync_statuses`]. `after_poll` runs +/// after the status write and does not change those statuses. pub fn start_sync_monitor( clients: Vec, statuses: SharedSyncStatuses, thresholds: TierThresholds, interval: Duration, mut shutdown: tokio::sync::watch::Receiver, + after_poll: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { check_all_sync_statuses(&clients, &statuses, &thresholds).await; + if let Some(after_poll) = &after_poll { + after_poll().await; + } tokio::select! { _ = tokio::time::sleep(interval) => {} @@ -691,6 +701,7 @@ mod tests { TierThresholds::default(), Duration::from_millis(50), shutdown_rx, + None, ); // Wait a bit for at least one poll @@ -728,6 +739,7 @@ mod tests { TierThresholds::default(), Duration::from_millis(50), shutdown_rx, + None, ); // Wait for initial poll diff --git a/crates/bn-manager/src/traits.rs b/crates/bn-manager/src/traits.rs index f950de07..974556fd 100644 --- a/crates/bn-manager/src/traits.rs +++ b/crates/bn-manager/src/traits.rs @@ -349,6 +349,15 @@ pub trait NodeStatusApi: Send + Sync { expected_root_hex: &str, ) -> Result; + /// Every responder must be checked against `expected_root_hex`. + /// + /// A pooled client does not stop at the first success. A responder whose + /// root differs is rejected. One match is enough; none is an error. + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError>; + async fn get_config_spec(&self) -> Result; async fn get_fork_schedule(&self) -> Result; diff --git a/crates/doppelganger/Cargo.toml b/crates/doppelganger/Cargo.toml index 7e90fb9f..4720eafd 100644 --- a/crates/doppelganger/Cargo.toml +++ b/crates/doppelganger/Cargo.toml @@ -17,7 +17,6 @@ crypto = { workspace = true } observability = { workspace = true } eth-types = { workspace = true } slashing = { workspace = true } -async-trait = { workspace = true } hex = { workspace = true } parking_lot = { workspace = true } thiserror = { workspace = true } diff --git a/crates/doppelganger/src/epoch_clock.rs b/crates/doppelganger/src/epoch_clock.rs index 7d2fd6f1..cb72d530 100644 --- a/crates/doppelganger/src/epoch_clock.rs +++ b/crates/doppelganger/src/epoch_clock.rs @@ -6,45 +6,50 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use eth_types::{Epoch, SLOTS_PER_EPOCH, SLOT_DURATION_MS}; +use eth_types::{Epoch, SLOTS_PER_EPOCH}; /// Epoch provider immune to post-start NTP steps. /// /// ```text /// now_unix = start_unix_time + start_instant.elapsed() -/// current_epoch = (now_unix - genesis_time) * 1000 / SLOT_DURATION_MS / SLOTS_PER_EPOCH +/// current_epoch = (now_unix - genesis_time) * 1000 / slot_duration_ms / SLOTS_PER_EPOCH /// ``` /// -/// If `genesis_time` is far in the future relative to process start, epoch -/// computation saturates to 0. Callers that must not apply the pre-genesis -/// Safe bypass (API import) should use [`crate::ForwardWindowMachine::register_for_import`] -/// rather than trusting epoch 0 as "safe to sign". +/// `slot_duration_ms` is the beacon node's resolved slot length. A zero +/// duration does not yield epoch 0: that epoch is the pre-genesis signing +/// bypass. If `genesis_time` is far in the future relative to process start, +/// epoch computation saturates to 0. Callers that must not apply the +/// pre-genesis Safe bypass (API import) should use +/// [`crate::ForwardWindowMachine::register_for_import`] rather than trusting +/// epoch 0 as "safe to sign". #[derive(Debug, Clone)] pub struct MonotonicEpochClock { genesis_time: u64, start_instant: Instant, start_unix_time: u64, + slot_duration_ms: u64, } impl MonotonicEpochClock { /// Capture the current wall clock once and freeze the monotonic base. - pub fn new(genesis_time: u64) -> Self { + /// + /// `slot_duration_ms` is the value resolved from the beacon node spec. + pub fn new(genesis_time: u64, slot_duration_ms: u64) -> Self { let start_unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - if genesis_time - > start_unix_time - .saturating_add(SLOT_DURATION_MS.saturating_mul(SLOTS_PER_EPOCH) / 1000) - { + let one_epoch_secs = slot_duration_ms.saturating_mul(SLOTS_PER_EPOCH) / 1000; + if genesis_time > start_unix_time.saturating_add(one_epoch_secs) { // Genesis more than one epoch in the future → every call yields 0 // until wall time catches up. Import path must use register_for_import. tracing::warn!( genesis_time, start_unix_time, + slot_duration_ms, "MonotonicEpochClock: genesis_time is in the future; epoch will stay 0 \ until genesis (import path must not treat that as Safe)" ); } - Self { genesis_time, start_instant: Instant::now(), start_unix_time } + Self { genesis_time, start_instant: Instant::now(), start_unix_time, slot_duration_ms } } /// Test/helper constructor with explicit anchors. @@ -55,8 +60,14 @@ impl MonotonicEpochClock { genesis_time: u64, start_instant: Instant, start_unix_time: u64, + slot_duration_ms: u64, ) -> Self { - Self { genesis_time, start_instant, start_unix_time } + Self { genesis_time, start_instant, start_unix_time, slot_duration_ms } + } + + /// Beacon-node slot length in milliseconds used by this clock. + pub fn slot_duration_ms(&self) -> u64 { + self.slot_duration_ms } /// Current epoch from the monotonic clock. @@ -65,19 +76,25 @@ impl MonotonicEpochClock { } /// Current slot from the monotonic clock (SEC-2c liveness loop). + /// + /// A zero `slot_duration_ms` returns [`u64::MAX`] so the result is not + /// epoch 0. Epoch 0 would take the pre-genesis signing bypass. pub fn current_slot(&self) -> u64 { + // Out of range (zero or huge) must not become slot 0 / epoch 0. + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&self.slot_duration_ms) + { + return u64::MAX; + } let elapsed_secs = self.start_instant.elapsed().as_secs(); let now_unix = self.start_unix_time.saturating_add(elapsed_secs); let secs_since_genesis = now_unix.saturating_sub(self.genesis_time); - secs_since_genesis.saturating_mul(1000) / SLOT_DURATION_MS + let elapsed_ms = secs_since_genesis.saturating_mul(1000); + elapsed_ms.checked_div(self.slot_duration_ms).unwrap_or(u64::MAX) } /// Slot index within the current epoch (`0..SLOTS_PER_EPOCH`). pub fn slot_in_epoch(&self) -> u64 { self.current_slot() % SLOTS_PER_EPOCH } - - pub fn genesis_time(&self) -> u64 { - self.genesis_time - } } diff --git a/crates/doppelganger/src/error.rs b/crates/doppelganger/src/error.rs index ee912556..bc948aba 100644 --- a/crates/doppelganger/src/error.rs +++ b/crates/doppelganger/src/error.rs @@ -20,12 +20,6 @@ use eth_types::Epoch; fail-closed and MUST be retried, not suppressed"] #[derive(Debug, Error)] pub enum DoppelgangerError { - #[error("liveness check failed: {0}")] - LivenessCheckFailed(String), - - #[error("slashing DB query failed: {0}")] - SlashingDbError(String), - /// Returned by [`crate::ForwardWindowMachine::observe_liveness`] when the /// beacon-node response is missing entries for one or more validators that /// were expected (i.e. `Pending` with a window that includes `epoch`). diff --git a/crates/doppelganger/src/forward_window.rs b/crates/doppelganger/src/forward_window.rs index 6cc5c508..16309082 100644 --- a/crates/doppelganger/src/forward_window.rs +++ b/crates/doppelganger/src/forward_window.rs @@ -64,6 +64,8 @@ pub struct ForwardWindowMachine { monitoring_epochs: u64, slashing_reader: Arc, gvr: Root, + /// Epoch 0 marks keys Safe only after genesis times agreed across BNs. + genesis_confirmed: bool, } impl ForwardWindowMachine { @@ -78,7 +80,21 @@ impl ForwardWindowMachine { gvr: Root, ) -> Self { assert!(monitoring_epochs >= 1, "monitoring_epochs must be >= 1"); - Self { states: Mutex::new(HashMap::new()), monitoring_epochs, slashing_reader, gvr } + Self { + states: Mutex::new(HashMap::new()), + monitoring_epochs, + slashing_reader, + gvr, + genesis_confirmed: false, + } + } + + /// Allow the epoch-0 Safe bypass. Production sets this only for a named + /// network whose responding beacon nodes matched the preset genesis. + /// Custom stays closed: those nodes also choose the slot duration. + pub fn with_confirmed_genesis(mut self) -> Self { + self.genesis_confirmed = true; + self } /// Register a validator for monitoring, starting at `current_epoch`. @@ -113,7 +129,10 @@ impl ForwardWindowMachine { // Rationale: placing this branch AFTER the idempotency guard ensures that // a validator already in Pending/Safe/Detected keeps its existing state // even if `register` is called again at epoch 0 (idempotency wins). - if current_epoch == 0 { + // Epoch 0 is also what an inflated slot duration produces. That must + // not open signing. The bypass runs only when genesis was confirmed + // from the named-network preset, not from the beacon-reported duration. + if current_epoch == 0 && self.genesis_confirmed { tracing::info!( pubkey = %observability::logging::TruncatedPubkey::new(&pubkey_hex), "doppelganger: pre-genesis (epoch 0) bypass — validator marked Safe without a monitoring window" diff --git a/crates/doppelganger/src/lib.rs b/crates/doppelganger/src/lib.rs index d26a5a82..00990cba 100644 --- a/crates/doppelganger/src/lib.rs +++ b/crates/doppelganger/src/lib.rs @@ -18,32 +18,9 @@ pub use error::DoppelgangerError; pub use forward_window::ForwardWindowMachine; pub use restart_skip::should_skip_restart_monitoring; pub use state::{ForwardWindowStatus, ValidatorState}; -pub use traits::{LivenessChecker, ValidatorLivenessData}; +pub use traits::ValidatorLivenessData; /// Default forward-window length in epochs (≈ 2 epochs / ~12.8 min on mainnet). /// /// Shared by [`crate::ForwardWindowMachine`] production wiring (SEC-2b). pub const DEFAULT_MONITORING_EPOCHS: u64 = 2; - -/// Status of doppelganger detection for a validator. -/// -/// Distinct from [`ForwardWindowStatus`], which is the 4-variant status for -/// [`ForwardWindowMachine`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DoppelgangerStatus { - /// Validator is safe to sign (restart-aware skip or monitoring complete). - Safe, - /// Detection is still in progress. - DetectionInProgress, - /// A doppelganger was detected for this validator. - DoppelgangerDetected, -} - -/// Result of a doppelganger check for a set of validators. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DoppelgangerResult { - /// Validators that are safe to sign. - pub safe_validators: Vec, - /// Validators for which a doppelganger was detected. - pub detected: Vec, -} diff --git a/crates/doppelganger/src/state.rs b/crates/doppelganger/src/state.rs index 45688743..5c462de0 100644 --- a/crates/doppelganger/src/state.rs +++ b/crates/doppelganger/src/state.rs @@ -32,9 +32,6 @@ pub enum ValidatorState { /// Observable status returned by [`ForwardWindowMachine::tick`] and /// [`ForwardWindowMachine::status`]. /// -/// Named `ForwardWindowStatus` (not `DoppelgangerStatus`) to avoid confusion -/// with the 3-variant crate-level [`crate::DoppelgangerStatus`]. -/// /// `Copy` so callers can cheaply pass it around without cloning. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ForwardWindowStatus { diff --git a/crates/doppelganger/src/traits.rs b/crates/doppelganger/src/traits.rs index 9ad26753..33a832cd 100644 --- a/crates/doppelganger/src/traits.rs +++ b/crates/doppelganger/src/traits.rs @@ -1,9 +1,4 @@ -//! Trait abstractions for doppelganger detection dependencies. - -use async_trait::async_trait; -use eth_types::Epoch; - -use crate::DoppelgangerError; +//! Liveness samples consumed by the forward-window doppelganger machine. /// Liveness data for a single validator at a given epoch. #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,36 +26,3 @@ pub struct ValidatorLivenessData { /// during the queried epoch. pub is_live: bool, } - -/// Abstraction for querying validator liveness from a beacon node. -/// -/// # Completeness contract (D-2, Issue 2.7) -/// -/// A response from `check_liveness` MUST include an entry for every validator -/// index that was requested. A missing entry is treated as **inconclusive** -/// (fail-closed): `ForwardWindowMachine::observe_liveness` will not record -/// that epoch as observed for the absent validator, preventing a `Safe` -/// transition until a complete response is received. -/// -/// Implementors must NOT silently drop entries for validators whose liveness -/// could not be determined. If the beacon node returns a partial response, -/// the implementor should return `Err(DoppelgangerError::IncompleteLiveness)` -/// rather than an incomplete `Vec`. -/// -/// # Pubkey-hex key contract (SEC-001) -/// -/// The `index` field of each returned `ValidatorLivenessData` MUST be the -/// lowercase `hex::encode(pubkey.to_bytes())` for the corresponding validator -/// (see [`ValidatorLivenessData::index`]). Implementations that receive -/// numeric validator indices from the beacon node MUST perform the -/// numeric → pubkey-hex translation before returning. An untranslatable -/// numeric index MUST be treated as a missing entry (fail-closed). The -/// production translation adapter lands in Issue 2.10. -#[async_trait] -pub trait LivenessChecker: Send + Sync { - async fn check_liveness( - &self, - epoch: Epoch, - validator_indices: &[String], - ) -> Result, DoppelgangerError>; -} diff --git a/crates/doppelganger/tests/clock_m7.rs b/crates/doppelganger/tests/clock_m7.rs index 716a9e36..c41c9498 100644 --- a/crates/doppelganger/tests/clock_m7.rs +++ b/crates/doppelganger/tests/clock_m7.rs @@ -17,18 +17,26 @@ const SECONDS_PER_EPOCH: u64 = SLOT_DURATION_MS * SLOTS_PER_EPOCH / 1000; #[test] fn test_slot_zero_at_genesis() { let genesis = 1_606_824_023_u64; - let clock = MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis); + let clock = MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis, 12_000); assert_eq!(clock.current_slot(), 0); assert_eq!(clock.current_epoch(), 0); let one_slot_secs = SLOT_DURATION_MS / 1000; assert_eq!(one_slot_secs, 12, "mainnet slot is 12 s"); - let one_slot_later = - MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + one_slot_secs); + let one_slot_later = MonotonicEpochClock::with_start_time( + genesis, + Instant::now(), + genesis + one_slot_secs, + 12_000, + ); assert_eq!(one_slot_later.current_slot(), 1); - let still_genesis = - MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + one_slot_secs - 1); + let still_genesis = MonotonicEpochClock::with_start_time( + genesis, + Instant::now(), + genesis + one_slot_secs - 1, + 12_000, + ); assert_eq!(still_genesis.current_slot(), 0); } @@ -37,8 +45,12 @@ fn test_slot_zero_at_genesis() { fn test_epoch_duration_is_384_seconds() { assert_eq!(SECONDS_PER_EPOCH, 384); let genesis = 0_u64; - let clock = - MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + SECONDS_PER_EPOCH); + let clock = MonotonicEpochClock::with_start_time( + genesis, + Instant::now(), + genesis + SECONDS_PER_EPOCH, + 12_000, + ); assert_eq!(clock.current_epoch(), 1); assert_eq!(clock.current_slot(), SLOTS_PER_EPOCH); } @@ -58,7 +70,8 @@ fn test_wall_clock_jump_does_not_shift_epoch() { let start_unix_time = SECONDS_PER_EPOCH * 5; // 1920s => epoch 5 let start_instant = Instant::now(); - let clock = MonotonicEpochClock::with_start_time(genesis_time, start_instant, start_unix_time); + let clock = + MonotonicEpochClock::with_start_time(genesis_time, start_instant, start_unix_time, 12_000); let epoch = clock.current_epoch(); assert_eq!(epoch, 5, "epoch should be anchored at 5 epochs past genesis"); @@ -86,8 +99,10 @@ fn test_genesis_time_anchored() { // genesis2 is 1_000 epochs later: 1_000 * 384 = 384_000 seconds let genesis2 = genesis1 + SECONDS_PER_EPOCH * 1_000; // 1_384_000 - let clock1 = MonotonicEpochClock::with_start_time(genesis1, start_instant, start_unix_time); - let clock2 = MonotonicEpochClock::with_start_time(genesis2, start_instant, start_unix_time); + let clock1 = + MonotonicEpochClock::with_start_time(genesis1, start_instant, start_unix_time, 12_000); + let clock2 = + MonotonicEpochClock::with_start_time(genesis2, start_instant, start_unix_time, 12_000); let epoch1 = clock1.current_epoch(); let epoch2 = clock2.current_epoch(); @@ -110,10 +125,49 @@ fn test_epoch_advances_with_elapsed() { let one_epoch_ago = Instant::now() - Duration::from_secs(SECONDS_PER_EPOCH); let start_unix_time = SECONDS_PER_EPOCH; // started 1 epoch after genesis - let clock = MonotonicEpochClock::with_start_time(genesis_time, one_epoch_ago, start_unix_time); + let clock = + MonotonicEpochClock::with_start_time(genesis_time, one_epoch_ago, start_unix_time, 12_000); // elapsed ≈ 384s, start_unix = 384, genesis = 0 // now_unix ≈ 384 + 384 = 768, epoch ≈ 768 / 384 = 2 let epoch = clock.current_epoch(); assert!(epoch >= 2, "with ~1 epoch elapsed the computed epoch should be ≥ 2, got {epoch}"); } + +/// 10 epochs at 6 s slots (192 s) is epoch 5 when the clock still assumes 12 s. +#[test] +fn test_six_second_slots_are_epoch_ten_not_five() { + let genesis = 1_700_000_000_u64; + let start = Instant::now(); + let now = genesis + 10 * 192; + let six = MonotonicEpochClock::with_start_time(genesis, start, now, 6_000); + assert_eq!(six.slot_duration_ms(), 6_000); + assert_eq!(six.current_epoch(), 10); + let twelve = MonotonicEpochClock::with_start_time(genesis, start, now, 12_000); + assert_eq!(twelve.current_epoch(), 5); +} + +/// Epoch 0 is the pre-genesis signing bypass. A zero duration must not land there. +#[test] +fn test_zero_slot_duration_is_not_epoch_zero() { + let clock = MonotonicEpochClock::with_start_time(0, Instant::now(), 1_000, 0); + assert_eq!(clock.current_slot(), u64::MAX); + assert_ne!(clock.current_epoch(), 0); +} + +/// A huge duration used to divide down to epoch 0 and take the Safe bypass. +#[test] +fn test_huge_slot_duration_is_not_epoch_zero() { + let genesis = 1_700_000_000_u64; + let clock = + MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + 10 * 192, u64::MAX); + assert_eq!(clock.current_slot(), u64::MAX); + assert_ne!(clock.current_epoch(), 0); + let day = MonotonicEpochClock::with_start_time( + genesis, + Instant::now(), + genesis + 10 * 192, + 86_400_000, + ); + assert_ne!(day.current_epoch(), 0); +} diff --git a/crates/doppelganger/tests/forward_window_pre_genesis.rs b/crates/doppelganger/tests/forward_window_pre_genesis.rs index 4be9f4b9..201e162f 100644 --- a/crates/doppelganger/tests/forward_window_pre_genesis.rs +++ b/crates/doppelganger/tests/forward_window_pre_genesis.rs @@ -43,7 +43,7 @@ fn new_pubkey() -> crypto::PublicKey { fn machine(monitoring_epochs: u64) -> ForwardWindowMachine { let reader: Arc = Arc::new(NoPriorAttestation); - ForwardWindowMachine::new(reader, monitoring_epochs, gvr()) + ForwardWindowMachine::new(reader, monitoring_epochs, gvr()).with_confirmed_genesis() } // --------------------------------------------------------------------------- @@ -72,6 +72,18 @@ fn test_register_at_epoch_0_is_immediately_safe() { ); } +#[test] +fn test_register_at_epoch_0_without_genesis_confirmation_stays_pending() { + let reader: Arc = Arc::new(NoPriorAttestation); + let machine = ForwardWindowMachine::new(reader, 2, gvr()); + let pubkey = new_pubkey(); + machine.register(&pubkey, 0); + assert!( + !machine.is_signing_enabled(&pubkey), + "epoch 0 without confirmed genesis must stay Pending" + ); +} + /// S-3: the epoch-0 bypass is epoch-0-ONLY. /// /// A validator registered at `current_epoch = 5` must be `Pending` diff --git a/crates/eth-types/src/lib.rs b/crates/eth-types/src/lib.rs index abb0c448..1c9297c6 100644 --- a/crates/eth-types/src/lib.rs +++ b/crates/eth-types/src/lib.rs @@ -120,6 +120,15 @@ pub const SIGNATURE_BYTES_LEN: usize = 96; pub const SLOTS_PER_EPOCH: u64 = 32; pub const SLOT_DURATION_MS: u64 = 12_000; + +/// Inclusive bounds for a beacon-reported slot length. +/// +/// One second is the duty-clock floor. Sixty seconds still accepts the 6 s +/// and 12 s lengths this client runs. A length inside this range can still +/// stretch epoch 0 well past real genesis (60 s × 32 slots). The bound does +/// not prove the chain is pre-genesis. +pub const MIN_SLOT_DURATION_MS: u64 = 1_000; +pub const MAX_SLOT_DURATION_MS: u64 = 60_000; pub const TARGET_AGGREGATORS_PER_COMMITTEE: u64 = 16; /// SSZ preset bound on a single committee's size. Sets the `Bitlist[N]` limit for a pre-Electra diff --git a/crates/rvc/benches/deadline_rebenchmark.rs b/crates/rvc/benches/deadline_rebenchmark.rs index c0819d9e..392b45be 100644 --- a/crates/rvc/benches/deadline_rebenchmark.rs +++ b/crates/rvc/benches/deadline_rebenchmark.rs @@ -70,7 +70,9 @@ fn main() { let slot_duration_ms = config.network.slot_duration_ms(); assert_eq!(slot_duration_ms, SLOT_DURATION_MS); - let orch = ServiceBuilder::new(config).build_orchestrator_config([0u8; 32], fork_schedule); + let orch = ServiceBuilder::new(config) + .build_orchestrator_config([0u8; 32], fork_schedule) + .expect("timing"); let (fork, deadline_ms) = resolve_attestation_deadline(&orch, slot, slot_duration_ms); assert!(fork >= ForkName::Gloas, "6.14 must resolve a Gloas-epoch slot; got {}", fork.as_ref()); diff --git a/crates/rvc/src/bootstrap/beacon.rs b/crates/rvc/src/bootstrap/beacon.rs index 2d27a187..d1c50d00 100644 --- a/crates/rvc/src/bootstrap/beacon.rs +++ b/crates/rvc/src/bootstrap/beacon.rs @@ -31,6 +31,16 @@ pub struct BeaconHandles { pub genesis_validators_root_hex: String, /// Genesis Unix time from config/network preset (slot/epoch clock anchor). pub genesis_time: u64, + /// Slot length in milliseconds, resolved once from the beacon node spec. + /// + /// Doppelganger, liveness, exit, and payload timing share this value. + /// A missing, zero, conflicting, out-of-range, or cross-node-disagreement + /// spec fails [`connect_beacon`] rather than assuming 12 seconds. + pub slot_duration_ms: u64, + /// True only on a named network whose responding BNs matched the preset + /// genesis. Custom stays false: those BNs also supply the slot duration, + /// and an inflated duration can hold the clock at epoch 0. + pub genesis_confirmed: bool, } /// Create the beacon client and BnManager, parse GVR once, validate against the @@ -95,6 +105,18 @@ pub async fn connect_beacon( } }; + // One resolution for every later clock. Do not fall back to 12 s. + // Responding BNs must agree. A named network skips an unreachable BN + // and must match its preset. Custom requires every configured BN. + let (slot_duration_ms, genesis_confirmed) = + match builder.resolve_slot_duration_ms(bn_manager.as_ref()).await { + Ok(resolved) => resolved, + Err(e) => { + error!("Failed to resolve slot duration from beacon node: {}", e); + return Err(e.into()); + } + }; + // Step 5: Check beacon reachability startup::check_beacon_reachability(bn_manager.as_ref()).await; @@ -110,6 +132,8 @@ pub async fn connect_beacon( genesis_validators_root, genesis_validators_root_hex, genesis_time, + slot_duration_ms, + genesis_confirmed, }) } @@ -154,6 +178,31 @@ mod tests { .await; } + async fn mount_spec(server: &MockServer, data: serde_json::Value) { + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": data + }))) + .mount(server) + .await; + } + + fn expect_err(result: Result, what: &str) -> BootstrapError { + match result { + Ok(_) => panic!("{what}"), + Err(err) => err, + } + } + + async fn mount_spec_status(server: &MockServer, status: u16) { + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(status).set_body_string("unavailable")) + .mount(server) + .await; + } + async fn mount_version(server: &MockServer, status: u16) { let template = if status == 200 { ResponseTemplate::new(200) @@ -172,6 +221,11 @@ mod tests { async fn test_connect_beacon_parses_gvr_once_into_bytes_and_hex() { let server = MockServer::start().await; mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; mount_version(&server, 200).await; let dir = TempDir::new().unwrap(); @@ -194,6 +248,7 @@ mod tests { format!("0x{}", hex::encode(handles.genesis_validators_root)) ); assert_eq!(handles.genesis_time, NetworkPreset::MAINNET.genesis_time); + assert_eq!(handles.slot_duration_ms, 12_000); // Clients constructed and usable let _ = handles.beacon_client; let _ = handles.bn_manager; @@ -254,6 +309,11 @@ mod tests { async fn test_connect_beacon_version_log_is_non_fatal_on_error() { let server = MockServer::start().await; mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SECONDS_PER_SLOT": "12", "SLOTS_PER_EPOCH": "32" }), + ) + .await; // Version endpoint fails; connect_beacon must still succeed. mount_version(&server, 500).await; @@ -266,5 +326,336 @@ mod tests { .await .expect("version failure must be non-fatal"); assert_eq!(handles.genesis_validators_root_hex, MAINNET_GVR.to_lowercase()); + assert_eq!(handles.slot_duration_ms, 12_000); + assert!(handles.genesis_confirmed); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_six_second_duration_on_named_network() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "named network must match the preset duration", + ); + assert!(err.to_string().contains("preset"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_custom_network_accepts_six_second_duration() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = mainnet_config(&server.uri(), &db_path); + config.network = crate::config::Network::Custom; + config.genesis_validators_root = Some(MAINNET_GVR.to_string()); + config.genesis_time = Some(NetworkPreset::MAINNET.genesis_time); + + let handles = connect_beacon(&config, OperationTimeouts::default(), &db) + .await + .expect("custom network accepts a bounded 6 s duration"); + assert_eq!(handles.slot_duration_ms, 6_000); + assert!( + !handles.genesis_confirmed, + "custom duration is not an independent genesis confirmation" + ); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_huge_slot_duration() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "86400000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "huge duration must fail closed", + ); + assert!(err.to_string().contains("outside"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_disagreeing_slot_duration() { + let first = MockServer::start().await; + let second = MockServer::start().await; + mount_genesis(&first, MAINNET_GVR).await; + mount_genesis(&second, MAINNET_GVR).await; + mount_spec( + &first, + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_spec( + &second, + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&first, 200).await; + mount_version(&second, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = mainnet_config(&first.uri(), &db_path); + config.beacon_nodes = vec![first.uri(), second.uri()]; + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "disagreeing durations must fail closed", + ); + assert!(err.to_string().contains("disagree"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_slots_per_epoch_other_than_preset() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "16" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "SLOTS_PER_EPOCH must match the preset", + ); + assert!(err.to_string().contains("SLOTS_PER_EPOCH"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_disagreeing_genesis_time() { + let first = MockServer::start().await; + let second = MockServer::start().await; + mount_genesis(&first, MAINNET_GVR).await; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "genesis_time": "1", + "genesis_validators_root": MAINNET_GVR, + "genesis_fork_version": "0x00000000" + } + }))) + .mount(&second) + .await; + let spec = serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }); + mount_spec(&first, spec.clone()).await; + mount_spec(&second, spec).await; + mount_version(&first, 200).await; + mount_version(&second, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = mainnet_config(&first.uri(), &db_path); + config.beacon_nodes = vec![first.uri(), second.uri()]; + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "genesis times must agree", + ); + assert!(err.to_string().contains("genesis_time"), "{err}"); + } + + /// Responders agree with each other, but not with the configured genesis. + #[tokio::test] + async fn test_connect_beacon_rejects_genesis_time_that_is_not_configured() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "genesis_time": "1", + "genesis_validators_root": MAINNET_GVR, + "genesis_fork_version": "0x00000000" + } + }))) + .mount(&server) + .await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "BN genesis must match the configured genesis", + ); + assert!(err.to_string().contains("configured"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_named_network_skips_unreachable_bn() { + let live = MockServer::start().await; + mount_genesis(&live, MAINNET_GVR).await; + mount_spec( + &live, + serde_json::json!({ "SLOT_DURATION_MS": "12000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&live, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = mainnet_config(&live.uri(), &db_path); + config.beacon_nodes = vec!["http://127.0.0.1:1".to_string(), live.uri()]; + + let handles = connect_beacon(&config, OperationTimeouts::default(), &db) + .await + .expect("one unreachable BN must not block a named network"); + assert_eq!(handles.slot_duration_ms, 12_000); + assert!(handles.genesis_confirmed); + } + + #[tokio::test] + async fn test_connect_beacon_custom_requires_every_bn() { + let live = MockServer::start().await; + mount_genesis(&live, MAINNET_GVR).await; + mount_spec( + &live, + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" }), + ) + .await; + mount_version(&live, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = mainnet_config(&live.uri(), &db_path); + config.network = crate::config::Network::Custom; + config.genesis_validators_root = Some(MAINNET_GVR.to_string()); + config.genesis_time = Some(NetworkPreset::MAINNET.genesis_time); + config.beacon_nodes = vec!["http://127.0.0.1:1".to_string(), live.uri()]; + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "custom must fail when a configured BN does not respond", + ); + assert!(err.to_string().contains("HTTP request failed"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_spec_without_slot_duration() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec(&server, serde_json::json!({ "SLOTS_PER_EPOCH": "32" })).await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "missing duration must fail closed", + ); + let msg = err.to_string(); + assert!(msg.contains("SLOT_DURATION_MS") || msg.contains("SECONDS_PER_SLOT"), "{msg}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_conflicting_slot_duration() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec( + &server, + serde_json::json!({ "SLOT_DURATION_MS": "6000", "SECONDS_PER_SLOT": "12" }), + ) + .await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "conflicting keys must fail closed", + ); + assert!(err.to_string().contains("conflicting"), "{err}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_zero_slot_duration() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec(&server, serde_json::json!({ "SLOT_DURATION_MS": "0" })).await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "zero duration must fail closed", + ); + let msg = err.to_string(); + assert!(msg.contains("greater than zero"), "{msg}"); + } + + #[tokio::test] + async fn test_connect_beacon_rejects_spec_http_error() { + let server = MockServer::start().await; + mount_genesis(&server, MAINNET_GVR).await; + mount_spec_status(&server, 404).await; + mount_version(&server, 200).await; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let config = mainnet_config(&server.uri(), &db_path); + + let err = expect_err( + connect_beacon(&config, OperationTimeouts::default(), &db).await, + "spec 404 must fail closed", + ); + assert!(!err.to_string().is_empty()); } } diff --git a/crates/rvc/src/bootstrap/enablement.rs b/crates/rvc/src/bootstrap/enablement.rs index 3e71038b..5e1c0cbe 100644 --- a/crates/rvc/src/bootstrap/enablement.rs +++ b/crates/rvc/src/bootstrap/enablement.rs @@ -88,7 +88,8 @@ pub async fn wire_signing_enablement( // Shared monotonic epoch clock (M-7) for boot register + keymanager import // so NTP cannot compress the window and wall-clock alone cannot force epoch 0. - let epoch_clock = Arc::new(MonotonicEpochClock::new(beacon.genesis_time)); + let epoch_clock = + Arc::new(MonotonicEpochClock::new(beacon.genesis_time, beacon.slot_duration_ms)); let enablement_epoch = epoch_clock.current_epoch(); // Resolve validator indices into the shared registry (duty tracker + liveness @@ -148,6 +149,7 @@ pub async fn wire_signing_enablement( beacon.genesis_validators_root, enablement_epoch, &keys.pubkey_map, + beacon.genesis_confirmed, ); // Shared with keymanager DELETE/import and forward-window sidecar cleanup. @@ -300,6 +302,8 @@ mod tests { genesis_validators_root: GVR, genesis_validators_root_hex: format!("0x{}", hex::encode(GVR)), genesis_time, + slot_duration_ms: 12_000, + genesis_confirmed: true, }, ) } @@ -429,7 +433,7 @@ mod tests { { let slashing_db = Arc::new(SlashingDb::open_in_memory().unwrap()); slashing_db.set_genesis_validators_root(&GVR).unwrap(); - let epoch = MonotonicEpochClock::new(0).current_epoch(); + let epoch = MonotonicEpochClock::new(0, 12_000).current_epoch(); assert!(epoch > 2, "test needs epoch > monitoring window"); let target = epoch - 1; slashing_db @@ -507,4 +511,75 @@ mod tests { let _ = Signer::public_keys(keys.composite_signer.as_ref()); executor.token().cancel(); } + + /// BN `SLOT_DURATION_MS=6000` and genesis 10 epochs ago at 6 s slots. + /// + /// A clock that still divides by 12 s reports epoch 5 for the same instant. + #[tokio::test] + async fn test_six_second_beacon_spec_reports_epoch_ten() { + use crate::bootstrap::connect_beacon; + + let server = MockServer::start().await; + let gvr = "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"; + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let genesis = now - 10 * 192 - 96; + Mock::given(method("GET")) + .and(path("/eth/v1/beacon/genesis")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "genesis_time": genesis.to_string(), + "genesis_validators_root": gvr, + "genesis_fork_version": "0x00000000" + } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { "SLOT_DURATION_MS": "6000", "SLOTS_PER_EPOCH": "32" } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/eth/v1/node/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { "version": "mock-bn/rf5-04" } + }))) + .mount(&server) + .await; + + let dir = tempfile::TempDir::new().unwrap(); + let db_path = dir.path().join("slashing.db"); + let db = SlashingDb::open(&db_path).unwrap(); + let mut config = base_config(true); + config.beacon_url = server.uri(); + config.beacon_nodes = vec![server.uri()]; + config.network = crate::config::Network::Custom; + config.genesis_validators_root = Some(gvr.to_string()); + config.genesis_time = Some(genesis); + config.slashing_db_path = db_path; + config.allow_fresh_db = true; + config.disable_keystore_locking = true; + + let beacon = + connect_beacon(&config, OperationTimeouts::default(), &db).await.expect("connect"); + assert_eq!(beacon.slot_duration_ms, 6_000); + + let keys = empty_loaded_keys(); + let (executor, _rx) = TaskExecutor::new(CancellationToken::new()); + let handles = wire_signing_enablement( + &config, + &keys, + &beacon, + Arc::new(SlashingDb::open_in_memory().unwrap()), + &executor, + ) + .await + .expect("enablement"); + assert_eq!(handles.epoch_clock.slot_duration_ms(), 6_000); + assert_eq!(handles.epoch_clock.current_epoch(), 10); + executor.token().cancel(); + } } diff --git a/crates/rvc/src/bootstrap/run.rs b/crates/rvc/src/bootstrap/run.rs index 5eb091c4..d3a571f0 100644 --- a/crates/rvc/src/bootstrap/run.rs +++ b/crates/rvc/src/bootstrap/run.rs @@ -12,6 +12,7 @@ use bn_manager::{BeaconNodeClient, OperationTimeouts}; use metrics::{new_health_status, SharedHealthStatus}; use observability::logging::TruncatedPubkey; use secret_provider::SecretProvider; +use timing::SlotClock; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -173,6 +174,8 @@ pub async fn run( genesis_validators_root, genesis_validators_root_hex: _, genesis_time: _, + slot_duration_ms: _, + genesis_confirmed: _, } = beacon_handles; let LoadedKeys { @@ -294,6 +297,7 @@ pub async fn run( .unwrap_or_else(|| crate::orchestrator::HeadEventGate::pair().1); // DSR-2.1 / FR-P1-3: register bn.sync_monitor (Background). spawn_sync_monitor(Some(Arc::clone(&bn_manager)), &executor, None); + let slot_duration_ms = slot_clock.slot_duration().as_millis() as u64; let (mut orchestrator, orchestrator_handle) = crate::orchestrator::DutyOrchestrator::new(crate::orchestrator::OrchestratorDeps { clock: slot_clock, @@ -321,6 +325,7 @@ pub async fn run( validator_store.clone(), slashed_action, &executor, + slot_duration_ms, ); if slashed_action != crate::slashing_monitor::SlashedAction::None { info!(action = %slashed_action, "Slashing monitor started"); @@ -918,7 +923,7 @@ mod tests { Arc::new(ValidatorStore::new([0u8; 20], 30_000_000)), Arc::clone(&denylist), None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); (admissions, denylist, pubkey_map) } diff --git a/crates/rvc/src/bootstrap/services.rs b/crates/rvc/src/bootstrap/services.rs index b07404e1..d8a320bf 100644 --- a/crates/rvc/src/bootstrap/services.rs +++ b/crates/rvc/src/bootstrap/services.rs @@ -145,13 +145,8 @@ pub async fn build_services( let main_beacon: Arc = Arc::clone(&beacon.bn_manager) as Arc; - let slot_duration_ms = match builder.resolve_slot_duration_ms(main_beacon.as_ref()).await { - Ok(ms) => ms, - Err(e) => { - error!("Failed to resolve slot duration from beacon node: {}", e); - return Err(e.into()); - } - }; + // Same value `connect_beacon` stored. Do not resolve the spec again. + let slot_duration_ms = beacon.slot_duration_ms; let slot_clock = match builder.build_slot_clock(slot_duration_ms) { Ok(clock) => clock, @@ -200,7 +195,7 @@ pub async fn build_services( )?; let orchestrator_config = builder - .build_orchestrator_config(beacon.genesis_validators_root, fork_schedule) + .build_orchestrator_config(beacon.genesis_validators_root, fork_schedule)? .with_timeouts(timeouts); // Build proposer BnManager if proposer nodes are configured (T3.1) @@ -287,6 +282,7 @@ mod tests { use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use tempfile::TempDir; + use timing::SlotClock; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -325,7 +321,7 @@ mod tests { EnablementHandles { signing_enablement: Arc::new(DoppelgangerDisabledByOperator), forward_window_machine: None, - epoch_clock: Arc::new(MonotonicEpochClock::new(0)), + epoch_clock: Arc::new(MonotonicEpochClock::new(0, 12_000)), pubkey_map, liveness_task: None, pubkey_index: crate::pubkey_index::PubkeyIndexRegistry::shared(), @@ -414,6 +410,8 @@ mod tests { genesis_validators_root: GVR, genesis_validators_root_hex: format!("0x{}", hex::encode(GVR)), genesis_time: 0, + slot_duration_ms: 12_000, + genesis_confirmed: true, } } @@ -485,6 +483,20 @@ mod tests { } } + /// The slot clock is the duration stored by `connect_beacon`, not a second spec read. + #[tokio::test] + async fn test_build_services_uses_stored_slot_duration() { + let dir = TempDir::new().unwrap(); + let server = mock_bn_server("0x05000000").await; + let config = base_config(&dir, &server.uri()); + let mut beacon = beacon_handles(&server.uri()).await; + beacon.slot_duration_ms = 6_000; + let keys = loaded_keys(pubkey_map_with(&[])); + + let handles = run_phase(&config, &keys, &beacon).await.expect("phase succeeds"); + assert_eq!(handles.slot_clock.slot_duration(), std::time::Duration::from_secs(6)); + } + #[tokio::test] async fn test_build_services_fork_mismatch_is_fatal_by_default() { let dir = TempDir::new().unwrap(); diff --git a/crates/rvc/src/config/builder.rs b/crates/rvc/src/config/builder.rs index 51c04248..a6a7ab60 100644 --- a/crates/rvc/src/config/builder.rs +++ b/crates/rvc/src/config/builder.rs @@ -12,7 +12,7 @@ use observability::logging::RedactedUrl; use crate::orchestrator::dispatch::DispatchLimits; use crate::orchestrator::{OrchestratorConfig, PubkeyMap}; use crate::quiesce::{QuiesceRegistry, QuiescingEnablement, TrackedKeys}; -use beacon::{parse_slot_duration_ms, BeaconClient, BeaconClientConfig}; +use beacon::{BeaconClient, BeaconClientConfig}; #[cfg(test)] use bn_manager::DutiesProvider; use bn_manager::{AttestationSubmitter, BeaconNodeClient, BnManager, BnManagerConfig, Propagator}; @@ -24,7 +24,7 @@ use doppelganger::{ }; #[cfg(test)] use duty_tracker::DutyTracker; -use eth_types::{Epoch, ForkSchedule, Root, SLOTS_PER_EPOCH, SLOT_DURATION_MS}; +use eth_types::{Epoch, ForkSchedule, Root, SLOTS_PER_EPOCH}; use rvc_config::TimingConfig; use signer::{SignerService, ValidatorSigner}; use slashing::{GroupCommitConfig, SlashingDb}; @@ -129,12 +129,14 @@ pub struct ServiceBuilder { /// Wall-clock length of the doppelganger store hold and import-meta re-arm. /// -/// [`DEFAULT_MONITORING_EPOCHS`] at the spec slot duration. Boot sidecar -/// classification does not use this value: a sidecar stays unfinished until -/// this process removes it after the forward window reaches Safe. -pub(crate) fn doppelganger_import_window() -> Duration { +/// [`DEFAULT_MONITORING_EPOCHS`] at `slot_duration_ms` from the beacon node. +/// Boot sidecar classification does not use this value: a sidecar stays +/// unfinished until this process removes it after the forward window reaches Safe. +/// +/// `slot_duration_ms` must be the positive duration from the beacon node spec. +pub(crate) fn doppelganger_import_window(slot_duration_ms: u64) -> Duration { Duration::from_millis( - DEFAULT_MONITORING_EPOCHS.saturating_mul(SLOTS_PER_EPOCH).saturating_mul(SLOT_DURATION_MS), + DEFAULT_MONITORING_EPOCHS.saturating_mul(SLOTS_PER_EPOCH).saturating_mul(slot_duration_ms), ) } @@ -801,6 +803,7 @@ impl ServiceBuilder { gvr: Root, current_epoch: Epoch, pubkey_map: &PubkeyMap, + genesis_confirmed: bool, ) -> (Arc, Option>) { if !self.config.doppelganger_detection { tracing::warn!( @@ -813,7 +816,11 @@ impl ServiceBuilder { } let reader: Arc = slashing_db; - let machine = Arc::new(ForwardWindowMachine::new(reader, DEFAULT_MONITORING_EPOCHS, gvr)); + let mut machine = ForwardWindowMachine::new(reader, DEFAULT_MONITORING_EPOCHS, gvr); + if genesis_confirmed { + machine = machine.with_confirmed_genesis(); + } + let machine = Arc::new(machine); // Unfinished imports must be classified before `register`. A later // `register_for_import` leaves an existing Safe state unchanged. @@ -882,28 +889,85 @@ impl ServiceBuilder { Arc::new(tracker) } + /// Duration and confirmation from the configured beacon nodes. + /// + /// Named networks must report [`Network::slot_duration_ms`]. Responding + /// nodes must report the same duration, the same `SLOTS_PER_EPOCH` as + /// [`Network::slots_per_epoch`], and the same genesis time as the + /// configured genesis. A named network skips a BN that does not respond + /// and fails when none do. Custom requires every configured BN. A miss, + /// a bound failure, or a disagreement fails closed. + /// + /// The returned flag is the epoch-0 Safe bypass. It is set only on a + /// named network, where the preset duration and genesis are independent + /// of the beacon nodes. Custom duration comes from those same nodes, so + /// an inflated length can hold the clock at epoch 0; the flag stays false. pub async fn resolve_slot_duration_ms( &self, - beacon: &dyn BeaconNodeClient, - ) -> Result { - info!("Fetching slot duration from beacon node config spec"); - let spec = beacon.get_config_spec().await?; - let slot_duration_ms = parse_slot_duration_ms(&spec.data)?; - info!(slot_duration_ms, "Resolved slot duration from beacon node spec"); - Ok(slot_duration_ms) + beacon: &BnManager, + ) -> Result<(u64, bool), ConfigError> { + info!("Fetching slot duration from configured beacon nodes"); + let agreement = if matches!(self.config.network, super::Network::Custom) { + bn_manager::SpecAgreement::All + } else { + bn_manager::SpecAgreement::Responding + }; + let configured_root = self.config.effective_genesis_validators_root()?; + let (slot_duration_ms, slots_per_epoch, genesis_time) = + beacon.agreed_spec_and_genesis(agreement, &configured_root).await?; + let expected_slots = self.config.network.slots_per_epoch(); + if slots_per_epoch != expected_slots { + return Err(ConfigError::InvalidNetwork(format!( + "SLOTS_PER_EPOCH {slots_per_epoch} does not match preset {expected_slots}" + ))); + } + if !matches!(self.config.network, super::Network::Custom) { + let preset = self.config.network.slot_duration_ms(); + if slot_duration_ms != preset { + return Err(ConfigError::InvalidNetwork(format!( + "slot duration {slot_duration_ms} ms does not match {} preset {preset} ms", + self.config.network + ))); + } + } + let configured_genesis = self.config.effective_genesis_time()?; + if genesis_time != configured_genesis { + return Err(ConfigError::InvalidNetwork(format!( + "beacon genesis_time {genesis_time} does not match configured {configured_genesis}" + ))); + } + info!(slot_duration_ms, slots_per_epoch, genesis_time, "Resolved chain timing"); + let genesis_confirmed = !matches!(self.config.network, super::Network::Custom); + Ok((slot_duration_ms, genesis_confirmed)) } pub fn build_slot_clock( &self, slot_duration_ms: u64, ) -> Result, ConfigError> { + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&slot_duration_ms) + { + return Err(ConfigError::InvalidNetwork(format!( + "slot duration {slot_duration_ms} ms is outside {}..={}", + eth_types::MIN_SLOT_DURATION_MS, + eth_types::MAX_SLOT_DURATION_MS + ))); + } let genesis_time = self.config.effective_genesis_time()?; let slot_duration = Duration::from_millis(slot_duration_ms); + // Preset value. `resolve_slot_duration_ms` already rejected a BN that + // omitted SLOTS_PER_EPOCH or reported a different count. let slots_per_epoch = self.config.network.slots_per_epoch(); + if slots_per_epoch == 0 { + return Err(ConfigError::InvalidNetwork( + "SLOTS_PER_EPOCH must be greater than zero".to_string(), + )); + } let clock = SystemSlotClock::new(genesis_time, slot_duration, slots_per_epoch) .map_err(|e| ConfigError::MissingField(format!("invalid slot clock: {e}")))? - .with_deadline_schedule(deadline_schedule_from_timing(&self.config.timing)); + .with_deadline_schedule(deadline_schedule_from_timing(&self.config.timing)?); info!( genesis_time = genesis_time, slot_duration_ms, @@ -1136,42 +1200,98 @@ impl ServiceBuilder { &self, genesis_validators_root: Root, fork_schedule: Arc, - ) -> OrchestratorConfig { + ) -> Result { let limits = DispatchLimits::validated( self.config.duties.duty_dispatch_concurrency, self.config.duties.duty_publish_concurrency, ) .expect("Config::validate rejects zero duty concurrency"); - OrchestratorConfig::new(genesis_validators_root, fork_schedule) + let schedule = deadline_schedule_from_timing(&self.config.timing)?; + Ok(OrchestratorConfig::new(genesis_validators_root, fork_schedule) .with_shutdown_timeout(Duration::from_secs(30)) - .with_deadline_schedule(deadline_schedule_from_timing(&self.config.timing)) - .with_dispatch_limits(limits) + .with_deadline_schedule(schedule) + .with_dispatch_limits(limits)) + } +} + +fn checked_bps(field: &'static str, value: u64) -> Result { + if (1..=timing::BASIS_POINTS).contains(&value) { + Ok(value) + } else { + Err(ConfigError::Invalid { + field, + message: format!("must be 1..={}, got {value}", timing::BASIS_POINTS), + source_layer: rvc_config::ConfigSource::Default, + }) } } -/// Build both deadline sets from `[timing]`. Gloas members are listed in full so -/// omitting one is a compile error; 1.7 serde defaults already fill missing TOML -/// keys, so runtime "missing" cannot occur. -fn deadline_schedule_from_timing(timing: &TimingConfig) -> DeadlineSchedule { - DeadlineSchedule { +/// Build both deadline sets from `[timing]`. +/// +/// Every pre-Gloas and Gloas basis-point field must be in `1..=10000`. +/// Attestation must not land after aggregate, and the Gloas aggregate must +/// not land after the payload attestation. A zero or oversized field fails +/// closed instead of building a deadline at the start of the slot. +fn deadline_schedule_from_timing(timing: &TimingConfig) -> Result { + let attestation = checked_bps("timing.attestation_due_bps", timing.attestation_due_bps)?; + let aggregate = checked_bps("timing.aggregate_due_bps", timing.aggregate_due_bps)?; + let attestation_gloas = + checked_bps("timing.attestation_due_bps_gloas", timing.attestation_due_bps_gloas)?; + let aggregate_gloas = + checked_bps("timing.aggregate_due_bps_gloas", timing.aggregate_due_bps_gloas)?; + let sync_message = + checked_bps("timing.sync_message_due_bps_gloas", timing.sync_message_due_bps_gloas)?; + let contribution = + checked_bps("timing.contribution_due_bps_gloas", timing.contribution_due_bps_gloas)?; + let payload = checked_bps("timing.payload_due_bps", timing.payload_due_bps)?; + let payload_attestation = + checked_bps("timing.payload_attestation_due_bps", timing.payload_attestation_due_bps)?; + if attestation > aggregate { + return Err(ConfigError::Invalid { + field: "timing.aggregate_due_bps", + message: format!( + "must be >= timing.attestation_due_bps ({attestation}), got {aggregate}" + ), + source_layer: rvc_config::ConfigSource::Default, + }); + } + if attestation_gloas > aggregate_gloas { + return Err(ConfigError::Invalid { + field: "timing.aggregate_due_bps_gloas", + message: format!( + "must be >= timing.attestation_due_bps_gloas ({attestation_gloas}), got {aggregate_gloas}" + ), + source_layer: rvc_config::ConfigSource::Default, + }); + } + if aggregate_gloas > payload_attestation { + return Err(ConfigError::Invalid { + field: "timing.payload_attestation_due_bps", + message: format!( + "must be >= timing.aggregate_due_bps_gloas ({aggregate_gloas}), got {payload_attestation}" + ), + source_layer: rvc_config::ConfigSource::Default, + }); + } + Ok(DeadlineSchedule { pre_gloas: DeadlineBps { - attestation: timing.attestation_due_bps, - aggregate: timing.aggregate_due_bps, + attestation, + aggregate, // Pre-Gloas waits: sync messages share attestation, contributions share aggregate. - sync_message: timing.attestation_due_bps, - contribution: timing.aggregate_due_bps, - payload: timing.aggregate_due_bps, - payload_attestation: timing.aggregate_due_bps, + sync_message: attestation, + contribution: aggregate, + payload: aggregate, + payload_attestation: aggregate, }, gloas: DeadlineBps { - attestation: timing.attestation_due_bps_gloas, - aggregate: timing.aggregate_due_bps_gloas, - sync_message: timing.sync_message_due_bps_gloas, - contribution: timing.contribution_due_bps_gloas, - payload: timing.payload_due_bps, - payload_attestation: timing.payload_attestation_due_bps, + attestation: attestation_gloas, + aggregate: aggregate_gloas, + sync_message, + contribution, + payload, + payload_attestation, }, - } + }) } #[cfg(test)] @@ -1710,6 +1830,50 @@ mod tests { assert_eq!(clock.deadlines().aggregate, 4000); } + #[test] + fn test_build_slot_clock_rejects_zero_and_huge_duration() { + let builder = ServiceBuilder::new(create_minimal_config()); + for bad in [0, 86_400_000] { + let err = match builder.build_slot_clock(bad) { + Ok(_) => panic!("out of range duration must fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("outside"), "{err}"); + } + } + + #[test] + fn test_gloas_aggregate_deadline_cannot_follow_payload_attestation() { + let config = Config { + timing: TimingConfig { + aggregate_due_bps_gloas: 8000, + payload_attestation_due_bps: 7500, + ..Default::default() + }, + ..create_minimal_config() + }; + let err = match ServiceBuilder::new(config).build_slot_clock(12_000) { + Ok(_) => panic!("Gloas aggregate after payload attestation must fail"), + Err(err) => err, + }; + let msg = err.to_string(); + assert!(msg.contains("payload_attestation_due_bps"), "{msg}"); + assert!(msg.contains("aggregate_due_bps_gloas"), "{msg}"); + } + + #[test] + fn test_build_slot_clock_rejects_zero_gloas_payload_bps() { + let config = Config { + timing: TimingConfig { payload_due_bps: 0, ..Default::default() }, + ..create_minimal_config() + }; + let err = match ServiceBuilder::new(config).build_slot_clock(12_000) { + Ok(_) => panic!("zero payload bps must fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("payload_due_bps"), "{err}"); + } + #[test] fn test_parse_genesis_validators_root() { let config = Config { @@ -1828,7 +1992,8 @@ mod tests { let builder = ServiceBuilder::new(config); let root = [0xaa; 32]; - let orch_config = builder.build_orchestrator_config(root, sample_fork_schedule()); + let orch_config = + builder.build_orchestrator_config(root, sample_fork_schedule()).expect("timing"); assert_eq!(orch_config.genesis_validators_root, root); assert_eq!(orch_config.shutdown_timeout, Duration::from_secs(30)); @@ -1858,7 +2023,8 @@ mod tests { ..create_minimal_config() }; let builder = ServiceBuilder::new(config); - let orch_config = builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()); + let orch_config = + builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()).expect("timing"); assert_eq!(orch_config.deadline_schedule.pre_gloas.attestation, 2500); assert_eq!(orch_config.deadline_schedule.pre_gloas.aggregate, 4000); } @@ -1870,7 +2036,8 @@ mod tests { ..create_minimal_config() }; let builder = ServiceBuilder::new(config); - let orch_config = builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()); + let orch_config = + builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()).expect("timing"); assert_eq!(orch_config.deadline_schedule.gloas.payload, 1234); assert_eq!( timing::due_ms(orch_config.deadline_schedule.gloas.payload, 12_000), @@ -1895,7 +2062,8 @@ mod tests { }; let builder = ServiceBuilder::new(config); let clock = builder.build_slot_clock(12_000).unwrap(); - let orch = builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()); + let orch = + builder.build_orchestrator_config([0xaa; 32], sample_fork_schedule()).expect("timing"); assert_eq!(clock.deadlines().attestation, 3333); assert_eq!(clock.deadlines().aggregate, 6667); assert_eq!(orch.deadline_schedule.pre_gloas.attestation, 3333); @@ -1912,7 +2080,8 @@ mod tests { let root = [0xaau8; 32]; let schedule = sample_fork_schedule(); let defaulted = ServiceBuilder::new(create_minimal_config()) - .build_orchestrator_config(root, schedule.clone()); + .build_orchestrator_config(root, schedule.clone()) + .expect("timing"); assert_eq!(defaulted.deadline_schedule.gloas.aggregate, 5000); assert_eq!(timing::due_ms(defaulted.deadline_schedule.gloas.aggregate, 12_000), 6000); @@ -1920,7 +2089,8 @@ mod tests { toml::from_str("aggregate_due_bps_gloas = 6667").expect("TOML-only override"); let mut config = create_minimal_config(); config.timing = timing; - let orch = ServiceBuilder::new(config).build_orchestrator_config(root, schedule); + let orch = + ServiceBuilder::new(config).build_orchestrator_config(root, schedule).expect("timing"); assert_eq!(orch.deadline_schedule.gloas.aggregate, 6667); assert_eq!(timing::due_ms(orch.deadline_schedule.gloas.aggregate, 12_000), 8000); assert_eq!(orch.deadline_schedule.pre_gloas.aggregate, 6667); @@ -1954,7 +2124,8 @@ mod tests { ); let defaulted = ServiceBuilder::new(create_minimal_config()) - .build_orchestrator_config(root, fork_schedule.clone()); + .build_orchestrator_config(root, fork_schedule.clone()) + .expect("timing"); let default_deadline = defaulted.attestation_deadline_ms(gloas_slot, slot_duration_ms); let default_pre = defaulted.attestation_deadline_ms(pre_gloas_slot, slot_duration_ms); @@ -1962,7 +2133,9 @@ mod tests { toml::from_str("attestation_due_bps_gloas = 4000").expect("TOML-only override"); let mut config = create_minimal_config(); config.timing = timing; - let orch = ServiceBuilder::new(config).build_orchestrator_config(root, fork_schedule); + let orch = ServiceBuilder::new(config) + .build_orchestrator_config(root, fork_schedule) + .expect("timing"); let moved = orch.attestation_deadline_ms(gloas_slot, slot_duration_ms); assert_ne!(moved, default_deadline); @@ -2514,7 +2687,7 @@ mod tests { let (_, pubkey_map) = loaded_key_manager(&builder, 1); let (enablement, machine) = - builder.build_signing_enablement(slashing_db, gvr, 10, &pubkey_map); + builder.build_signing_enablement(slashing_db, gvr, 10, &pubkey_map, false); let machine = machine.expect("doppelganger on must construct ForwardWindowMachine"); // Same object is both the enablement and the machine handle. @@ -2546,7 +2719,7 @@ mod tests { let pk = key_manager.list_public_keys()[0].clone(); let (enablement, machine) = - builder.build_signing_enablement(slashing_db, gvr, 50, &pubkey_map); + builder.build_signing_enablement(slashing_db, gvr, 50, &pubkey_map, false); let machine = machine.expect("machine present"); assert!(!enablement.is_signing_enabled(&pk)); @@ -2571,7 +2744,7 @@ mod tests { let pk = key_manager.list_public_keys()[0].clone(); let (enablement, machine) = - builder.build_signing_enablement(slashing_db, gvr, 0, &pubkey_map); + builder.build_signing_enablement(slashing_db, gvr, 0, &pubkey_map, true); assert!(machine.is_some()); assert!( @@ -2580,6 +2753,21 @@ mod tests { ); } + #[test] + fn test_epoch0_without_genesis_confirmation_stays_closed() { + let config = create_minimal_config(); + let builder = ServiceBuilder::new(config); + let slashing_db = Arc::new(SlashingDb::open_in_memory().unwrap()); + let (key_manager, pubkey_map) = loaded_key_manager(&builder, 1); + let pk = key_manager.list_public_keys()[0].clone(); + let (enablement, _) = + builder.build_signing_enablement(slashing_db, [0x33u8; 32], 0, &pubkey_map, false); + assert!( + !enablement.is_signing_enabled(&pk), + "epoch 0 without a confirmed genesis must not mark keys Safe" + ); + } + /// Operator opt-out: no machine, every key enabled. #[test] fn test_doppelganger_opt_out_uses_disabled_by_operator() { @@ -2591,7 +2779,7 @@ mod tests { let pk = key_manager.list_public_keys()[0].clone(); let (enablement, machine) = - builder.build_signing_enablement(slashing_db, gvr, 99, &pubkey_map); + builder.build_signing_enablement(slashing_db, gvr, 99, &pubkey_map, false); assert!(machine.is_none(), "opt-out must not construct ForwardWindowMachine"); assert!( @@ -2675,9 +2863,11 @@ mod tests { #[test] fn doppelganger_import_window_is_two_spec_epochs() { assert_eq!( - doppelganger_import_window().as_secs(), + doppelganger_import_window(SLOT_DURATION_MS).as_secs(), DEFAULT_MONITORING_EPOCHS * SLOTS_PER_EPOCH * SLOT_DURATION_MS / 1000 ); + assert_eq!(doppelganger_import_window(6_000).as_secs(), 384); + assert_eq!(doppelganger_import_window(12_000).as_secs(), 768); } // ── #626: unfinished import must not take the boot bypass ────────────── @@ -2832,8 +3022,13 @@ mod tests { } let pubkey_map = map_of(&[pk_import.clone(), pk_normal.clone(), pk_disabled.clone()]); let builder = builder_for(&paths, true); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let composite = composite_with(vec![sk_import, sk_normal, sk_disabled]); let (signer, _quiesce) = builder.build_signer(composite, db, enablement); @@ -2894,8 +3089,13 @@ mod tests { seed_recent_attestation(&db, &pk_normal, &gvr); let pubkey_map = map_of(&[pk_import.clone(), pk_normal.clone()]); let builder = builder_for(&paths, false); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk_import, sk_normal]), db, enablement); @@ -2921,7 +3121,8 @@ mod tests { let db = Arc::new(SlashingDb::open_in_memory().unwrap()); let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, true); - let (_enablement, machine) = builder.build_signing_enablement(db, gvr, 0, &pubkey_map); + let (_enablement, machine) = + builder.build_signing_enablement(db, gvr, 0, &pubkey_map, false); let machine = machine.expect("doppelganger on"); assert_eq!( machine.status(&pk), @@ -2944,7 +3145,7 @@ mod tests { let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, false); let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, 0, &pubkey_map); + builder.build_signing_enablement(Arc::clone(&db), gvr, 0, &pubkey_map, false); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!( @@ -2970,8 +3171,13 @@ mod tests { seed_recent_attestation(&db, &pk, &gvr); let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, false); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!(machine.status(&pk), doppelganger::ForwardWindowStatus::Pending); @@ -2994,8 +3200,13 @@ mod tests { seed_recent_attestation(&db, &pk, &gvr); let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, false); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!(machine.status(&pk), doppelganger::ForwardWindowStatus::Pending); @@ -3031,7 +3242,7 @@ mod tests { let pubkey_map = map_of(&[pk_import.clone(), pk_normal.clone()]); let builder = builder_for(&paths, true); let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map); + builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map, false); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk_import, sk_normal]), db, enablement); @@ -3160,8 +3371,13 @@ mod tests { seed_recent_attestation(&db, &pk, &gvr); let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, false); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!( @@ -3202,7 +3418,7 @@ mod tests { let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, true); let (_enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map); + builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map, false); let machine = machine.expect("doppelganger on"); assert_eq!(machine.status(&pk), doppelganger::ForwardWindowStatus::Pending); @@ -3215,7 +3431,7 @@ mod tests { assert!(!sidecar.exists(), "Safe observation must remove the unfinished marker"); let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map); + builder.build_signing_enablement(Arc::clone(&db), gvr, BOOT_EPOCH, &pubkey_map, false); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!( @@ -3237,8 +3453,13 @@ mod tests { seed_recent_attestation(&db, &pk, &gvr); let pubkey_map = map_of(std::slice::from_ref(&pk)); let builder = builder_for(&paths, true); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!(machine.status(&pk), doppelganger::ForwardWindowStatus::Pending); @@ -3263,8 +3484,13 @@ mod tests { let db = Arc::new(SlashingDb::open_in_memory().unwrap()); seed_recent_attestation(&db, &pk, &gvr); let pubkey_map = map_of(std::slice::from_ref(&pk)); - let (enablement, machine) = - builder.build_signing_enablement(Arc::clone(&db), gvr, RESTART_EPOCH, &pubkey_map); + let (enablement, machine) = builder.build_signing_enablement( + Arc::clone(&db), + gvr, + RESTART_EPOCH, + &pubkey_map, + false, + ); let machine = machine.expect("doppelganger on"); let (signer, _quiesce) = builder.build_signer(composite_with(vec![sk]), db, enablement); assert_eq!(machine.status(&pk), doppelganger::ForwardWindowStatus::Pending); diff --git a/crates/rvc/src/config/types.rs b/crates/rvc/src/config/types.rs index eb058567..d41f40b8 100644 --- a/crates/rvc/src/config/types.rs +++ b/crates/rvc/src/config/types.rs @@ -809,14 +809,27 @@ impl Config { Self::from_toml_value(value) } + /// Genesis unix time. + /// + /// Named networks use the preset. An explicit `genesis_time` that differs + /// from that preset is an error. Custom has no preset and requires the field. pub fn effective_genesis_time(&self) -> Result { - if let Some(genesis_time) = self.genesis_time { - return Ok(genesis_time); + match self.network.genesis_time() { + Some(preset) => { + if let Some(configured) = self.genesis_time { + if configured != preset { + return Err(ConfigError::InvalidNetwork(format!( + "genesis_time {configured} does not match {} preset {preset}", + self.network + ))); + } + } + Ok(preset) + } + None => self + .genesis_time + .ok_or_else(|| ConfigError::MissingField("genesis_time".to_string())), } - - self.network - .genesis_time() - .ok_or_else(|| ConfigError::MissingField("genesis_time".to_string())) } pub fn effective_genesis_validators_root(&self) -> Result { @@ -2116,10 +2129,11 @@ allow_fresh_db = true } #[test] - fn test_effective_genesis_time_override() { + fn test_effective_genesis_time_override_on_named_network_fails() { let config = Config { network: Network::Mainnet, genesis_time: Some(12345), ..Default::default() }; - assert_eq!(config.effective_genesis_time().unwrap(), 12345); + let err = config.effective_genesis_time().expect_err("named network is not overridable"); + assert!(err.to_string().contains("preset"), "{err}"); } #[test] diff --git a/crates/rvc/src/doppelganger_adapter.rs b/crates/rvc/src/doppelganger_adapter.rs deleted file mode 100644 index d48c3950..00000000 --- a/crates/rvc/src/doppelganger_adapter.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Adapter implementations bridging production types to doppelganger detection traits. -//! -//! SEC-2c: [`BeaconLivenessAdapter`] queries liveness through -//! [`bn_manager::BeaconNodeClient`] (production: `BnManager` with multi-BN -//! `query_first` failover) and translates numeric validator indices to bare -//! pubkey-hex before returning samples for [`ForwardWindowMachine::observe_liveness`]. - -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use bn_manager::BeaconNodeClient; -use doppelganger::{DoppelgangerError, LivenessChecker, ValidatorLivenessData}; -use eth_types::Epoch; -use parking_lot::RwLock; - -/// Adapter implementing [`LivenessChecker`] via -/// [`BeaconNodeClient::post_validator_liveness`] (bn-manager failover in production). -/// -/// # Pubkey-hex translation (SEC-001) -/// -/// Beacon nodes return **numeric** validator indices. The machine keys state by -/// bare lowercase `hex::encode(pubkey.to_bytes())`. This adapter translates -/// using `index_to_pubkey_hex` and **omits** untranslatable indices (fail-closed). -pub struct BeaconLivenessAdapter { - beacon: Arc, - /// Numeric index string → bare pubkey hex (machine state key). - index_to_pubkey_hex: Arc>>, -} - -impl BeaconLivenessAdapter { - /// Construct with an empty translation map. - pub fn new(beacon: Arc) -> Self { - Self { beacon, index_to_pubkey_hex: Arc::new(RwLock::new(HashMap::new())) } - } - - /// Construct with a pre-built numeric-index → bare-pubkey-hex map (SEC-2c). - pub fn with_index_map( - beacon: Arc, - index_to_pubkey_hex: Arc>>, - ) -> Self { - Self { beacon, index_to_pubkey_hex } - } - - /// Shared handle so keymanager / index refresh can update the map. - pub fn index_map(&self) -> Arc>> { - Arc::clone(&self.index_to_pubkey_hex) - } -} - -#[async_trait] -impl LivenessChecker for BeaconLivenessAdapter { - async fn check_liveness( - &self, - epoch: Epoch, - validator_indices: &[String], - ) -> Result, DoppelgangerError> { - let response = self - .beacon - .post_validator_liveness(epoch, validator_indices) - .await - .map_err(|e| DoppelgangerError::LivenessCheckFailed(e.to_string()))?; - - let map = self.index_to_pubkey_hex.read(); - let samples: Vec = response - .data - .into_iter() - .filter_map(|v| { - // Prefer translation map; if map is empty (legacy), pass index through. - let index = if map.is_empty() { v.index } else { map.get(&v.index)?.clone() }; - Some(ValidatorLivenessData { index, is_live: v.is_live }) - }) - .collect(); - - Ok(samples) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_beacon_liveness_adapter_construction() { - let config = beacon::BeaconClientConfig::new("http://localhost:5052"); - let client = Arc::new(beacon::BeaconClient::new(config).unwrap()); - let beacon: Arc = client; - let _adapter = BeaconLivenessAdapter::new(beacon); - } -} diff --git a/crates/rvc/src/index_resolver.rs b/crates/rvc/src/index_resolver.rs index 939ce866..1463cb42 100644 --- a/crates/rvc/src/index_resolver.rs +++ b/crates/rvc/src/index_resolver.rs @@ -11,7 +11,7 @@ use std::time::Duration; use bn_manager::BeaconNodeClient; use doppelganger::MonotonicEpochClock; -use eth_types::{SLOTS_PER_EPOCH, SLOT_DURATION_MS}; +use eth_types::SLOTS_PER_EPOCH; use tokio::sync::watch; use tokio_util::sync::CancellationToken; use tracing::{debug, info, Instrument}; @@ -107,7 +107,7 @@ impl IndexResolver { pub async fn run(mut self) { info!("index resolver started"); self.publish_gauge(); - let interval = epoch_tick_interval(); + let interval = self.tick_interval(); let mut next_tick = tokio::time::Instant::now() + interval; // detached: process-lifetime loop root; the tick is the exported span. let loop_span = tracing::info_span!(parent: None, "index.resolve"); @@ -144,6 +144,11 @@ impl IndexResolver { } } + /// Per-epoch wake interval from the clock's slot duration. + pub fn tick_interval(&self) -> Duration { + epoch_tick_interval(self.epoch_clock.slot_duration_ms()) + } + /// One resolve pass at `epoch`. /// /// Tests call this instead of sleeping for an epoch. Production [`Self::run`] @@ -281,8 +286,21 @@ impl IndexResolver { } } -fn epoch_tick_interval() -> Duration { - Duration::from_millis(SLOTS_PER_EPOCH.saturating_mul(SLOT_DURATION_MS)) +/// One epoch at the beacon-node slot length. +/// +/// Zero is not a slot. A zero tick would spin the resolver, and assuming 12 s +/// would be wrong on a longer slot. A zero duration waits indefinitely. +fn epoch_tick_interval(slot_duration_ms: u64) -> Duration { + // `Instant + Duration::MAX` panics. A zero or huge duration waits a day + // instead of spinning or aborting the resolver. + const CAP: Duration = Duration::from_secs(24 * 60 * 60); + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&slot_duration_ms) + { + return CAP; + } + let ms = SLOTS_PER_EPOCH.saturating_mul(slot_duration_ms); + Duration::from_millis(ms).min(CAP) } #[cfg(test)] @@ -356,6 +374,8 @@ mod tests { genesis_validators_root: GVR, genesis_validators_root_hex: format!("0x{}", hex::encode(GVR)), genesis_time, + slot_duration_ms: 12_000, + genesis_confirmed: true, }, ) } @@ -398,7 +418,7 @@ mod tests { beacon, key_gen_tx: key_gen_tx.clone(), key_gen_rx: key_gen_rx.clone(), - epoch_clock: Arc::new(MonotonicEpochClock::new(0)), + epoch_clock: Arc::new(MonotonicEpochClock::new(0, 12_000)), }, CancellationToken::new(), ); @@ -792,4 +812,34 @@ mod tests { assert_eq!(calls(liveness), 1, "liveness keeps the definition only"); assert_eq!(calls(resolver), 1, "IndexResolver is the only production caller"); } + + fn resolver_at(slot_duration_ms: u64) -> IndexResolver { + let (key_gen_tx, key_gen_rx) = watch::channel(0u64); + IndexResolver::new( + IndexResolverDeps { + registry: PubkeyIndexRegistry::shared(), + pubkey_map: Arc::new(parking_lot::RwLock::new(HashMap::new())), + beacon: Arc::new(MockBeaconNodeClient::new()), + key_gen_tx, + key_gen_rx, + epoch_clock: Arc::new(MonotonicEpochClock::with_start_time( + 0, + std::time::Instant::now(), + 0, + slot_duration_ms, + )), + }, + CancellationToken::new(), + ) + } + + #[test] + fn tick_interval_follows_beacon_slot_duration() { + assert_eq!(resolver_at(6_000).tick_interval(), Duration::from_secs(192)); + assert_eq!(resolver_at(12_000).tick_interval(), Duration::from_secs(384)); + let capped = Duration::from_secs(24 * 60 * 60); + assert_eq!(resolver_at(0).tick_interval(), capped); + assert_eq!(resolver_at(u64::MAX).tick_interval(), capped); + assert!(std::time::Instant::now().checked_add(capped).is_some()); + } } diff --git a/crates/rvc/src/key_admission.rs b/crates/rvc/src/key_admission.rs index af84f455..37a8cb7e 100644 --- a/crates/rvc/src/key_admission.rs +++ b/crates/rvc/src/key_admission.rs @@ -474,7 +474,7 @@ mod tests { let composite_signer = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); let validator_store = Arc::new(ValidatorStore::new([0u8; 20], 30_000_000)); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let machine = if enable_machine { let db: Arc = Arc::new(SlashingDb::open_in_memory().expect("in-memory slashing db")); @@ -669,7 +669,7 @@ mod tests { let pubkey_map: PubkeyMap = Arc::new(parking_lot::RwLock::new(HashMap::new())); let (key_gen_tx, _key_gen_rx) = watch::channel(0u64); let composite_signer = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let service = KeyAdmissionService::new( Arc::clone(&pubkey_map), key_gen_tx, @@ -712,7 +712,7 @@ mod tests { let pubkey_map: PubkeyMap = Arc::new(parking_lot::RwLock::new(HashMap::new())); let (key_gen_tx, _key_gen_rx) = watch::channel(0u64); let composite_signer = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let service = KeyAdmissionService::new( Arc::clone(&pubkey_map), key_gen_tx, @@ -772,7 +772,7 @@ mod tests { let pubkey_map: PubkeyMap = Arc::new(parking_lot::RwLock::new(HashMap::new())); let (key_gen_tx, _key_gen_rx) = watch::channel(0u64); let composite_signer = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let service = KeyAdmissionService::new( Arc::clone(&pubkey_map), key_gen_tx, @@ -831,7 +831,7 @@ mod tests { let pubkey_map: PubkeyMap = Arc::new(parking_lot::RwLock::new(HashMap::new())); let (key_gen_tx, _key_gen_rx) = watch::channel(0u64); let composite_signer = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let service = KeyAdmissionService::new( Arc::clone(&pubkey_map), key_gen_tx, @@ -898,7 +898,7 @@ mod tests { Arc::clone(&validator_store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), ); let outcome = service.admit(sk, AdmissionSource::RawSecret).expect("admit"); let (key_gen, inserted_row) = match outcome { diff --git a/crates/rvc/src/keymanager_adapters/doppelganger.rs b/crates/rvc/src/keymanager_adapters/doppelganger.rs index 795927cb..dce3f21b 100644 --- a/crates/rvc/src/keymanager_adapters/doppelganger.rs +++ b/crates/rvc/src/keymanager_adapters/doppelganger.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crypto::PublicKey; use doppelganger::{ForwardWindowMachine, SigningEnablement}; -use eth_types::{Epoch, SLOTS_PER_EPOCH, SLOT_DURATION_MS}; +use eth_types::Epoch; use keymanager_api::traits::{DoppelgangerMonitor, Pubkey}; use observability::logging::TruncatedPubkey; use tracing::{info, warn}; @@ -247,15 +247,3 @@ impl DoppelgangerMonitor for ForwardWindowMonitor { } } } - -/// Wall-clock epoch from genesis. -/// -/// Prefer [`doppelganger::MonotonicEpochClock`] for production register paths -/// (M-7). Kept for tests and non-critical fallbacks. -pub fn wall_clock_epoch(genesis_time: u64) -> Epoch { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now.saturating_sub(genesis_time).saturating_mul(1000) / SLOT_DURATION_MS / SLOTS_PER_EPOCH -} diff --git a/crates/rvc/src/keymanager_adapters/import_window.rs b/crates/rvc/src/keymanager_adapters/import_window.rs index ae62431e..31d2a5de 100644 --- a/crates/rvc/src/keymanager_adapters/import_window.rs +++ b/crates/rvc/src/keymanager_adapters/import_window.rs @@ -1383,7 +1383,9 @@ mod tests { let genesis = (u64::MAX / 1000) + 1; let clock = Arc::new(MockSlotClock::new(genesis, Duration::from_secs(12), 32)); clock.set_current_time_ms(0); - assert!(matches!(clock.current_slot(), Err(TimingError::BeforeGenesis { .. }))); + // Genesis cannot be represented in milliseconds, so the slot clock + // fails closed instead of reporting a pre-genesis epoch. + assert!(matches!(clock.current_slot(), Err(TimingError::TimeOverflow))); assert!(matches!(clock.time_until_slot(0), Err(TimingError::TimeOverflow))); let gate = ImportWindowGate::new(Arc::clone(&clock) as Arc); let err = gate.admit(1).await.expect_err("overflow must refuse import"); diff --git a/crates/rvc/src/keymanager_adapters/keystore.rs b/crates/rvc/src/keymanager_adapters/keystore.rs index 3dc48b9c..037afae9 100644 --- a/crates/rvc/src/keymanager_adapters/keystore.rs +++ b/crates/rvc/src/keymanager_adapters/keystore.rs @@ -127,7 +127,7 @@ impl KeystoreManagerAdapter { Arc::new(ValidatorStore::new([0u8; 20], 30_000_000)), default_denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); Self { keystore_dir, diff --git a/crates/rvc/src/keymanager_adapters/mod.rs b/crates/rvc/src/keymanager_adapters/mod.rs index 82f5d292..141914a2 100644 --- a/crates/rvc/src/keymanager_adapters/mod.rs +++ b/crates/rvc/src/keymanager_adapters/mod.rs @@ -19,9 +19,7 @@ mod voluntary_exit; mod tests; pub use config::ValidatorConfigManagerAdapter; -pub use doppelganger::{ - scan_and_rearm_gate, wall_clock_epoch, DoppelgangerDisabledMonitor, ForwardWindowMonitor, -}; +pub use doppelganger::{scan_and_rearm_gate, DoppelgangerDisabledMonitor, ForwardWindowMonitor}; pub use keystore::KeystoreManagerAdapter; pub use notifier::KeyChangeNotifier; pub use remote_keys::RemoteKeyManagerAdapter; diff --git a/crates/rvc/src/keymanager_adapters/spawn.rs b/crates/rvc/src/keymanager_adapters/spawn.rs index 638acc8d..492d7ad9 100644 --- a/crates/rvc/src/keymanager_adapters/spawn.rs +++ b/crates/rvc/src/keymanager_adapters/spawn.rs @@ -199,6 +199,8 @@ pub fn build_keymanager_api( // One gate per `SlashingDb`, built here at bootstrap and stored in the // only adapter that imports interchange. The gate must see the DB's // effective group-commit knobs (not `GroupCommitConfig::default`). + let slot_duration_ms = deps.slot_clock.slot_duration().as_millis() as u64; + let genesis_time = deps.slot_clock.genesis_time(); let import_gate = Arc::new( ImportWindowGate::new(deps.slot_clock) .with_group_commit(deps.slashing_db.group_commit_config()), @@ -217,9 +219,10 @@ pub fn build_keymanager_api( // M-12: time-based window for the delayed set_enabled task. When // doppelganger is disabled the window is Duration::ZERO so keys are - // immediately enabled. When on: 2 epochs × 32 slots × 12_000 ms = 768 s. + // immediately enabled. When on, the hold is two epochs at the beacon + // node's slot duration (768 s when that duration is 12 s). let doppelganger_window = if config.doppelganger_detection { - crate::config::doppelganger_import_window() + crate::config::doppelganger_import_window(slot_duration_ms) } else { Duration::ZERO }; @@ -245,13 +248,16 @@ pub fn build_keymanager_api( let config_mgr = Arc::new(ValidatorConfigManagerAdapter::new(Arc::clone(&deps.validator_store))); - let exit_mgr: Option> = - Some(Arc::new(VoluntaryExitManagerAdapter::new( + let exit_mgr: Option> = Some(Arc::new( + VoluntaryExitManagerAdapter::new( deps.beacon_client, Arc::clone(&deps.signer), deps.fork_schedule, deps.genesis_validators_root, - ))); + genesis_time, + ) + .with_slot_duration_ms(slot_duration_ms), + )); let signing_quiesce: Arc = Arc::new( SigningQuiesceAdapter::new(Arc::clone(&deps.quiesce_registry), deps.signer) diff --git a/crates/rvc/src/keymanager_adapters/tests/exit.rs b/crates/rvc/src/keymanager_adapters/tests/exit.rs index 6f1b9d1f..27444fd7 100644 --- a/crates/rvc/src/keymanager_adapters/tests/exit.rs +++ b/crates/rvc/src/keymanager_adapters/tests/exit.rs @@ -35,7 +35,13 @@ fn create_exit_adapter(beacon_url: &str, secret_key: SecretKey) -> VoluntaryExit let genesis_validators_root = [0xaa; 32]; - VoluntaryExitManagerAdapter::new(beacon_client, signer, fork_schedule, genesis_validators_root) + VoluntaryExitManagerAdapter::new( + beacon_client, + signer, + fork_schedule, + genesis_validators_root, + 0, + ) } #[test] diff --git a/crates/rvc/src/keymanager_adapters/tests/keystore.rs b/crates/rvc/src/keymanager_adapters/tests/keystore.rs index e5bcac40..be1fecce 100644 --- a/crates/rvc/src/keymanager_adapters/tests/keystore.rs +++ b/crates/rvc/src/keymanager_adapters/tests/keystore.rs @@ -111,7 +111,7 @@ fn failed_admit_leaves_no_keystore_json() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); @@ -226,7 +226,7 @@ fn keystore_files_are_durable_before_the_signer_is_live() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); @@ -294,7 +294,7 @@ fn windowed_import_keeps_the_keystore_partial_until_the_hold_is_durable() { Arc::clone(&store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -360,7 +360,7 @@ fn hold_deadline_is_sampled_after_keystore_io() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -424,7 +424,7 @@ fn rollback_revokes_before_discarding_keystore_files() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); @@ -483,7 +483,7 @@ fn failed_import_keeps_the_hold_row_until_the_keystore_json_is_unlinked() { Arc::clone(&store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -554,7 +554,7 @@ fn failed_unlink_keeps_a_disabled_tombstone() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -623,7 +623,7 @@ fn publish_failure_after_rename_keeps_the_hold_until_the_json_is_unlinked() { store, denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -695,7 +695,7 @@ fn already_present_import_does_not_unpublish_a_concurrent_refresh() { Arc::clone(&store), denylist, Some(Arc::clone(&machine)), - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(Arc::clone(&admissions)); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -774,7 +774,7 @@ fn already_present_import_leaves_a_midflight_refresh_hold() { Arc::clone(&store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(Arc::clone(&admissions)); let window_secs = 30u64; @@ -901,7 +901,7 @@ fn late_already_present_with_a_failed_refresh_row_is_not_boot_loadable() { Arc::clone(&store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(Arc::clone(&admissions)); @@ -1032,7 +1032,7 @@ fn tombstone_disable_does_not_deadlock_with_delete() { Arc::clone(&store), denylist, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = Arc::new(adapter.with_admission_service(admissions)); adapter.set_doppelganger_window(std::time::Duration::from_secs(30)); @@ -1228,7 +1228,7 @@ async fn disconnect_import_withholds_signing_without_on_import() { Arc::clone(&store), denylist, Some(Arc::clone(&machine)), - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = Arc::new(adapter.with_admission_service(admissions)); adapter.set_doppelganger_window(Duration::from_secs(30)); @@ -1385,7 +1385,7 @@ fn validators_backed_adapter( store, Arc::clone(&denylist), None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = adapter.with_admission_service(admissions).with_denylist(denylist); (adapter, composite, validators_dir) diff --git a/crates/rvc/src/keymanager_adapters/tests/quiesce.rs b/crates/rvc/src/keymanager_adapters/tests/quiesce.rs index 11091fd2..18d2d9ce 100644 --- a/crates/rvc/src/keymanager_adapters/tests/quiesce.rs +++ b/crates/rvc/src/keymanager_adapters/tests/quiesce.rs @@ -229,7 +229,7 @@ struct Build { fn clock_at(epoch: u64) -> Arc { let genesis = 1_700_000_000u64; let secs = epoch * SLOTS_PER_EPOCH * SLOT_DURATION_MS / 1000; - Arc::new(MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + secs)) + Arc::new(MonotonicEpochClock::with_start_time(genesis, Instant::now(), genesis + secs, 12_000)) } fn build(opts: Build) -> Harness { @@ -279,7 +279,7 @@ fn build(opts: Build) -> Harness { Arc::clone(&store), Arc::clone(&denylist), machine.clone(), - clock.clone().unwrap_or_else(|| Arc::new(MonotonicEpochClock::new(0))), + clock.clone().unwrap_or_else(|| Arc::new(MonotonicEpochClock::new(0, 12_000))), )); let adapter = Arc::new( KeystoreManagerAdapter::new( diff --git a/crates/rvc/src/keymanager_adapters/tests/spawn.rs b/crates/rvc/src/keymanager_adapters/tests/spawn.rs index bbe0241e..65bfae01 100644 --- a/crates/rvc/src/keymanager_adapters/tests/spawn.rs +++ b/crates/rvc/src/keymanager_adapters/tests/spawn.rs @@ -45,7 +45,7 @@ fn spawn_test_deps( let validator_store = Arc::new(ValidatorStore::new([0u8; 20], 100)); let deletion_denylist = Arc::new(DeletionDenylist::empty_at(keystore_dir.join(".rvc.deleted_keys"))); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let admissions = Arc::new(KeyAdmissionService::new( Arc::clone(&pubkey_map), key_gen_tx.clone(), @@ -178,6 +178,18 @@ fn test_spawn_keymanager_api_rearms_gate_exactly_once_for_both_monitors() { } } +/// Two epochs at a 6 s slot is 384 s. A 12 s clock would hold for 768 s. +#[test] +fn test_spawn_keymanager_api_six_second_slots_hold_for_two_epochs() { + let dir = TempDir::new().unwrap(); + let config = spawn_test_config(&dir, true, "127.0.0.1:0"); + let mut deps = spawn_test_deps(dir.path(), None); + deps.slot_clock = + Arc::new(timing::MockSlotClock::new(1_606_824_023, std::time::Duration::from_secs(6), 32)); + let built = build_keymanager_api(&config, deps).expect("build").expect("enabled"); + assert_eq!(built.doppelganger_window, std::time::Duration::from_secs(384)); +} + /// Opt-out (`forward_window_machine: None`) selects the always-safe monitor. /// /// Written against `DoppelgangerMonitorKind::Disabled` so this is RED until diff --git a/crates/rvc/src/keymanager_adapters/voluntary_exit.rs b/crates/rvc/src/keymanager_adapters/voluntary_exit.rs index 0eb358f8..fa5608ca 100644 --- a/crates/rvc/src/keymanager_adapters/voluntary_exit.rs +++ b/crates/rvc/src/keymanager_adapters/voluntary_exit.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use async_trait::async_trait; use bn_manager::BeaconNodeClient; use crypto::PublicKey; -use eth_types::{ - ForkSchedule, Root, SignedVoluntaryExit, VoluntaryExit, SLOTS_PER_EPOCH, SLOT_DURATION_MS, -}; +#[cfg(test)] +use eth_types::SLOT_DURATION_MS; +use eth_types::{ForkSchedule, Root, SignedVoluntaryExit, VoluntaryExit, SLOTS_PER_EPOCH}; use keymanager_api::error::ApiError; use keymanager_api::traits::{Pubkey, VoluntaryExitManager}; use signer::{SignerService, ValidatorSigner}; @@ -20,16 +20,35 @@ pub struct VoluntaryExitManagerAdapter { signer: Arc, fork_schedule: Arc, genesis_validators_root: Root, + /// Startup-validated slot length. Not re-read from a pool BN. + slot_duration_ms: u64, + /// Startup-validated genesis time. Not re-read from a pool BN. + genesis_time: u64, } impl VoluntaryExitManagerAdapter { + /// `genesis_time` is the startup-validated genesis. It has no zero default. pub fn new( beacon: Arc, signer: Arc, fork_schedule: Arc, genesis_validators_root: Root, + genesis_time: u64, ) -> Self { - Self { beacon, signer, fork_schedule, genesis_validators_root } + Self { + beacon, + signer, + fork_schedule, + genesis_validators_root, + slot_duration_ms: eth_types::SLOT_DURATION_MS, + genesis_time, + } + } + + /// Use the duration `connect_beacon` already checked against every BN. + pub fn with_slot_duration_ms(mut self, slot_duration_ms: u64) -> Self { + self.slot_duration_ms = slot_duration_ms; + self } } @@ -66,24 +85,12 @@ impl VoluntaryExitManager for VoluntaryExitManagerAdapter { let epoch = match epoch { Some(e) => e, None => { - let expected_root = format!("0x{}", hex::encode(self.genesis_validators_root)); - // A 200 whose validators root is not ours is not this chain's clock. - let genesis = self - .beacon - .get_genesis_matching_validators_root(&expected_root) - .await - .map_err(|e| ApiError::Internal(format!("failed to get genesis: {e}")))?; - - let genesis_time: u64 = genesis.data.genesis_time.parse().map_err(|e| { - ApiError::Internal(format!("failed to parse genesis time: {e}")) - })?; - let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .expect("system time before UNIX epoch") + .map_err(|_| ApiError::Internal("system time before UNIX epoch".to_string()))? .as_secs(); - current_epoch_at(now, genesis_time) + current_epoch_at(now, self.genesis_time, self.slot_duration_ms)? } }; @@ -113,10 +120,26 @@ impl VoluntaryExitManager for VoluntaryExitManagerAdapter { } } -/// Current epoch at `now_unix_secs` given genesis, using millisecond slot duration. -fn current_epoch_at(now_unix_secs: u64, genesis_time: u64) -> u64 { +/// Current epoch at `now_unix_secs` given genesis and the beacon-node slot length. +/// +/// A zero duration is an error. Dividing by it would panic, and treating it as +/// epoch 0 would sign an exit on the pre-genesis clock. +fn current_epoch_at( + now_unix_secs: u64, + genesis_time: u64, + slot_duration_ms: u64, +) -> Result { + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&slot_duration_ms) + { + return Err(ApiError::Internal(format!( + "slot duration {slot_duration_ms} ms is outside {}..={}", + eth_types::MIN_SLOT_DURATION_MS, + eth_types::MAX_SLOT_DURATION_MS + ))); + } let elapsed_ms = now_unix_secs.saturating_sub(genesis_time).saturating_mul(1000); - elapsed_ms / SLOT_DURATION_MS / SLOTS_PER_EPOCH + Ok(elapsed_ms / slot_duration_ms / SLOTS_PER_EPOCH) } #[cfg(test)] @@ -132,7 +155,7 @@ mod tests { #[test] fn test_current_epoch_at_genesis_is_zero() { let genesis = 1_606_824_023_u64; - assert_eq!(current_epoch_at(genesis, genesis), 0); + assert_eq!(current_epoch_at(genesis, genesis, 12_000).unwrap(), 0); } #[test] @@ -140,8 +163,35 @@ mod tests { let genesis = 1_606_824_023_u64; let epoch_secs = SLOT_DURATION_MS / 1000 * SLOTS_PER_EPOCH; assert_eq!(epoch_secs, 384, "one epoch is 384 s"); - assert_eq!(current_epoch_at(genesis + epoch_secs, genesis), 1); - assert_eq!(current_epoch_at(genesis + epoch_secs - 1, genesis), 0); + assert_eq!(current_epoch_at(genesis + epoch_secs, genesis, 12_000).unwrap(), 1); + assert_eq!(current_epoch_at(genesis + epoch_secs - 1, genesis, 12_000).unwrap(), 0); + } + + #[test] + fn test_current_epoch_at_six_second_slots_is_twice_the_twelve_second_epoch() { + let genesis = 1_606_824_023_u64; + let ten_epochs_at_six_seconds = 10 * 192; + assert_eq!( + current_epoch_at(genesis + ten_epochs_at_six_seconds, genesis, 6_000).unwrap(), + 10 + ); + assert_eq!( + current_epoch_at(genesis + ten_epochs_at_six_seconds, genesis, 12_000).unwrap(), + 5 + ); + } + + #[test] + fn test_current_epoch_at_zero_slot_duration_is_an_error() { + let err = current_epoch_at(100, 0, 0).unwrap_err(); + assert!(err.to_string().contains("outside"), "{err}"); + } + + #[test] + fn test_current_epoch_at_huge_slot_duration_is_an_error() { + let err = current_epoch_at(1_700_000_000 + 10 * 192, 1_700_000_000, u64::MAX).unwrap_err(); + assert!(err.to_string().contains("outside"), "{err}"); + assert!(current_epoch_at(1_700_000_000 + 10 * 192, 1_700_000_000, 86_400_000).is_err()); } fn test_fork_schedule() -> Arc { @@ -167,6 +217,14 @@ mod tests { fn adapter_for( beacon: Arc, secret_key: SecretKey, + ) -> VoluntaryExitManagerAdapter { + adapter_at_genesis(beacon, secret_key, 0) + } + + fn adapter_at_genesis( + beacon: Arc, + secret_key: SecretKey, + genesis_time: u64, ) -> VoluntaryExitManagerAdapter { let key_manager = KeyManager::new(); let composite = Arc::new(CompositeSigner::new(LocalSigner::new(key_manager))); @@ -174,7 +232,13 @@ mod tests { let slashing_db = Arc::new(SlashingDb::open_in_memory().expect("memory db")); let signer = Arc::new(SignerService::new(composite, slashing_db).with_enablement(always_enabled())); - VoluntaryExitManagerAdapter::new(beacon, signer, test_fork_schedule(), [0xaa; 32]) + VoluntaryExitManagerAdapter::new( + beacon, + signer, + test_fork_schedule(), + [0xaa; 32], + genesis_time, + ) } fn validator_body(pubkey_hex: &str) -> serde_json::Value { @@ -363,37 +427,91 @@ mod tests { } #[tokio::test] - async fn exit_fails_over_when_genesis_root_does_not_match() { - let foreign = MockServer::start().await; - let ours = MockServer::start().await; + async fn default_exit_epoch_uses_cached_genesis_not_a_pool_bn() { + let server = MockServer::start().await; let sk = SecretKey::generate(); let pubkey = sk.public_key().to_bytes(); let hex = super::pubkey_hex(pubkey); - let configured = format!("0x{}", hex::encode([0xaa_u8; 32])); - let other_root = format!("0x{}", hex::encode([0xbb_u8; 32])); - // Both nodes know the validator. Only the genesis root differs, so a - // validators-only failover cannot explain which clock was signed. - mount_validators(&foreign, validator_body(&hex), 200).await; - mount_validators(&ours, validator_body(&hex), 200).await; - mount_genesis(&foreign, "1", &other_root).await; - // Far enough ahead of wall time that the epoch stays 0. - mount_genesis(&ours, "4000000000", &configured).await; - - let adapter = adapter_for(pool(vec![foreign.uri(), ours.uri()]), sk); - let signed = - adapter.sign_voluntary_exit(&pubkey, None).await.expect("genesis root fails over"); - assert_eq!(signed.message.validator_index, 42); - assert_eq!(signed.message.epoch, 0, "must not sign the foreign genesis_time"); + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let genesis = now - 10 * 192 - 96; + mount_validators(&server, validator_body(&hex), 200).await; + // A pool BN that advertises a different clock must not move the epoch. + mount_genesis(&server, "1", &format!("0x{}", hex::encode([0xbb_u8; 32]))).await; + mount_spec(&server, "12000").await; + + let adapter = + adapter_at_genesis(pool(vec![server.uri()]), sk, genesis).with_slot_duration_ms(6_000); + let signed = adapter.sign_voluntary_exit(&pubkey, None).await.expect("cached genesis"); + assert_eq!(signed.message.epoch, 10); + + let paths = request_paths(&server).await; + assert!( + paths.iter().all(|p| !p.contains("genesis") && !p.contains("config/spec")), + "exit must not re-fetch genesis or spec, paths={paths:?}" + ); + } - let foreign_paths = request_paths(&foreign).await; - let our_paths = request_paths(&ours).await; + async fn mount_spec(server: &MockServer, slot_duration_ms: &str) { + Mock::given(method("GET")) + .and(path("/eth/v1/config/spec")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { "SLOT_DURATION_MS": slot_duration_ms } + }))) + .mount(server) + .await; + } + + /// Default exit epoch follows a 6 s beacon node, not the 12 s constant. + #[tokio::test] + async fn default_exit_epoch_matches_six_second_slot_clock() { + let server = MockServer::start().await; + let sk = SecretKey::generate(); + let pubkey = sk.public_key().to_bytes(); + let hex = super::pubkey_hex(pubkey); + let configured = format!("0x{}", hex::encode([0xaa_u8; 32])); + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + // Middle of epoch 10 at 6 s slots (192 s per epoch). A 12 s clock would + // report epoch 5 for the same genesis. + let genesis = now - 10 * 192 - 96; + mount_validators(&server, validator_body(&hex), 200).await; + mount_genesis(&server, "1", &configured).await; + mount_spec(&server, "12000").await; + + let adapter = + adapter_at_genesis(pool(vec![server.uri()]), sk, genesis).with_slot_duration_ms(6_000); + let signed = adapter.sign_voluntary_exit(&pubkey, None).await.expect("default epoch"); + assert_eq!(signed.message.epoch, 10); + let paths = request_paths(&server).await; assert!( - foreign_paths.iter().any(|p| p.contains("genesis")), - "the first BN's genesis must be checked, paths={foreign_paths:?}" + paths.iter().all(|p| !p.contains("genesis")), + "exit must not re-fetch genesis, paths={paths:?}" ); + } + + #[tokio::test] + async fn default_exit_epoch_rejects_zero_and_huge_stored_duration() { + let server = MockServer::start().await; + let sk = SecretKey::generate(); + let pubkey = sk.public_key().to_bytes(); + let hex = super::pubkey_hex(pubkey); + let configured = format!("0x{}", hex::encode([0xaa_u8; 32])); + mount_validators(&server, validator_body(&hex), 200).await; + mount_genesis(&server, "1606824023", &configured).await; + mount_spec(&server, "6000").await; + + for bad in [0_u64, u64::MAX] { + // Duration is rejected before signing, so the local key is unused. + let adapter = adapter_for(pool(vec![server.uri()]), SecretKey::generate()) + .with_slot_duration_ms(bad); + let err = adapter.sign_voluntary_exit(&pubkey, None).await.expect_err("bad duration"); + assert!(err.to_string().contains("outside"), "{err}"); + } + let paths = request_paths(&server).await; assert!( - our_paths.iter().any(|p| p.contains("genesis")), - "the second BN must be contacted for genesis, paths={our_paths:?}" + !paths.iter().any(|p| p.contains("config/spec")), + "exit must not re-fetch spec, paths={paths:?}" ); } diff --git a/crates/rvc/src/lib.rs b/crates/rvc/src/lib.rs index b388cd4f..da39b675 100644 --- a/crates/rvc/src/lib.rs +++ b/crates/rvc/src/lib.rs @@ -5,7 +5,6 @@ pub mod beacon_adapter; pub mod bootstrap; pub mod config; pub mod deletion_denylist; -pub mod doppelganger_adapter; pub mod index_resolver; pub mod key_admission; pub mod keymanager_adapters; diff --git a/crates/rvc/src/liveness_loop.rs b/crates/rvc/src/liveness_loop.rs index 0d3cdffa..169c00e7 100644 --- a/crates/rvc/src/liveness_loop.rs +++ b/crates/rvc/src/liveness_loop.rs @@ -36,7 +36,7 @@ use doppelganger::{ ForwardWindowMachine, ForwardWindowStatus, MonotonicEpochClock, ValidatorLivenessData, DEFAULT_MONITORING_EPOCHS, }; -use eth_types::{Epoch, SLOT_DURATION_MS}; +use eth_types::Epoch; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn, Instrument}; @@ -82,7 +82,7 @@ pub struct LivenessObservationLoop { /// Live keystore/keymanager pubkey set for periodic BN index re-resolve. pubkey_map: Option, epoch_clock: Arc, - /// Slot duration used for the sleep interval (defaults to mainnet 12s). + /// Slot duration used for the sleep interval (the beacon-node slot length). slot_duration: Duration, cancel: CancellationToken, /// Keystore directory whose import sidecars are removed once a key is Safe. @@ -137,6 +137,17 @@ macro_rules! refresh_indices_from_pubkey_map_impl { }}; } +/// Sleep between liveness samples. +/// +/// Zero is not a slot. Sleeping zero would spin the loop, and assuming 12 s +/// would be wrong on a longer slot. A zero duration waits indefinitely. +fn slot_sleep(slot_duration_ms: u64) -> Duration { + if slot_duration_ms == 0 { + return Duration::MAX; + } + Duration::from_millis(slot_duration_ms) +} + impl LivenessObservationLoop { pub fn new( machine: Arc, @@ -150,8 +161,8 @@ impl LivenessObservationLoop { beacon, pubkey_index, pubkey_map: None, + slot_duration: slot_sleep(epoch_clock.slot_duration_ms()), epoch_clock, - slot_duration: Duration::from_millis(SLOT_DURATION_MS), cancel, import_sidecar_dir: None, tracked_keys: None, @@ -173,6 +184,11 @@ impl LivenessObservationLoop { self } + /// Slot sleep used by [`Self::run`]. + pub fn slot_duration(&self) -> Duration { + self.slot_duration + } + /// Directory scanned after each tick. A Safe key's import sidecar is removed. /// /// That removal is the durable record that this process observed the @@ -345,7 +361,6 @@ impl LivenessObservationLoop { debug!(epoch = e, missing_count, "observe_liveness incomplete (D-2 fail-closed)"); Ok(false) } - Err(e) => Err(e.to_string()), } } @@ -645,7 +660,7 @@ mod tests { machine, beacon, reg, - Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0)), + Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0, 12_000)), CancellationToken::new(), ) .with_slot_duration(Duration::from_millis(1)) @@ -736,7 +751,7 @@ mod tests { Arc::clone(&m), bn, reg, - Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0)), + Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0, 12_000)), CancellationToken::new(), ) .with_slot_duration(Duration::from_millis(1)) @@ -812,7 +827,12 @@ mod tests { bn, Arc::clone(®), None, - Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 1_000_000)), + Arc::new(MonotonicEpochClock::with_start_time( + 0, + std::time::Instant::now(), + 1_000_000, + 12_000, + )), &exec, None, ); @@ -826,7 +846,7 @@ mod tests { Arc::new(all_not_live(&[]).0), empty, None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), &exec2, None, ); @@ -838,7 +858,7 @@ mod tests { Arc::new(all_not_live(&["1"]).0), Arc::clone(®), None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), &exec3, None, ); @@ -873,7 +893,7 @@ mod tests { Arc::clone(&m), bn.clone(), Arc::clone(&pubkey_index), - Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0)), + Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0, 12_000)), CancellationToken::new(), ) .with_pubkey_map(pubkey_map) @@ -957,7 +977,7 @@ mod tests { Arc::new(all_not_live(&[]).0), empty, Some(pubkey_map), - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), &exec, None, ); @@ -1028,10 +1048,32 @@ mod tests { machine(), Arc::new(LivenessOnlyMock), PubkeyIndexRegistry::shared(), - Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0)), + Arc::new(MonotonicEpochClock::with_start_time(0, std::time::Instant::now(), 0, 12_000)), CancellationToken::new(), ); let statuses = loop_.drive_once_for_test(None, 0, 0).await.expect("tick"); assert!(statuses.is_empty()); } + + fn loop_at(slot_duration_ms: u64) -> LivenessObservationLoop { + LivenessObservationLoop::new( + machine(), + Arc::new(MockBeaconNodeClient::new()), + PubkeyIndexRegistry::shared(), + Arc::new(MonotonicEpochClock::with_start_time( + 0, + std::time::Instant::now(), + 0, + slot_duration_ms, + )), + CancellationToken::new(), + ) + } + + #[test] + fn slot_sleep_follows_beacon_slot_duration() { + assert_eq!(loop_at(6_000).slot_duration(), Duration::from_secs(6)); + assert_eq!(loop_at(12_000).slot_duration(), Duration::from_secs(12)); + assert_eq!(loop_at(0).slot_duration(), Duration::MAX); + } } diff --git a/crates/rvc/src/orchestrator/coordinator/mod.rs b/crates/rvc/src/orchestrator/coordinator/mod.rs index 6126ee7a..b24f8ce5 100644 --- a/crates/rvc/src/orchestrator/coordinator/mod.rs +++ b/crates/rvc/src/orchestrator/coordinator/mod.rs @@ -518,7 +518,8 @@ where config.genesis_validators_root, circuit_breaker.clone(), ) - .with_deadline_schedule(config.deadline_schedule); + .with_deadline_schedule(config.deadline_schedule) + .with_slot_duration_ms(clock.slot_duration().as_millis() as u64); if let Some(ref builder) = builder_service { block_service = block_service .with_builder_config_provider(Arc::new(BuilderConfigAdapter(builder.clone()))); diff --git a/crates/rvc/src/orchestrator/coordinator/tests/core.rs b/crates/rvc/src/orchestrator/coordinator/tests/core.rs index 80c17618..5f5b34a0 100644 --- a/crates/rvc/src/orchestrator/coordinator/tests/core.rs +++ b/crates/rvc/src/orchestrator/coordinator/tests/core.rs @@ -728,3 +728,43 @@ async fn test_find_pubkey_not_found() { let found = utils::find_pubkey(&orchestrator.pubkey_map, "0x1234567890abcdef"); assert!(found.is_none()); } + +fn orchestrator_with_slot( + slot: Duration, +) -> DutyOrchestrator { + let clock = Arc::new(MockSlotClock::new(TEST_GENESIS_TIME, slot, 32)); + let beacon_config = BeaconClientConfig::new("http://localhost:5052"); + let beacon = Arc::new(BeaconClient::new(beacon_config).unwrap()); + let duty_tracker = Arc::new(DutyTracker::new(beacon.clone(), vec!["1234".to_string()])); + let composite = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); + let slashing_db = Arc::new(SlashingDb::open_in_memory().unwrap()); + let signer = + Arc::new(SignerService::new(composite, slashing_db).with_enablement(always_enabled())); + let submitter = Arc::new(MockSubmitter::new()); + let propagator = Arc::new(Propagator::new(submitter)); + let (orchestrator, _handle) = DutyOrchestrator::new(OrchestratorDeps::for_test( + clock, + duty_tracker, + signer, + propagator, + beacon, + create_mock_block_beacon(), + None, + create_mock_validator_store(), + create_test_config(), + Arc::new(parking_lot::RwLock::new(HashMap::new())), + )); + orchestrator +} + +#[test] +fn test_block_service_takes_slot_duration_from_the_clock() { + assert_eq!( + orchestrator_with_slot(Duration::from_secs(6)).block_service.slot_duration_ms(), + 6_000 + ); + assert_eq!( + orchestrator_with_slot(Duration::from_secs(12)).block_service.slot_duration_ms(), + 12_000 + ); +} diff --git a/crates/rvc/src/slashing_monitor.rs b/crates/rvc/src/slashing_monitor.rs index 82922984..2aa5ae35 100644 --- a/crates/rvc/src/slashing_monitor.rs +++ b/crates/rvc/src/slashing_monitor.rs @@ -22,9 +22,17 @@ pub enum SlashedOutcome { ShutdownRequested, } -/// Epoch interval between slashing checks (`SLOT_DURATION_MS * SLOTS_PER_EPOCH`). -pub fn epoch_check_interval() -> Duration { - Duration::from_millis(eth_types::SLOT_DURATION_MS.saturating_mul(eth_types::SLOTS_PER_EPOCH)) +/// Epoch interval between slashing checks at the startup-validated slot length. +/// +/// A zero or out-of-range duration does not become a zero sleep (that would +/// spin) and does not assume 12 s. It waits a day. +pub fn epoch_check_interval(slot_duration_ms: u64) -> Duration { + if !(eth_types::MIN_SLOT_DURATION_MS..=eth_types::MAX_SLOT_DURATION_MS) + .contains(&slot_duration_ms) + { + return Duration::from_secs(24 * 60 * 60); + } + Duration::from_millis(slot_duration_ms.saturating_mul(eth_types::SLOTS_PER_EPOCH)) } /// Query enabled validators and apply the configured slashed action. @@ -114,8 +122,9 @@ pub fn spawn( store: Arc, action: SlashedAction, executor: &TaskExecutor, + slot_duration_ms: u64, ) { - spawn_with_interval(beacon, store, action, executor, epoch_check_interval()); + spawn_with_interval(beacon, store, action, executor, epoch_check_interval(slot_duration_ms)); } fn spawn_with_interval( @@ -287,13 +296,16 @@ mod tests { #[test] fn test_spawn_uses_epoch_tick_from_eth_types_constants() { - let interval = epoch_check_interval(); + let interval = epoch_check_interval(eth_types::SLOT_DURATION_MS); assert_eq!( interval, Duration::from_millis( eth_types::SLOT_DURATION_MS.saturating_mul(eth_types::SLOTS_PER_EPOCH) ) ); + assert_eq!(epoch_check_interval(6_000), Duration::from_secs(192)); + assert_eq!(epoch_check_interval(0), Duration::from_secs(24 * 60 * 60)); + assert_eq!(epoch_check_interval(u64::MAX), Duration::from_secs(24 * 60 * 60)); // Same numeric value as the pre-ms tree (384 s epoch). A naive // `from_secs(SLOT_DURATION_MS * SLOTS_PER_EPOCH)` is 384_000 s. assert_eq!(interval, Duration::from_secs(384)); @@ -385,6 +397,7 @@ mod tests { Arc::new(ValidatorStore::new([0u8; 20], 100)), SlashedAction::None, &exec, + 12_000, ); assert!(exec.registered_names().is_empty(), "SlashedAction::None must register no task"); diff --git a/crates/rvc/src/startup.rs b/crates/rvc/src/startup.rs index f3cecf5f..f7487201 100644 --- a/crates/rvc/src/startup.rs +++ b/crates/rvc/src/startup.rs @@ -117,26 +117,26 @@ pub async fn validate_genesis_root( ) -> Result<(), StartupError> { info!("Validating genesis validators root against beacon node"); - let genesis_response = beacon.get_genesis().await?; - let beacon_root = &genesis_response.data.genesis_validators_root; - - let local_normalized = normalize_hex(local_root_hex)?; - let beacon_normalized = normalize_hex(beacon_root)?; - - if local_normalized != beacon_normalized { - error!( - local = %local_root_hex, - beacon = %beacon_root, - "Genesis validators root mismatch" - ); - return Err(StartupError::GenesisRootMismatch { - local: local_root_hex.to_string(), - beacon: beacon_root.clone(), - }); + // Every responder, not the first `get_genesis` success. A pooled client + // rejects a node whose root differs and still accepts a later match. + if let Err(err) = beacon.confirm_genesis_validators_root(local_root_hex).await { + if let beacon::BeaconError::GenesisValidatorsRootMismatch { root } = &err { + error!( + local = %local_root_hex, + beacon = %root, + "Genesis validators root mismatch" + ); + return Err(StartupError::GenesisRootMismatch { + local: local_root_hex.to_string(), + beacon: root.clone(), + }); + } + return Err(err.into()); } // SlashingDb takes a typed Root, compares by bytes, and stores canonical // `0x` + lowercase hex. Bare or mixed-case legacy metadata remains compatible. + let local_normalized = normalize_hex(local_root_hex)?; let local_root = eth_types::canonical::gvr_hex::parse_gvr_hex(&local_normalized) .map_err(|e| StartupError::InvalidHexInput(format!("genesis_validators_root: {e}")))?; slashing_db.set_genesis_validators_root(&local_root)?; diff --git a/crates/rvc/tests/duty_coverage.rs b/crates/rvc/tests/duty_coverage.rs index 9c44188a..d942854f 100644 --- a/crates/rvc/tests/duty_coverage.rs +++ b/crates/rvc/tests/duty_coverage.rs @@ -141,6 +141,7 @@ impl Harness { 0, std::time::Instant::now(), SLOTS_PER_EPOCH * 12, + 12_000, )); let (enablement, machine) = if doppelganger_enabled { @@ -736,6 +737,8 @@ async fn beacon_handles(config: &Config) -> BeaconHandles { genesis_validators_root: GVR, genesis_validators_root_hex: format!("0x{}", hex::encode(GVR)), genesis_time: config.genesis_time.unwrap_or(1_606_824_023), + slot_duration_ms: 12_000, + genesis_confirmed: true, } } diff --git a/crates/rvc/tests/proposal_first_budget.rs b/crates/rvc/tests/proposal_first_budget.rs index 73e9cd75..61608669 100644 --- a/crates/rvc/tests/proposal_first_budget.rs +++ b/crates/rvc/tests/proposal_first_budget.rs @@ -155,6 +155,13 @@ impl NodeStatusApi for DutyStallBeacon { ) -> Result { beacon::ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) } + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError> { + beacon::ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) + .map(|_| ()) + } async fn get_config_spec(&self) -> Result { self.inner.get_config_spec().await } diff --git a/crates/rvc/tests/proposal_under_duty_stall.rs b/crates/rvc/tests/proposal_under_duty_stall.rs index 989ffc78..cf714df8 100644 --- a/crates/rvc/tests/proposal_under_duty_stall.rs +++ b/crates/rvc/tests/proposal_under_duty_stall.rs @@ -171,6 +171,13 @@ impl NodeStatusApi for DutyStallBeacon { ) -> Result { beacon::ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) } + async fn confirm_genesis_validators_root( + &self, + expected_root_hex: &str, + ) -> Result<(), BeaconError> { + beacon::ensure_genesis_validators_root(self.get_genesis().await?, expected_root_hex) + .map(|_| ()) + } async fn get_config_spec(&self) -> Result { self.inner.get_config_spec().await } diff --git a/crates/rvc/tests/provider_refreshed_key_is_sampled_by_the_liveness_loop.rs b/crates/rvc/tests/provider_refreshed_key_is_sampled_by_the_liveness_loop.rs index 292c7b9e..1588c0ed 100644 --- a/crates/rvc/tests/provider_refreshed_key_is_sampled_by_the_liveness_loop.rs +++ b/crates/rvc/tests/provider_refreshed_key_is_sampled_by_the_liveness_loop.rs @@ -42,7 +42,7 @@ async fn a_provider_refreshed_key_leaves_pending() { let (key_gen_tx, mut key_gen_rx) = tokio::sync::watch::channel(0u64); let composite = Arc::new(CompositeSigner::new(LocalSigner::new(KeyManager::new()))); let validator_store = Arc::new(ValidatorStore::new([0u8; 20], 30_000_000)); - let epoch_clock = Arc::new(MonotonicEpochClock::new(0)); + let epoch_clock = Arc::new(MonotonicEpochClock::new(0, 12_000)); let db: Arc = Arc::new(SlashingDb::open_in_memory().expect("in-memory slashing db")); let machine = Arc::new(ForwardWindowMachine::new(db, 2, [0xabu8; 32])); @@ -189,7 +189,7 @@ async fn keymanager_import_admits_through_the_service() { Arc::clone(&validator_store), Arc::clone(&denylist), None, - Arc::new(MonotonicEpochClock::new(0)), + Arc::new(MonotonicEpochClock::new(0, 12_000)), )); let adapter = KeystoreManagerAdapter::new( diff --git a/crates/rvc/tests/task_executor_migration.rs b/crates/rvc/tests/task_executor_migration.rs index b6f64733..a61ab2d8 100644 --- a/crates/rvc/tests/task_executor_migration.rs +++ b/crates/rvc/tests/task_executor_migration.rs @@ -60,6 +60,7 @@ async fn test_every_background_task_is_registered_by_name() { Arc::new(ValidatorStore::new([0u8; 20], 100)), SlashedAction::DisableOnly, &executor, + 12_000, ); let names = executor.registered_names(); @@ -101,6 +102,7 @@ async fn test_disabled_slashing_monitor_registers_no_task() { Arc::new(ValidatorStore::new([0u8; 20], 100)), SlashedAction::None, &executor, + 12_000, ); assert!(executor.registered_names().is_empty(), "disabled slashing must register no task"); diff --git a/crates/timing/src/clock.rs b/crates/timing/src/clock.rs index 9682a663..2e5b156b 100644 --- a/crates/timing/src/clock.rs +++ b/crates/timing/src/clock.rs @@ -66,8 +66,18 @@ pub trait SlotClock: Send + Sync { self.deadlines() } + /// Slot length in milliseconds. `0` when the duration does not fit in `u64`. + fn slot_duration_ms(&self) -> u64 { + u64::try_from(self.slot_duration().as_millis()).unwrap_or(0) + } + + /// Unix seconds at the start of `slot`, floored. + /// + /// Sub-second slot lengths keep the remainder in [`Self::slot_start_ms`]. + /// A 1500 ms slot starts slot 2 at genesis+3000 ms, which is unix second + /// genesis+3, not genesis+2. fn slot_start_time(&self, slot: Slot) -> u64 { - self.genesis_time() + (slot * self.slot_duration().as_secs()) + self.slot_start_ms(slot) / 1000 } /// Unix milliseconds at the start of `slot`. @@ -76,20 +86,26 @@ pub trait SlotClock: Send + Sync { /// to a small timestamp. Prefer [`Self::slot_start_ms_checked`] on paths /// that must fail closed (e.g. pre-genesis import). fn slot_start_ms(&self, slot: Slot) -> u64 { - self.slot_start_time(slot).saturating_mul(1000) + let genesis_ms = self.genesis_time().saturating_mul(1000); + let offset = slot.saturating_mul(self.slot_duration_ms()); + genesis_ms.saturating_add(offset) } /// Checked unix milliseconds at the start of `slot`. /// - /// Fails with [`TimingError::TimeOverflow`] when `genesis_time`, the slot - /// offset, or the seconds-to-milliseconds conversion would overflow `u64`. - /// Pre-genesis import uses this via [`Self::time_until_slot`]`(0)`. + /// The offset is `slot * slot_duration_ms`, not whole seconds. Fails with + /// [`TimingError::TimeOverflow`] when genesis milliseconds or that offset + /// would overflow `u64`, and with [`TimingError::InvalidSlotDuration`] + /// when the duration is zero. Pre-genesis import uses this via + /// [`Self::time_until_slot`]`(0)`. fn slot_start_ms_checked(&self, slot: Slot) -> Result { - let slot_secs = - slot.checked_mul(self.slot_duration().as_secs()).ok_or(TimingError::TimeOverflow)?; - let start_secs = - self.genesis_time().checked_add(slot_secs).ok_or(TimingError::TimeOverflow)?; - start_secs.checked_mul(1000).ok_or(TimingError::TimeOverflow) + let slot_ms = self.slot_duration_ms(); + if slot_ms == 0 { + return Err(TimingError::InvalidSlotDuration); + } + let offset = slot.checked_mul(slot_ms).ok_or(TimingError::TimeOverflow)?; + let genesis_ms = self.genesis_time().checked_mul(1000).ok_or(TimingError::TimeOverflow)?; + genesis_ms.checked_add(offset).ok_or(TimingError::TimeOverflow) } fn ms_into_slot(&self, slot: Slot) -> u64 { @@ -195,10 +211,6 @@ impl SystemSlotClock { pub fn new_mainnet(genesis_time: u64) -> Result { Self::new(genesis_time, Duration::from_millis(SLOT_DURATION_MS), SLOTS_PER_EPOCH) } - - fn current_unix_time(&self) -> u64 { - self.now_since_epoch().as_secs() - } } impl SlotClock for SystemSlotClock { @@ -227,23 +239,34 @@ impl SlotClock for SystemSlotClock { } fn current_slot(&self) -> Result { - let current_time = self.current_unix_time(); - if current_time < self.genesis_time { - return Err(TimingError::BeforeGenesis { - current_time, - genesis_time: self.genesis_time, - }); - } - let seconds_since_genesis = current_time - self.genesis_time; - let slot_duration_secs = self.slot_duration.as_secs(); - let slot = seconds_since_genesis / slot_duration_secs; - let epoch = slot / self.slots_per_epoch; - let time_into_slot = (seconds_since_genesis % slot_duration_secs) * 1000; + let now = self.now_since_epoch(); + let now_ms = u64::try_from(now.as_millis()).map_err(|_| TimingError::TimeOverflow)?; + let slot = slot_from_millis(now_ms, self.genesis_time, self.slot_duration)?; + let epoch = slot / self.slots_per_epoch.max(1); + let slot_ms = u64::try_from(self.slot_duration.as_millis()).unwrap_or(0); + let genesis_ms = self.genesis_time.saturating_mul(1000); + let time_into_slot = now_ms.saturating_sub(genesis_ms) % slot_ms.max(1); tracing::trace!(slot, epoch, time_into_slot, "slot transition"); Ok(slot) } } +fn slot_from_millis( + now_ms: u64, + genesis_time: u64, + slot_duration: Duration, +) -> Result { + let genesis_ms = genesis_time.checked_mul(1000).ok_or(TimingError::TimeOverflow)?; + if now_ms < genesis_ms { + return Err(TimingError::BeforeGenesis { current_time: now_ms / 1000, genesis_time }); + } + let slot_ms = u64::try_from(slot_duration.as_millis()).unwrap_or(0); + if slot_ms == 0 { + return Err(TimingError::InvalidSlotDuration); + } + Ok((now_ms - genesis_ms) / slot_ms) +} + pub struct MockSlotClock { genesis_time: u64, slot_duration: Duration, @@ -296,19 +319,13 @@ impl MockSlotClock { } pub fn set_slot(&self, slot: Slot) { - let slot_start = self.genesis_time + (slot * self.slot_duration.as_secs()); - self.set_current_time(slot_start); + self.set_current_time_ms(self.slot_start_ms(slot)); } /// Place the clock `offset_ms` after the start of `slot`. pub fn set_slot_with_offset_ms(&self, slot: Slot, offset_ms: u64) { self.set_current_time_ms(self.slot_start_ms(slot) + offset_ms); } - - /// Whole seconds. The sub-second remainder is visible through `now_since_epoch`. - fn get_current_time(&self) -> u64 { - self.current_time_ms.load(std::sync::atomic::Ordering::SeqCst) / 1000 - } } impl SlotClock for MockSlotClock { @@ -337,15 +354,8 @@ impl SlotClock for MockSlotClock { } fn current_slot(&self) -> Result { - let current_time = self.get_current_time(); - if current_time < self.genesis_time { - return Err(TimingError::BeforeGenesis { - current_time, - genesis_time: self.genesis_time, - }); - } - let seconds_since_genesis = current_time - self.genesis_time; - Ok(seconds_since_genesis / self.slot_duration.as_secs()) + let now_ms = self.current_time_ms.load(std::sync::atomic::Ordering::SeqCst); + slot_from_millis(now_ms, self.genesis_time, self.slot_duration) } } @@ -378,6 +388,42 @@ mod tests { assert_eq!(clock.current_slot().unwrap(), 0); } + #[test] + fn test_current_slot_keeps_subsecond_slot_length() { + let clock = MockSlotClock::new(TEST_GENESIS_TIME, Duration::from_millis(1500), 32); + // 2.0 s is slot 1 at 1500 ms (2000/1500). Whole-second math (2/1) reports slot 2. + clock.set_current_time_ms(TEST_GENESIS_TIME * 1000 + 2000); + assert_eq!(clock.current_slot().unwrap(), 1); + } + + /// A 1500 ms slot must not truncate to 1 s. Slot 2 starts at genesis+3000 ms. + /// Truncation would start it at genesis+2000 ms, put the attestation + /// deadline in the past, and make `time_until_slot(2)` return zero so the + /// coordinator wait spins. + #[test] + fn test_1500ms_slot_two_starts_three_seconds_after_genesis() { + let clock = MockSlotClock::new(TEST_GENESIS_TIME, Duration::from_millis(1500), 32); + let genesis_ms = TEST_GENESIS_TIME * 1000; + assert_eq!(clock.slot_start_ms(2), genesis_ms + 3000); + assert_eq!(clock.slot_start_ms_checked(2).unwrap(), genesis_ms + 3000); + assert_ne!(clock.slot_start_ms(2), genesis_ms + 2000); + + clock.set_current_time_ms(genesis_ms + 2000); + assert_eq!(clock.current_slot().unwrap(), 1); + assert_eq!(clock.time_until_slot(2).unwrap(), Duration::from_millis(1000)); + + // 100 ms into slot 1. Attestation is 3333 bps of 1500 ms (499 ms). + clock.set_current_time_ms(genesis_ms + 1500 + 100); + assert_eq!(clock.ms_into_slot(1), 100); + let until_due = clock.time_until_due(1, clock.deadlines().attestation).unwrap(); + assert_eq!(until_due, Duration::from_millis(399)); + assert_ne!(until_due, Duration::ZERO); + + clock.set_slot(2); + assert_eq!(clock.current_time_ms(), genesis_ms + 3000); + assert_eq!(clock.current_slot().unwrap(), 2); + } + #[test] fn test_current_slot_after_one_slot() { let clock = create_mock_clock(); diff --git a/crates/timing/src/lib.rs b/crates/timing/src/lib.rs index 2032e588..bf8fa480 100644 --- a/crates/timing/src/lib.rs +++ b/crates/timing/src/lib.rs @@ -78,5 +78,21 @@ impl Default for DeadlineBps { /// assert_eq!(due_ms(AGGREGATE_DUE_BPS, 12000), 8000); /// ``` pub fn due_ms(bps: u64, slot_duration_ms: u64) -> u64 { - bps * slot_duration_ms / BASIS_POINTS + match bps.checked_mul(slot_duration_ms) { + Some(product) => product / BASIS_POINTS, + // A wrapped product must not become 0. Zero would fire the deadline + // at the start of the slot. The end of the slot stays inside the slot. + None => slot_duration_ms, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn due_ms_overflow_does_not_fire_at_zero() { + assert_eq!(due_ms(u64::MAX, u64::MAX), u64::MAX); + assert_ne!(due_ms(u64::MAX, u64::MAX), 0); + } } diff --git a/docs/forks.md b/docs/forks.md index f9ba7e59..ab4c460a 100644 --- a/docs/forks.md +++ b/docs/forks.md @@ -69,13 +69,13 @@ Proposal path that already has the BN `consensus_version`: | What | File | Lines | Role | |---|---|---|---| -| SSZ `BlockContents` KZG bind | `crates/block-service/src/service/mod.rs` | 673–687 | `body_fork_layout` → `blob_kzg_count` / `kzg_commitment_root`. Fail-closed on bad SSZ. Skipped unless `ssz_block_format` returned `BlockContents`. | -| JSON `BlockAndBlobs` KZG bind | `crates/block-service/src/service/mod.rs` | 790–796 | Same `body_fork_layout` dispatch. | -| `ssz_block_format` | `crates/block-service/src/service/mod.rs` | 1092–1113 | Exhaustive `match ForkName`. Blinded → always `BeaconBlock`. Unblinded Deneb/Electra/Fulu → `BlockContents`. **Named Gloas `BeaconBlock` arm** (bare `SignedBeaconBlock`; must not share a catch-all with pre-Deneb). Unknown version strings fail closed via `ForkName::from_str`. | -| `reject_blinded_at_gloas` | `crates/block-service/src/service/mod.rs` | 1118–1132 | Open-ended `>= Gloas` (slot fork or `consensus_version == "gloas"`) drops blinded production. | -| produceBlockV4 / v3 | `crates/block-service/src/service/mod.rs` | 307–320 | Slot fork `>= Gloas` calls `produce_block_v4` (`POST /eth/v4/validator/blocks/{slot}`); pre-Gloas stays `produce_block_v3` (`GET /eth/v3/validator/blocks/{slot}`). Dispatch is on the slot fork, never on response headers. | +| SSZ `BlockContents` KZG bind | `crates/block-service/src/service/mod.rs` | 699–713 | `body_fork_layout` → `blob_kzg_count` / `kzg_commitment_root`. Fail-closed on bad SSZ. Skipped unless `ssz_block_format` returned `BlockContents`. | +| JSON `BlockAndBlobs` KZG bind | `crates/block-service/src/service/mod.rs` | 816–822 | Same `body_fork_layout` dispatch. | +| `ssz_block_format` | `crates/block-service/src/service/mod.rs` | 1118–1139 | Exhaustive `match ForkName`. Blinded → always `BeaconBlock`. Unblinded Deneb/Electra/Fulu → `BlockContents`. **Named Gloas `BeaconBlock` arm** (bare `SignedBeaconBlock`; must not share a catch-all with pre-Deneb). Unknown version strings fail closed via `ForkName::from_str`. | +| `reject_blinded_at_gloas` | `crates/block-service/src/service/mod.rs` | 1144–1158 | Open-ended `>= Gloas` (slot fork or `consensus_version == "gloas"`) drops blinded production. | +| produceBlockV4 / v3 | `crates/block-service/src/service/mod.rs` | 333–346 | Slot fork `>= Gloas` calls `produce_block_v4` (`POST /eth/v4/validator/blocks/{slot}`); pre-Gloas stays `produce_block_v3` (`GET /eth/v3/validator/blocks/{slot}`). Dispatch is on the slot fork, never on response headers. | | V4 HTTP | `crates/beacon/src/client.rs` | 643–693 | `produce_block_v4`; path prefix in `crates/beacon/src/v4_wire.rs` 13. Pre-Gloas v3 is 526. | -| V4 sign/publish | `crates/block-service/src/service/mod.rs` | 367–398 | Slot fork `>= Gloas` uses `sign_and_publish_v4`; pre-Gloas keeps V3 blinded/unblinded. | +| V4 sign/publish | `crates/block-service/src/service/mod.rs` | 393–424 | Slot fork `>= Gloas` uses `sign_and_publish_v4`; pre-Gloas keeps V3 blinded/unblinded. | ### 1.3 Duty and signing dispatch @@ -116,10 +116,10 @@ inventory in [gloas-fork-hazard-audit.md](gloas-fork-hazard-audit.md)): | What | File | Lines | Role | |---|---|---|---| | Proposer-duties v1/v2 | `crates/beacon/src/client.rs` | 482–490 | `>= Gloas` → `/eth/v2/validator/duties/proposer/{epoch}`. No silent v1 fallback. | -| produceBlockV4 / v3 | `crates/block-service/src/service/mod.rs` | 307–320 | `>= Gloas` → `produce_block_v4` (`POST /eth/v4/validator/blocks/{slot}`); else `produce_block_v3` (`GET /eth/v3/validator/blocks/{slot}`). HTTP in `crates/beacon/src/client.rs` 526 / 643; path prefix `crates/beacon/src/v4_wire.rs` 13. | +| produceBlockV4 / v3 | `crates/block-service/src/service/mod.rs` | 333–346 | `>= Gloas` → `produce_block_v4` (`POST /eth/v4/validator/blocks/{slot}`); else `produce_block_v3` (`GET /eth/v3/validator/blocks/{slot}`). HTTP in `crates/beacon/src/client.rs` 526 / 643; path prefix `crates/beacon/src/v4_wire.rs` 13. | | Deadline set | `crates/timing/src/clock.rs` | 29–35 | `DeadlineSchedule::for_fork`: `>= Gloas` selects the six Gloas `*_DUE_BPS*` offsets. | -| Coordinator attestation offset | `crates/rvc/src/orchestrator/coordinator/mod.rs` | 162–165, 586–588 | `from_epoch` then `for_fork`; one resolved fork per slot. | -| PTC phase | `crates/rvc/src/orchestrator/coordinator/mod.rs` | 1478–1513 | `run_payload_attestation_phase`: open-ended `>= Gloas` waits then calls `crates/rvc/src/orchestrator/payload_attestation.rs`. | +| Coordinator attestation offset | `crates/rvc/src/orchestrator/coordinator/mod.rs` | 162–165, 587–589 | `from_epoch` then `for_fork`; one resolved fork per slot. | +| PTC phase | `crates/rvc/src/orchestrator/coordinator/mod.rs` | 1479–1514 | `run_payload_attestation_phase`: open-ended `>= Gloas` waits then calls `crates/rvc/src/orchestrator/payload_attestation.rs`. | | Legacy proposer ops | `crates/builder/src/service.rs` | 26–28 | `legacy_proposer_ops_retired`: `>= Gloas` retires `prepare_beacon_proposer` / `register_validator`. | | Signer header RPC | `crates/signer/src/lib.rs` | 953 | `>= Gloas` uses `SignBlockHeader` (no Gloas body SSZ into a legacy decoder). | | Signer aggregate RPC | `crates/signer/src/lib.rs` | 1229, 1283 | `>= Gloas` rejects legacy `sign_aggregate_and_proof` / `sign_electra_aggregate_and_proof` (`UnsupportedDuty`); Gloas signs via the root RPC (`sign_aggregate_and_proof_root` 1248). | @@ -264,7 +264,7 @@ variant and new body structs. A body-changing fork adds a `BodyForkLayout` variant and arms in `extract_blob_kzg_commitments` (`crates/eth-types/src/block.rs` 57) and both `*_for_layout` functions (Gloas → `GloasUnsupported`). -- [ ] Arm `ssz_block_format` (`crates/block-service/src/service/mod.rs` 1092–1113) with a +- [ ] Arm `ssz_block_format` (`crates/block-service/src/service/mod.rs` 1118–1139) with a **named** exhaustive `match ForkName` arm. Do not reintroduce a `"deneb" | "electra" | "fulu"` string table or a catch-all `BeaconBlock` inherit (Gloas's named `BeaconBlock` arm is separate from pre-Deneb on purpose). diff --git a/docs/gloas-fork-hazard-audit.md b/docs/gloas-fork-hazard-audit.md index 5351eac1..cd5926c1 100644 --- a/docs/gloas-fork-hazard-audit.md +++ b/docs/gloas-fork-hazard-audit.md @@ -50,22 +50,22 @@ Kind `exhaustive` / `_` applies to classes 3 and 4. Other classes use `—`. | Site | Class | Kind | Verdict | Rationale | Issue | |---|---|---|---|---|---| | `bin/rvc-keygen/src/exit.rs` 107 | 1 | — | inherit-intentionally | Test re-implements the EIP-7044 Capella cap. Open-ended `>= Capella` is correct. Production definition is `signing_root.rs` 301. | — | -| `crates/beacon/src/client.rs` 540 | 1 | — | inherit-intentionally | Proposer-duties v1/v2 routing (4.5). Open-ended `>= Gloas` keeps later forks on v2 rather than silently falling back to deprecated v1. | 4.5 | -| `crates/block-service/src/service/mod.rs` 305 | 1 | — | inherit-intentionally | Produce dispatch: open-ended `>= Gloas` stays on V4 so a later fork does not fall back to V3. | — | -| `crates/block-service/src/service/mod.rs` 395 | 1 | — | inherit-intentionally | Response log omits the pre-Gloas blinded field once `>= Gloas`. Later forks keep that shape. | — | -| `crates/block-service/src/service/mod.rs` 417 | 1 | — | inherit-intentionally | Sign/publish dispatch: open-ended `>= Gloas` stays on `sign_and_publish_v4`. | — | -| `crates/block-service/src/service/mod.rs` 1139 | 1 | — | inherit-intentionally | `reject_blinded_at_gloas`: open-ended `>= Gloas` so later forks keep the no-blinded gate rather than silently re-entering pre-Gloas blinded sign/publish. | 6.4 | -| `crates/block-service/src/service/mod.rs` 1151 | 1 | — | inherit-intentionally | `reject_gloas_version_pre_gloas`: a Gloas-or-later slot is not a pre-Gloas version mismatch. | — | -| `crates/block-service/src/service/mod.rs` 1155 | 1 | — | inherit-intentionally | Pre-Gloas slot rejects a Gloas-or-later consensus version so a later fork is not hashed as Electra/Deneb. | — | +| `crates/beacon/src/client.rs` 547 | 1 | — | inherit-intentionally | Proposer-duties v1/v2 routing (4.5). Open-ended `>= Gloas` keeps later forks on v2 rather than silently falling back to deprecated v1. | 4.5 | +| `crates/block-service/src/service/mod.rs` 331 | 1 | — | inherit-intentionally | Produce dispatch: open-ended `>= Gloas` stays on V4 so a later fork does not fall back to V3. | — | +| `crates/block-service/src/service/mod.rs` 421 | 1 | — | inherit-intentionally | Response log omits the pre-Gloas blinded field once `>= Gloas`. Later forks keep that shape. | — | +| `crates/block-service/src/service/mod.rs` 443 | 1 | — | inherit-intentionally | Sign/publish dispatch: open-ended `>= Gloas` stays on `sign_and_publish_v4`. | — | +| `crates/block-service/src/service/mod.rs` 1165 | 1 | — | inherit-intentionally | `reject_blinded_at_gloas`: open-ended `>= Gloas` so later forks keep the no-blinded gate rather than silently re-entering pre-Gloas blinded sign/publish. | 6.4 | +| `crates/block-service/src/service/mod.rs` 1177 | 1 | — | inherit-intentionally | `reject_gloas_version_pre_gloas`: a Gloas-or-later slot is not a pre-Gloas version mismatch. | — | +| `crates/block-service/src/service/mod.rs` 1181 | 1 | — | inherit-intentionally | Pre-Gloas slot rejects a Gloas-or-later consensus version so a later fork is not hashed as Electra/Deneb. | — | | `crates/builder/src/service.rs` 27 | 1 | — | inherit-intentionally | `legacy_proposer_ops_retired`: open-ended `>= Gloas` so later forks keep prepare/register retired rather than silently restoring the pre-Gloas BN calls. | 6.10 | | `crates/crypto/src/signing_root.rs` 301 | 1 | — | inherit-intentionally | EIP-7044: voluntary-exit domain stays Capella-capped however many post-Capella forks exist. Open-ended `>=` is the spec. | — | | `crates/crypto/src/signing_root.rs` 350 | 1 | — | inherit-intentionally | Test mirror of 301 (`legacy_voluntary_exit_root`). Same Capella-cap inherit. | — | | `crates/crypto/src/typed_signer.rs` 342 | 1 | — | inherit-intentionally | Pre-Electra aggregate signing fails closed at `>= Gloas` so a later fork does not sign `AggregateAndProof`. | — | | `crates/crypto/src/typed_signer.rs` 361 | 1 | — | inherit-intentionally | Electra aggregate signing fails closed at `>= Gloas` so a later fork does not sign `ElectraAggregateAndProof`. | — | | `crates/grpc-signer/src/client.rs` 277 | 1 | — | inherit-intentionally | `uses_gloas_rpc`: open-ended `>= Gloas` so later forks keep the Gloas-safe header/root RPCs rather than silently falling back to decoder-bound legacy RPCs. | 4.20c | -| `crates/rvc/benches/deadline_rebenchmark.rs` 75 | 1 | — | test-only | Bench asserts the slot resolved `>= Gloas`. Not a production guard. | — | +| `crates/rvc/benches/deadline_rebenchmark.rs` 77 | 1 | — | test-only | Bench asserts the slot resolved `>= Gloas`. Not a production guard. | — | | `crates/rvc/src/orchestrator/aggregation.rs` 384 | 1 | — | inherit-intentionally | Aggregate wrap: open-ended `>= Gloas` uses the island root so a later fork does not fall back to tree_hash 0.9. | — | -| `crates/rvc/src/orchestrator/coordinator/mod.rs` 1591 | 1 | — | inherit-intentionally | PTC phase (4.13): open-ended `>= Gloas` so later forks keep the payload-attestation duty rather than silently dropping it. Threaded once-per-slot `ForkName`; not gated on decode failure. | 4.13 | +| `crates/rvc/src/orchestrator/coordinator/mod.rs` 1592 | 1 | — | inherit-intentionally | PTC phase (4.13): open-ended `>= Gloas` so later forks keep the payload-attestation duty rather than silently dropping it. Threaded once-per-slot `ForkName`; not gated on decode failure. | 4.13 | | `crates/rvc/src/orchestrator/duty_management.rs` 112 | 1 | — | inherit-intentionally | PTC duty fetch gate: open-ended `>= Gloas` so later forks keep skipping pre-Gloas `duties/ptc` (BN 500 spam) and keep fetching PTC once Gloas is active. | — | | `crates/rvc/src/orchestrator/duty_management.rs` 339 | 1 | — | inherit-intentionally | PTC reorg-check gate: same open-ended `>= Gloas` as the fetch path so uncached pre-Gloas epochs do not POST `duties/ptc`. | — | | `crates/rvc/src/orchestrator/utils.rs` 127 | 1 | — | inherit-intentionally | `uses_electra_attestation_wire`: open-ended `>= Electra` so Gloas (and later forks) keep the Electra+ wire. Index zeroing is the separate `zeroes_committee_index` (`Electra..Gloas`). | 2.8 | @@ -84,9 +84,9 @@ Kind `exhaustive` / `_` applies to classes 3 and 4. Other classes use `—`. | `crates/rvc/tests/gloas_data_index_round_trip.rs` 363 | 2 | — | test-only | Gloas round-trip builds a zeroed index to prove the signing path did not zero it. | — | | `crates/rvc/tests/gloas_data_index_round_trip.rs` 390 | 2 | — | test-only | Electra/Fulu round-trip expects the submitted index to be zeroed. | — | | `bin/rvc/tests/common/mock_bn.rs` 296 | 3 | exhaustive | test-only | `match fork` → version hex. Compile error on a new variant. 2.5b/2.6 add Gloas `0x07000000`. | 2.5b | -| `crates/beacon/src/client.rs` 1239 | 3 | exhaustive | inherit-intentionally | Aggregate fetch v2 request table. Exhaustive `match fork` so a new variant is a compile error, not a silent v1 or v2 inherit. | RR-3.2 | -| `crates/beacon/src/client.rs` 2097 | 3 | exhaustive | inherit-intentionally | v2 200 is accepted only when the response header, body `version`, and the requested fork agree. Pre-Electra is not decoded on this path. | RR-3.2 | -| `crates/block-service/src/service/mod.rs` 1117 | 3 | exhaustive | inherit-intentionally | `ssz_block_format` named Gloas `BeaconBlock` arm; exhaustive `match fork` so a new variant is a compile error, not a silent `BlockContents`/`BeaconBlock` inherit. Unknown version strings fail closed before the match. | 6.4 | +| `crates/beacon/src/client.rs` 1246 | 3 | exhaustive | inherit-intentionally | Aggregate fetch v2 request table. Exhaustive `match fork` so a new variant is a compile error, not a silent v1 or v2 inherit. | RR-3.2 | +| `crates/beacon/src/client.rs` 2123 | 3 | exhaustive | inherit-intentionally | v2 200 is accepted only when the response header, body `version`, and the requested fork agree. Pre-Electra is not decoded on this path. | RR-3.2 | +| `crates/block-service/src/service/mod.rs` 1143 | 3 | exhaustive | inherit-intentionally | `ssz_block_format` named Gloas `BeaconBlock` arm; exhaustive `match fork` so a new variant is a compile error, not a silent `BlockContents`/`BeaconBlock` inherit. Unknown version strings fail closed before the match. | 6.4 | | `crates/eth-types/src/block.rs` 42 | 3 | exhaustive | inherit-intentionally | `expected_kzg_proof_count` is keyed on `ForkName`, not `BodyForkLayout` (Fulu must not inherit Electra's one-proof rule). Deneb/Electra are one proof per blob, Fulu is `CELLS_PER_EXT_BLOB` cell proofs, Gloas and pre-Deneb error. A new variant is a compile error. | RR-3.9 | | `crates/eth-types/src/fork.rs` 190 | 3 | exhaustive | inherit-intentionally | `ForkName::id` exhaustive `match self` with no `_ =>`. Deliberate fork-addition tripwire (2.1). 2.5b adds the Gloas arm. | 2.5b | | `crates/eth-types/src/fork.rs` 229 | 3 | exhaustive | inherit-intentionally | `body_layout()` exhaustive match. 2.7 adds `Gloas => Some(BodyForkLayout::Gloas)`. | 2.7 | @@ -99,7 +99,7 @@ Kind `exhaustive` / `_` applies to classes 3 and 4. Other classes use `—`. | `crates/block-service/src/service/tests/v4.rs` 138 | 4 | exhaustive | test-only | Fulu slot with a gloas JSON version must fail closed. | — | | `crates/block-service/src/service/tests/v4.rs` 170 | 4 | exhaustive | test-only | Fulu slot with a gloas SSZ version must fail closed. | — | | `crates/block-service/src/service/tests/v4.rs` 203 | 4 | exhaustive | test-only | Gloas slot with a fulu version must fail closed. | — | -| `crates/bn-manager/src/manager.rs` 2306 | 4 | _ | must-bound | V3 SSZ duty check picks `BlockContents` or `BeaconBlock` from the consensus version string. `_ => None` rejects an unknown version instead of inheriting either format. | — | +| `crates/bn-manager/src/manager.rs` 2359 | 4 | _ | must-bound | V3 SSZ duty check picks `BlockContents` or `BeaconBlock` from the consensus version string. `_ => None` rejects an unknown version instead of inheriting either format. | — | | `crates/rvc/src/orchestrator/coordinator/tests/fork_transition.rs` 2437 | 4 | exhaustive | test-only | Envelope publish assert names consensus version `gloas`. Not a production dispatch. | — | | `crates/crypto/src/typed_signer.rs` 61 | 5 | — | must-bound | `SignContext::resolve` first-matches `fork_info.current_version` over `entries()`. Two `[0xFF;4]` rows (unscheduled Fulu+Gloas after 2.6) resolve Fulu, so `Gloas.fork_version()` does not round-trip. 2.6 pins the collision; 2.10 confines it to both-unscheduled. | 2.6 | | `crates/eth-types/src/fork.rs` 118 | 5 | — | inherit-intentionally | `next_activation` scans `entries()` and skips `u64::MAX`. A new row participates by construction. | — | diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 6c346c37..f54eb22e 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -3,6 +3,47 @@ Operator-visible behavior changes land here during the development cycle and are folded into `docs/releases/vX.Y.Z.md` at release time. +## Slot duration comes from the beacon node + +Doppelganger epoch, liveness sleep, the index-resolver tick, the import hold, +the keymanager and CLI default exit epoch, the slashing-monitor interval, and +the Gloas payload deadline use the slot duration from `GET /eth/v1/config/spec`. +Responding beacon nodes must report the same `SLOT_DURATION_MS` or +`SECONDS_PER_SLOT`, the same `SLOTS_PER_EPOCH` as the network preset, and the +same genesis time as the configured genesis. A missing, zero, conflicting, +out-of-range (outside 1 s..=60 s), or disagreeing value fails startup. A +named network must report that network's preset duration and preset genesis +time. An explicit `genesis_time` that differs from the preset is rejected. +On a named network, a beacon node that does not respond is skipped, and at +least one must respond. Custom still requires every configured beacon node. +A beacon node whose genesis validators root is not the configured root is +rejected. It is not admitted, it is not the chain identity, and it is not a +source of liveness or head and reorg events. A skipped node stays out of +duties and liveness until its slot duration, slots per epoch, genesis time, +and genesis validators root match the boot agreement. Recovering `/eth/v1/node/syncing` does not admit it. A spec that +arrives before a genesis timeout is still compared. HTTP 502, 503, 504, and +429 count as not responding. HTTP 500 does not. +The client does not assume 12 seconds. Slot boundaries use that duration in +milliseconds, including lengths that are not a whole number of seconds. + +The keymanager voluntary exit uses the duration and genesis time checked at +startup. It does not read the spec or genesis again from whichever pool node +answers first. The CLI exit command has a single beacon URL, so it cannot +cross-check a pool; it still rejects a missing, zero, conflicting, +out-of-range, or non-preset duration, a `SLOTS_PER_EPOCH` that is not the +preset, and a genesis time that is not the named-network preset. + +Epoch 0 marks normally registered keys Safe only on a named network, after +the responding beacon nodes match that preset genesis. Custom does not open +the bypass: the same nodes choose the slot duration, and a long duration can +hold the clock at epoch 0. A duration outside the bound cannot produce epoch 0. + +`BlockService::new` still stores the 12 s constant until the coordinator +passes the beacon-node duration. `KeystoreManagerAdapter::new` builds its +private admission clock at 12 s; production replaces that clock with +`with_admission_service`. Those two constructors are the remaining 12 s +assumptions. + ## Keymanager import passwords survive a restart `POST /eth/v1/keystores` writes each import password under diff --git a/plan/tracing-2026-08-06/spawn-census.md b/plan/tracing-2026-08-06/spawn-census.md index 783a39f3..57233d92 100644 --- a/plan/tracing-2026-08-06/spawn-census.md +++ b/plan/tracing-2026-08-06/spawn-census.md @@ -26,8 +26,8 @@ That command prints 14 lines, one per row below. | File | Line | Task | Call | Bucket | Reason | |---|---:|---|---|---|---| | `bin/rvc/src/logging.rs` | 312 | `log_reload` | spawn | detached | SIGHUP log-reload handler; process-lifetime signal loop, no caller trace to inherit. | -| `crates/rvc/src/bootstrap/run.rs` | 359 | `duty_orchestrator` | spawn | detached | Already a per-slot root inside the task (`DutyOrchestrator::run`, span `slot.process`); not shape B. `executor.spawn_result`. | -| `crates/rvc/src/bootstrap/run.rs` | 551 | `secret_provider_refresh` | spawn | detached | `secret_provider::RefreshService::run`; loop body is `crates/secret-provider/src/refresh.rs:185`, out of scope. | +| `crates/rvc/src/bootstrap/run.rs` | 364 | `duty_orchestrator` | spawn | detached | Already a per-slot root inside the task (`DutyOrchestrator::run`, span `slot.process`); not shape B. `executor.spawn_result`. | +| `crates/rvc/src/bootstrap/run.rs` | 556 | `secret_provider_refresh` | spawn | detached | `secret_provider::RefreshService::run`; loop body is `crates/secret-provider/src/refresh.rs:185`, out of scope. | | `crates/rvc/src/bootstrap/tasks.rs` | 118 | `metrics_server` | spawn | detached | Metrics server; serve loop is `crates/metrics/src/server.rs:96` (`serve_metrics_with_health`), out of P1-1 scope. | | `crates/rvc/src/bootstrap/tasks.rs` | 140 | `monitoring_push` | spawn | B | Iteration loop. Loop site `crates/rvc/src/background_tasks/monitoring.rs:161`. TRC-6b owns the per-iteration root. | | `crates/rvc/src/bootstrap/tasks.rs` | 164 | `proposer_config_refresh` | spawn | B | Iteration loop. Loop site `crates/rvc/src/background_tasks/config_url.rs:358`. TRC-6b owns the per-iteration root. | @@ -36,9 +36,9 @@ That command prints 14 lines, one per row below. | `crates/rvc/src/bootstrap/tasks.rs` | 249 | `bn.sync_monitor` | register | detached | BN sync monitor; process-lifetime Infra handle, loop lives in bn-manager, out of P1-1 scope. | | `crates/rvc/src/bootstrap/tasks.rs` | 253 | `bn.sync_monitor.cancel` | spawn | detached | Cancel-forwarder for `bn.sync_monitor`; not an iteration loop. | | `crates/rvc/src/index_resolver.rs` | 86 | `index.resolve` | spawn | B | Iteration loop. Loop site `crates/rvc/src/index_resolver.rs:114` (`IndexResolver::run`). TRC-6b owns the per-iteration root. | -| `crates/rvc/src/keymanager_adapters/spawn.rs` | 336 | `keymanager_api` | spawn | detached | Keymanager API server; serve loop is `KeymanagerServer::serve_on` in `crates/keymanager-api/src/server.rs:203`, out of scope. | -| `crates/rvc/src/liveness_loop.rs` | 461 | `liveness_loop` | spawn | B | Iteration loop. Loop site `crates/rvc/src/liveness_loop.rs:213` (`LivenessObservationLoop::run` loop body). TRC-6b owns the per-iteration root. | -| `crates/rvc/src/slashing_monitor.rs` | 136 | `slashing_monitor` | spawn | B | Iteration loop. Loop site `crates/rvc/src/slashing_monitor.rs:139`. TRC-6b owns the per-iteration root. | +| `crates/rvc/src/keymanager_adapters/spawn.rs` | 342 | `keymanager_api` | spawn | detached | Keymanager API server; serve loop is `KeymanagerServer::serve_on` in `crates/keymanager-api/src/server.rs:203`, out of scope. | +| `crates/rvc/src/liveness_loop.rs` | 476 | `liveness_loop` | spawn | B | Iteration loop. Loop site `crates/rvc/src/liveness_loop.rs:229` (`LivenessObservationLoop::run` loop body). TRC-6b owns the per-iteration root. | +| `crates/rvc/src/slashing_monitor.rs` | 145 | `slashing_monitor` | spawn | B | Iteration loop. Loop site `crates/rvc/src/slashing_monitor.rs:148`. TRC-6b owns the per-iteration root. | Shape A = 0 at this HEAD. None of the 14 inherit a caller trace as a shape A root. @@ -48,8 +48,8 @@ Shape A = 0 at this HEAD. None of the 14 inherit a caller trace as a shape A roo |---|---|---| | `monitoring_push` | `crates/rvc/src/bootstrap/tasks.rs:140` | `crates/rvc/src/background_tasks/monitoring.rs:161` | | `proposer_config_refresh` | `crates/rvc/src/bootstrap/tasks.rs:164` | `crates/rvc/src/background_tasks/config_url.rs:358` | -| `liveness_loop` | `crates/rvc/src/liveness_loop.rs:461` | `crates/rvc/src/liveness_loop.rs:213` | -| `slashing_monitor` | `crates/rvc/src/slashing_monitor.rs:136` | `crates/rvc/src/slashing_monitor.rs:139` | +| `liveness_loop` | `crates/rvc/src/liveness_loop.rs:476` | `crates/rvc/src/liveness_loop.rs:229` | +| `slashing_monitor` | `crates/rvc/src/slashing_monitor.rs:145` | `crates/rvc/src/slashing_monitor.rs:148` | | `index.resolve` | `crates/rvc/src/index_resolver.rs:86` | `crates/rvc/src/index_resolver.rs:114` | ## Not sites — `register_opt(..., None)` @@ -69,15 +69,15 @@ done |---|---:|---|---| | `crates/rvc/src/bootstrap/tasks.rs` | 200 | `register_opt::<()>(bn.sse, …, None)` | not-a-site | | `crates/rvc/src/bootstrap/tasks.rs` | 240 | `register_opt::<()>(bn.sync_monitor, …, None)` | not-a-site | -| `crates/rvc/src/slashing_monitor.rs` | 130 | `register_opt::<()>("slashing_monitor", …, None)` | not-a-site | +| `crates/rvc/src/slashing_monitor.rs` | 139 | `register_opt::<()>("slashing_monitor", …, None)` | not-a-site | The slashing-monitor `None` call is the old no-op detached site. It left the census. ## `liveness_loop.rs` `#[cfg(test)]` trap -`crates/rvc/src/liveness_loop.rs` has a method-level `#[cfg(test)]` at `:303` and an inner `#[cfg(test)]` at `:404`. The column-0 module attribute is at `:467`. The production spawn is `:461`, above that module attribute. +`crates/rvc/src/liveness_loop.rs` has a method-level `#[cfg(test)]` at `:319` and an inner `#[cfg(test)]` at `:419`. The column-0 module attribute is at `:482`. The production spawn is `:476`, above that module attribute. -Truncating the file at the first `#[cfg(test)]` (the method-level attribute at `:303`) hides `:461` and drops a real production site. The command above keeps `:461` because it only treats a column-0 `#[cfg(test)]` as the end of the production region. +Truncating the file at the first `#[cfg(test)]` (the method-level attribute at `:319`) hides `:476` and drops a real production site. The command above keeps `:476` because it only treats a column-0 `#[cfg(test)]` as the end of the production region. `crates/architecture-tests/tests/spawn_span_continuity.rs` pins this census at 14 under tracing ADR-009. The gate ends the production region at the first column-0 `#[cfg(test)]` followed by `mod` (not the first bare `#[cfg(test)]`), skips `///` and `//!` lines, and does not count `register_opt(..., None)`. A site passes only when the preceding non-empty line is a `// detached:` comment, or it is one of the five shape-B rows and the loop file in the table above contains `parent: None` and `follows_from` for that task. The allow-list is empty.