diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a8919337a9..cdec442276 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,3 @@ /beacon_node/network/ @jxs /beacon_node/lighthouse_network/ @jxs +/beacon_node/store/ @michaelsproul diff --git a/Cargo.lock b/Cargo.lock index 7d77ce4044..5e22c9742a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9933,6 +9933,7 @@ name = "validator_manager" version = "0.1.0" dependencies = [ "account_utils", + "beacon_chain", "clap", "clap_utils", "derivative", @@ -9942,9 +9943,11 @@ dependencies = [ "eth2_wallet", "ethereum_serde_utils", "hex", + "http_api", "regex", "serde", "serde_json", + "slot_clock", "tempfile", "tokio", "tree_hash", diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index de377dab97..9900535b2c 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -654,6 +654,10 @@ impl BeaconChain { /// Persists the custody information to disk. pub fn persist_custody_context(&self) -> Result<(), Error> { + if !self.spec.is_peer_das_scheduled() { + return Ok(()); + } + let custody_context: CustodyContextSsz = self .data_availability_checker .custody_context() @@ -987,6 +991,14 @@ impl BeaconChain { return Ok(root_opt); } + // Do not try to access the previous slot if it's older than the oldest block root + // stored in the database. Instead, load just the block root at `oldest_block_slot`, + // under the assumption that the `oldest_block_slot` *is not* a skipped slot (should be + // true because it is set by the oldest *block*). + if request_slot == self.store.get_anchor_info().oldest_block_slot { + return self.block_root_at_slot_skips_prev(request_slot); + } + if let Some(((prev_root, _), (curr_root, curr_slot))) = process_results( self.forwards_iter_block_roots_until(prev_slot, request_slot)?, |iter| iter.tuple_windows().next(), diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index ce4264d550..c46cc015c9 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -514,9 +514,26 @@ where "Storing split from weak subjectivity state" ); - // Set the store's split point *before* storing genesis so that genesis is stored - // immediately in the freezer DB. + // Set the store's split point *before* storing genesis so that if the genesis state + // is prior to the split slot, it will immediately be stored in the freezer DB. store.set_split(weak_subj_slot, weak_subj_state_root, weak_subj_block_root); + + // It is also possible for the checkpoint state to be equal to the genesis state, in which + // case it will be stored in the hot DB. In this case, we need to ensure the store's anchor + // is initialised prior to storing the state, as the anchor is required for working out + // hdiff storage strategies. + let retain_historic_states = self.chain_config.reconstruct_historic_states; + self.pending_io_batch.push( + store + .init_anchor_info( + weak_subj_block.parent_root(), + weak_subj_block.slot(), + weak_subj_slot, + retain_historic_states, + ) + .map_err(|e| format!("Failed to initialize anchor info: {:?}", e))?, + ); + let (_, updated_builder) = self.set_genesis_state(genesis_state)?; self = updated_builder; @@ -541,20 +558,6 @@ where "Stored frozen block roots at skipped slots" ); - // Write the anchor to memory before calling `put_state` otherwise hot hdiff can't store - // states that do not align with the `start_slot` grid. - let retain_historic_states = self.chain_config.reconstruct_historic_states; - self.pending_io_batch.push( - store - .init_anchor_info( - weak_subj_block.parent_root(), - weak_subj_block.slot(), - weak_subj_slot, - retain_historic_states, - ) - .map_err(|e| format!("Failed to initialize anchor info: {:?}", e))?, - ); - // Write the state, block and blobs non-atomically, it doesn't matter if they're forgotten // about on a crash restart. store diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index 1bc95c22ac..5404718048 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -486,14 +486,9 @@ impl DataAvailabilityChecker { /// The epoch at which we require a data availability check in block processing. /// `None` if the `Deneb` fork is disabled. pub fn data_availability_boundary(&self) -> Option { - let fork_epoch = self.spec.deneb_fork_epoch?; - let current_slot = self.slot_clock.now()?; - Some(std::cmp::max( - fork_epoch, - current_slot - .epoch(T::EthSpec::slots_per_epoch()) - .saturating_sub(self.spec.min_epochs_for_blob_sidecars_requests), - )) + let current_epoch = self.slot_clock.now()?.epoch(T::EthSpec::slots_per_epoch()); + self.spec + .min_epoch_data_availability_boundary(current_epoch) } /// Returns true if the given epoch lies within the da boundary and false otherwise. @@ -670,15 +665,17 @@ async fn availability_cache_maintenance_service( .fork_choice_read_lock() .finalized_checkpoint() .epoch; + + let Some(min_epochs_for_blobs) = chain + .spec + .min_epoch_data_availability_boundary(current_epoch) + else { + // Shutdown service if deneb fork epoch not set. Unreachable as the same check is performed above. + break; + }; + // any data belonging to an epoch before this should be pruned - let cutoff_epoch = std::cmp::max( - finalized_epoch + 1, - std::cmp::max( - current_epoch - .saturating_sub(chain.spec.min_epochs_for_blob_sidecars_requests), - deneb_fork_epoch, - ), - ); + let cutoff_epoch = std::cmp::max(finalized_epoch + 1, min_epochs_for_blobs); if let Err(e) = overflow_cache.do_maintenance(cutoff_epoch) { error!(error = ?e,"Failed to maintain availability cache"); diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index deaea3eb24..3c1fd1e7bc 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -481,7 +481,8 @@ impl DataAvailabilityCheckerInner { if let Some(available_block) = pending_components.make_available( &self.spec, - self.custody_context.sampling_size(Some(epoch), &self.spec), + self.custody_context + .num_of_data_columns_to_sample(Some(epoch), &self.spec), |block| self.state_cache.recover_pending_executed_block(block), )? { // We keep the pending components in the availability cache during block import (#5845). @@ -526,7 +527,9 @@ impl DataAvailabilityCheckerInner { // Merge in the data columns. pending_components.merge_data_columns(kzg_verified_data_columns)?; - let num_expected_columns = self.custody_context.sampling_size(Some(epoch), &self.spec); + let num_expected_columns = self + .custody_context + .num_of_data_columns_to_sample(Some(epoch), &self.spec); debug!( component = "data_columns", ?block_root, @@ -622,7 +625,9 @@ impl DataAvailabilityCheckerInner { // Merge in the block. pending_components.merge_block(diet_executed_block); - let num_expected_columns = self.custody_context.sampling_size(Some(epoch), &self.spec); + let num_expected_columns = self + .custody_context + .num_of_data_columns_to_sample(Some(epoch), &self.spec); debug!( component = "block", ?block_root, @@ -631,11 +636,11 @@ impl DataAvailabilityCheckerInner { ); // Check if we have all components and entire set is consistent. - if let Some(available_block) = pending_components.make_available( - &self.spec, - self.custody_context.sampling_size(Some(epoch), &self.spec), - |block| self.state_cache.recover_pending_executed_block(block), - )? { + if let Some(available_block) = + pending_components.make_available(&self.spec, num_expected_columns, |block| { + self.state_cache.recover_pending_executed_block(block) + })? + { // We keep the pending components in the availability cache during block import (#5845). write_lock.put(block_root, pending_components); drop(write_lock); diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 4a7a430532..df253bf72c 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -45,7 +45,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; -mod persisted_beacon_chain; +pub mod persisted_beacon_chain; pub mod persisted_custody; mod persisted_fork_choice; mod pre_finalization_cache; diff --git a/beacon_node/beacon_chain/src/persisted_custody.rs b/beacon_node/beacon_chain/src/persisted_custody.rs index 6ede473b36..b685ea36b7 100644 --- a/beacon_node/beacon_chain/src/persisted_custody.rs +++ b/beacon_node/beacon_chain/src/persisted_custody.rs @@ -7,7 +7,7 @@ use types::{EthSpec, Hash256}; /// 32-byte key for accessing the `CustodyContext`. All zero because `CustodyContext` has its own column. pub const CUSTODY_DB_KEY: Hash256 = Hash256::ZERO; -pub struct PersistedCustody(CustodyContextSsz); +pub struct PersistedCustody(pub CustodyContextSsz); pub fn load_custody_context, Cold: ItemStore>( store: Arc>, diff --git a/beacon_node/beacon_chain/src/schema_change.rs b/beacon_node/beacon_chain/src/schema_change.rs index 0abb48494a..317b89cbdd 100644 --- a/beacon_node/beacon_chain/src/schema_change.rs +++ b/beacon_node/beacon_chain/src/schema_change.rs @@ -2,6 +2,7 @@ mod migration_schema_v23; mod migration_schema_v24; mod migration_schema_v25; +mod migration_schema_v26; use crate::beacon_chain::BeaconChainTypes; use std::sync::Arc; @@ -58,6 +59,14 @@ pub fn migrate_schema( let ops = migration_schema_v25::downgrade_from_v25()?; db.store_schema_version_atomically(to, ops) } + (SchemaVersion(25), SchemaVersion(26)) => { + let ops = migration_schema_v26::upgrade_to_v26::(db.clone())?; + db.store_schema_version_atomically(to, ops) + } + (SchemaVersion(26), SchemaVersion(25)) => { + let ops = migration_schema_v26::downgrade_from_v26::(db.clone())?; + db.store_schema_version_atomically(to, ops) + } // Anything else is an error. (_, _) => Err(HotColdDBError::UnsupportedSchemaVersion { target_version: to, diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs new file mode 100644 index 0000000000..2e2a6bdc4f --- /dev/null +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs @@ -0,0 +1,91 @@ +use crate::persisted_custody::{PersistedCustody, CUSTODY_DB_KEY}; +use crate::validator_custody::CustodyContextSsz; +use crate::BeaconChainTypes; +use ssz::{Decode, Encode}; +use ssz_derive::{Decode, Encode}; +use std::sync::Arc; +use store::{DBColumn, Error, HotColdDB, KeyValueStoreOp, StoreItem}; +use tracing::info; + +#[derive(Debug, Encode, Decode, Clone)] +pub(crate) struct CustodyContextSszV24 { + pub(crate) validator_custody_at_head: u64, + pub(crate) persisted_is_supernode: bool, +} + +pub(crate) struct PersistedCustodyV24(CustodyContextSszV24); + +impl StoreItem for PersistedCustodyV24 { + fn db_column() -> DBColumn { + DBColumn::CustodyContext + } + + fn as_store_bytes(&self) -> Vec { + self.0.as_ssz_bytes() + } + + fn from_store_bytes(bytes: &[u8]) -> Result { + let custody_context = CustodyContextSszV24::from_ssz_bytes(bytes)?; + Ok(PersistedCustodyV24(custody_context)) + } +} + +/// Upgrade the `CustodyContext` entry to v26. +pub fn upgrade_to_v26( + db: Arc>, +) -> Result, Error> { + let ops = if db.spec.is_peer_das_scheduled() { + match db.get_item::(&CUSTODY_DB_KEY) { + Ok(Some(PersistedCustodyV24(ssz_v24))) => { + info!("Migrating `CustodyContext` to v26 schema"); + let custody_context_v2 = CustodyContextSsz { + validator_custody_at_head: ssz_v24.validator_custody_at_head, + persisted_is_supernode: ssz_v24.persisted_is_supernode, + epoch_validator_custody_requirements: vec![], + }; + vec![KeyValueStoreOp::PutKeyValue( + DBColumn::CustodyContext, + CUSTODY_DB_KEY.as_slice().to_vec(), + PersistedCustody(custody_context_v2).as_store_bytes(), + )] + } + _ => { + vec![] + } + } + } else { + // Delete it from db if PeerDAS hasn't been scheduled + vec![KeyValueStoreOp::DeleteKey( + DBColumn::CustodyContext, + CUSTODY_DB_KEY.as_slice().to_vec(), + )] + }; + + Ok(ops) +} + +pub fn downgrade_from_v26( + db: Arc>, +) -> Result, Error> { + let res = db.get_item::(&CUSTODY_DB_KEY); + let ops = match res { + Ok(Some(PersistedCustody(ssz_v26))) => { + info!("Migrating `CustodyContext` back from v26 schema"); + let custody_context_v24 = CustodyContextSszV24 { + validator_custody_at_head: ssz_v26.validator_custody_at_head, + persisted_is_supernode: ssz_v26.persisted_is_supernode, + }; + vec![KeyValueStoreOp::PutKeyValue( + DBColumn::CustodyContext, + CUSTODY_DB_KEY.as_slice().to_vec(), + PersistedCustodyV24(custody_context_v24).as_store_bytes(), + )] + } + _ => { + // no op if it's not on the db, as previous versions gracefully handle data missing from disk. + vec![] + } + }; + + Ok(ops) +} diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index db4e2fab26..2c4981078d 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -777,7 +777,7 @@ where self.chain .data_availability_checker .custody_context() - .sampling_size(None, &self.chain.spec) as usize + .num_of_data_columns_to_sample(None, &self.chain.spec) as usize } pub fn slots_per_epoch(&self) -> u64 { diff --git a/beacon_node/beacon_chain/src/validator_custody.rs b/beacon_node/beacon_chain/src/validator_custody.rs index 1169b64537..7dc5b18ae4 100644 --- a/beacon_node/beacon_chain/src/validator_custody.rs +++ b/beacon_node/beacon_chain/src/validator_custody.rs @@ -163,7 +163,13 @@ impl CustodyContext { validator_custody_count: AtomicU64::new(ssz_context.validator_custody_at_head), current_is_supernode: is_supernode, persisted_is_supernode: ssz_context.persisted_is_supernode, - validator_registrations: Default::default(), + validator_registrations: RwLock::new(ValidatorRegistrations { + validators: Default::default(), + epoch_validator_custody_requirements: ssz_context + .epoch_validator_custody_requirements + .into_iter() + .collect(), + }), } } @@ -209,7 +215,8 @@ impl CustodyContext { ); return Some(CustodyCountChanged { new_custody_group_count: updated_cgc, - sampling_count: self.sampling_size(Some(effective_epoch), spec), + sampling_count: self + .num_of_custody_groups_to_sample(Some(effective_epoch), spec), }); } } @@ -234,9 +241,13 @@ impl CustodyContext { } } - /// Returns the count of custody columns this node must sample for a block at `epoch` to import. - /// If an `epoch` is not specified, returns the *current* validator custody requirement. - pub fn sampling_size(&self, epoch_opt: Option, spec: &ChainSpec) -> u64 { + /// This function is used to determine the custody group count at a given epoch. + /// + /// This differs from the number of custody groups sampled per slot, as the spec requires a + /// minimum sampling size which may exceed the custody group count (CGC). + /// + /// See also: [`Self::num_of_custody_groups_to_sample`]. + fn custody_group_count_at_epoch(&self, epoch_opt: Option, spec: &ChainSpec) -> u64 { let custody_group_count = if self.current_is_supernode { spec.number_of_custody_groups } else if let Some(epoch) = epoch_opt { @@ -247,8 +258,26 @@ impl CustodyContext { } else { self.custody_group_count_at_head(spec) }; + custody_group_count + } - spec.sampling_size(custody_group_count) + /// Returns the count of custody groups this node must _sample_ for a block at `epoch` to import. + /// If an `epoch` is not specified, returns the *current* validator custody requirement. + pub fn num_of_custody_groups_to_sample( + &self, + epoch_opt: Option, + spec: &ChainSpec, + ) -> u64 { + let custody_group_count = self.custody_group_count_at_epoch(epoch_opt, spec); + spec.sampling_size_custody_groups(custody_group_count) + .expect("should compute node sampling size from valid chain spec") + } + + /// Returns the count of columns this node must _sample_ for a block at `epoch` to import. + /// If an `epoch` is not specified, returns the *current* validator custody requirement. + pub fn num_of_data_columns_to_sample(&self, epoch_opt: Option, spec: &ChainSpec) -> u64 { + let custody_group_count = self.custody_group_count_at_epoch(epoch_opt, spec); + spec.sampling_size_columns(custody_group_count) .expect("should compute node sampling size from valid chain spec") } } @@ -263,8 +292,9 @@ pub struct CustodyCountChanged { /// The custody information that gets persisted across runs. #[derive(Debug, Encode, Decode, Clone)] pub struct CustodyContextSsz { - validator_custody_at_head: u64, - persisted_is_supernode: bool, + pub validator_custody_at_head: u64, + pub persisted_is_supernode: bool, + pub epoch_validator_custody_requirements: Vec<(Epoch, u64)>, } impl From<&CustodyContext> for CustodyContextSsz { @@ -272,6 +302,13 @@ impl From<&CustodyContext> for CustodyContextSsz { CustodyContextSsz { validator_custody_at_head: context.validator_custody_count.load(Ordering::Relaxed), persisted_is_supernode: context.persisted_is_supernode, + epoch_validator_custody_requirements: context + .validator_registrations + .read() + .epoch_validator_custody_requirements + .iter() + .map(|(epoch, count)| (*epoch, *count)) + .collect(), } } } @@ -293,7 +330,7 @@ mod tests { spec.number_of_custody_groups ); assert_eq!( - custody_context.sampling_size(None, &spec), + custody_context.num_of_custody_groups_to_sample(None, &spec), spec.number_of_custody_groups ); } @@ -308,7 +345,7 @@ mod tests { "head custody count should be minimum spec custody requirement" ); assert_eq!( - custody_context.sampling_size(None, &spec), + custody_context.num_of_custody_groups_to_sample(None, &spec), spec.samples_per_slot ); } @@ -398,7 +435,7 @@ mod tests { register_validators_and_assert_cgc(&custody_context, validators_and_expected_cgc, &spec); assert_eq!( - custody_context.sampling_size(None, &spec), + custody_context.num_of_custody_groups_to_sample(None, &spec), spec.number_of_custody_groups ); } @@ -409,7 +446,7 @@ mod tests { let spec = E::default_spec(); let current_slot = Slot::new(10); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let default_sampling_size = custody_context.sampling_size(None, &spec); + let default_sampling_size = custody_context.num_of_custody_groups_to_sample(None, &spec); let validator_custody_units = 10; let _cgc_changed = custody_context.register_validators::( @@ -423,12 +460,12 @@ mod tests { // CGC update is not applied for `current_epoch`. assert_eq!( - custody_context.sampling_size(Some(current_epoch), &spec), + custody_context.num_of_custody_groups_to_sample(Some(current_epoch), &spec), default_sampling_size ); // CGC update is applied for the next epoch. assert_eq!( - custody_context.sampling_size(Some(current_epoch + 1), &spec), + custody_context.num_of_custody_groups_to_sample(Some(current_epoch + 1), &spec), validator_custody_units ); } diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index 942ce81684..f0978c5f05 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -7,6 +7,7 @@ mod events; mod op_verification; mod payload_invalidation; mod rewards; +mod schema_stability; mod store_tests; mod sync_committee_verification; mod tests; diff --git a/beacon_node/beacon_chain/tests/schema_stability.rs b/beacon_node/beacon_chain/tests/schema_stability.rs new file mode 100644 index 0000000000..fc37a1159b --- /dev/null +++ b/beacon_node/beacon_chain/tests/schema_stability.rs @@ -0,0 +1,152 @@ +use beacon_chain::{ + persisted_beacon_chain::PersistedBeaconChain, + persisted_custody::PersistedCustody, + test_utils::{test_spec, BeaconChainHarness, DiskHarnessType}, + ChainConfig, +}; +use logging::create_test_tracing_subscriber; +use operation_pool::PersistedOperationPool; +use ssz::Encode; +use std::sync::{Arc, LazyLock}; +use store::{ + database::interface::BeaconNodeBackend, hot_cold_store::Split, metadata::DataColumnInfo, + DBColumn, HotColdDB, StoreConfig, StoreItem, +}; +use strum::IntoEnumIterator; +use tempfile::{tempdir, TempDir}; +use types::{ChainSpec, Hash256, Keypair, MainnetEthSpec}; + +type E = MainnetEthSpec; +type Store = Arc, BeaconNodeBackend>>; +type TestHarness = BeaconChainHarness>; + +const VALIDATOR_COUNT: usize = 32; + +/// A cached set of keys. +static KEYPAIRS: LazyLock> = + LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(VALIDATOR_COUNT)); + +fn get_store(db_path: &TempDir, config: StoreConfig, spec: Arc) -> Store { + create_test_tracing_subscriber(); + let hot_path = db_path.path().join("chain_db"); + let cold_path = db_path.path().join("freezer_db"); + let blobs_path = db_path.path().join("blobs_db"); + + HotColdDB::open( + &hot_path, + &cold_path, + &blobs_path, + |_, _, _| Ok(()), + config, + spec, + ) + .expect("disk store should initialize") +} + +/// This test checks the database schema stability against previous versions of Lighthouse's code. +/// +/// If you are changing something about how Lighthouse stores data on disk, you almost certainly +/// need to implement a database schema change. This is true even if the data being stored only +/// applies to an upcoming fork that isn't live on mainnet. We never want to be in the situation +/// where commit A writes data in some format, and then a later commit B changes that format +/// without a schema change. This is liable to break any nodes that update from A to B, even if +/// these nodes are just testnet nodes. +/// +/// This test implements partial, imperfect checks on the DB schema which are designed to quickly +/// catch common changes. +/// +/// This test uses hardcoded values, rather than trying to access previous versions of Lighthouse's +/// code. If you've successfully implemented a schema change and you're sure that the new values are +/// correct, you can update the hardcoded values here. +#[tokio::test] +async fn schema_stability() { + let spec = Arc::new(test_spec::()); + + let datadir = tempdir().unwrap(); + let store_config = StoreConfig::default(); + let store = get_store(&datadir, store_config, spec.clone()); + + let chain_config = ChainConfig { + reconstruct_historic_states: true, + ..ChainConfig::default() + }; + + let harness = TestHarness::builder(MainnetEthSpec) + .spec(spec) + .keypairs(KEYPAIRS.to_vec()) + .fresh_disk_store(store.clone()) + .mock_execution_layer() + .chain_config(chain_config) + .build(); + harness.advance_slot(); + + let chain = &harness.chain; + + chain.persist_op_pool().unwrap(); + chain.persist_custody_context().unwrap(); + + check_db_columns(); + check_metadata_sizes(&store); + check_op_pool(&store); + check_custody_context(&store, &harness.spec); + check_persisted_chain(&store); + + // Not covered here: + // - Fork choice (not tested) + // - DBColumn::DhtEnrs (tested in network crate) +} + +/// Check that the set of database columns is unchanged. +fn check_db_columns() { + let current_columns: Vec<&'static str> = DBColumn::iter().map(|c| c.as_str()).collect(); + let expected_columns = vec![ + "bma", "blk", "blb", "bdc", "ste", "hsd", "hsn", "bsn", "bsd", "bss", "bs3", "bcs", "bst", + "exp", "bch", "opo", "etc", "frk", "pkc", "brp", "bsx", "bsr", "bbx", "bbr", "bhr", "brm", + "dht", "cus", "otb", "bhs", "olc", "lcu", "scb", "scm", "dmy", + ]; + assert_eq!(expected_columns, current_columns); +} + +/// Check the SSZ sizes of known on-disk metadata. +/// +/// New types can be added here as the schema evolves. +fn check_metadata_sizes(store: &Store) { + assert_eq!(Split::default().ssz_bytes_len(), 40); + assert_eq!(store.get_anchor_info().ssz_bytes_len(), 64); + assert_eq!( + store.get_blob_info().ssz_bytes_len(), + if store.get_chain_spec().deneb_fork_epoch.is_some() { + 14 + } else { + 6 + } + ); + assert_eq!(DataColumnInfo::default().ssz_bytes_len(), 5); +} + +fn check_op_pool(store: &Store) { + let op_pool = store + .get_item::>(&Hash256::ZERO) + .unwrap() + .unwrap(); + assert!(matches!(op_pool, PersistedOperationPool::V20(_))); + assert_eq!(op_pool.ssz_bytes_len(), 28); + assert_eq!(op_pool.as_store_bytes().len(), 28); +} + +fn check_custody_context(store: &Store, spec: &ChainSpec) { + let custody_context_opt = store.get_item::(&Hash256::ZERO).unwrap(); + if spec.is_peer_das_scheduled() { + assert_eq!(custody_context_opt.unwrap().as_store_bytes().len(), 13); + } else { + assert!(custody_context_opt.is_none()); + } +} + +fn check_persisted_chain(store: &Store) { + let chain = store + .get_item::(&Hash256::ZERO) + .unwrap() + .unwrap(); + assert_eq!(chain.as_store_bytes().len(), 32); +} diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 1be2879e1a..e9b19ee6e0 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -2238,7 +2238,15 @@ async fn weak_subjectivity_sync_easy() { let num_initial_slots = E::slots_per_epoch() * 11; let checkpoint_slot = Slot::new(E::slots_per_epoch() * 9); let slots = (1..num_initial_slots).map(Slot::new).collect(); - weak_subjectivity_sync_test(slots, checkpoint_slot).await + weak_subjectivity_sync_test(slots, checkpoint_slot, None).await +} + +#[tokio::test] +async fn weak_subjectivity_sync_single_block_batches() { + let num_initial_slots = E::slots_per_epoch() * 11; + let checkpoint_slot = Slot::new(E::slots_per_epoch() * 9); + let slots = (1..num_initial_slots).map(Slot::new).collect(); + weak_subjectivity_sync_test(slots, checkpoint_slot, Some(1)).await } #[tokio::test] @@ -2252,7 +2260,7 @@ async fn weak_subjectivity_sync_unaligned_advanced_checkpoint() { slot <= checkpoint_slot - 3 || slot > checkpoint_slot }) .collect(); - weak_subjectivity_sync_test(slots, checkpoint_slot).await + weak_subjectivity_sync_test(slots, checkpoint_slot, None).await } #[tokio::test] @@ -2266,7 +2274,7 @@ async fn weak_subjectivity_sync_unaligned_unadvanced_checkpoint() { slot <= checkpoint_slot || slot > checkpoint_slot + 3 }) .collect(); - weak_subjectivity_sync_test(slots, checkpoint_slot).await + weak_subjectivity_sync_test(slots, checkpoint_slot, None).await } // Regression test for https://github.com/sigp/lighthouse/issues/4817 @@ -2278,10 +2286,27 @@ async fn weak_subjectivity_sync_skips_at_genesis() { let end_slot = E::slots_per_epoch() * 4; let slots = (start_slot..end_slot).map(Slot::new).collect(); let checkpoint_slot = Slot::new(E::slots_per_epoch() * 2); - weak_subjectivity_sync_test(slots, checkpoint_slot).await + weak_subjectivity_sync_test(slots, checkpoint_slot, None).await } -async fn weak_subjectivity_sync_test(slots: Vec, checkpoint_slot: Slot) { +// Checkpoint sync from the genesis state. +// +// This is a regression test for a bug we had involving the storage of the genesis state in the hot +// DB. +#[tokio::test] +async fn weak_subjectivity_sync_from_genesis() { + let start_slot = 1; + let end_slot = E::slots_per_epoch() * 2; + let slots = (start_slot..end_slot).map(Slot::new).collect(); + let checkpoint_slot = Slot::new(0); + weak_subjectivity_sync_test(slots, checkpoint_slot, None).await +} + +async fn weak_subjectivity_sync_test( + slots: Vec, + checkpoint_slot: Slot, + backfill_batch_size: Option, +) { // Build an initial chain on one harness, representing a synced node with full history. let num_final_blocks = E::slots_per_epoch() * 2; @@ -2367,7 +2392,15 @@ async fn weak_subjectivity_sync_test(slots: Vec, checkpoint_slot: Slot) { ); slot_clock.set_slot(harness.get_current_slot().as_u64()); + let chain_config = ChainConfig { + // Set reconstruct_historic_states to true from the start in the genesis case. This makes + // some of the later checks more uniform across the genesis/non-genesis cases. + reconstruct_historic_states: checkpoint_slot == 0, + ..ChainConfig::default() + }; + let beacon_chain = BeaconChainBuilder::>::new(MinimalEthSpec, kzg) + .chain_config(chain_config) .store(store.clone()) .custom_spec(test_spec::().into()) .task_executor(harness.chain.task_executor.clone()) @@ -2381,7 +2414,6 @@ async fn weak_subjectivity_sync_test(slots: Vec, checkpoint_slot: Slot) { .store_migrator_config(MigratorConfig::default().blocking()) .slot_clock(slot_clock) .shutdown_sender(shutdown_tx) - .chain_config(ChainConfig::default()) .event_handler(Some(ServerSentEventHandler::new_with_capacity(1))) .execution_layer(Some(mock.el)) .rng(Box::new(StdRng::seed_from_u64(42))) @@ -2449,97 +2481,146 @@ async fn weak_subjectivity_sync_test(slots: Vec, checkpoint_slot: Slot) { assert_eq!(state.update_tree_hash_cache().unwrap(), state_root); } - // Forwards iterator from 0 should fail as we lack blocks. - assert!(matches!( - beacon_chain.forwards_iter_block_roots(Slot::new(0)), - Err(BeaconChainError::HistoricalBlockOutOfRange { .. }) - )); - - // Simulate processing of a `StatusMessage` with an older finalized epoch by calling - // `block_root_at_slot` with an old slot for which we don't know the block root. It should - // return `None` rather than erroring. - assert_eq!( - beacon_chain - .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) - .unwrap(), - None - ); - - // Simulate querying the API for a historic state that is unknown. It should also return - // `None` rather than erroring. - assert_eq!(beacon_chain.state_root_at_slot(Slot::new(1)).unwrap(), None); - - // Supply blocks backwards to reach genesis. Omit the genesis block to check genesis handling. - let historical_blocks = chain_dump[..wss_block.slot().as_usize()] - .iter() - .filter(|s| s.beacon_block.slot() != 0) - .map(|s| s.beacon_block.clone()) - .collect::>(); - - let mut available_blocks = vec![]; - for blinded in historical_blocks { - let block_root = blinded.canonical_root(); - let full_block = harness - .chain - .get_block(&block_root) - .await - .expect("should get block") - .expect("should get block"); - - if let MaybeAvailableBlock::Available(block) = harness - .chain - .data_availability_checker - .verify_kzg_for_rpc_block( - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)), - ) - .expect("should verify kzg") - { - available_blocks.push(block); - } + if checkpoint_slot != 0 { + // Forwards iterator from 0 should fail as we lack blocks (unless checkpoint slot is 0). + assert!(matches!( + beacon_chain.forwards_iter_block_roots(Slot::new(0)), + Err(BeaconChainError::HistoricalBlockOutOfRange { .. }) + )); + } else { + assert_eq!( + beacon_chain + .forwards_iter_block_roots(Slot::new(0)) + .unwrap() + .next() + .unwrap() + .unwrap(), + (wss_block_root, Slot::new(0)) + ); } - // Corrupt the signature on the 1st block to ensure that the backfill processor is checking - // signatures correctly. Regression test for https://github.com/sigp/lighthouse/pull/5120. - let mut batch_with_invalid_first_block = - available_blocks.iter().map(clone_block).collect::>(); - batch_with_invalid_first_block[0] = { - let (block_root, block, data) = clone_block(&available_blocks[0]).deconstruct(); - let mut corrupt_block = (*block).clone(); - *corrupt_block.signature_mut() = Signature::empty(); - AvailableBlock::__new_for_testing(block_root, Arc::new(corrupt_block), data, Arc::new(spec)) - }; + // The checks in this block only make sense if some data is missing as a result of the + // checkpoint sync, i.e. if we are not just checkpoint syncing from genesis. + if checkpoint_slot != 0 { + // Simulate processing of a `StatusMessage` with an older finalized epoch by calling + // `block_root_at_slot` with an old slot for which we don't know the block root. It should + // return `None` rather than erroring. + assert_eq!( + beacon_chain + .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) + .unwrap(), + None + ); - // Importing the invalid batch should error. - assert!(matches!( - beacon_chain - .import_historical_block_batch(batch_with_invalid_first_block) - .unwrap_err(), - HistoricalBlockError::InvalidSignature - )); + // Simulate querying the API for a historic state that is unknown. It should also return + // `None` rather than erroring. + assert_eq!(beacon_chain.state_root_at_slot(Slot::new(1)).unwrap(), None); - let available_blocks_slots = available_blocks - .iter() - .map(|block| (block.block().slot(), block.block().canonical_root())) - .collect::>(); - info!( - ?available_blocks_slots, - "wss_block_slot" = wss_block.slot().as_usize(), - "Importing historical block batch" - ); + // Supply blocks backwards to reach genesis. Omit the genesis block to check genesis handling. + let historical_blocks = chain_dump[..wss_block.slot().as_usize()] + .iter() + .filter(|s| s.beacon_block.slot() != 0) + .map(|s| s.beacon_block.clone()) + .collect::>(); - // Importing the batch with valid signatures should succeed. - let available_blocks_dup = available_blocks.iter().map(clone_block).collect::>(); - assert_eq!(beacon_chain.store.get_oldest_block_slot(), wss_block.slot()); - beacon_chain - .import_historical_block_batch(available_blocks_dup) - .unwrap(); + let mut available_blocks = vec![]; + for blinded in historical_blocks { + let block_root = blinded.canonical_root(); + let full_block = harness + .chain + .get_block(&block_root) + .await + .expect("should get block") + .expect("should get block"); + + if let MaybeAvailableBlock::Available(block) = harness + .chain + .data_availability_checker + .verify_kzg_for_rpc_block( + harness + .build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)), + ) + .expect("should verify kzg") + { + available_blocks.push(block); + } + } + + // Corrupt the signature on the 1st block to ensure that the backfill processor is checking + // signatures correctly. Regression test for https://github.com/sigp/lighthouse/pull/5120. + let mut batch_with_invalid_first_block = + available_blocks.iter().map(clone_block).collect::>(); + batch_with_invalid_first_block[0] = { + let (block_root, block, data) = clone_block(&available_blocks[0]).deconstruct(); + let mut corrupt_block = (*block).clone(); + *corrupt_block.signature_mut() = Signature::empty(); + AvailableBlock::__new_for_testing( + block_root, + Arc::new(corrupt_block), + data, + Arc::new(spec), + ) + }; + + // Importing the invalid batch should error. + assert!(matches!( + beacon_chain + .import_historical_block_batch(batch_with_invalid_first_block) + .unwrap_err(), + HistoricalBlockError::InvalidSignature + )); + assert_eq!(beacon_chain.store.get_oldest_block_slot(), wss_block.slot()); + + let batch_size = backfill_batch_size.unwrap_or(available_blocks.len()); + + for batch in available_blocks.rchunks(batch_size) { + let available_blocks_slots = batch + .iter() + .map(|block| (block.block().slot(), block.block().canonical_root())) + .collect::>(); + info!( + ?available_blocks_slots, + "wss_block_slot" = wss_block.slot().as_usize(), + "Importing historical block batch" + ); + + // Importing the batch with valid signatures should succeed. + let available_blocks_batch1 = batch.iter().map(clone_block).collect::>(); + beacon_chain + .import_historical_block_batch(available_blocks_batch1) + .unwrap(); + + // We should be able to load the block root at the `oldest_block_slot`. + // + // This is a regression test for: https://github.com/sigp/lighthouse/issues/7690 + let oldest_block_imported = &batch[0]; + let (oldest_block_slot, oldest_block_root) = + if oldest_block_imported.block().parent_root() == beacon_chain.genesis_block_root { + (Slot::new(0), beacon_chain.genesis_block_root) + } else { + available_blocks_slots[0] + }; + assert_eq!( + beacon_chain.store.get_oldest_block_slot(), + oldest_block_slot + ); + assert_eq!( + beacon_chain + .block_root_at_slot(oldest_block_slot, WhenSlotSkipped::None) + .unwrap() + .unwrap(), + oldest_block_root + ); + + // Resupplying the blocks should not fail, they can be safely ignored. + let available_blocks_batch2 = batch.iter().map(clone_block).collect::>(); + beacon_chain + .import_historical_block_batch(available_blocks_batch2) + .unwrap(); + } + } assert_eq!(beacon_chain.store.get_oldest_block_slot(), 0); - // Resupplying the blocks should not fail, they can be safely ignored. - beacon_chain - .import_historical_block_batch(available_blocks) - .unwrap(); - // Sanity check for non-aligned WSS starts, to make sure the WSS block is persisted properly if wss_block_slot != wss_state_slot { let new_node_block_root_at_wss_block = beacon_chain @@ -2615,7 +2696,11 @@ async fn weak_subjectivity_sync_test(slots: Vec, checkpoint_slot: Slot) { assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); assert_eq!( store.get_anchor_info().state_upper_limit, - Slot::new(u64::MAX) + if checkpoint_slot == 0 { + Slot::new(0) + } else { + Slot::new(u64::MAX) + } ); info!(anchor = ?store.get_anchor_info(), "anchor pre"); diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index e01b8de9e3..aefb6d6750 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -29,7 +29,7 @@ use super::DEFAULT_TERMINAL_BLOCK; const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz"); const TEST_BLOB_BUNDLE_V2: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle_v2.ssz"); -pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; +pub const DEFAULT_GAS_LIMIT: u64 = 45_000_000; const GAS_USED: u64 = DEFAULT_GAS_LIMIT - 1; #[derive(Clone, Debug, PartialEq)] diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 3704bcc592..751e99494c 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -40,7 +40,7 @@ use warp::reply::{self, Reply}; use warp::{Filter, Rejection}; pub const DEFAULT_FEE_RECIPIENT: Address = Address::repeat_byte(42); -pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; +pub const DEFAULT_GAS_LIMIT: u64 = 45_000_000; pub const DEFAULT_BUILDER_PRIVATE_KEY: &str = "607a11b45a7219cc61a3d9c5fd08c7eebd602a6a19a977f8d3771d5711a550f2"; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index a627fb0353..e9b2e8e6bf 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -211,9 +211,11 @@ pub fn prometheus_metrics() -> warp::filters::log::Log( .and(warp::path::end()) .and(warp_utils::json::json()) .and(validator_subscription_tx_filter.clone()) - .and(network_tx_filter.clone()) .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .then( |committee_subscriptions: Vec, validator_subscription_tx: Sender, - network_tx: UnboundedSender>, task_spawner: TaskSpawner, chain: Arc>| { task_spawner.blocking_json_task(Priority::P0, move || { @@ -3761,42 +3761,6 @@ pub fn serve( )); } - if chain.spec.is_peer_das_scheduled() { - let (finalized_beacon_state, _, _) = - StateId(CoreStateId::Finalized).state(&chain)?; - let validators_and_balances = committee_subscriptions - .iter() - .filter_map(|subscription| { - if let Ok(effective_balance) = finalized_beacon_state - .get_effective_balance(subscription.validator_index as usize) - { - Some((subscription.validator_index as usize, effective_balance)) - } else { - None - } - }) - .collect::>(); - - let current_slot = - chain.slot().map_err(warp_utils::reject::unhandled_error)?; - if let Some(cgc_change) = chain - .data_availability_checker - .custody_context() - .register_validators::( - validators_and_balances, - current_slot, - &chain.spec, - ) { - network_tx.send(NetworkMessage::CustodyCountChanged { - new_custody_group_count: cgc_change.new_custody_group_count, - sampling_count: cgc_change.sampling_count, - }).unwrap_or_else(|e| { - debug!(error = %e, "Could not send message to the network service. \ - Likely shutdown") - }); - } - } - Ok(()) }) }, @@ -3808,11 +3772,13 @@ pub fn serve( .and(warp::path("prepare_beacon_proposer")) .and(warp::path::end()) .and(not_while_syncing_filter.clone()) + .and(network_tx_filter.clone()) .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(warp_utils::json::json()) .then( |not_synced_filter: Result<(), Rejection>, + network_tx: UnboundedSender>, task_spawner: TaskSpawner, chain: Arc>, preparation_data: Vec| { @@ -3849,6 +3815,42 @@ pub fn serve( )) })?; + if chain.spec.is_peer_das_scheduled() { + let (finalized_beacon_state, _, _) = + StateId(CoreStateId::Finalized).state(&chain)?; + let validators_and_balances = preparation_data + .iter() + .filter_map(|preparation| { + if let Ok(effective_balance) = finalized_beacon_state + .get_effective_balance(preparation.validator_index as usize) + { + Some((preparation.validator_index as usize, effective_balance)) + } else { + None + } + }) + .collect::>(); + + let current_slot = + chain.slot().map_err(warp_utils::reject::unhandled_error)?; + if let Some(cgc_change) = chain + .data_availability_checker + .custody_context() + .register_validators::( + validators_and_balances, + current_slot, + &chain.spec, + ) { + network_tx.send(NetworkMessage::CustodyCountChanged { + new_custody_group_count: cgc_change.new_custody_group_count, + sampling_count: cgc_change.sampling_count, + }).unwrap_or_else(|e| { + debug!(error = %e, "Could not send message to the network service. \ + Likely shutdown") + }); + } + } + Ok::<_, warp::reject::Rejection>(warp::reply::json(&()).into_response()) }) }, diff --git a/beacon_node/http_api/src/test_utils.rs b/beacon_node/http_api/src/test_utils.rs index 0ea8588125..a52df6c863 100644 --- a/beacon_node/http_api/src/test_utils.rs +++ b/beacon_node/http_api/src/test_utils.rs @@ -103,6 +103,13 @@ impl InteractiveTester { tokio::spawn(server); + // Override the default timeout to 2s to timeouts on CI, as CI seems to require longer + // to process. The 1s timeouts for other tasks have been working for a long time, so we'll + // keep it as it is, as it may help identify a performance regression. + let timeouts = Timeouts { + default: Duration::from_secs(2), + ..Timeouts::set_all(Duration::from_secs(1)) + }; let client = BeaconNodeHttpClient::new( SensitiveUrl::parse(&format!( "http://{}:{}", @@ -110,7 +117,7 @@ impl InteractiveTester { listening_socket.port() )) .unwrap(), - Timeouts::set_all(Duration::from_secs(1)), + timeouts, ); Self { diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 955b44c36c..ecd20f3f79 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4669,7 +4669,7 @@ impl ApiTester { self.mock_builder .as_ref() .unwrap() - .add_operation(Operation::GasLimit(30_000_000)); + .add_operation(Operation::GasLimit(DEFAULT_GAS_LIMIT as usize)); let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); @@ -4692,7 +4692,7 @@ impl ApiTester { let expected_fee_recipient = Address::from_low_u64_be(proposer_index); assert_eq!(payload.fee_recipient(), expected_fee_recipient); - assert_eq!(payload.gas_limit(), 30_000_000); + assert_eq!(payload.gas_limit(), DEFAULT_GAS_LIMIT); self } diff --git a/beacon_node/lighthouse_network/src/types/globals.rs b/beacon_node/lighthouse_network/src/types/globals.rs index d1ed1c33b0..cc4d758b4a 100644 --- a/beacon_node/lighthouse_network/src/types/globals.rs +++ b/beacon_node/lighthouse_network/src/types/globals.rs @@ -66,7 +66,7 @@ impl NetworkGlobals { // The below `expect` calls will panic on start up if the chain spec config values used // are invalid let sampling_size = spec - .sampling_size(custody_group_count) + .sampling_size_custody_groups(custody_group_count) .expect("should compute node sampling size from valid chain spec"); let custody_groups = get_custody_groups(node_id, sampling_size, &spec) .expect("should compute node custody groups"); @@ -114,7 +114,7 @@ impl NetworkGlobals { // are invalid let sampling_size = self .spec - .sampling_size(custody_group_count) + .sampling_size_custody_groups(custody_group_count) .expect("should compute node sampling size from valid chain spec"); let custody_groups = get_custody_groups(self.local_enr().node_id().raw(), sampling_size, &self.spec) @@ -298,7 +298,13 @@ mod test { spec.fulu_fork_epoch = Some(Epoch::new(0)); let custody_group_count = spec.number_of_custody_groups / 2; - let subnet_sampling_size = spec.sampling_size(custody_group_count).unwrap(); + let sampling_size_custody_groups = spec + .sampling_size_custody_groups(custody_group_count) + .unwrap(); + let expected_sampling_subnet_count = sampling_size_custody_groups + * spec.data_column_sidecar_subnet_count + / spec.number_of_custody_groups; + let metadata = get_metadata(custody_group_count); let config = Arc::new(NetworkConfig::default()); @@ -310,7 +316,7 @@ mod test { ); assert_eq!( globals.sampling_subnets.read().len(), - subnet_sampling_size as usize + expected_sampling_subnet_count as usize ); } @@ -321,7 +327,7 @@ mod test { spec.fulu_fork_epoch = Some(Epoch::new(0)); let custody_group_count = spec.number_of_custody_groups / 2; - let subnet_sampling_size = spec.sampling_size(custody_group_count).unwrap(); + let expected_sampling_columns = spec.sampling_size_columns(custody_group_count).unwrap(); let metadata = get_metadata(custody_group_count); let config = Arc::new(NetworkConfig::default()); @@ -333,7 +339,7 @@ mod test { ); assert_eq!( globals.sampling_columns.read().len(), - subnet_sampling_size as usize + expected_sampling_columns as usize ); } diff --git a/beacon_node/network/src/persisted_dht.rs b/beacon_node/network/src/persisted_dht.rs index 9c112dba86..938b08a315 100644 --- a/beacon_node/network/src/persisted_dht.rs +++ b/beacon_node/network/src/persisted_dht.rs @@ -86,5 +86,9 @@ mod tests { .unwrap(); let dht: PersistedDht = store.get_item(&DHT_DB_KEY).unwrap().unwrap(); assert_eq!(dht.enrs, enrs); + + // This hardcoded length check is for database schema compatibility. If the on-disk format + // of `PersistedDht` changes, we need a DB schema change. + assert_eq!(dht.as_store_bytes().len(), 136); } } diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index f5e44f7ac9..0c230494b8 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -3074,18 +3074,17 @@ impl, Cold: ItemStore> HotColdDB /// Try to prune blobs, approximating the current epoch from the split slot. pub fn try_prune_most_blobs(&self, force: bool) -> Result<(), Error> { - let Some(deneb_fork_epoch) = self.spec.deneb_fork_epoch else { - debug!("Deneb fork is disabled"); - return Ok(()); - }; // The current epoch is >= split_epoch + 2. It could be greater if the database is // configured to delay updating the split or finalization has ceased. In this instance we // choose to also delay the pruning of blobs (we never prune without finalization anyway). let min_current_epoch = self.get_split_slot().epoch(E::slots_per_epoch()) + 2; - let min_data_availability_boundary = std::cmp::max( - deneb_fork_epoch, - min_current_epoch.saturating_sub(self.spec.min_epochs_for_blob_sidecars_requests), - ); + let Some(min_data_availability_boundary) = self + .spec + .min_epoch_data_availability_boundary(min_current_epoch) + else { + debug!("Deneb fork is disabled"); + return Ok(()); + }; self.try_prune_blobs(force, min_data_availability_boundary) } diff --git a/beacon_node/store/src/metadata.rs b/beacon_node/store/src/metadata.rs index 63cb4661cd..39a46451fc 100644 --- a/beacon_node/store/src/metadata.rs +++ b/beacon_node/store/src/metadata.rs @@ -4,7 +4,7 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use types::{Hash256, Slot}; -pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(25); +pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(26); // All the keys that get stored under the `BeaconMeta` column. // diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index ade111983b..4bd8f12ead 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -47,6 +47,12 @@ where let lower_limit_slot = anchor.state_lower_limit; let upper_limit_slot = std::cmp::min(split.slot, anchor.state_upper_limit); + // If the split is at 0 we can't reconstruct historic states. + if split.slot == 0 { + debug!("No state reconstruction possible"); + return Ok(()); + } + // If `num_blocks` is not specified iterate all blocks. Add 1 so that we end on an epoch // boundary when `num_blocks` is a multiple of an epoch boundary. We want to be *inclusive* // of the state at slot `lower_limit_slot + num_blocks`. diff --git a/book/src/advanced_database_migrations.md b/book/src/advanced_database_migrations.md index cb9aaac856..e29397619c 100644 --- a/book/src/advanced_database_migrations.md +++ b/book/src/advanced_database_migrations.md @@ -17,7 +17,7 @@ validator client or the slasher**. | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|----------------------| -| v7.1.0 | Jul 2025 | v25 | yes | +| v7.1.0 | Jul 2025 | v26 | yes | | v7.0.0 | Apr 2025 | v22 | no | | v6.0.0 | Nov 2024 | v22 | no | @@ -207,7 +207,7 @@ Here are the steps to prune historic states: | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|-------------------------------------| -| v7.1.0 | Jul 2025 | v25 | yes | +| v7.1.0 | Jul 2025 | v26 | yes | | v7.0.0 | Apr 2025 | v22 | no | | v6.0.0 | Nov 2024 | v22 | no | | v5.3.0 | Aug 2024 | v21 | yes before Electra using <= v7.0.0 | diff --git a/book/src/help_vc.md b/book/src/help_vc.md index 15b5c209a7..0bc4bbf53d 100644 --- a/book/src/help_vc.md +++ b/book/src/help_vc.md @@ -40,7 +40,7 @@ Options: The gas limit to be used in all builder proposals for all validators managed by this validator client. Note this will not necessarily be used if the gas limit set here moves too far from the previous block's - gas limit. [default: 36000000] + gas limit. [default: 45000000] --genesis-state-url A URL of a beacon-API compatible server from which to download the genesis state. Checkpoint sync server URLs can generally be used with diff --git a/book/src/help_vm.md b/book/src/help_vm.md index 8ff54122ef..f58537ae1c 100644 --- a/book/src/help_vm.md +++ b/book/src/help_vm.md @@ -28,6 +28,10 @@ Commands: delete Deletes one or more validators from a validator client using the HTTP API. + exit + Exits one or more validators using the HTTP API. It can also be used + to generate a presigned voluntary exit message for a particular future + epoch. help Print this message or the help of the given subcommand(s) diff --git a/book/src/validator_manager.md b/book/src/validator_manager.md index b0190c1812..609f176901 100644 --- a/book/src/validator_manager.md +++ b/book/src/validator_manager.md @@ -32,4 +32,4 @@ The `validator-manager` boasts the following features: - [Creating and importing validators using the `create` and `import` commands.](./validator_manager_create.md) - [Moving validators between two VCs using the `move` command.](./validator_manager_move.md) -- [Managing validators such as delete, import and list validators.](./validator_manager_api.md) +- [Managing validators such as exit, delete, import and list validators.](./validator_manager_api.md) diff --git a/book/src/validator_manager_api.md b/book/src/validator_manager_api.md index 7bc5be8557..0542008463 100644 --- a/book/src/validator_manager_api.md +++ b/book/src/validator_manager_api.md @@ -2,6 +2,54 @@ The `lighthouse validator-manager` uses the [Keymanager API](https://ethereum.github.io/keymanager-APIs/#/) to list, import and delete keystores via the HTTP API. This requires the validator client running with the flag `--http`. By default, the validator client HTTP address is `http://localhost:5062`. If a different IP address or port is used, add the flag `--vc-url http://IP:port_number` to the command below. +## Exit + +The `exit` command exits one or more validators from the validator client. To `exit`: + +> **Important note: Once the --beacon-node flag is used, it will publish the voluntary exit to the network. This action is irreversible.** + +```bash +lighthouse vm exit --vc-token --validators pubkey1,pubkey2 --beacon-node http://beacon-node-url:5052 +``` + +Example: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4,0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f --beacon-node http://localhost:5052 +``` + +If successful, the following log will be returned: + +```text +Successfully validated and published voluntary exit for validator 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4 +Successfully validated and published voluntary exit for validator +0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f +``` + +To exit all validators on the validator client, use the keyword `all`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --beacon-node http://localhost:5052 +``` + +To check the voluntary exit status, refer to [the list command](./validator_manager_api.md#list). + +The following command will only generate a presigned voluntary exit message and save it to a file named `{validator_pubkey}.json`. It **will not** publish the voluntary exit to the network. + +To generate a presigned exit message and save it to a file, use the flag `--presign`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --presign +``` + +To generate a presigned exit message for a particular (future) epoch, use the flag `--exit-epoch`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --presign --exit-epoch 1234567 +``` + +The generated presigned exit message will only be valid at or after the specified exit-epoch, in this case, epoch 1234567. + ## Delete The `delete` command deletes one or more validators from the validator client. It will also modify the `validator_definitions.yml` file automatically so there is no manual action required from the user after the delete. To `delete`: @@ -16,6 +64,12 @@ Example: lighthouse vm delete --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4,0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f ``` +To delete all validators on the validator client, use the keyword `all`: + +```bash +lighthouse vm delete --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all +``` + ## Import The `import` command imports validator keystores generated by the `ethstaker-deposit-cli`. To import a validator keystore: @@ -37,3 +91,26 @@ To list the validators running on the validator client: ```bash lighthouse vm list --vc-token ~/.lighthouse/mainnet/validators/api-token.txt ``` + +The `list` command can also be used to check the voluntary exit status of validators. To do so, use both `--beacon-node` and `--validators` flags. The `--validators` flag accepts a comma-separated list of validator public keys, or the keyword `all` to check the voluntary exit status of all validators attached to the validator client. + +```bash +lighthouse vm list --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 --beacon-node http://localhost:5052 +``` + +If the validator voluntary exit has been accepted by the chain, the following log will be returned: + +```text +Voluntary exit for validator 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 has been accepted into the beacon chain, but not yet finalized. Finalization may take several minutes or longer. Before finalization there is a low probability that the exit may be reverted. +Current epoch: 2, Exit epoch: 7, Withdrawable epoch: 263 +Please keep your validator running till exit epoch +Exit epoch in approximately 480 secs +``` + +When the exit epoch is reached, querying the status will return: + +```text +Validator 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 has exited at epoch: 7 +``` + +You can safely shut down the validator client at this point. diff --git a/book/src/validator_voluntary_exit.md b/book/src/validator_voluntary_exit.md index 2a45852f32..ff404518b7 100644 --- a/book/src/validator_voluntary_exit.md +++ b/book/src/validator_voluntary_exit.md @@ -10,6 +10,8 @@ A validator can initiate a voluntary exit provided that the validator is current It takes at a minimum 5 epochs (32 minutes) for a validator to exit after initiating a voluntary exit. This number can be much higher depending on how many other validators are queued to exit. +You can also perform voluntary exit for one or more validators using the validator manager, see [Managing Validators](./validator_manager_api.md#exit) for more details. + ## Initiating a voluntary exit In order to initiate an exit, users can use the `lighthouse account validator exit` command. diff --git a/common/eth2_network_config/built_in_network_configs/holesky/config.yaml b/common/eth2_network_config/built_in_network_configs/holesky/config.yaml index 19a3f79cc0..76d8d482c2 100644 --- a/common/eth2_network_config/built_in_network_configs/holesky/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/holesky/config.yaml @@ -141,6 +141,9 @@ MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 NUMBER_OF_COLUMNS: 128 NUMBER_OF_CUSTODY_GROUPS: 128 DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 -MAX_BLOBS_PER_BLOCK_FULU: 12 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 \ No newline at end of file diff --git a/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml b/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml index 5cca1cd037..a1365e3464 100644 --- a/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml @@ -156,7 +156,8 @@ DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 -MAX_BLOBS_PER_BLOCK_FULU: 12 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 # EIP7732 diff --git a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml index 886e5d12ed..0b68a27f4d 100644 --- a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml @@ -156,6 +156,9 @@ MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 NUMBER_OF_COLUMNS: 128 NUMBER_OF_CUSTODY_GROUPS: 128 DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 -MAX_BLOBS_PER_BLOCK_FULU: 12 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 diff --git a/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml b/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml index 10be107263..ccd71cdce9 100644 --- a/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml @@ -142,6 +142,9 @@ MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 NUMBER_OF_COLUMNS: 128 NUMBER_OF_CUSTODY_GROUPS: 128 DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 -MAX_BLOBS_PER_BLOCK_FULU: 12 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 diff --git a/consensus/types/src/chain_spec.rs b/consensus/types/src/chain_spec.rs index b4fd5afe87..631389ce43 100644 --- a/consensus/types/src/chain_spec.rs +++ b/consensus/types/src/chain_spec.rs @@ -246,6 +246,7 @@ pub struct ChainSpec { * Networking Fulu */ blob_schedule: BlobSchedule, + min_epochs_for_data_column_sidecars_requests: u64, /* * Networking Derived @@ -720,17 +721,38 @@ impl ChainSpec { } /// Returns the number of column sidecars to sample per slot. - pub fn sampling_size(&self, custody_group_count: u64) -> Result { + pub fn sampling_size_columns(&self, custody_group_count: u64) -> Result { + let sampling_size_groups = self.sampling_size_custody_groups(custody_group_count)?; + let columns_per_custody_group = self .number_of_columns .safe_div(self.number_of_custody_groups) .map_err(|_| "number_of_custody_groups must be greater than 0")?; - let custody_column_count = columns_per_custody_group - .safe_mul(custody_group_count) + let sampling_size_columns = columns_per_custody_group + .safe_mul(sampling_size_groups) .map_err(|_| "Computing sampling size should not overflow")?; - Ok(std::cmp::max(custody_column_count, self.samples_per_slot)) + Ok(sampling_size_columns) + } + + /// Returns the number of custody groups to sample per slot. + pub fn sampling_size_custody_groups(&self, custody_group_count: u64) -> Result { + Ok(std::cmp::max(custody_group_count, self.samples_per_slot)) + } + + /// Returns the min epoch for blob / data column sidecar requests based on the current epoch. + /// Switch to use the column sidecar config once the `blob_retention_epoch` has passed Fulu fork epoch. + pub fn min_epoch_data_availability_boundary(&self, current_epoch: Epoch) -> Option { + let fork_epoch = self.deneb_fork_epoch?; + let blob_retention_epoch = + current_epoch.saturating_sub(self.min_epochs_for_blob_sidecars_requests); + match self.fulu_fork_epoch { + Some(fulu_fork_epoch) if blob_retention_epoch > fulu_fork_epoch => Some( + current_epoch.saturating_sub(self.min_epochs_for_data_column_sidecars_requests), + ), + _ => Some(std::cmp::max(fork_epoch, blob_retention_epoch)), + } } pub fn all_data_column_sidecar_subnets(&self) -> impl Iterator { @@ -1020,6 +1042,8 @@ impl ChainSpec { * Networking Fulu specific */ blob_schedule: BlobSchedule::default(), + min_epochs_for_data_column_sidecars_requests: + default_min_epochs_for_data_column_sidecars_requests(), /* * Application specific @@ -1356,6 +1380,8 @@ impl ChainSpec { * Networking Fulu specific */ blob_schedule: BlobSchedule::default(), + min_epochs_for_data_column_sidecars_requests: + default_min_epochs_for_data_column_sidecars_requests(), /* * Application specific @@ -1654,6 +1680,9 @@ pub struct Config { #[serde(default = "default_balance_per_additional_custody_group")] #[serde(with = "serde_utils::quoted_u64")] balance_per_additional_custody_group: u64, + #[serde(default = "default_min_epochs_for_data_column_sidecars_requests")] + #[serde(with = "serde_utils::quoted_u64")] + min_epochs_for_data_column_sidecars_requests: u64, } fn default_bellatrix_fork_version() -> [u8; 4] { @@ -1827,6 +1856,10 @@ const fn default_balance_per_additional_custody_group() -> u64 { 32000000000 } +const fn default_min_epochs_for_data_column_sidecars_requests() -> u64 { + 4096 +} + fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { let max_request_blocks = max_request_blocks as usize; RuntimeVariableList::::from_vec( @@ -2038,6 +2071,8 @@ impl Config { blob_schedule: spec.blob_schedule.clone(), validator_custody_requirement: spec.validator_custody_requirement, balance_per_additional_custody_group: spec.balance_per_additional_custody_group, + min_epochs_for_data_column_sidecars_requests: spec + .min_epochs_for_data_column_sidecars_requests, } } @@ -2119,6 +2154,7 @@ impl Config { ref blob_schedule, validator_custody_requirement, balance_per_additional_custody_group, + min_epochs_for_data_column_sidecars_requests, } = self; if preset_base != E::spec_name().to_string().as_str() { @@ -2205,6 +2241,7 @@ impl Config { blob_schedule: blob_schedule.clone(), validator_custody_requirement, balance_per_additional_custody_group, + min_epochs_for_data_column_sidecars_requests, ..chain_spec.clone() }) @@ -2343,6 +2380,7 @@ mod tests { mod yaml_tests { use super::*; use paste::paste; + use std::sync::Arc; use tempfile::NamedTempFile; #[test] @@ -2642,4 +2680,65 @@ mod yaml_tests { let _ = spec.max_message_size(); let _ = spec.max_compressed_len(); } + + #[test] + fn min_epochs_for_data_sidecar_requests_deneb() { + type E = MainnetEthSpec; + let spec = Arc::new(ForkName::Deneb.make_genesis_spec(E::default_spec())); + let blob_retention_epochs = spec.min_epochs_for_blob_sidecars_requests; + + // `min_epochs_for_data_sidecar_requests` cannot be earlier than Deneb fork epoch. + assert_eq!( + spec.deneb_fork_epoch, + spec.min_epoch_data_availability_boundary(Epoch::new(blob_retention_epochs / 2)) + ); + + let current_epoch = Epoch::new(blob_retention_epochs * 2); + let expected_min_blob_epoch = current_epoch - blob_retention_epochs; + assert_eq!( + Some(expected_min_blob_epoch), + spec.min_epoch_data_availability_boundary(current_epoch) + ); + } + + #[test] + fn min_epochs_for_data_sidecar_requests_fulu() { + type E = MainnetEthSpec; + let spec = { + let mut spec = ForkName::Deneb.make_genesis_spec(E::default_spec()); + // 4096 * 2 = 8192 + spec.fulu_fork_epoch = Some(Epoch::new(spec.min_epochs_for_blob_sidecars_requests * 2)); + // set a different value for testing purpose, 4096 / 2 = 2048 + spec.min_epochs_for_data_column_sidecars_requests = + spec.min_epochs_for_blob_sidecars_requests / 2; + Arc::new(spec) + }; + let blob_retention_epochs = spec.min_epochs_for_blob_sidecars_requests; + let data_column_retention_epochs = spec.min_epochs_for_data_column_sidecars_requests; + + // `min_epochs_for_data_sidecar_requests` at fulu fork epoch still uses `min_epochs_for_blob_sidecars_requests` + let fulu_fork_epoch = spec.fulu_fork_epoch.unwrap(); + let expected_blob_retention_epoch = fulu_fork_epoch - blob_retention_epochs; + assert_eq!( + Some(expected_blob_retention_epoch), + spec.min_epoch_data_availability_boundary(fulu_fork_epoch) + ); + + // `min_epochs_for_data_sidecar_requests` at fulu fork epoch + min_epochs_for_blob_sidecars_request + let blob_retention_epoch_after_fulu = fulu_fork_epoch + blob_retention_epochs; + let expected_blob_retention_epoch = blob_retention_epoch_after_fulu - blob_retention_epochs; + assert_eq!( + Some(expected_blob_retention_epoch), + spec.min_epoch_data_availability_boundary(blob_retention_epoch_after_fulu) + ); + + // After the final blob retention epoch, `min_epochs_for_data_sidecar_requests` should be calculated + // using `min_epochs_for_data_column_sidecars_request` + let current_epoch = blob_retention_epoch_after_fulu + 1; + let expected_data_column_retention_epoch = current_epoch - data_column_retention_epochs; + assert_eq!( + Some(expected_data_column_retention_epoch), + spec.min_epoch_data_availability_boundary(current_epoch) + ); + } } diff --git a/lighthouse/tests/validator_client.rs b/lighthouse/tests/validator_client.rs index f99fc3c460..7bda1868c8 100644 --- a/lighthouse/tests/validator_client.rs +++ b/lighthouse/tests/validator_client.rs @@ -505,7 +505,7 @@ fn no_doppelganger_protection_flag() { fn no_gas_limit_flag() { CommandLineTest::new() .run() - .with_config(|config| assert!(config.validator_store.gas_limit == Some(36_000_000))); + .with_config(|config| assert!(config.validator_store.gas_limit == Some(45_000_000))); } #[test] fn gas_limit_flag() { diff --git a/lighthouse/tests/validator_manager.rs b/lighthouse/tests/validator_manager.rs index 04e3eafe6e..5ee9b0263a 100644 --- a/lighthouse/tests/validator_manager.rs +++ b/lighthouse/tests/validator_manager.rs @@ -10,6 +10,7 @@ use types::*; use validator_manager::{ create_validators::CreateConfig, delete_validators::DeleteConfig, + exit_validators::ExitConfig, import_validators::ImportConfig, list_validators::ListConfig, move_validators::{MoveConfig, PasswordSource, Validators}, @@ -119,6 +120,12 @@ impl CommandLineTest { } } +impl CommandLineTest { + fn validators_exit() -> Self { + Self::default().flag("exit", None) + } +} + #[test] pub fn validator_create_without_output_path() { CommandLineTest::validators_create().assert_failed(); @@ -443,6 +450,8 @@ pub fn validator_list_defaults() { let expected = ListConfig { vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), vc_token_path: PathBuf::from("./token.json"), + beacon_url: None, + validators_to_display: vec![], }; assert_eq!(expected, config); }); @@ -468,3 +477,106 @@ pub fn validator_delete_defaults() { assert_eq!(expected, config); }); } + +#[test] +pub fn validator_delete_missing_validator_flag() { + CommandLineTest::validators_delete() + .flag("--vc-token", Some("./token.json")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_defaults() { + CommandLineTest::validators_exit() + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--vc-token", Some("./token.json")) + .flag("--beacon-node", Some("http://localhost:5052")) + .assert_success(|config| { + let expected = ExitConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + validators_to_exit: vec![ + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_0).unwrap(), + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_1).unwrap(), + ], + beacon_url: Some(SensitiveUrl::parse("http://localhost:5052").unwrap()), + exit_epoch: None, + presign: false, + }; + assert_eq!(expected, config); + }); +} + +#[test] +pub fn validator_exit_exit_epoch_and_presign_flags() { + CommandLineTest::validators_exit() + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--vc-token", Some("./token.json")) + .flag("--exit-epoch", Some("1234567")) + .flag("--presign", None) + .assert_success(|config| { + let expected = ExitConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + validators_to_exit: vec![ + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_0).unwrap(), + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_1).unwrap(), + ], + beacon_url: None, + exit_epoch: Some(Epoch::new(1234567)), + presign: true, + }; + assert_eq!(expected, config); + }); +} + +#[test] +pub fn validator_exit_missing_validator_flag() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_using_beacon_and_presign_flags() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--beacon-node", Some("http://localhost:1001")) + .flag("--presign", None) + .assert_failed(); +} + +#[test] +pub fn validator_exit_using_beacon_and_exit_epoch_flags() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--beacon-node", Some("http://localhost:1001")) + .flag("--exit-epoch", Some("1234567")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_exit_epoch_flag_without_presign_flag() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--exit-epoch", Some("1234567")) + .assert_failed(); +} diff --git a/validator_client/http_api/src/test_utils.rs b/validator_client/http_api/src/test_utils.rs index 8c23f79fd3..feb71c3a46 100644 --- a/validator_client/http_api/src/test_utils.rs +++ b/validator_client/http_api/src/test_utils.rs @@ -26,6 +26,7 @@ use std::time::Duration; use task_executor::test_utils::TestRuntime; use tempfile::{tempdir, TempDir}; use tokio::sync::oneshot; +use types::ChainSpec; use validator_services::block_service::BlockService; use zeroize::Zeroizing; @@ -61,6 +62,7 @@ pub struct ApiTester { pub _server_shutdown: oneshot::Sender<()>, pub validator_dir: TempDir, pub secrets_dir: TempDir, + pub spec: Arc, } impl ApiTester { @@ -69,6 +71,19 @@ impl ApiTester { } pub async fn new_with_http_config(http_config: HttpConfig) -> Self { + let slot_clock = + TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1)); + let genesis_validators_root = Hash256::repeat_byte(42); + let spec = Arc::new(E::default_spec()); + Self::new_with_options(http_config, slot_clock, genesis_validators_root, spec).await + } + + pub async fn new_with_options( + http_config: HttpConfig, + slot_clock: TestingSlotClock, + genesis_validators_root: Hash256, + spec: Arc, + ) -> Self { let validator_dir = tempdir().unwrap(); let secrets_dir = tempdir().unwrap(); let token_path = tempdir().unwrap().path().join(PK_FILENAME); @@ -91,20 +106,15 @@ impl ApiTester { ..Default::default() }; - let spec = Arc::new(E::default_spec()); - let slashing_db_path = validator_dir.path().join(SLASHING_PROTECTION_FILENAME); let slashing_protection = SlashingDatabase::open_or_create(&slashing_db_path).unwrap(); - let slot_clock = - TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1)); - let test_runtime = TestRuntime::default(); let validator_store = Arc::new(LighthouseValidatorStore::new( initialized_validators, slashing_protection, - Hash256::repeat_byte(42), + genesis_validators_root, spec.clone(), Some(Arc::new(DoppelgangerService::default())), slot_clock.clone(), @@ -127,7 +137,7 @@ impl ApiTester { validator_store: Some(validator_store.clone()), graffiti_file: None, graffiti_flag: Some(Graffiti::default()), - spec, + spec: spec.clone(), config: http_config, sse_logging_components: None, slot_clock, @@ -161,6 +171,7 @@ impl ApiTester { _server_shutdown: shutdown_tx, validator_dir, secrets_dir, + spec, } } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 2cb6ba435e..67af1d73fe 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -55,8 +55,8 @@ const SLASHING_PROTECTION_HISTORY_EPOCHS: u64 = 512; /// Currently used as the default gas limit in execution clients. /// -/// https://ethresear.ch/t/on-increasing-the-block-gas-limit-technical-considerations-path-forward/21225. -pub const DEFAULT_GAS_LIMIT: u64 = 36_000_000; +/// https://ethpandaops.io/posts/gaslimit-scaling/. +pub const DEFAULT_GAS_LIMIT: u64 = 45_000_000; pub struct LighthouseValidatorStore { validators: Arc>, diff --git a/validator_client/src/cli.rs b/validator_client/src/cli.rs index cdbf9f8472..e1cce5c9da 100644 --- a/validator_client/src/cli.rs +++ b/validator_client/src/cli.rs @@ -388,7 +388,7 @@ pub struct ValidatorClient { #[clap( long, value_name = "INTEGER", - default_value_t = 36_000_000, + default_value_t = 45_000_000, requires = "builder_proposals", help = "The gas limit to be used in all builder proposals for all validators managed \ by this validator client. Note this will not necessarily be used if the gas limit \ diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index 7cb05616f4..9192f0e86b 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -17,12 +17,15 @@ ethereum_serde_utils = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +slot_clock = { workspace = true } tokio = { workspace = true } tree_hash = { workspace = true } types = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +beacon_chain = { workspace = true } +http_api = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } validator_http_api = { workspace = true } diff --git a/validator_manager/src/delete_validators.rs b/validator_manager/src/delete_validators.rs index 5ef647c5af..cb0557427c 100644 --- a/validator_manager/src/delete_validators.rs +++ b/validator_manager/src/delete_validators.rs @@ -45,7 +45,10 @@ pub fn cli_app() -> Command { Arg::new(VALIDATOR_FLAG) .long(VALIDATOR_FLAG) .value_name("STRING") - .help("Comma-separated list of validators (pubkey) that will be deleted.") + .help( + "Comma-separated list of validators (pubkey) that will be deleted. \ + To delete all validators, use the keyword \"all\".", + ) .action(ArgAction::Set) .required(true) .display_order(0), @@ -64,10 +67,14 @@ impl DeleteConfig { let validators_to_delete_str = clap_utils::parse_required::(matches, VALIDATOR_FLAG)?; - let validators_to_delete = validators_to_delete_str - .split(',') - .map(|s| s.trim().parse()) - .collect::, _>>()?; + let validators_to_delete = if validators_to_delete_str.trim() == "all" { + Vec::new() + } else { + validators_to_delete_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + }; Ok(Self { vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, @@ -90,11 +97,16 @@ async fn run(config: DeleteConfig) -> Result<(), String> { let DeleteConfig { vc_url, vc_token_path, - validators_to_delete, + mut validators_to_delete, } = config; let (http_client, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + // Delete all validators on the VC + if validators_to_delete.is_empty() { + validators_to_delete = validators.iter().map(|v| v.validating_pubkey).collect(); + } + for validator_to_delete in &validators_to_delete { if !validators .iter() diff --git a/validator_manager/src/exit_validators.rs b/validator_manager/src/exit_validators.rs new file mode 100644 index 0000000000..30d8c5c47d --- /dev/null +++ b/validator_manager/src/exit_validators.rs @@ -0,0 +1,585 @@ +use crate::{common::vc_http_client, DumpConfig}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use clap_utils::FLAG_HEADER; +use eth2::types::{ConfigAndPreset, Epoch, StateId, ValidatorId, ValidatorStatus}; +use eth2::{BeaconNodeHttpClient, SensitiveUrl, Timeouts}; +use serde::{Deserialize, Serialize}; +use serde_json; +use slot_clock::{SlotClock, SystemTimeSlotClock}; +use std::fs::write; +use std::path::PathBuf; +use std::time::Duration; +use types::{ChainSpec, EthSpec, PublicKeyBytes}; + +pub const CMD: &str = "exit"; +pub const BEACON_URL_FLAG: &str = "beacon-node"; +pub const VC_URL_FLAG: &str = "vc-url"; +pub const VC_TOKEN_FLAG: &str = "vc-token"; +pub const VALIDATOR_FLAG: &str = "validators"; +pub const EXIT_EPOCH_FLAG: &str = "exit-epoch"; +pub const PRESIGN_FLAG: &str = "presign"; + +pub fn cli_app() -> Command { + Command::new(CMD) + .about( + "Exits one or more validators using the HTTP API. It can \ + also be used to generate a presigned voluntary exit message for a particular future epoch.", + ) + .arg( + Arg::new(BEACON_URL_FLAG) + .long(BEACON_URL_FLAG) + .value_name("NETWORK_ADDRESS") + .help("Address to a beacon node HTTP API") + .action(ArgAction::Set) + .display_order(0) + .conflicts_with(PRESIGN_FLAG), + ) + .arg( + Arg::new(VC_URL_FLAG) + .long(VC_URL_FLAG) + .value_name("HTTP_ADDRESS") + .help("A HTTP(S) address of a validator client using the keymanager-API.") + .default_value("http://localhost:5062") + .requires(VC_TOKEN_FLAG) + .action(ArgAction::Set) + .display_order(0), + ) + .arg( + Arg::new(VC_TOKEN_FLAG) + .long(VC_TOKEN_FLAG) + .value_name("PATH") + .help("The file containing a token required by the validator client.") + .action(ArgAction::Set) + .display_order(0), + ) + .arg( + Arg::new(VALIDATOR_FLAG) + .long(VALIDATOR_FLAG) + .value_name("STRING") + .help( + "Comma-separated list of validators (pubkey) to exit. \ + To exit all validators, use the keyword \"all\".", + ) + .action(ArgAction::Set) + .required(true) + .display_order(0), + ) + .arg( + Arg::new(EXIT_EPOCH_FLAG) + .long(EXIT_EPOCH_FLAG) + .value_name("EPOCH") + .help( + "Provide the minimum epoch for processing voluntary exit. \ + This flag is required to be used in combination with `--presign` to \ + save the voluntary exit presign to a file for future use.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(PRESIGN_FLAG) + .conflicts_with(BEACON_URL_FLAG), + ) + .arg( + Arg::new(PRESIGN_FLAG) + .long(PRESIGN_FLAG) + .help( + "Generate the voluntary exit presign and save it to a file \ + named {validator_pubkey}.json. Note: Using this without the \ + `--beacon-node` flag will not publish the voluntary exit to the network.", + ) + .help_heading(FLAG_HEADER) + .action(ArgAction::SetTrue) + .display_order(0) + .conflicts_with(BEACON_URL_FLAG), + ) +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct ExitConfig { + pub vc_url: SensitiveUrl, + pub vc_token_path: PathBuf, + pub validators_to_exit: Vec, + pub beacon_url: Option, + pub exit_epoch: Option, + pub presign: bool, +} + +impl ExitConfig { + fn from_cli(matches: &ArgMatches) -> Result { + let validators_to_exit_str = clap_utils::parse_required::(matches, VALIDATOR_FLAG)?; + + // Keyword "all" to exit all validators, vector to be created later + let validators_to_exit = if validators_to_exit_str.trim() == "all" { + Vec::new() + } else { + validators_to_exit_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + }; + + Ok(Self { + vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, + validators_to_exit, + beacon_url: clap_utils::parse_optional(matches, BEACON_URL_FLAG)?, + exit_epoch: clap_utils::parse_optional(matches, EXIT_EPOCH_FLAG)?, + presign: matches.get_flag(PRESIGN_FLAG), + }) + } +} + +pub async fn cli_run( + matches: &ArgMatches, + dump_config: DumpConfig, +) -> Result<(), String> { + let config = ExitConfig::from_cli(matches)?; + + if dump_config.should_exit_early(&config)? { + Ok(()) + } else { + run::(config).await + } +} + +async fn run(config: ExitConfig) -> Result<(), String> { + let ExitConfig { + vc_url, + vc_token_path, + mut validators_to_exit, + beacon_url, + exit_epoch, + presign, + } = config; + + let (http_client, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + + if validators_to_exit.is_empty() { + validators_to_exit = validators.iter().map(|v| v.validating_pubkey).collect(); + } + + for validator_to_exit in validators_to_exit { + // Check that the validators_to_exit is in the validator client + if !validators + .iter() + .any(|validator| validator.validating_pubkey == validator_to_exit) + { + return Err(format!("Validator {} doesn't exist", validator_to_exit)); + } + + let exit_message = http_client + .post_validator_voluntary_exit(&validator_to_exit, exit_epoch) + .await + .map_err(|e| format!("Failed to generate voluntary exit message: {}", e))?; + + if presign { + let exit_message_json = serde_json::to_string(&exit_message.data); + + match exit_message_json { + Ok(json) => { + // Save the exit message to JSON file(s) + let file_path = format!("{}.json", validator_to_exit); + write(&file_path, json).map_err(|e| { + format!("Failed to write voluntary exit message to file: {}", e) + })?; + println!("Voluntary exit message saved to {}", file_path); + } + Err(e) => eprintln!("Failed to serialize voluntary exit message: {}", e), + } + } + + // Only publish the voluntary exit if the --beacon-node flag is present + if let Some(ref beacon_url) = beacon_url { + let beacon_node = BeaconNodeHttpClient::new( + SensitiveUrl::parse(beacon_url.as_ref()) + .map_err(|e| format!("Failed to parse beacon http server: {:?}", e))?, + Timeouts::set_all(Duration::from_secs(12)), + ); + + if beacon_node + .get_node_syncing() + .await + .map_err(|e| format!("Failed to get beacon node sync status: {:?}", e))? + .data + .is_syncing + { + return Err( + "Beacon node is syncing, submit the voluntary exit later when beacon node is synced" + .to_string(), + ); + } + + let genesis_data = beacon_node + .get_beacon_genesis() + .await + .map_err(|e| format!("Failed to get genesis data: {}", e))? + .data; + + let config_and_preset = beacon_node + .get_config_spec::() + .await + .map_err(|e| format!("Failed to get config spec: {}", e))? + .data; + + let spec = ChainSpec::from_config::(config_and_preset.config()) + .ok_or("Failed to create chain spec")?; + + let validator_data = beacon_node + .get_beacon_states_validator_id( + StateId::Head, + &ValidatorId::PublicKey(validator_to_exit), + ) + .await + .map_err(|e| format!("Failed to get validator details: {:?}", e))? + .ok_or_else(|| { + format!( + "Validator {} is not present in the beacon state. \ + Please ensure that your beacon node is synced \ + and the validator has been deposited.", + validator_to_exit + ) + })? + .data; + + let activation_epoch = validator_data.validator.activation_epoch; + let current_epoch = get_current_epoch::(genesis_data.genesis_time, &spec) + .ok_or("Failed to get current epoch. Please check your system time")?; + + // Check if validator is eligible for exit + if validator_data.status == ValidatorStatus::ActiveOngoing + && current_epoch < activation_epoch + spec.shard_committee_period + { + eprintln!( + "Validator {} is not eligible for exit. It will become eligible at epoch {}", + validator_to_exit, + activation_epoch + spec.shard_committee_period + ) + } else if validator_data.status != ValidatorStatus::ActiveOngoing { + eprintln!( + "Validator {} is not eligible for exit. Validator status is: {:?}", + validator_to_exit, validator_data.status + ) + } else { + // Only publish voluntary exit if validator status is ActiveOngoing + beacon_node + .post_beacon_pool_voluntary_exits(&exit_message.data) + .await + .map_err(|e| format!("Failed to publish voluntary exit: {}", e))?; + eprintln!( + "Successfully validated and published voluntary exit for validator {}", + validator_to_exit + ); + } + } + } + + Ok(()) +} + +pub fn get_current_epoch(genesis_time: u64, spec: &ChainSpec) -> Option { + let slot_clock = SystemTimeSlotClock::new( + spec.genesis_slot, + Duration::from_secs(genesis_time), + Duration::from_secs(spec.seconds_per_slot), + ); + slot_clock.now().map(|s| s.epoch(E::slots_per_epoch())) +} + +#[cfg(not(debug_assertions))] +#[cfg(test)] +mod test { + use super::*; + use crate::{ + common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, + }; + use account_utils::eth2_keystore::KeystoreBuilder; + use beacon_chain::test_utils::{AttestationStrategy, BlockStrategy}; + use eth2::lighthouse_vc::types::KeystoreJsonStr; + use http_api::test_utils::InteractiveTester; + use std::{ + fs::{self, File}, + io::Write, + sync::Arc, + }; + use types::{ChainSpec, MainnetEthSpec}; + use validator_http_api::{test_utils::ApiTester, Config as HttpConfig}; + use zeroize::Zeroizing; + type E = MainnetEthSpec; + + struct TestBuilder { + exit_config: Option, + src_import_builder: Option, + http_config: HttpConfig, + vc_token: Option, + validators: Vec, + beacon_node: InteractiveTester, + index_of_validators_to_exit: Vec, + spec: Arc, + } + + impl TestBuilder { + async fn new() -> Self { + let mut spec = ChainSpec::mainnet(); + spec.shard_committee_period = 1; + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(1)); + spec.capella_fork_epoch = Some(Epoch::new(2)); + spec.deneb_fork_epoch = Some(Epoch::new(3)); + + let beacon_node = InteractiveTester::new(Some(spec.clone()), 64).await; + + let harness = &beacon_node.harness; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let execution_ctx = mock_el.server.ctx.clone(); + + // Move to terminal block. + mock_el.server.all_payloads_valid(); + execution_ctx + .execution_block_generator + .write() + .move_to_terminal_block() + .unwrap(); + + Self { + exit_config: None, + src_import_builder: None, + http_config: ApiTester::default_http_config(), + vc_token: None, + validators: vec![], + beacon_node, + index_of_validators_to_exit: vec![], + spec: spec.into(), + } + } + + async fn with_validators(mut self, index_of_validators_to_exit: Vec) -> Self { + // Ensure genesis validators root matches the beacon node. + let genesis_validators_root = self + .beacon_node + .harness + .get_current_state() + .genesis_validators_root(); + // And use a single slot clock and same spec for BN and VC to keep things simple. + let slot_clock = self.beacon_node.harness.chain.slot_clock.clone(); + let vc = ApiTester::new_with_options( + self.http_config.clone(), + slot_clock, + genesis_validators_root, + self.spec.clone(), + ) + .await; + let mut builder = ImportTestBuilder::new_with_vc(vc).await; + + self.vc_token = + Some(fs::read_to_string(builder.get_import_config().vc_token_path).unwrap()); + + let local_validators: Vec = index_of_validators_to_exit + .iter() + .map(|&index| { + let keystore = KeystoreBuilder::new( + &self.beacon_node.harness.validator_keypairs[index], + "password".as_bytes(), + "".into(), + ) + .unwrap() + .build() + .unwrap(); + + ValidatorSpecification { + voting_keystore: KeystoreJsonStr(keystore), + voting_keystore_password: Zeroizing::new("password".into()), + slashing_protection: None, + fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + enabled: Some(true), + } + }) + .collect(); + + let beacon_url = SensitiveUrl::parse(self.beacon_node.client.as_ref()).unwrap(); + + let validators_to_exit = index_of_validators_to_exit + .iter() + .map(|&index| { + self.beacon_node.harness.validator_keypairs[index] + .pk + .clone() + .into() + }) + .collect(); + + let import_config = builder.get_import_config(); + + let validators_dir = import_config.vc_token_path.parent().unwrap(); + let validators_file = validators_dir.join("validators.json"); + + builder = builder.mutate_import_config(|config| { + config.validators_file_path = Some(validators_file.clone()); + }); + + fs::write( + &validators_file, + serde_json::to_string(&local_validators).unwrap(), + ) + .unwrap(); + + self.exit_config = Some(ExitConfig { + vc_url: import_config.vc_url, + vc_token_path: import_config.vc_token_path, + validators_to_exit, + beacon_url: Some(beacon_url), + exit_epoch: None, + presign: false, + }); + + self.validators = local_validators.clone(); + self.src_import_builder = Some(builder); + self.index_of_validators_to_exit = index_of_validators_to_exit; + self + } + + pub async fn run_test(self) -> TestResult { + let import_builder = self.src_import_builder.unwrap(); + let initialized_validators = import_builder.vc.initialized_validators.clone(); + let import_test_result = import_builder.run_test().await; + assert!(import_test_result.result.is_ok()); + + // only assign the validator index after validator is imported to the VC + for &index in &self.index_of_validators_to_exit { + initialized_validators.write().set_index( + &self.beacon_node.harness.validator_keypairs[index] + .pk + .compress(), + index as u64, + ); + } + + let path = self.exit_config.clone().unwrap().vc_token_path; + let parent = path.parent().unwrap(); + + fs::create_dir_all(parent).expect("Was not able to create parent directory"); + + File::options() + .write(true) + .read(true) + .create(true) + .truncate(true) + .open(path.clone()) + .unwrap() + .write_all(self.vc_token.clone().unwrap().as_bytes()) + .unwrap(); + + // Advance beacon chain + self.beacon_node.harness.advance_slot(); + + self.beacon_node + .harness + .extend_chain( + 100, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let result = run::(self.exit_config.clone().unwrap()).await; + + self.beacon_node.harness.advance_slot(); + + self.beacon_node + .harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let validator_data = self + .index_of_validators_to_exit + .iter() + .map(|&index| { + self.beacon_node + .harness + .get_current_state() + .get_validator(index) + .unwrap() + .clone() + }) + .collect::>(); + + let validator_exit_epoch = validator_data + .iter() + .map(|validator| validator.exit_epoch) + .collect::>(); + + let validator_withdrawable_epoch = validator_data + .iter() + .map(|validator| validator.withdrawable_epoch) + .collect::>(); + + let current_epoch = self.beacon_node.harness.get_current_state().current_epoch(); + let max_seed_lookahead = self.beacon_node.harness.spec.max_seed_lookahead; + let min_withdrawability_delay = self + .beacon_node + .harness + .spec + .min_validator_withdrawability_delay; + + // As per the spec: + // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_activation_exit_epoch + let beacon_exit_epoch = current_epoch + 1 + max_seed_lookahead; + let beacon_withdrawable_epoch = beacon_exit_epoch + min_withdrawability_delay; + + assert!(validator_exit_epoch + .iter() + .all(|&epoch| epoch == beacon_exit_epoch)); + + assert!(validator_withdrawable_epoch + .iter() + .all(|&epoch| epoch == beacon_withdrawable_epoch)); + + if result.is_ok() { + return TestResult { result: Ok(()) }; + } + + TestResult { + result: Err(result.unwrap_err()), + } + } + } + + #[must_use] + struct TestResult { + result: Result<(), String>, + } + + impl TestResult { + fn assert_ok(self) { + assert_eq!(self.result, Ok(())) + } + } + #[tokio::test] + async fn exit_single_validator() { + TestBuilder::new() + .await + .with_validators(vec![0]) + .await + .run_test() + .await + .assert_ok(); + } + + #[tokio::test] + async fn exit_multiple_validators() { + TestBuilder::new() + .await + .with_validators(vec![10, 20, 30]) + .await + .run_test() + .await + .assert_ok(); + } +} diff --git a/validator_manager/src/import_validators.rs b/validator_manager/src/import_validators.rs index 6cfbf7b54e..e5047f3f37 100644 --- a/validator_manager/src/import_validators.rs +++ b/validator_manager/src/import_validators.rs @@ -404,8 +404,12 @@ pub mod tests { } pub async fn new_with_http_config(http_config: HttpConfig) -> Self { - let dir = tempdir().unwrap(); let vc = ApiTester::new_with_http_config(http_config).await; + Self::new_with_vc(vc).await + } + + pub async fn new_with_vc(vc: ApiTester) -> Self { + let dir = tempdir().unwrap(); let vc_token_path = dir.path().join(VC_TOKEN_FILE_NAME); fs::write(&vc_token_path, &vc.api_token).unwrap(); diff --git a/validator_manager/src/lib.rs b/validator_manager/src/lib.rs index 9beccd3bde..fb74779304 100644 --- a/validator_manager/src/lib.rs +++ b/validator_manager/src/lib.rs @@ -9,6 +9,7 @@ use types::EthSpec; pub mod common; pub mod create_validators; pub mod delete_validators; +pub mod exit_validators; pub mod import_validators; pub mod list_validators; pub mod move_validators; @@ -51,6 +52,7 @@ pub fn cli_app() -> Command { .subcommand(move_validators::cli_app()) .subcommand(list_validators::cli_app()) .subcommand(delete_validators::cli_app()) + .subcommand(exit_validators::cli_app()) } /// Run the account manager, returning an error if the operation did not succeed. @@ -79,11 +81,14 @@ pub fn run(matches: &ArgMatches, env: Environment) -> Result<(), move_validators::cli_run(matches, dump_config).await } Some((list_validators::CMD, matches)) => { - list_validators::cli_run(matches, dump_config).await + list_validators::cli_run::(matches, dump_config).await } Some((delete_validators::CMD, matches)) => { delete_validators::cli_run(matches, dump_config).await } + Some((exit_validators::CMD, matches)) => { + exit_validators::cli_run::(matches, dump_config).await + } Some(("", _)) => Err("No command supplied. See --help.".to_string()), Some((unknown, _)) => Err(format!( "{} is not a valid {} command. See --help.", diff --git a/validator_manager/src/list_validators.rs b/validator_manager/src/list_validators.rs index a0a1c5fb40..6016b89eea 100644 --- a/validator_manager/src/list_validators.rs +++ b/validator_manager/src/list_validators.rs @@ -1,14 +1,20 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use eth2::lighthouse_vc::types::SingleKeystoreResponse; -use eth2::SensitiveUrl; +use eth2::types::{ConfigAndPreset, StateId, ValidatorId, ValidatorStatus}; +use eth2::{BeaconNodeHttpClient, SensitiveUrl, Timeouts}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::time::Duration; +use types::{ChainSpec, EthSpec, PublicKeyBytes}; +use crate::exit_validators::get_current_epoch; use crate::{common::vc_http_client, DumpConfig}; pub const CMD: &str = "list"; pub const VC_URL_FLAG: &str = "vc-url"; pub const VC_TOKEN_FLAG: &str = "vc-token"; +pub const BEACON_URL_FLAG: &str = "beacon-node"; +pub const VALIDATOR_FLAG: &str = "validators"; pub fn cli_app() -> Command { Command::new(CMD) @@ -31,47 +37,177 @@ pub fn cli_app() -> Command { .action(ArgAction::Set) .display_order(0), ) + .arg( + Arg::new(BEACON_URL_FLAG) + .long(BEACON_URL_FLAG) + .value_name("NETWORK_ADDRESS") + .help( + "Address to a beacon node HTTP API. When supplied, \ + the status of validators (with regard to voluntary exit) \ + will be displayed. This flag is to be used together with \ + the --validators flag.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(VALIDATOR_FLAG), + ) + .arg( + Arg::new(VALIDATOR_FLAG) + .long(VALIDATOR_FLAG) + .value_name("STRING") + .help( + "Comma-separated list of validators (pubkey) to display status for. \ + To display the status for all validators, use the keyword \"all\". \ + This flag is to be used together with the --beacon-node flag.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(BEACON_URL_FLAG), + ) } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] pub struct ListConfig { pub vc_url: SensitiveUrl, pub vc_token_path: PathBuf, + pub beacon_url: Option, + pub validators_to_display: Vec, } impl ListConfig { fn from_cli(matches: &ArgMatches) -> Result { + let validators_to_display_str = + clap_utils::parse_optional::(matches, VALIDATOR_FLAG)?; + + // Keyword "all" to list all validators, vector to be created later + let validators_to_display = match validators_to_display_str { + Some(str) => { + if str.trim() == "all" { + Vec::new() + } else { + str.split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + } + } + None => Vec::new(), + }; + Ok(Self { vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + beacon_url: clap_utils::parse_optional(matches, BEACON_URL_FLAG)?, + validators_to_display, }) } } -pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<(), String> { +pub async fn cli_run( + matches: &ArgMatches, + dump_config: DumpConfig, +) -> Result<(), String> { let config = ListConfig::from_cli(matches)?; if dump_config.should_exit_early(&config)? { Ok(()) } else { - run(config).await?; + run::(config).await?; Ok(()) } } -async fn run(config: ListConfig) -> Result, String> { +async fn run(config: ListConfig) -> Result, String> { let ListConfig { vc_url, vc_token_path, + beacon_url, + mut validators_to_display, } = config; let (_, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; println!("List of validators ({}):", validators.len()); - for validator in &validators { - println!("{}", validator.validating_pubkey); + if validators_to_display.is_empty() { + validators_to_display = validators.iter().map(|v| v.validating_pubkey).collect(); } + if let Some(ref beacon_url) = beacon_url { + for validator in &validators_to_display { + let beacon_node = BeaconNodeHttpClient::new( + SensitiveUrl::parse(beacon_url.as_ref()) + .map_err(|e| format!("Failed to parse beacon http server: {:?}", e))?, + Timeouts::set_all(Duration::from_secs(12)), + ); + + let validator_data = beacon_node + .get_beacon_states_validator_id(StateId::Head, &ValidatorId::PublicKey(*validator)) + .await + .map_err(|e| format!("Failed to get updated validator details: {:?}", e))? + .ok_or_else(|| { + format!("Validator {} is not present in the beacon state", validator) + })? + .data; + + match validator_data.status { + ValidatorStatus::ActiveExiting => { + let exit_epoch = validator_data.validator.exit_epoch; + let withdrawal_epoch = validator_data.validator.withdrawable_epoch; + + let genesis_data = beacon_node + .get_beacon_genesis() + .await + .map_err(|e| format!("Failed to get genesis data: {}", e))? + .data; + + let config_and_preset = beacon_node + .get_config_spec::() + .await + .map_err(|e| format!("Failed to get config spec: {}", e))? + .data; + + let spec = ChainSpec::from_config::(config_and_preset.config()) + .ok_or("Failed to create chain spec")?; + + let current_epoch = get_current_epoch::(genesis_data.genesis_time, &spec) + .ok_or("Failed to get current epoch. Please check your system time")?; + + eprintln!( + "Voluntary exit for validator {} has been accepted into the beacon chain. \ + Note that the voluntary exit is subject chain finalization. \ + Before the chain has finalized, there is a low \ + probability that the exit may be reverted.", + validator + ); + eprintln!( + "Current epoch: {}, Exit epoch: {}, Withdrawable epoch: {}", + current_epoch, exit_epoch, withdrawal_epoch + ); + eprintln!("Please keep your validator running till exit epoch"); + eprintln!( + "Exit epoch in approximately {} secs", + (exit_epoch - current_epoch) * spec.seconds_per_slot * E::slots_per_epoch() + ); + } + ValidatorStatus::ExitedSlashed | ValidatorStatus::ExitedUnslashed => { + eprintln!( + "Validator {} has exited at epoch: {}", + validator, validator_data.validator.exit_epoch + ); + } + _ => { + eprintln!( + "Validator {} has not initiated voluntary exit or the voluntary exit \ + is yet to be accepted into the beacon chain. Validator status is: {}", + validator, validator_data.status + ) + } + } + } + } else { + for validator in &validators { + println!("{}", validator.validating_pubkey); + } + } Ok(validators) } @@ -87,7 +223,9 @@ mod test { use crate::{ common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, }; + use types::MainnetEthSpec; use validator_http_api::{test_utils::ApiTester, Config as HttpConfig}; + type E = MainnetEthSpec; struct TestBuilder { list_config: Option, @@ -116,6 +254,8 @@ mod test { self.list_config = Some(ListConfig { vc_url: builder.get_import_config().vc_url, vc_token_path: builder.get_import_config().vc_token_path, + beacon_url: None, + validators_to_display: vec![], }); self.vc_token = @@ -152,7 +292,7 @@ mod test { .write_all(self.vc_token.clone().unwrap().as_bytes()) .unwrap(); - let result = run(self.list_config.clone().unwrap()).await; + let result = run::(self.list_config.clone().unwrap()).await; if result.is_ok() { let result_ref = result.as_ref().unwrap(); diff --git a/wordlist.txt b/wordlist.txt index 6679ad0bbf..fdb2f43e42 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -197,6 +197,8 @@ pem performant pid pre +presign +presigned pubkey pubkeys rc