diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 8cd991cc103..3bf75284779 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1351,14 +1351,27 @@ impl BeaconChain { ) -> Result<(), Error> { self.light_client_server_cache.recompute_and_cache_updates( self.store.clone(), - &parent_root, slot, + &parent_root, &sync_aggregate, &self.log, &self.spec, ) } + pub fn get_light_client_updates( + &self, + sync_committee_period: u64, + count: u64, + ) -> Result>, Error> { + self.light_client_server_cache.get_light_client_updates( + &self.store, + sync_committee_period, + count, + &self.spec, + ) + } + /// Returns the current heads of the `BeaconChain`. For the canonical head, see `Self::head`. /// /// Returns `(block_root, block_slot)`. diff --git a/beacon_node/beacon_chain/src/light_client_server_cache.rs b/beacon_node/beacon_chain/src/light_client_server_cache.rs index 87513885f77..efc746675dc 100644 --- a/beacon_node/beacon_chain/src/light_client_server_cache.rs +++ b/beacon_node/beacon_chain/src/light_client_server_cache.rs @@ -1,14 +1,23 @@ use crate::errors::BeaconChainError; use crate::{metrics, BeaconChainTypes, BeaconStore}; use parking_lot::{Mutex, RwLock}; +use safe_arith::SafeArith; use slog::{debug, Logger}; +use ssz::Decode; +use ssz::Encode; use ssz_types::FixedVector; use std::num::NonZeroUsize; -use types::light_client_update::{FinalizedRootProofLen, FINALIZED_ROOT_INDEX}; +use std::sync::Arc; +use store::DBColumn; +use store::KeyValueStore; +use types::light_client_update::{ + FinalizedRootProofLen, NextSyncCommitteeProofLen, FINALIZED_ROOT_INDEX, + NEXT_SYNC_COMMITTEE_INDEX, +}; use types::non_zero_usize::new_non_zero_usize; use types::{ BeaconBlockRef, BeaconState, ChainSpec, EthSpec, ForkName, Hash256, LightClientFinalityUpdate, - LightClientOptimisticUpdate, Slot, SyncAggregate, + LightClientOptimisticUpdate, LightClientUpdate, Slot, SyncAggregate, SyncCommittee, }; /// A prev block cache miss requires to re-generate the state of the post-parent block. Items in the @@ -30,8 +39,10 @@ pub struct LightClientServerCache { latest_finality_update: RwLock>>, /// Tracks a single global latest optimistic update out of all imported blocks. latest_optimistic_update: RwLock>>, + /// Caches the most recent light client update + latest_light_client_update: RwLock>>, /// Caches state proofs by block root - prev_block_cache: Mutex>, + prev_block_cache: Mutex>>, } impl LightClientServerCache { @@ -39,13 +50,14 @@ impl LightClientServerCache { Self { latest_finality_update: None.into(), latest_optimistic_update: None.into(), + latest_light_client_update: None.into(), prev_block_cache: lru::LruCache::new(PREV_BLOCK_CACHE_SIZE).into(), } } /// Compute and cache state proofs for latter production of light-client messages. Does not /// trigger block replay. - pub fn cache_state_data( + pub(crate) fn cache_state_data( &self, spec: &ChainSpec, block: BeaconBlockRef, @@ -67,13 +79,13 @@ impl LightClientServerCache { Ok(()) } - /// Given a block with a SyncAggregte computes better or more recent light client updates. The + /// Given a block with a SyncAggregate computes better or more recent light client updates. The /// results are cached either on disk or memory to be served via p2p and rest API pub fn recompute_and_cache_updates( &self, store: BeaconStore, - block_parent_root: &Hash256, block_slot: Slot, + block_parent_root: &Hash256, sync_aggregate: &SyncAggregate, log: &Logger, chain_spec: &ChainSpec, @@ -100,11 +112,17 @@ impl LightClientServerCache { let attested_slot = attested_block.slot(); + let maybe_finalized_block = store.get_blinded_block(&cached_parts.finalized_block_root)?; + + let sync_period = block_slot + .epoch(T::EthSpec::slots_per_epoch()) + .sync_committee_period(chain_spec)?; + // Spec: Full nodes SHOULD provide the LightClientOptimisticUpdate with the highest // attested_header.beacon.slot (if multiple, highest signature_slot) as selected by fork choice let is_latest_optimistic = match &self.latest_optimistic_update.read().clone() { Some(latest_optimistic_update) => { - is_latest_optimistic_update(latest_optimistic_update, attested_slot, signature_slot) + latest_optimistic_update.is_latest(attested_slot, signature_slot) } None => true, }; @@ -122,18 +140,17 @@ impl LightClientServerCache { // attested_header.beacon.slot (if multiple, highest signature_slot) as selected by fork choice let is_latest_finality = match &self.latest_finality_update.read().clone() { Some(latest_finality_update) => { - is_latest_finality_update(latest_finality_update, attested_slot, signature_slot) + latest_finality_update.is_latest(attested_slot, signature_slot) } None => true, }; + if is_latest_finality & !cached_parts.finalized_block_root.is_zero() { // Immediately after checkpoint sync the finalized block may not be available yet. - if let Some(finalized_block) = - store.get_blinded_block(&cached_parts.finalized_block_root)? - { + if let Some(finalized_block) = maybe_finalized_block.as_ref() { *self.latest_finality_update.write() = Some(LightClientFinalityUpdate::new( &attested_block, - &finalized_block, + finalized_block, cached_parts.finality_branch.clone(), sync_aggregate.clone(), signature_slot, @@ -148,9 +165,142 @@ impl LightClientServerCache { } } + let new_light_client_update = LightClientUpdate::new( + sync_aggregate, + block_slot, + cached_parts.next_sync_committee, + cached_parts.next_sync_committee_branch, + cached_parts.finality_branch, + &attested_block, + maybe_finalized_block.as_ref(), + chain_spec, + )?; + + // Spec: Full nodes SHOULD provide the best derivable LightClientUpdate (according to is_better_update) + // for each sync committee period + let prev_light_client_update = match &self.latest_light_client_update.read().clone() { + Some(prev_light_client_update) => Some(prev_light_client_update.clone()), + None => self.get_light_client_update(&store, sync_period, chain_spec)?, + }; + + let should_persist_light_client_update = + if let Some(prev_light_client_update) = prev_light_client_update { + let prev_sync_period = prev_light_client_update + .signature_slot() + .epoch(T::EthSpec::slots_per_epoch()) + .sync_committee_period(chain_spec)?; + + if sync_period != prev_sync_period { + true + } else { + prev_light_client_update + .is_better_light_client_update(&new_light_client_update, chain_spec)? + } + } else { + true + }; + + if should_persist_light_client_update { + self.store_light_client_update(&store, sync_period, &new_light_client_update)?; + } + Ok(()) } + fn store_light_client_update( + &self, + store: &BeaconStore, + sync_committee_period: u64, + light_client_update: &LightClientUpdate, + ) -> Result<(), BeaconChainError> { + let column = DBColumn::LightClientUpdate; + + store.hot_db.put_bytes( + column.into(), + &sync_committee_period.to_le_bytes(), + &light_client_update.as_ssz_bytes(), + )?; + + *self.latest_light_client_update.write() = Some(light_client_update.clone()); + + Ok(()) + } + + // Used to fetch the most recently persisted "best" light client update. + // Should not be used outside the light client server, as it also caches the fetched + // light client update. + fn get_light_client_update( + &self, + store: &BeaconStore, + sync_committee_period: u64, + chain_spec: &ChainSpec, + ) -> Result>, BeaconChainError> { + if let Some(latest_light_client_update) = self.latest_light_client_update.read().clone() { + let latest_lc_update_sync_committee_period = latest_light_client_update + .signature_slot() + .epoch(T::EthSpec::slots_per_epoch()) + .sync_committee_period(chain_spec)?; + if latest_lc_update_sync_committee_period == sync_committee_period { + return Ok(Some(latest_light_client_update)); + } + } + + let column = DBColumn::LightClientUpdate; + let res = store + .hot_db + .get_bytes(column.into(), &sync_committee_period.to_le_bytes())?; + + if let Some(light_client_update_bytes) = res { + let epoch = sync_committee_period + .safe_mul(chain_spec.epochs_per_sync_committee_period.into())?; + + let fork_name = chain_spec.fork_name_at_epoch(epoch.into()); + + let light_client_update = + LightClientUpdate::from_ssz_bytes(&light_client_update_bytes, &fork_name) + .map_err(store::errors::Error::SszDecodeError)?; + + *self.latest_light_client_update.write() = Some(light_client_update.clone()); + return Ok(Some(light_client_update)); + } + + Ok(None) + } + + pub fn get_light_client_updates( + &self, + store: &BeaconStore, + start_period: u64, + count: u64, + chain_spec: &ChainSpec, + ) -> Result>, BeaconChainError> { + let column = DBColumn::LightClientUpdate; + let mut light_client_updates = vec![]; + for res in store + .hot_db + .iter_column_from::>(column, &start_period.to_le_bytes()) + { + let (sync_committee_bytes, light_client_update_bytes) = res?; + let sync_committee_period = u64::from_ssz_bytes(&sync_committee_bytes) + .map_err(store::errors::Error::SszDecodeError)?; + let epoch = sync_committee_period + .safe_mul(chain_spec.epochs_per_sync_committee_period.into())?; + + let fork_name = chain_spec.fork_name_at_epoch(epoch.into()); + + let light_client_update = + LightClientUpdate::from_ssz_bytes(&light_client_update_bytes, &fork_name) + .map_err(store::errors::Error::SszDecodeError)?; + + light_client_updates.push(light_client_update); + + if sync_committee_period >= start_period + count { + break; + } + } + Ok(light_client_updates) + } + /// Retrieves prev block cached data from cache. If not present re-computes by retrieving the /// parent state, and inserts an entry to the cache. /// @@ -161,7 +311,7 @@ impl LightClientServerCache { block_root: &Hash256, block_state_root: &Hash256, block_slot: Slot, - ) -> Result { + ) -> Result, BeaconChainError> { // Attempt to get the value from the cache first. if let Some(cached_parts) = self.prev_block_cache.lock().get(block_root) { return Ok(cached_parts.clone()); @@ -199,52 +349,25 @@ impl Default for LightClientServerCache { } type FinalityBranch = FixedVector; +type NextSyncCommitteeBranch = FixedVector; #[derive(Clone)] -struct LightClientCachedData { +struct LightClientCachedData { finality_branch: FinalityBranch, + next_sync_committee_branch: NextSyncCommitteeBranch, + next_sync_committee: Arc>, finalized_block_root: Hash256, } -impl LightClientCachedData { - fn from_state(state: &mut BeaconState) -> Result { +impl LightClientCachedData { + fn from_state(state: &mut BeaconState) -> Result { Ok(Self { finality_branch: state.compute_merkle_proof(FINALIZED_ROOT_INDEX)?.into(), + next_sync_committee: state.next_sync_committee()?.clone(), + next_sync_committee_branch: state + .compute_merkle_proof(NEXT_SYNC_COMMITTEE_INDEX)? + .into(), finalized_block_root: state.finalized_checkpoint().root, }) } } - -// Implements spec prioritization rules: -// > Full nodes SHOULD provide the LightClientFinalityUpdate with the highest attested_header.beacon.slot (if multiple, highest signature_slot) -// -// ref: https://github.com/ethereum/consensus-specs/blob/113c58f9bf9c08867f6f5f633c4d98e0364d612a/specs/altair/light-client/full-node.md#create_light_client_finality_update -fn is_latest_finality_update( - prev: &LightClientFinalityUpdate, - attested_slot: Slot, - signature_slot: Slot, -) -> bool { - let prev_slot = prev.get_attested_header_slot(); - if attested_slot > prev_slot { - true - } else { - attested_slot == prev_slot && signature_slot > *prev.signature_slot() - } -} - -// Implements spec prioritization rules: -// > Full nodes SHOULD provide the LightClientOptimisticUpdate with the highest attested_header.beacon.slot (if multiple, highest signature_slot) -// -// ref: https://github.com/ethereum/consensus-specs/blob/113c58f9bf9c08867f6f5f633c4d98e0364d612a/specs/altair/light-client/full-node.md#create_light_client_optimistic_update -fn is_latest_optimistic_update( - prev: &LightClientOptimisticUpdate, - attested_slot: Slot, - signature_slot: Slot, -) -> bool { - let prev_slot = prev.get_slot(); - if attested_slot > prev_slot { - true - } else { - attested_slot == prev_slot && signature_slot > *prev.signature_slot() - } -} diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 01d7798b92c..7049bf14fde 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -5,6 +5,7 @@ use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::builder::BeaconChainBuilder; use beacon_chain::data_availability_checker::AvailableBlock; use beacon_chain::schema_change::migrate_schema; +use beacon_chain::test_utils::RelativeSyncCommittee; use beacon_chain::test_utils::{ mock_execution_layer_from_parts, test_spec, AttestationStrategy, BeaconChainHarness, BlockStrategy, DiskHarnessType, KZG, @@ -103,6 +104,256 @@ fn get_harness_generic( harness } +#[tokio::test] +async fn light_client_updates_test() { + let spec = test_spec::(); + let Some(_) = spec.altair_fork_epoch else { + // No-op prior to Altair. + return; + }; + + let num_final_blocks = E::slots_per_epoch() * 2; + let checkpoint_slot = Slot::new(E::slots_per_epoch() * 9); + let db_path = tempdir().unwrap(); + let log = test_logger(); + + let seconds_per_slot = spec.seconds_per_slot; + let store = get_store_generic( + &db_path, + StoreConfig { + slots_per_restore_point: 2 * E::slots_per_epoch(), + ..Default::default() + }, + test_spec::(), + ); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); + let num_initial_slots = E::slots_per_epoch() * 10; + let slots: Vec = (1..num_initial_slots).map(Slot::new).collect(); + + let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + harness + .add_attested_blocks_at_slots( + genesis_state.clone(), + genesis_state_root, + &slots, + &all_validators, + ) + .await; + + let wss_block_root = harness + .chain + .block_root_at_slot(checkpoint_slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + let wss_state_root = harness + .chain + .state_root_at_slot(checkpoint_slot) + .unwrap() + .unwrap(); + let wss_block = harness + .chain + .store + .get_full_block(&wss_block_root) + .unwrap() + .unwrap(); + let wss_blobs_opt = harness.chain.store.get_blobs(&wss_block_root).unwrap(); + let wss_state = store + .get_state(&wss_state_root, Some(checkpoint_slot)) + .unwrap() + .unwrap(); + + let kzg = spec.deneb_fork_epoch.map(|_| KZG.clone()); + + let mock = + mock_execution_layer_from_parts(&harness.spec, harness.runtime.task_executor.clone()); + + harness.advance_slot(); + harness + .extend_chain( + num_final_blocks as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Initialise a new beacon chain from the finalized checkpoint. + // The slot clock must be set to a time ahead of the checkpoint state. + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(harness.chain.genesis_time), + Duration::from_secs(seconds_per_slot), + ); + slot_clock.set_slot(harness.get_current_slot().as_u64()); + + let (shutdown_tx, _shutdown_rx) = futures::channel::mpsc::channel(1); + + let beacon_chain = BeaconChainBuilder::>::new(MinimalEthSpec) + .store(store.clone()) + .custom_spec(test_spec::()) + .task_executor(harness.chain.task_executor.clone()) + .logger(log.clone()) + .weak_subjectivity_state( + wss_state, + wss_block.clone(), + wss_blobs_opt.clone(), + genesis_state, + ) + .unwrap() + .store_migrator_config(MigratorConfig::default().blocking()) + .dummy_eth1_backend() + .expect("should build dummy backend") + .slot_clock(slot_clock) + .shutdown_sender(shutdown_tx) + .chain_config(ChainConfig::default()) + .event_handler(Some(ServerSentEventHandler::new_with_capacity( + log.clone(), + 1, + ))) + .execution_layer(Some(mock.el)) + .kzg(kzg) + .build() + .expect("should build"); + + let beacon_chain = Arc::new(beacon_chain); + + let current_state = harness.get_current_state(); + + if ForkName::Electra == current_state.fork_name_unchecked() { + // TODO(electra) fix beacon state `compute_merkle_proof` + return; + } + + let block_root = *current_state + .get_block_root(current_state.slot() - Slot::new(1)) + .unwrap(); + + let contributions = harness.make_sync_contributions( + ¤t_state, + block_root, + current_state.slot() - Slot::new(1), + RelativeSyncCommittee::Current, + ); + + // generate sync aggregates + for (_, contribution_and_proof) in contributions { + let contribution = contribution_and_proof + .expect("contribution exists for committee") + .message + .contribution; + beacon_chain + .op_pool + .insert_sync_contribution(contribution.clone()) + .unwrap(); + beacon_chain + .op_pool + .insert_sync_contribution(contribution) + .unwrap(); + } + + // check that we can fetch the newly generated sync aggregate + let sync_aggregate = beacon_chain + .op_pool + .get_sync_aggregate(¤t_state) + .unwrap() + .unwrap(); + + // cache light client data + beacon_chain + .light_client_server_cache + .recompute_and_cache_updates( + store.clone(), + current_state.slot() - Slot::new(1), + &block_root, + &sync_aggregate, + &log, + &spec, + ) + .unwrap(); + + // calculate the sync period from the previous slot + let sync_period = (current_state.slot() - Slot::new(1)) + .epoch(E::slots_per_epoch()) + .sync_committee_period(&spec) + .unwrap(); + + // fetch a range of light client updates. right now there should only be one light client update + // in the db. + let lc_updates = beacon_chain + .get_light_client_updates(sync_period, 100) + .unwrap(); + + assert_eq!(lc_updates.len(), 1); + + // Advance to the next sync committee period + for _i in 0..(E::slots_per_epoch() * u64::from(spec.epochs_per_sync_committee_period)) { + harness.advance_slot(); + } + + harness + .extend_chain( + num_final_blocks as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let current_state = harness.get_current_state(); + + let block_root = *current_state + .get_block_root(current_state.slot() - Slot::new(1)) + .unwrap(); + + let contributions = harness.make_sync_contributions( + ¤t_state, + block_root, + current_state.slot() - Slot::new(1), + RelativeSyncCommittee::Current, + ); + + // generate new sync aggregates from this new state + for (_, contribution_and_proof) in contributions { + let contribution = contribution_and_proof + .expect("contribution exists for committee") + .message + .contribution; + beacon_chain + .op_pool + .insert_sync_contribution(contribution.clone()) + .unwrap(); + beacon_chain + .op_pool + .insert_sync_contribution(contribution) + .unwrap(); + } + + let sync_aggregate = beacon_chain + .op_pool + .get_sync_aggregate(¤t_state) + .unwrap() + .unwrap(); + + // cache new light client data + beacon_chain + .light_client_server_cache + .recompute_and_cache_updates( + store.clone(), + current_state.slot() - Slot::new(1), + &block_root, + &sync_aggregate, + &log, + &spec, + ) + .unwrap(); + + // we should now have two light client updates in the db + let lc_updates = beacon_chain + .get_light_client_updates(sync_period, 100) + .unwrap(); + + assert_eq!(lc_updates.len(), 2); +} + /// Tests that `store.heal_freezer_block_roots_at_split` inserts block roots between last restore point /// slot and the split slot. #[tokio::test] diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index f98f4493964..aa47d5c4649 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -13,6 +13,7 @@ mod block_rewards; mod build_block_contents; mod builder_states; mod database; +mod light_client; mod metrics; mod produce_block; mod proposer_duties; @@ -30,6 +31,7 @@ mod validator_inclusion; mod validators; mod version; +use crate::light_client::get_light_client_updates; use crate::produce_block::{produce_blinded_block_v2, produce_block_v2, produce_block_v3}; use crate::version::fork_versioned_response; use beacon_chain::{ @@ -44,8 +46,8 @@ use bytes::Bytes; use directory::DEFAULT_ROOT_DIR; use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceNode, - PublishBlockRequest, ValidatorBalancesRequestBody, ValidatorId, ValidatorStatus, - ValidatorsRequestBody, + LightClientUpdatesQuery, PublishBlockRequest, ValidatorBalancesRequestBody, ValidatorId, + ValidatorStatus, ValidatorsRequestBody, }; use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::{types::SyncState, EnrExt, NetworkGlobals, PeerId, PubsubMessage}; @@ -2484,6 +2486,25 @@ pub fn serve( }, ); + // GET beacon/light_client/updates + let get_beacon_light_client_updates = beacon_light_client_path + .clone() + .and(task_spawner_filter.clone()) + .and(warp::path("updates")) + .and(warp::path::end()) + .and(warp::query::()) + .and(warp::header::optional::("accept")) + .then( + |chain: Arc>, + task_spawner: TaskSpawner, + query: LightClientUpdatesQuery, + accept_header: Option| { + task_spawner.blocking_response_task(Priority::P1, move || { + get_light_client_updates::(chain, query, accept_header) + }) + }, + ); + /* * beacon/rewards */ @@ -4640,6 +4661,10 @@ pub fn serve( enable(ctx.config.enable_light_client_server) .and(get_beacon_light_client_bootstrap), ) + .uor( + enable(ctx.config.enable_light_client_server) + .and(get_beacon_light_client_updates), + ) .uor(get_lighthouse_block_packing_efficiency) .uor(get_lighthouse_merge_readiness) .uor(get_events) diff --git a/beacon_node/http_api/src/light_client.rs b/beacon_node/http_api/src/light_client.rs new file mode 100644 index 00000000000..a6543114b85 --- /dev/null +++ b/beacon_node/http_api/src/light_client.rs @@ -0,0 +1,143 @@ +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use eth2::types::{ + self as api_types, ChainSpec, ForkVersionedResponse, LightClientUpdate, + LightClientUpdateResponseChunk, LightClientUpdateSszResponse, LightClientUpdatesQuery, +}; +use ssz::Encode; +use std::sync::Arc; +use warp::{ + hyper::{Body, Response}, + reply::Reply, + Rejection, +}; + +use crate::version::{add_ssz_content_type_header, fork_versioned_response, V1}; + +const MAX_REQUEST_LIGHT_CLIENT_UPDATES: u64 = 128; + +pub fn get_light_client_updates( + chain: Arc>, + query: LightClientUpdatesQuery, + accept_header: Option, +) -> Result, Rejection> { + validate_light_client_updates_request(&chain, &query)?; + + let light_client_updates = chain + .get_light_client_updates(query.start_period, query.count) + .map_err(|_| { + warp_utils::reject::custom_not_found("No LightClientUpdates found".to_string()) + })?; + + match accept_header { + Some(api_types::Accept::Ssz) => { + let response_chunks = light_client_updates + .iter() + .map(|update| map_light_client_update_to_ssz_chunk::(&chain, update)) + .collect::>(); + + let ssz_response = LightClientUpdateSszResponse { + response_chunk_len: (light_client_updates.len() as u64).to_le_bytes().to_vec(), + response_chunk: response_chunks.as_ssz_bytes(), + } + .as_ssz_bytes(); + + Response::builder() + .status(200) + .body(ssz_response) + .map(|res: Response>| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }) + } + _ => { + let fork_versioned_response = light_client_updates + .iter() + .map(|update| map_light_client_update_to_json_response::(&chain, update.clone())) + .collect::>>, Rejection>>()?; + Ok(warp::reply::json(&fork_versioned_response).into_response()) + } + } +} + +pub fn validate_light_client_updates_request( + chain: &BeaconChain, + query: &LightClientUpdatesQuery, +) -> Result<(), Rejection> { + if query.count > MAX_REQUEST_LIGHT_CLIENT_UPDATES { + return Err(warp_utils::reject::custom_bad_request( + "Invalid count requested".to_string(), + )); + } + + let current_sync_period = chain + .epoch() + .map_err(|_| { + warp_utils::reject::custom_server_error("failed to get current epoch".to_string()) + })? + .sync_committee_period(&chain.spec) + .map_err(|_| { + warp_utils::reject::custom_server_error( + "failed to get current sync committee period".to_string(), + ) + })?; + + if query.start_period > current_sync_period { + return Err(warp_utils::reject::custom_bad_request( + "Invalid sync committee period requested".to_string(), + )); + } + + let earliest_altair_sync_committee = chain + .spec + .altair_fork_epoch + .ok_or(warp_utils::reject::custom_server_error( + "failed to get altair fork epoch".to_string(), + ))? + .sync_committee_period(&chain.spec) + .map_err(|_| { + warp_utils::reject::custom_server_error( + "failed to get earliest altair sync committee".to_string(), + ) + })?; + + if query.start_period < earliest_altair_sync_committee { + return Err(warp_utils::reject::custom_bad_request( + "Invalid sync committee period requested".to_string(), + )); + } + + Ok(()) +} + +fn map_light_client_update_to_ssz_chunk( + chain: &BeaconChain, + light_client_update: &LightClientUpdate, +) -> LightClientUpdateResponseChunk { + let fork_name = chain + .spec + .fork_name_at_slot::(*light_client_update.signature_slot()); + + let fork_digest = ChainSpec::compute_fork_digest( + chain.spec.fork_version_for_name(fork_name), + chain.genesis_validators_root, + ); + + LightClientUpdateResponseChunk { + context: fork_digest, + payload: light_client_update.as_ssz_bytes(), + } +} + +fn map_light_client_update_to_json_response( + chain: &BeaconChain, + light_client_update: LightClientUpdate, +) -> Result>, Rejection> { + let fork_name = chain + .spec + .fork_name_at_slot::(*light_client_update.signature_slot()); + + fork_versioned_response(V1, fork_name, light_client_update) +} diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index d51799b8661..9377e277c21 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1813,6 +1813,36 @@ impl ApiTester { self } + pub async fn test_get_beacon_light_client_updates(self) -> Self { + let current_epoch = self.chain.epoch().unwrap(); + let current_sync_committee_period = current_epoch + .sync_committee_period(&self.chain.spec) + .unwrap(); + + let result = match self + .client + .get_beacon_light_client_updates::(current_sync_committee_period as u64, 1) + .await + { + Ok(result) => result, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + let expected = self + .chain + .light_client_server_cache + .get_light_client_updates( + &self.chain.store, + current_sync_committee_period as u64, + 1, + &self.chain.spec, + ) + .unwrap(); + + assert_eq!(result.clone().unwrap().len(), expected.len()); + self + } + pub async fn test_get_beacon_light_client_bootstrap(self) -> Self { let block_id = BlockId(CoreBlockId::Finalized); let (block_root, _, _) = block_id.root(&self.chain).unwrap(); @@ -6171,6 +6201,18 @@ async fn node_get() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_light_client_updates() { + let config = ApiTesterConfig { + spec: ForkName::Altair.make_genesis_spec(E::default_spec()), + ..<_>::default() + }; + ApiTester::new_from_config(config) + .await + .test_get_beacon_light_client_updates() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_light_client_bootstrap() { let config = ApiTesterConfig { diff --git a/beacon_node/store/src/leveldb_store.rs b/beacon_node/store/src/leveldb_store.rs index 32ff942ddc7..28e04f56205 100644 --- a/beacon_node/store/src/leveldb_store.rs +++ b/beacon_node/store/src/leveldb_store.rs @@ -182,7 +182,6 @@ impl KeyValueStore for LevelDB { fn iter_column_from(&self, column: DBColumn, from: &[u8]) -> ColumnIter { let start_key = BytesKey::from_vec(get_key_for_col(column.into(), from)); - let iter = self.db.iter(self.read_options()); iter.seek(&start_key); diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index 1f8cc8ca019..e8631cc5ec1 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -300,6 +300,9 @@ pub enum DBColumn { BeaconHistoricalSummaries, #[strum(serialize = "olc")] OverflowLRUCache, + /// For persisting eagerly computed light client data + #[strum(serialize = "lcu")] + LightClientUpdate, } /// A block from the database, which might have an execution payload or not. @@ -342,7 +345,8 @@ impl DBColumn { | Self::BeaconStateRoots | Self::BeaconHistoricalRoots | Self::BeaconHistoricalSummaries - | Self::BeaconRandaoMixes => 8, + | Self::BeaconRandaoMixes + | Self::LightClientUpdate => 8, Self::BeaconDataColumn => DATA_COLUMN_DB_KEY_SIZE, } } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 6d000f576f9..48cdf7031a1 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -763,6 +763,31 @@ impl BeaconNodeHttpClient { self.get_opt(path).await } + /// `GET beacon/light_client/updates` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_light_client_updates( + &self, + start_period: u64, + count: u64, + ) -> Result>>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("light_client") + .push("updates"); + + path.query_pairs_mut() + .append_pair("start_period", &start_period.to_string()); + + path.query_pairs_mut() + .append_pair("count", &count.to_string()); + + self.get_opt(path).await + } + /// `GET beacon/light_client/bootstrap` /// /// Returns `Ok(None)` on a 404 error. diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index fa5fb654b72..793d839ceea 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -784,6 +784,24 @@ pub struct ValidatorAggregateAttestationQuery { pub committee_index: Option, } +#[derive(Clone, Deserialize)] +pub struct LightClientUpdatesQuery { + pub start_period: u64, + pub count: u64, +} + +#[derive(Encode, Decode)] +pub struct LightClientUpdateSszResponse { + pub response_chunk_len: Vec, + pub response_chunk: Vec, +} + +#[derive(Encode, Decode)] +pub struct LightClientUpdateResponseChunk { + pub context: [u8; 4], + pub payload: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct BeaconCommitteeSubscription { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/light_client_finality_update.rs b/consensus/types/src/light_client_finality_update.rs index e65b0572923..dc7561f5fcc 100644 --- a/consensus/types/src/light_client_finality_update.rs +++ b/consensus/types/src/light_client_finality_update.rs @@ -192,6 +192,19 @@ impl LightClientFinalityUpdate { // `2 *` because there are two headers in the update fixed_size + 2 * LightClientHeader::::ssz_max_var_len_for_fork(fork_name) } + + // Implements spec prioritization rules: + // > Full nodes SHOULD provide the LightClientFinalityUpdate with the highest attested_header.beacon.slot (if multiple, highest signature_slot) + // + // ref: https://github.com/ethereum/consensus-specs/blob/113c58f9bf9c08867f6f5f633c4d98e0364d612a/specs/altair/light-client/full-node.md#create_light_client_finality_update + pub fn is_latest(&self, attested_slot: Slot, signature_slot: Slot) -> bool { + let prev_slot = self.get_attested_header_slot(); + if attested_slot > prev_slot { + true + } else { + attested_slot == prev_slot && signature_slot > *self.signature_slot() + } + } } impl ForkVersionDeserialize for LightClientFinalityUpdate { diff --git a/consensus/types/src/light_client_header.rs b/consensus/types/src/light_client_header.rs index 1feb748fae1..a1d5f85eac0 100644 --- a/consensus/types/src/light_client_header.rs +++ b/consensus/types/src/light_client_header.rs @@ -149,6 +149,15 @@ impl LightClientHeaderAltair { } } +impl Default for LightClientHeaderAltair { + fn default() -> Self { + Self { + beacon: BeaconBlockHeader::empty(), + _phantom_data: PhantomData, + } + } +} + impl LightClientHeaderCapella { pub fn block_to_light_client_header( block: &SignedBlindedBeaconBlock, @@ -180,6 +189,17 @@ impl LightClientHeaderCapella { } } +impl Default for LightClientHeaderCapella { + fn default() -> Self { + Self { + beacon: BeaconBlockHeader::empty(), + execution: ExecutionPayloadHeaderCapella::default(), + execution_branch: FixedVector::default(), + _phantom_data: PhantomData, + } + } +} + impl LightClientHeaderDeneb { pub fn block_to_light_client_header( block: &SignedBlindedBeaconBlock, @@ -211,6 +231,17 @@ impl LightClientHeaderDeneb { } } +impl Default for LightClientHeaderDeneb { + fn default() -> Self { + Self { + beacon: BeaconBlockHeader::empty(), + execution: ExecutionPayloadHeaderDeneb::default(), + execution_branch: FixedVector::default(), + _phantom_data: PhantomData, + } + } +} + impl LightClientHeaderElectra { pub fn block_to_light_client_header( block: &SignedBlindedBeaconBlock, @@ -242,6 +273,17 @@ impl LightClientHeaderElectra { } } +impl Default for LightClientHeaderElectra { + fn default() -> Self { + Self { + beacon: BeaconBlockHeader::empty(), + execution: ExecutionPayloadHeaderElectra::default(), + execution_branch: FixedVector::default(), + _phantom_data: PhantomData, + } + } +} + impl ForkVersionDeserialize for LightClientHeader { fn deserialize_by_fork<'de, D: serde::Deserializer<'de>>( value: serde_json::value::Value, diff --git a/consensus/types/src/light_client_optimistic_update.rs b/consensus/types/src/light_client_optimistic_update.rs index f5b749be706..3cae31edf80 100644 --- a/consensus/types/src/light_client_optimistic_update.rs +++ b/consensus/types/src/light_client_optimistic_update.rs @@ -178,6 +178,19 @@ impl LightClientOptimisticUpdate { }; fixed_len + LightClientHeader::::ssz_max_var_len_for_fork(fork_name) } + + // Implements spec prioritization rules: + // > Full nodes SHOULD provide the LightClientOptimisticUpdate with the highest attested_header.beacon.slot (if multiple, highest signature_slot) + // + // ref: https://github.com/ethereum/consensus-specs/blob/113c58f9bf9c08867f6f5f633c4d98e0364d612a/specs/altair/light-client/full-node.md#create_light_client_optimistic_update + pub fn is_latest(&self, attested_slot: Slot, signature_slot: Slot) -> bool { + let prev_slot = self.get_slot(); + if attested_slot > prev_slot { + true + } else { + attested_slot == prev_slot && signature_slot > *self.signature_slot() + } + } } impl ForkVersionDeserialize for LightClientOptimisticUpdate { diff --git a/consensus/types/src/light_client_update.rs b/consensus/types/src/light_client_update.rs index 8a3eaff487f..3b48a68df31 100644 --- a/consensus/types/src/light_client_update.rs +++ b/consensus/types/src/light_client_update.rs @@ -1,12 +1,13 @@ use super::{EthSpec, FixedVector, Hash256, Slot, SyncAggregate, SyncCommittee}; use crate::light_client_header::LightClientHeaderElectra; use crate::{ - beacon_state, test_utils::TestRandom, BeaconBlock, BeaconBlockHeader, BeaconState, ChainSpec, - ForkName, ForkVersionDeserialize, LightClientHeaderAltair, LightClientHeaderCapella, - LightClientHeaderDeneb, SignedBlindedBeaconBlock, + beacon_state, test_utils::TestRandom, ChainSpec, Epoch, ForkName, ForkVersionDeserialize, + LightClientHeaderAltair, LightClientHeaderCapella, LightClientHeaderDeneb, + SignedBlindedBeaconBlock, }; use derivative::Derivative; use safe_arith::ArithError; +use safe_arith::SafeArith; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use ssz::Decode; @@ -16,7 +17,6 @@ use ssz_types::typenum::{U4, U5, U6}; use std::sync::Arc; use superstruct::superstruct; use test_random_derive::TestRandom; -use tree_hash::TreeHash; use tree_hash_derive::TreeHash; pub const FINALIZED_ROOT_INDEX: usize = 105; @@ -35,6 +35,9 @@ pub const CURRENT_SYNC_COMMITTEE_PROOF_LEN: usize = 5; pub const NEXT_SYNC_COMMITTEE_PROOF_LEN: usize = 5; pub const EXECUTION_PAYLOAD_PROOF_LEN: usize = 4; +type FinalityBranch = FixedVector; +type NextSyncCommitteeBranch = FixedVector; + #[derive(Debug, PartialEq, Clone)] pub enum Error { SszTypesError(ssz_types::Error), @@ -117,7 +120,7 @@ pub struct LightClientUpdate { /// The `SyncCommittee` used in the next period. pub next_sync_committee: Arc>, /// Merkle proof for next sync committee - pub next_sync_committee_branch: FixedVector, + pub next_sync_committee_branch: NextSyncCommitteeBranch, /// The last `BeaconBlockHeader` from the last attested finalized block (end of epoch). #[superstruct(only(Altair), partial_getter(rename = "finalized_header_altair"))] pub finalized_header: LightClientHeaderAltair, @@ -128,7 +131,7 @@ pub struct LightClientUpdate { #[superstruct(only(Electra), partial_getter(rename = "finalized_header_electra"))] pub finalized_header: LightClientHeaderElectra, /// Merkle proof attesting finalized header. - pub finality_branch: FixedVector, + pub finality_branch: FinalityBranch, /// current sync aggreggate pub sync_aggregate: SyncAggregate, /// Slot of the sync aggregated signature @@ -152,45 +155,17 @@ impl ForkVersionDeserialize for LightClientUpdate { } impl LightClientUpdate { + #[allow(clippy::too_many_arguments)] pub fn new( - beacon_state: BeaconState, - block: BeaconBlock, - attested_state: &mut BeaconState, + sync_aggregate: &SyncAggregate, + block_slot: Slot, + next_sync_committee: Arc>, + next_sync_committee_branch: FixedVector, + finality_branch: FixedVector, attested_block: &SignedBlindedBeaconBlock, - finalized_block: &SignedBlindedBeaconBlock, + finalized_block: Option<&SignedBlindedBeaconBlock>, chain_spec: &ChainSpec, ) -> Result { - let sync_aggregate = block.body().sync_aggregate()?; - if sync_aggregate.num_set_bits() < chain_spec.min_sync_committee_participants as usize { - return Err(Error::NotEnoughSyncCommitteeParticipants); - } - - let signature_period = block.epoch().sync_committee_period(chain_spec)?; - // Compute and validate attested header. - let mut attested_header = attested_state.latest_block_header().clone(); - attested_header.state_root = attested_state.update_tree_hash_cache()?; - let attested_period = attested_header - .slot - .epoch(E::slots_per_epoch()) - .sync_committee_period(chain_spec)?; - if attested_period != signature_period { - return Err(Error::MismatchingPeriods); - } - // Build finalized header from finalized block - let finalized_header = BeaconBlockHeader { - slot: finalized_block.slot(), - proposer_index: finalized_block.message().proposer_index(), - parent_root: finalized_block.parent_root(), - state_root: finalized_block.state_root(), - body_root: finalized_block.message().body_root(), - }; - if finalized_header.tree_hash_root() != beacon_state.finalized_checkpoint().root { - return Err(Error::InvalidFinalizedBlock); - } - let next_sync_committee_branch = - attested_state.compute_merkle_proof(NEXT_SYNC_COMMITTEE_INDEX)?; - let finality_branch = attested_state.compute_merkle_proof(FINALIZED_ROOT_INDEX)?; - let light_client_update = match attested_block .fork_name(chain_spec) .map_err(|_| Error::InconsistentFork)? @@ -199,71 +174,91 @@ impl LightClientUpdate { ForkName::Altair | ForkName::Bellatrix => { let attested_header = LightClientHeaderAltair::block_to_light_client_header(attested_block)?; - let finalized_header = - LightClientHeaderAltair::block_to_light_client_header(finalized_block)?; + + let finalized_header = if let Some(finalized_block) = finalized_block { + LightClientHeaderAltair::block_to_light_client_header(finalized_block)? + } else { + LightClientHeaderAltair::default() + }; + Self::Altair(LightClientUpdateAltair { attested_header, - next_sync_committee: attested_state.next_sync_committee()?.clone(), - next_sync_committee_branch: FixedVector::new(next_sync_committee_branch)?, + next_sync_committee, + next_sync_committee_branch, finalized_header, - finality_branch: FixedVector::new(finality_branch)?, + finality_branch, sync_aggregate: sync_aggregate.clone(), - signature_slot: block.slot(), + signature_slot: block_slot, }) } ForkName::Capella => { let attested_header = LightClientHeaderCapella::block_to_light_client_header(attested_block)?; - let finalized_header = - LightClientHeaderCapella::block_to_light_client_header(finalized_block)?; + + let finalized_header = if let Some(finalized_block) = finalized_block { + LightClientHeaderCapella::block_to_light_client_header(finalized_block)? + } else { + LightClientHeaderCapella::default() + }; + Self::Capella(LightClientUpdateCapella { attested_header, - next_sync_committee: attested_state.next_sync_committee()?.clone(), - next_sync_committee_branch: FixedVector::new(next_sync_committee_branch)?, + next_sync_committee, + next_sync_committee_branch, finalized_header, - finality_branch: FixedVector::new(finality_branch)?, + finality_branch, sync_aggregate: sync_aggregate.clone(), - signature_slot: block.slot(), + signature_slot: block_slot, }) } ForkName::Deneb => { let attested_header = LightClientHeaderDeneb::block_to_light_client_header(attested_block)?; - let finalized_header = - LightClientHeaderDeneb::block_to_light_client_header(finalized_block)?; + + let finalized_header = if let Some(finalized_block) = finalized_block { + LightClientHeaderDeneb::block_to_light_client_header(finalized_block)? + } else { + LightClientHeaderDeneb::default() + }; + Self::Deneb(LightClientUpdateDeneb { attested_header, - next_sync_committee: attested_state.next_sync_committee()?.clone(), - next_sync_committee_branch: FixedVector::new(next_sync_committee_branch)?, + next_sync_committee, + next_sync_committee_branch, finalized_header, - finality_branch: FixedVector::new(finality_branch)?, + finality_branch, sync_aggregate: sync_aggregate.clone(), - signature_slot: block.slot(), + signature_slot: block_slot, }) } ForkName::Electra => { let attested_header = LightClientHeaderElectra::block_to_light_client_header(attested_block)?; - let finalized_header = - LightClientHeaderElectra::block_to_light_client_header(finalized_block)?; + + let finalized_header = if let Some(finalized_block) = finalized_block { + LightClientHeaderElectra::block_to_light_client_header(finalized_block)? + } else { + LightClientHeaderElectra::default() + }; + Self::Electra(LightClientUpdateElectra { attested_header, - next_sync_committee: attested_state.next_sync_committee()?.clone(), - next_sync_committee_branch: FixedVector::new(next_sync_committee_branch)?, + next_sync_committee, + next_sync_committee_branch, finalized_header, - finality_branch: FixedVector::new(finality_branch)?, + finality_branch, sync_aggregate: sync_aggregate.clone(), - signature_slot: block.slot(), + signature_slot: block_slot, }) } // To add a new fork, just append the new fork variant on the latest fork. Forks that - // have a distinct execution header will need a new LightClientUdpate variant only + // have a distinct execution header will need a new LightClientUpdate variant only // if you need to test or support lightclient usages }; Ok(light_client_update) } - pub fn from_ssz_bytes(bytes: &[u8], fork_name: ForkName) -> Result { + pub fn from_ssz_bytes(bytes: &[u8], fork_name: &ForkName) -> Result { let update = match fork_name { ForkName::Altair | ForkName::Bellatrix => { Self::Altair(LightClientUpdateAltair::from_ssz_bytes(bytes)?) @@ -280,6 +275,142 @@ impl LightClientUpdate { Ok(update) } + + pub fn attested_header_slot(&self) -> Slot { + match self { + LightClientUpdate::Altair(update) => update.attested_header.beacon.slot, + LightClientUpdate::Capella(update) => update.attested_header.beacon.slot, + LightClientUpdate::Deneb(update) => update.attested_header.beacon.slot, + LightClientUpdate::Electra(update) => update.attested_header.beacon.slot, + } + } + + pub fn finalized_header_slot(&self) -> Slot { + match self { + LightClientUpdate::Altair(update) => update.finalized_header.beacon.slot, + LightClientUpdate::Capella(update) => update.finalized_header.beacon.slot, + LightClientUpdate::Deneb(update) => update.finalized_header.beacon.slot, + LightClientUpdate::Electra(update) => update.finalized_header.beacon.slot, + } + } + + fn attested_header_sync_committee_period( + &self, + chain_spec: &ChainSpec, + ) -> Result { + compute_sync_committee_period_at_slot::(self.attested_header_slot(), chain_spec) + .map_err(Error::ArithError) + } + + fn signature_slot_sync_committee_period(&self, chain_spec: &ChainSpec) -> Result { + compute_sync_committee_period_at_slot::(*self.signature_slot(), chain_spec) + .map_err(Error::ArithError) + } + + pub fn is_sync_committee_update(&self, chain_spec: &ChainSpec) -> Result { + Ok(!self.is_next_sync_committee_branch_empty() + && (self.attested_header_sync_committee_period(chain_spec)? + == self.signature_slot_sync_committee_period(chain_spec)?)) + } + + pub fn has_sync_committee_finality(&self, chain_spec: &ChainSpec) -> Result { + Ok( + compute_sync_committee_period_at_slot::(self.finalized_header_slot(), chain_spec)? + == self.attested_header_sync_committee_period(chain_spec)?, + ) + } + + // Implements spec prioritization rules: + // Full nodes SHOULD provide the best derivable LightClientUpdate for each sync committee period + // ref: https://github.com/ethereum/consensus-specs/blob/113c58f9bf9c08867f6f5f633c4d98e0364d612a/specs/altair/light-client/full-node.md#create_light_client_update + pub fn is_better_light_client_update( + &self, + new: &Self, + chain_spec: &ChainSpec, + ) -> Result { + // Compare super majority (> 2/3) sync committee participation + let max_active_participants = new.sync_aggregate().sync_committee_bits.len(); + + let new_active_participants = new.sync_aggregate().sync_committee_bits.num_set_bits(); + let prev_active_participants = self.sync_aggregate().sync_committee_bits.num_set_bits(); + + let new_has_super_majority = + new_active_participants.safe_mul(3)? >= max_active_participants.safe_mul(2)?; + let prev_has_super_majority = + prev_active_participants.safe_mul(3)? >= max_active_participants.safe_mul(2)?; + + if new_has_super_majority != prev_has_super_majority { + return Ok(new_has_super_majority); + } + + if !new_has_super_majority && new_active_participants != prev_active_participants { + return Ok(new_active_participants > prev_active_participants); + } + + // Compare presence of relevant sync committee + let new_has_relevant_sync_committee = new.is_sync_committee_update(chain_spec)?; + let prev_has_relevant_sync_committee = self.is_sync_committee_update(chain_spec)?; + if new_has_relevant_sync_committee != prev_has_relevant_sync_committee { + return Ok(new_has_relevant_sync_committee); + } + + // Compare indication of any finality + let new_has_finality = !new.is_finality_branch_empty(); + let prev_has_finality = !self.is_finality_branch_empty(); + if new_has_finality != prev_has_finality { + return Ok(new_has_finality); + } + + // Compare sync committee finality + if new_has_finality { + let new_has_sync_committee_finality = new.has_sync_committee_finality(chain_spec)?; + let prev_has_sync_committee_finality = self.has_sync_committee_finality(chain_spec)?; + if new_has_sync_committee_finality != prev_has_sync_committee_finality { + return Ok(new_has_sync_committee_finality); + } + } + + // Tiebreaker 1: Sync committee participation beyond super majority + if new_active_participants != prev_active_participants { + return Ok(new_active_participants > prev_active_participants); + } + + let new_attested_header_slot = new.attested_header_slot(); + let prev_attested_header_slot = self.attested_header_slot(); + + // Tiebreaker 2: Prefer older data (fewer changes to best) + if new_attested_header_slot != prev_attested_header_slot { + return Ok(new_attested_header_slot < prev_attested_header_slot); + } + + return Ok(new.signature_slot() < self.signature_slot()); + } + + fn is_next_sync_committee_branch_empty(&self) -> bool { + for index in self.next_sync_committee_branch().iter() { + if *index != Hash256::default() { + return false; + } + } + true + } + + pub fn is_finality_branch_empty(&self) -> bool { + for index in self.finality_branch().iter() { + if *index != Hash256::default() { + return false; + } + } + true + } +} + +fn compute_sync_committee_period_at_slot( + slot: Slot, + chain_spec: &ChainSpec, +) -> Result { + slot.epoch(E::slots_per_epoch()) + .safe_div(chain_spec.epochs_per_sync_committee_period) } #[cfg(test)] diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index e1a308f7a40..f1ab5ad600d 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -26,7 +26,9 @@ "tests/.*/.*/ssz_static/Eth1Block/", "tests/.*/.*/ssz_static/PowBlock/", # light_client - "tests/.*/.*/light_client", + # "tests/.*/.*/light_client", + "tests/.*/.*/light_client/single_merkle_proof", + "tests/.*/.*/light_client/sync", # LightClientStore "tests/.*/.*/ssz_static/LightClientStore", # LightClientSnapshot diff --git a/testing/ef_tests/src/cases.rs b/testing/ef_tests/src/cases.rs index f328fa64047..2d6f661f0e4 100644 --- a/testing/ef_tests/src/cases.rs +++ b/testing/ef_tests/src/cases.rs @@ -24,6 +24,7 @@ mod kzg_compute_kzg_proof; mod kzg_verify_blob_kzg_proof; mod kzg_verify_blob_kzg_proof_batch; mod kzg_verify_kzg_proof; +mod light_client_verify_is_better_update; mod merkle_proof_validity; mod operations; mod rewards; @@ -54,6 +55,7 @@ pub use kzg_compute_kzg_proof::*; pub use kzg_verify_blob_kzg_proof::*; pub use kzg_verify_blob_kzg_proof_batch::*; pub use kzg_verify_kzg_proof::*; +pub use light_client_verify_is_better_update::*; pub use merkle_proof_validity::*; pub use operations::*; pub use rewards::RewardsTest; diff --git a/testing/ef_tests/src/cases/light_client_verify_is_better_update.rs b/testing/ef_tests/src/cases/light_client_verify_is_better_update.rs new file mode 100644 index 00000000000..de281d906c1 --- /dev/null +++ b/testing/ef_tests/src/cases/light_client_verify_is_better_update.rs @@ -0,0 +1,110 @@ +use super::*; +use decode::ssz_decode_light_client_update; +use serde::Deserialize; +use types::{LightClientUpdate, Slot}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LightClientVerifyIsBetterUpdate { + light_client_updates: Vec>, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Metadata { + updates_count: u64, +} + +impl LoadCase for LightClientVerifyIsBetterUpdate { + fn load_from_dir(path: &Path, fork_name: ForkName) -> Result { + let mut light_client_updates = vec![]; + let metadata: Metadata = decode::yaml_decode_file(path.join("meta.yaml").as_path())?; + for index in 0..metadata.updates_count { + let light_client_update = ssz_decode_light_client_update( + &path.join(format!("updates_{}.ssz_snappy", index)), + &fork_name, + )?; + light_client_updates.push(light_client_update); + } + + Ok(Self { + light_client_updates, + }) + } +} + +impl Case for LightClientVerifyIsBetterUpdate { + // Light client updates in `self.light_client_updates` are ordered in descending precedence + // where the update at index = 0 is considered the best update. This test iterates through + // all light client updates in a nested loop to make all possible comparisons. If a light client update + // at index `i`` is considered 'better' than a light client update at index `j`` when `i > j`, this test fails. + fn result(&self, _case_index: usize, fork_name: ForkName) -> Result<(), Error> { + let spec = fork_name.make_genesis_spec(E::default_spec()); + for (i, ith_light_client_update) in self.light_client_updates.iter().enumerate() { + for (j, jth_light_client_update) in self.light_client_updates.iter().enumerate() { + eprintln!("{i} {j}"); + if i == j { + continue; + } + + let is_better_update = ith_light_client_update + .is_better_light_client_update(jth_light_client_update, &spec) + .unwrap(); + + let ith_summary = + LightClientUpdateSummary::from_update(ith_light_client_update, &spec); + let jth_summary = + LightClientUpdateSummary::from_update(jth_light_client_update, &spec); + + let (best_index, other_index, best_update, other_update, failed) = if i < j { + // i is better, so is_better_update must return false + (i, j, ith_summary, jth_summary, is_better_update) + } else { + // j is better, so is_better must return true + (j, i, jth_summary, ith_summary, !is_better_update) + }; + + if failed { + eprintln!("is_better_update: {is_better_update}"); + eprintln!("index {best_index} update {best_update:?}"); + eprintln!("index {other_index} update {other_update:?}"); + eprintln!( + "update at index {best_index} must be considered better than update at index {other_index}" + ); + return Err(Error::FailedComparison(format!( + "update at index {best_index} must be considered better than update at index {other_index}" + ))); + } + } + } + + Ok(()) + } +} + +#[derive(Debug)] +#[allow(dead_code)] +struct LightClientUpdateSummary { + participants: usize, + supermajority: bool, + relevant_sync_committee: bool, + has_finality: bool, + has_sync_committee_finality: bool, + header_slot: Slot, + signature_slot: Slot, +} + +impl LightClientUpdateSummary { + fn from_update(update: &LightClientUpdate, spec: &ChainSpec) -> Self { + let max_participants = update.sync_aggregate().sync_committee_bits.len(); + let participants = update.sync_aggregate().sync_committee_bits.num_set_bits(); + Self { + participants, + supermajority: participants * 3 > max_participants * 2, + relevant_sync_committee: update.is_sync_committee_update(spec).unwrap(), + has_finality: !update.is_finality_branch_empty(), + has_sync_committee_finality: update.has_sync_committee_finality(spec).unwrap(), + header_slot: update.attested_header_slot(), + signature_slot: *update.signature_slot(), + } + } +} diff --git a/testing/ef_tests/src/decode.rs b/testing/ef_tests/src/decode.rs index 51ab682f3dc..757b9bf3c43 100644 --- a/testing/ef_tests/src/decode.rs +++ b/testing/ef_tests/src/decode.rs @@ -5,7 +5,7 @@ use std::fs::{self}; use std::io::Write; use std::path::Path; use std::path::PathBuf; -use types::BeaconState; +use types::{BeaconState, LightClientUpdate}; /// See `log_file_access` for details. const ACCESSED_FILE_LOG_FILENAME: &str = ".accessed_file_log.txt"; @@ -95,3 +95,13 @@ pub fn ssz_decode_state( log_file_access(path); ssz_decode_file_with(path, |bytes| BeaconState::from_ssz_bytes(bytes, spec)) } + +pub fn ssz_decode_light_client_update( + path: &Path, + fork_name: &ForkName, +) -> Result, Error> { + log_file_access(path); + ssz_decode_file_with(path, |bytes| { + LightClientUpdate::from_ssz_bytes(bytes, fork_name) + }) +} diff --git a/testing/ef_tests/src/error.rs b/testing/ef_tests/src/error.rs index c5795777ada..389308377c7 100644 --- a/testing/ef_tests/src/error.rs +++ b/testing/ef_tests/src/error.rs @@ -14,6 +14,8 @@ pub enum Error { SkippedKnownFailure, /// The test failed due to some internal error preventing the test from running. InternalError(String), + /// The test failed while making some comparison. + FailedComparison(String), } impl Error { @@ -26,6 +28,7 @@ impl Error { Error::SkippedBls => "SkippedBls", Error::SkippedKnownFailure => "SkippedKnownFailure", Error::InternalError(_) => "InternalError", + Error::FailedComparison(_) => "FailedComparison", } } diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 410a37e7682..52fc58f3d8c 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -837,6 +837,32 @@ impl Handler for KzgInclusionMerkleProofValidityHandler(PhantomData); + +impl Handler for LightClientUpdateHandler { + type Case = cases::LightClientVerifyIsBetterUpdate; + + fn config_name() -> &'static str { + E::name() + } + + fn runner_name() -> &'static str { + "light_client" + } + + fn handler_name(&self) -> String { + "update_ranking".into() + } + + fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { + // Enabled in Altair + // TODO(electra) re-enable once https://github.com/sigp/lighthouse/issues/6002 is resolved + fork_name != ForkName::Base && fork_name != ForkName::Electra + } +} + #[derive(Derivative)] #[derivative(Default(bound = ""))] pub struct OperationsHandler(PhantomData<(E, O)>); diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 10a57a6b45e..90143850443 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -900,6 +900,11 @@ fn merkle_proof_validity() { MerkleProofValidityHandler::::default().run(); } +#[test] +fn light_client_update() { + LightClientUpdateHandler::::default().run(); +} + #[test] #[cfg(feature = "fake_crypto")] fn kzg_inclusion_merkle_proof_validity() {