From 58e35bc96fab30e5f4085ac36f70db1f88ca5bb1 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 15 Jun 2026 16:56:09 -0700 Subject: [PATCH 01/26] Gloas alpha spec 9 (#9393) Changes implemented Ensure bids are for a higher slot than their parent (https://github.com/ethereum/consensus-specs/pull/5302) Ignore PTC attestations for empty assigned slots (https://github.com/ethereum/consensus-specs/pull/5281) Limit should_build_on_full checks to the previous slot (https://github.com/ethereum/consensus-specs/pull/5309) Apply proposer boost if dependent roots match (https://github.com/ethereum/consensus-specs/pull/5306) Exclude slashed validators from proposing (EIP-8045) (https://github.com/ethereum/consensus-specs/pull/5115) Force the proposer to reorg late payloads (https://github.com/ethereum/consensus-specs/pull/5210) Remove support for old deposit mechanism in Fulu (https://github.com/ethereum/consensus-specs/pull/4704) Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 43 ------ .../src/block_production/gloas.rs | 2 +- .../gossip_verified_payload_attestation.rs | 16 ++- .../payload_attestation_verification/mod.rs | 12 ++ .../payload_attestation_verification/tests.rs | 96 +++++--------- .../gossip_verified_bid.rs | 12 +- .../src/payload_bid_verification/mod.rs | 2 + .../src/payload_bid_verification/tests.rs | 62 ++++++--- .../payload_envelope_verification/import.rs | 3 - .../beacon_chain/tests/op_verification.rs | 61 +++++++++ .../tests/payload_invalidation.rs | 1 - .../src/beacon/execution_payload_envelopes.rs | 46 ++++--- beacon_node/http_api/src/block_id.rs | 5 - .../gossip_methods.rs | 31 +++-- .../network_beacon_processor/sync_methods.rs | 4 + beacon_node/operation_pool/src/lib.rs | 103 ++++++++++++++- consensus/fork_choice/src/fork_choice.rs | 67 +++++++--- consensus/fork_choice/tests/tests.rs | 2 - .../src/fork_choice_test_definition.rs | 33 +++++ .../gloas_payload.rs | 90 +++++++++++++ consensus/proto_array/src/proto_array.rs | 18 ++- .../src/proto_array_fork_choice.rs | 3 +- .../process_operations.rs | 7 +- .../src/per_epoch_processing/single_pass.rs | 6 +- consensus/types/src/state/beacon_state.rs | 52 +++++--- consensus/types/src/state/slashings_cache.rs | 124 ++++++++++++++++++ testing/ef_tests/Makefile | 2 +- testing/ef_tests/check_all_files_accessed.py | 2 + .../ef_tests/src/cases/epoch_processing.rs | 3 + testing/ef_tests/src/cases/fork_choice.rs | 81 +++++++++--- testing/ef_tests/src/cases/operations.rs | 7 +- testing/ef_tests/src/handler.rs | 12 +- testing/ef_tests/tests/tests.rs | 24 ++-- 33 files changed, 785 insertions(+), 247 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index a7f1a7cfcd..5a521d18e6 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4248,12 +4248,6 @@ impl BeaconChain { let cached_head = self.canonical_head.cached_head(); let old_head_slot = cached_head.head_slot(); - // Compute the expected proposer for `current_slot` on the canonical chain. This is used by - // `on_block` to gate proposer boost on the block's proposer matching the canonical proposer - // (per spec `update_proposer_boost_root` added in v1.7.0-alpha.5). - let canonical_head_proposer_index = - self.canonical_head_proposer_index(current_slot, &cached_head)?; - // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); @@ -4285,7 +4279,6 @@ impl BeaconChain { block_delay, &state, payload_verification_status, - canonical_head_proposer_index, &self.spec, ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; @@ -5033,42 +5026,6 @@ impl BeaconChain { })) } - /// Compute the expected beacon proposer for `slot` on the canonical chain extending `cached_head`. - /// - /// Uses the beacon proposer cache to avoid recomputing the shuffling on every block import. - /// - /// This is used by `update_proposer_boost_root` to gate proposer boost on the block's proposer - /// matching the canonical proposer, per consensus-specs v1.7.0-alpha.5. - /// - /// This function should never error unless there is some corruption of the head state. If a - /// state advance is needed, it will be handled by the proposer cache. - pub fn canonical_head_proposer_index( - &self, - slot: Slot, - cached_head: &CachedHead, - ) -> Result { - let proposal_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let head_block_root = cached_head.head_block_root(); - let head_state = &cached_head.snapshot.beacon_state; - - let shuffling_decision_root = head_state.proposer_shuffling_decision_root_at_epoch( - proposal_epoch, - head_block_root, - &self.spec, - )?; - - self.with_proposer_cache::<_, Error>( - shuffling_decision_root, - proposal_epoch, - |proposers| { - proposers - .get_slot::(slot) - .map(|p| p.index as u64) - }, - || Ok((cached_head.head_state_root(), head_state.clone())), - ) - } - pub fn get_expected_withdrawals( &self, forkchoice_update_params: &ForkchoiceUpdateParameters, diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 82dad6f6ad..90fc60524a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -163,7 +163,7 @@ impl BeaconChain { let should_build_on_full = self .canonical_head .fork_choice_read_lock() - .should_build_on_full(&parent_root, parent_payload_status) + .should_build_on_full(&parent_root, parent_payload_status, produce_at_slot) .map_err(|e| { BlockProductionError::BeaconChain(Box::new(BeaconChainError::ForkChoiceError(e))) })?; diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs index 3e9f9e4b60..f8a1143b2e 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -70,13 +70,21 @@ impl VerifiedPayloadAttestationMessage { // 2. Blocks we've seen that are invalid (REJECT). // Presently both cases return IGNORE. let beacon_block_root = payload_attestation_message.data.beacon_block_root; - if ctx + let block = ctx .canonical_head .fork_choice_read_lock() .get_block(&beacon_block_root) - .is_none() - { - return Err(Error::UnknownHeadBlock { beacon_block_root }); + .ok_or(Error::UnknownHeadBlock { beacon_block_root })?; + + // [IGNORE] The block referenced by `data.beacon_block_root` is at slot `data.slot`, i.e. + // the block has `block.slot == data.slot`. A PTC member assigned to an empty slot must not + // attest, so ignore messages that reference an earlier block. + if block.slot != slot { + return Err(Error::BlockNotAtSlot { + beacon_block_root, + block_slot: block.slot, + data_slot: slot, + }); } let message_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs index 89ae1bbbdd..d1fa7f52fb 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs @@ -60,6 +60,18 @@ pub enum Error { /// The attestation points to a block we have not yet imported. It's unclear if the /// attestation is valid or not. UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The block referenced by `data.beacon_block_root` is not at slot `data.slot`, i.e. the + /// PTC member's assigned slot was likely empty. + /// + /// ## Peer scoring + /// + /// PTC members should not attest for empty slots, so we + /// ignore the message. + BlockNotAtSlot { + beacon_block_root: Hash256, + block_slot: Slot, + data_slot: Slot, + }, /// The validator index is not a member of the PTC for this slot. /// /// ## Peer scoring diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index d4b82c41fc..6c52e5ce2d 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -3,7 +3,6 @@ use std::time::Duration; use bls::Signature; use slot_clock::{SlotClock, TestingSlotClock}; -use state_processing::AllCaches; use types::{ Domain, Epoch, EthSpec, ForkName, Hash256, MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, @@ -167,6 +166,25 @@ fn unknown_head_block() { ); } +#[test] +fn block_not_at_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + // The genesis block is at slot 0, but the message claims slot 1. A PTC member assigned to an + // empty slot must not attest, so this must be ignored (per consensus-specs #5281). + let msg = make_payload_attestation(Slot::new(1), 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + matches!(result, Err(PayloadAttestationError::BlockNotAtSlot { .. })), + "expected BlockNotAtSlot, got: {:?}", + result + ); +} + #[test] fn not_in_ptc() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -174,7 +192,7 @@ fn not_in_ptc() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let non_ptc_validator = (0..NUM_VALIDATORS as u64) @@ -196,7 +214,7 @@ fn invalid_signature() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -216,7 +234,7 @@ fn valid_payload_attestation() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -243,7 +261,7 @@ fn duplicate_after_valid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(1); + let slot = Slot::new(0); let ptc_members = ctx.ptc_members(slot); let validator_index = ptc_members[0] as u64; @@ -300,10 +318,8 @@ async fn ptc_cache_is_primed_at_gloas_fork_boundary() { .mock_execution_layer() .build(); - harness.extend_to_slot(fork_boundary_slot).await; - for slot in test_slots { - harness.chain.slot_clock.set_slot(slot.as_u64()); + harness.extend_to_slot(slot).await; assert!( harness .chain @@ -350,10 +366,9 @@ async fn ptc_cache_is_primed_at_gloas_fork_boundary() { } } -/// Exercises payload attestation gossip verification when the message epoch is ahead of the -/// canonical head due to many missed slots. +/// Check that a payload attestation whose assigned slot is empty is ignored. #[tokio::test] -async fn stale_head_payload_attestation() { +async fn stale_head_empty_slot_payload_attestation_ignored() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } @@ -363,9 +378,9 @@ async fn stale_head_payload_attestation() { let head_slot = Slot::new(slots_per_epoch); let missed_epochs = 4; let target_slot = Slot::new(slots_per_epoch * (1 + missed_epochs)); - let target_epoch = target_slot.epoch(slots_per_epoch); - // GIVEN a chain with blocks through epoch 1 (so the store has states). + // Given a chain with blocks through epoch 1, then a slot clock advanced 4 epochs without + // producing blocks (simulating missed slots). let harness = BeaconChainHarness::builder(E::default()) .default_spec() .deterministic_keypairs(64) @@ -373,71 +388,30 @@ async fn stale_head_payload_attestation() { .mock_execution_layer() .build(); harness.extend_to_slot(head_slot).await; - - let head = harness.chain.canonical_head.cached_head(); - let head_epoch = head.snapshot.beacon_state.current_epoch(); - assert!( - target_epoch > head_epoch + harness.spec.min_seed_lookahead, - "precondition: message epoch must exceed head + min_seed_lookahead" - ); - - // GIVEN a slot clock advanced to epoch 5 without producing blocks - // (simulating missed slots during a liveness failure). harness.chain.slot_clock.set_slot(target_slot.as_u64()); - // Advance a reference state to compute the PTC at the target slot. - let mut reference_state = head.snapshot.beacon_state.clone(); - state_processing::state_advance::partial_state_advance( - &mut reference_state, - Some(head.snapshot.beacon_state_root()), - target_slot, - &harness.spec, - ) - .expect("should advance reference state"); - reference_state - .build_all_caches(&harness.spec) - .expect("should build caches"); + let head = harness.chain.canonical_head.cached_head(); - let ptc = reference_state - .get_ptc(target_slot, &harness.spec) - .expect("should get PTC from reference state"); - let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; - - // WHEN a properly-signed payload attestation from a PTC member is verified. The signature - // domain should come from the spec fork schedule and genesis validators root, not a loaded - // state in the verifier. - let domain = harness.spec.get_domain( - target_epoch, - Domain::PTCAttester, - &reference_state.fork(), - reference_state.genesis_validators_root(), - ); + // When a payload attestation for empty target slot references a stale block root + // it is ignored because target_slot != block.slot let data = PayloadAttestationData { beacon_block_root: head.head_block_root(), slot: target_slot, payload_present: true, blob_data_available: true, }; - let message = data.signing_root(domain); - let signature = harness.validator_keypairs[validator_index as usize] - .sk - .sign(message); let msg = PayloadAttestationMessage { - validator_index, + validator_index: 0, data, - signature, + signature: Signature::empty(), }; - // THEN verification succeeds despite the head being 4 epochs stale. let result = harness .chain .verify_payload_attestation_message_for_gossip(msg); assert!( - result.is_ok(), - "expected Ok (head epoch {}, message epoch {}), got: {:?}", - head_epoch, - target_epoch, - result.unwrap_err() + matches!(result, Err(PayloadAttestationError::BlockNotAtSlot { .. })), + "expected BlockNotAtSlot, got: {result:?}" ); } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 354705b92c..1687760d25 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -144,9 +144,17 @@ impl GossipVerifiedPayloadBid { let fork_choice = ctx.canonical_head.fork_choice_read_lock(); // TODO(gloas) reprocess bids whose parent_block_root becomes known & canonical after a reorg? - if !fork_choice.contains_block(&bid_parent_block_root) { - return Err(PayloadBidError::ParentBlockRootUnknown { + let parent_block = fork_choice.get_block(&bid_parent_block_root).ok_or( + PayloadBidError::ParentBlockRootUnknown { parent_block_root: bid_parent_block_root, + }, + )?; + + // [REJECT] The bid is for a higher slot than its parent block. + if bid_slot <= parent_block.slot { + return Err(PayloadBidError::BidNotDescendantOfParent { + bid_slot, + parent_slot: parent_block.slot, }); } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs index a40fd14872..e23c537e18 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -37,6 +37,8 @@ pub enum PayloadBidError { }, /// The bids slot is not the current slot or the next slot. InvalidBidSlot { bid_slot: Slot }, + /// The bid's slot is not greater than the slot of its parent block. + BidNotDescendantOfParent { bid_slot: Slot, parent_slot: Slot }, /// The slot clock cannot be read. UnableToReadSlot, /// No proposer preferences for the current slot. diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index ccdf64d41d..04eb875bd9 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -310,7 +310,7 @@ fn builder_already_seen_for_slot() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); @@ -336,7 +336,7 @@ fn bid_value_below_cached() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let high_bid = GossipVerifiedPayloadBid { @@ -384,7 +384,7 @@ fn fee_recipient_mismatch() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::repeat_byte(0xaa), 30_000_000); let bid = make_signed_bid( @@ -406,7 +406,7 @@ fn gas_limit_mismatch() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid( @@ -428,7 +428,7 @@ fn execution_payment_nonzero() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = Arc::new(SignedExecutionPayloadBid { @@ -455,7 +455,7 @@ fn unknown_builder_index() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Use a builder_index that doesn't exist in the registry. @@ -483,7 +483,7 @@ fn inactive_builder() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = make_signed_bid( @@ -508,7 +508,7 @@ fn builder_cant_cover_bid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Builder index 0 exists but bid value far exceeds their balance. @@ -534,7 +534,7 @@ fn parent_block_root_unknown() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Parent block root not in fork choice. @@ -556,7 +556,9 @@ fn parent_block_root_not_canonical() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + // The non-canonical fork block is at slot 1, so use slot 2 to satisfy the `bid.slot > parent + // block slot` rule and exercise the bid descendant from parent check specifically. + let slot = Slot::new(2); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let fork_root = ctx.insert_non_canonical_block(); @@ -570,6 +572,36 @@ fn parent_block_root_not_canonical() { ); } +#[test] +fn bid_slot_not_after_parent() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + // The genesis (parent) block is at slot 0, so a bid at slot 0 is not for a higher slot than + // its parent and must be rejected. + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + matches!( + result, + Err(PayloadBidError::BidNotDescendantOfParent { .. }) + ), + "expected BidNotDescendantOfParent, got: {result:?}" + ); +} + #[test] fn invalid_blob_kzg_commitments() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -577,7 +609,7 @@ fn invalid_blob_kzg_commitments() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let max_blobs = ctx @@ -614,7 +646,7 @@ fn bad_signature() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // All checks pass but signature is empty/invalid. @@ -643,7 +675,7 @@ fn valid_bid() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid = ctx.sign_bid(ExecutionPayloadBid { @@ -670,7 +702,7 @@ fn two_builders_coexist_in_cache() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let bid_0 = ctx.sign_bid(ExecutionPayloadBid { @@ -725,7 +757,7 @@ fn bid_equal_to_cached_value_rejected() { } let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); - let slot = Slot::new(0); + let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); // Seed a cached bid with value 100. diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 00806f0e17..90cdb4fe97 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -117,9 +117,6 @@ impl BeaconChain { ); // TODO(gloas) do we need to send a `PayloadImported` event to the reprocess queue? - // TODO(gloas) do we need to recompute head? - // should canonical_head return the block and the payload now? - self.recompute_head_at_current_slot().await; metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_SUCCESSES); diff --git a/beacon_node/beacon_chain/tests/op_verification.rs b/beacon_node/beacon_chain/tests/op_verification.rs index adc14541a9..d1df72234f 100644 --- a/beacon_node/beacon_chain/tests/op_verification.rs +++ b/beacon_node/beacon_chain/tests/op_verification.rs @@ -299,6 +299,67 @@ async fn proposer_slashing_duplicate_in_state() { )); } +#[tokio::test] +async fn slashings_cache_matches_state_after_block_import() { + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), VALIDATOR_COUNT); + + // Slash a spread of validators by importing proposer slashings into the op pool, exactly as + // they would arrive over gossip. + let slashed_validators = [0u64, 7, VALIDATOR_COUNT as u64 - 1]; + for &validator_index in &slashed_validators { + let slashing = harness.make_proposer_slashing(validator_index); + let ObservationOutcome::New(verified_slashing) = harness + .chain + .verify_proposer_slashing_for_gossip(slashing) + .unwrap() + else { + panic!("slashing should verify"); + }; + harness.chain.import_proposer_slashing(verified_slashing); + } + + // Produce and import a block that includes the slashings. This drives the production flow: + // `per_block_processing` -> `slash_validator` -> `SlashingsCache::record_validator_slashing`. + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let state = harness.get_current_state(); + + // The block processing above should have left the slashings cache initialized for the head. + assert!( + state.slashings_cache_is_initialized(), + "slashings cache should be initialized after block import" + ); + + // The targeted validators must actually be slashed in the state (i.e. the slashings were + // included and applied, not silently dropped). + for &validator_index in &slashed_validators { + assert!( + state + .get_validator(validator_index as usize) + .unwrap() + .slashed, + "validator {validator_index} should be slashed in the state" + ); + } + + // The cache must agree with the `slashed` flag of *every* validator in the state. + for index in 0..state.validators().len() { + assert_eq!( + state.slashings_cache().is_slashed(index), + state.get_validator(index).unwrap().slashed, + "slashings cache disagrees with state at validator {index}" + ); + } +} + #[test] fn attester_slashing() { let db_path = tempdir().unwrap(); diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 42a78d740f..c89be0f5dd 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1077,7 +1077,6 @@ async fn invalid_parent() { Duration::from_secs(0), &state, PayloadVerificationStatus::Optimistic, - block.message().proposer_index(), &rig.harness.chain.spec, ), Err(ForkChoiceError::ProtoArrayStringError(message)) diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index f8ab8cddc8..b6b681e091 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -8,7 +8,9 @@ use crate::version::{ }; use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::payload_envelope_verification::EnvelopeError; -use beacon_chain::{BeaconChain, BeaconChainTypes, NotifyExecutionLayer}; +use beacon_chain::{ + AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, NotifyExecutionLayer, +}; use bytes::Bytes; use eth2::types as api_types; use lighthouse_network::PubsubMessage; @@ -160,12 +162,16 @@ pub async fn publish_execution_payload_envelope( ) .await; - if let Err(e) = import_result { - warn!(%slot, error = ?e, "Failed to import execution payload envelope"); - return Err(warp_utils::reject::custom_server_error(format!( - "envelope import failed: {e}" - ))); - } + let mut envelope_imported = match &import_result { + Ok(AvailabilityProcessingStatus::Imported(_)) => true, + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => false, + Err(e) => { + warn!(%slot, error = ?e, "Failed to import execution payload envelope"); + return Err(warp_utils::reject::custom_server_error(format!( + "envelope import failed: {e}" + ))); + } + }; // From here on the envelope is on the wire. `take_blobs` already consumed the cache // entry, so a retry would not republish columns; returning Err would mislead the @@ -201,19 +207,27 @@ pub async fn publish_execution_payload_envelope( .collect::>(); // Local processing only — envelope already broadcast, so log and fall through. - if !sampling_columns.is_empty() - && let Err(e) = - Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await - { - error!( - %slot, - error = ?e, - "Failed to process sampling data columns during envelope publication" - ); + if !sampling_columns.is_empty() { + match Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await + { + Ok(AvailabilityProcessingStatus::Imported(_)) => envelope_imported = true, + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => {} + Err(e) => { + error!( + %slot, + error = ?e, + "Failed to process sampling data columns during envelope publication" + ); + } + } } } } + if envelope_imported { + chain.recompute_head_at_current_slot().await; + } + Ok(warp::reply().into_response()) } diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index dce4713245..50d5c8d165 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -621,10 +621,6 @@ mod tests { .unwrap(); let current_slot = harness.get_current_slot(); - let cached_head = chain.canonical_head.cached_head(); - let canonical_head_proposer_index = chain - .canonical_head_proposer_index(current_slot, &cached_head) - .unwrap(); chain .canonical_head @@ -636,7 +632,6 @@ mod tests { Duration::ZERO, &post_state, PayloadVerificationStatus::Verified, - canonical_head_proposer_index, &chain.spec, ) .unwrap(); diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 98c143eaeb..b52732000e 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -3795,13 +3795,19 @@ impl NetworkBeaconProcessor { // TODO(gloas) metrics // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); - if let Err(e) = &result { - debug!( - ?beacon_block_root, - %peer_id, - error = ?e, - "Execution payload envelope processing failed" - ); + match &result { + Ok(AvailabilityProcessingStatus::Imported(_)) => { + self.chain.recompute_head_at_current_slot().await; + } + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => {} + Err(e) => { + debug!( + ?beacon_block_root, + %peer_id, + error = ?e, + "Execution payload envelope processing failed" + ); + } } } @@ -3829,7 +3835,8 @@ impl NetworkBeaconProcessor { | PayloadBidError::InvalidBuilder { .. } | PayloadBidError::InvalidFeeRecipient | PayloadBidError::ExecutionPaymentNonZero { .. } - | PayloadBidError::InvalidBlobKzgCommitments { .. }, + | PayloadBidError::InvalidBlobKzgCommitments { .. } + | PayloadBidError::BidNotDescendantOfParent { .. }, ) => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( @@ -4008,6 +4015,14 @@ impl NetworkBeaconProcessor { *beacon_block_root, )) } + PayloadAttestationError::BlockNotAtSlot { .. } => { + debug!( + %peer_id, + %message_slot, + "Payload attestation references block at wrong slot" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } PayloadAttestationError::NotInPTC { .. } => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index e2226af094..caf718732b 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -376,6 +376,10 @@ impl NetworkBeaconProcessor { let result: Result = result.map_err(|e| BlockError::InternalError(format!("envelope: {e}"))); + if matches!(result, Ok(AvailabilityProcessingStatus::Imported(_))) { + self.chain.recompute_head_at_current_slot().await; + } + self.send_sync_message(SyncMessage::BlockComponentProcessed { process_type, result: result.into(), diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index a1789e3b19..45563c2ee9 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -546,9 +546,15 @@ impl OperationPool { } }); + let max_attester_slashings = if state.fork_name_unchecked().electra_enabled() { + E::max_attester_slashings_electra() + } else { + E::MaxAttesterSlashings::to_usize() + }; + maximum_cover( relevant_attester_slashings, - E::MaxAttesterSlashings::to_usize(), + max_attester_slashings, "attester_slashings", ) .into_iter() @@ -927,6 +933,29 @@ mod release_tests { harness } + /// The maximum number of attester slashings allowed in a block for the state's fork. + fn max_attester_slashings(state: &BeaconState) -> usize { + if state.fork_name_unchecked().electra_enabled() { + E::max_attester_slashings_electra() + } else { + E::MaxAttesterSlashings::to_usize() + } + } + + /// Given the candidate slashings ordered most-profitable first, return the prefix that a + /// block on the state's fork would actually include (i.e. the N most profitable, where N + /// is the per-block attester slashing limit). This keeps the max-cover assertions generic + /// across forks. + fn most_profitable_slashings( + state: &BeaconState, + ordered_by_profitability: Vec, + ) -> Vec { + ordered_by_profitability + .into_iter() + .take(max_attester_slashings(state)) + .collect() + } + /// Test state for attestation-related tests. fn attestation_test_state( num_committees: usize, @@ -1594,7 +1623,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_4.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_4, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_4, slashing_3]) + ); } // Check that we get maximum coverage for attester slashings with overlapping indices @@ -1616,7 +1648,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_4.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_1, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_1, slashing_3]) + ); } // Max coverage of attester slashings taking into account proposer slashings @@ -1638,7 +1673,10 @@ mod release_tests { op_pool.insert_attester_slashing(a_slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![a_slashing_1, a_slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![a_slashing_1, a_slashing_3]) + ); } //Max coverage checking that non overlapping indices are still recognized for their value @@ -1661,7 +1699,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_1, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_1, slashing_3]) + ); } // Max coverage should be affected by the overall effective balances @@ -1684,7 +1725,10 @@ mod release_tests { op_pool.insert_attester_slashing(slashing_3.clone().validate(&state, spec).unwrap()); let best_slashings = op_pool.get_slashings_and_exits(&state, &harness.spec); - assert_eq!(best_slashings.1, vec![slashing_2, slashing_3]); + assert_eq!( + best_slashings.1, + most_profitable_slashings(&state, vec![slashing_2, slashing_3]) + ); } /// End-to-end test of basic sync contribution handling. @@ -2177,6 +2221,53 @@ mod release_tests { assert_eq!(op_pool.attester_slashings.read().len(), 1); } + /// Regression test to ensure that we are using the correct spec value for max attester slashings post-Electra. + #[tokio::test] + async fn attester_slashings_capped_at_electra_limit() { + let (harness, spec) = cross_fork_harness::(); + let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + + let op_pool = OperationPool::::new(); + + harness + .extend_to_slot(electra_fork_epoch.start_slot(slots_per_epoch)) + .await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; + assert!( + electra_head + .beacon_state + .fork_name_unchecked() + .electra_enabled() + ); + + // Create two slashings + for validators in [vec![0], vec![1]] { + let slashing = harness.make_attester_slashing_with_epochs( + validators, + Some(Epoch::new(0)), + Some(deneb_fork_epoch - 1), + Some(Epoch::new(0)), + Some(deneb_fork_epoch - 1), + ); + let verified = slashing + .validate(&electra_head.beacon_state, &harness.chain.spec) + .unwrap(); + op_pool.insert_attester_slashing(verified); + } + assert_eq!(op_pool.attester_slashings.read().len(), 2); + + // Despite two valid slashings being pending, only one may be extracted post-Electra. + let mut to_be_slashed = HashSet::new(); + let attester_slashings = + op_pool.get_attester_slashings(&electra_head.beacon_state, &mut to_be_slashed); + assert_eq!( + attester_slashings.len(), + MainnetEthSpec::max_attester_slashings_electra() + ); + } + fn make_payload_attestation_message( slot: Slot, validator_index: u64, diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 7816fb4483..90f2bb9a67 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -542,6 +542,27 @@ where } } + /// Returns the dependent root for `block_root`, per the spec `get_dependent_root` helper. + fn get_dependent_root( + &self, + block_root: Hash256, + current_slot: Slot, + spec: &ChainSpec, + ) -> Result, Error> { + let epoch = current_slot.epoch(E::slots_per_epoch()); + + if epoch <= spec.min_seed_lookahead { + return Ok(Some(Hash256::zero())); + } + + let dependent_slot = epoch + .saturating_sub(spec.min_seed_lookahead) + .start_slot(E::slots_per_epoch()) + .saturating_sub(1_u64); + + self.get_ancestor(block_root, dependent_slot) + } + /// Run the fork choice rule to determine the head. /// /// ## Specification @@ -768,7 +789,6 @@ where block_delay: Duration, state: &BeaconState, payload_verification_status: PayloadVerificationStatus, - canonical_head_proposer_index: u64, spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_BLOCK_TIMES); @@ -780,10 +800,17 @@ where return Ok(()); } - // Provide the slot (as per the system clock) to the `fc_store` and then return its view of - // the current slot. The `fc_store` will ensure that the `current_slot` is never - // decreasing, a property which we must maintain. - let current_slot = self.update_time(system_time_current_slot)?; + let head_root = if system_time_current_slot == self.fc_store.get_current_slot() { + // Fork choice has already run for the current slot, so we can safely use the cached + // head without recomputing it. + self.cached_fork_choice_view().head_block_root + } else { + // Fork choice hasn't run for the current slot yet: run it, updating the fork choice + // store's current slot in the process. + self.get_head(system_time_current_slot, spec)?.0 + }; + let current_slot = self.fc_store.get_current_slot(); + debug_assert_eq!(current_slot, system_time_current_slot); // Parent block must be known. let parent_block = self @@ -833,19 +860,24 @@ where let attestation_threshold = spec.get_attestation_due::(block.slot()); - // Add proposer score boost if the block is the first timely block for this slot and its - // proposer matches the expected proposer on the canonical chain (per spec - // `update_proposer_boost_root`, introduced in v1.7.0-alpha.5). + // Add proposer score boost if the block is the first timely block for this slot and it + // shares the same dependent root as the canonical chain head (per spec + // `update_proposer_boost_root`). let is_before_attesting_interval = block_delay < attestation_threshold; - + let is_timely = current_slot == block.slot() && is_before_attesting_interval; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); - let is_canonical_proposer = block.proposer_index() == canonical_head_proposer_index; - if current_slot == block.slot() - && is_before_attesting_interval - && is_first_block - && is_canonical_proposer - { - self.fc_store.set_proposer_boost_root(block_root); + + if is_timely && is_first_block { + // The block isn't in fork choice so resolve its dependent root via its parent. + let block_dependent_root = + self.get_dependent_root(block.parent_root(), current_slot, spec)?; + let head_dependent_root = self.get_dependent_root(head_root, current_slot, spec)?; + + // Add proposer score boost if the block is timely, not conflicting with an + // existing block, with the same dependent root as the canonical chain head. + if block_dependent_root.is_some() && block_dependent_root == head_dependent_root { + self.fc_store.set_proposer_boost_root(block_root); + } } // Update store with checkpoints if necessary @@ -1581,9 +1613,10 @@ where &self, block_root: &Hash256, parent_payload_status: PayloadStatus, + current_slot: Slot, ) -> Result> { self.proto_array - .should_build_on_full::(block_root, parent_payload_status) + .should_build_on_full::(block_root, parent_payload_status, current_slot) .map_err(Error::ProtoArrayStringError) } diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 848834b4d8..02229e6f33 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -316,7 +316,6 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, - block.message().proposer_index(), &self.harness.chain.spec, ) .unwrap(); @@ -360,7 +359,6 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, - block.message().proposer_index(), &self.harness.chain.spec, ) .expect_err("on_block did not return an error"); diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 3dc5406212..7ffa763308 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -124,6 +124,15 @@ pub enum Operation { #[serde(default)] proposer_boost_root: Option, }, + /// Assert the result of `should_build_on_full` for the parent `block_root`, where + /// `parent_payload_status` is the status the proposer would build on and `proposal_slot` + /// is the slot being proposed. + AssertShouldBuildOnFull { + block_root: Hash256, + parent_payload_status: PayloadStatus, + proposal_slot: Slot, + expected: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -606,6 +615,30 @@ impl ForkChoiceTestDefinition { op_index ); } + Operation::AssertShouldBuildOnFull { + block_root, + parent_payload_status, + proposal_slot, + expected, + } => { + let actual = fork_choice + .should_build_on_full::( + &block_root, + parent_payload_status, + proposal_slot, + ) + .unwrap_or_else(|e| { + panic!( + "should_build_on_full op at index {} returned error: {}", + op_index, e + ) + }); + assert_eq!( + actual, expected, + "should_build_on_full mismatch at op index {}", + op_index + ); + } } } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs index bf79a0170f..07262fb0d7 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -971,6 +971,90 @@ pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTe } } +/// Tests the slot check in `should_build_on_full`. When the parent is from an earlier slot the +/// function returns `true` and ignores PTC data-availability votes. It only checks those votes +/// when the parent is from the immediately preceding slot. +pub fn get_gloas_should_build_on_full_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1, child of genesis. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + + // PTC has voted the payload data unavailable. `is_timely` sets `payload_received` so the votes + // are consulted, and clearing the data-availability bits gives the "false" votes a majority. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(1), + is_timely: true, + is_data_available: false, + }); + + // When the parent is `Empty` `should_build_on_full` returns `false`. This check runs before + // the slot check, so the result is `false` for both the previous-slot case (block slot 1, proposal slot 2) + // and an earlier-slot case (proposal slot 3). + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Empty, + proposal_slot: Slot::new(2), + expected: false, + }); + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Empty, + proposal_slot: Slot::new(3), + expected: false, + }); + + // `Full` parent from the immediately preceding slot (block slot 1, proposal slot 2). The PTC + // votes are consulted, and since data is unavailable the proposer does not build on full. + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(2), + expected: false, + }); + + // `Full` parent from an *earlier* slot (block slot 1, proposal slot 3). The slot check + // short-circuits to `true` without consulting the (unavailable) PTC votes. + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(3), + expected: true, + }); + + // Flip the PTC view to *available* and re-check the previous-slot case. The votes now permit + // building on full. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(1), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::AssertShouldBuildOnFull { + block_root: get_root(1), + parent_payload_status: PayloadStatus::Full, + proposal_slot: Slot::new(2), + expected: true, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1160,6 +1244,12 @@ mod tests { test.run(); } + #[test] + fn should_build_on_full_slot_check() { + let test = get_gloas_should_build_on_full_test_definition(); + test.run(); + } + /// Test that execution payload invalidation propagates across the V17→V29 fork /// boundary: after invalidating a V17 parent, head must not select any descendant. /// diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index bd15bb4599..d45412c608 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1569,10 +1569,13 @@ impl ProtoArray { /// Called by the proposer to decide whether to build on the full or empty /// parent pending node. Returns false if the PTC has voted the data as unavailable. + /// For a parent from an earlier slot the `Empty` or `Full` node has already been resolved + /// by attestation weight in `get_head`. pub fn should_build_on_full( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, + current_slot: Slot, ) -> Result { if fc_node.payload_status == PayloadStatus::Pending { return Err(Error::InvalidPayloadStatus { @@ -1584,10 +1587,23 @@ impl ProtoArray { if fc_node.payload_status == PayloadStatus::Empty { return Ok(false); } + + if proto_node.slot().saturating_add(1u64) != current_slot { + return Ok(true); + } + // Check that false votes have not achieved an absolute majority. This allows the payload to be // considered available when either a majority have voted true or not enough votes have // been cast either way. - Ok(!proto_node.payload_data_availability::(false)?) + if proto_node.payload_data_availability::(false)? { + return Ok(false); + } + + if proto_node.payload_timeliness::(false)? { + return Ok(false); + } + + Ok(true) } pub fn should_extend_payload( diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index f0b599dbcd..69202486a7 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -968,6 +968,7 @@ impl ProtoArrayForkChoice { &self, block_root: &Hash256, parent_payload_status: PayloadStatus, + current_slot: Slot, ) -> Result { let block_index = self .proto_array @@ -985,7 +986,7 @@ impl ProtoArrayForkChoice { payload_status: parent_payload_status, }; self.proto_array - .should_build_on_full::(&fc_node, proto_node) + .should_build_on_full::(&fc_node, proto_node, current_slot) .map_err(|e| format!("{e:?}")) } diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index f88a325d4e..876e66d3af 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -880,8 +880,11 @@ pub fn process_deposit_requests_pre_gloas( spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { for request in deposit_requests { - // Set deposit receipt start index - if state.deposit_requests_start_index()? == spec.unset_deposit_requests_start_index { + // Set deposit receipt start index if pre-Fulu. + // Support for the former Eth1 bridge deposit mechanism was removed in Fulu. + if !state.fork_name_unchecked().fulu_enabled() + && state.deposit_requests_start_index()? == spec.unset_deposit_requests_start_index + { *state.deposit_requests_start_index_mut()? = request.index } let slot = state.slot(); diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 881e6bb16c..43d6606f07 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -1105,8 +1105,10 @@ impl PendingDepositsContext { let pending_deposits = state.pending_deposits()?; for deposit in pending_deposits.iter() { - // Do not process deposit requests if the Eth1 bridge deposits are not yet applied. - if deposit.slot > spec.genesis_slot + // Do not process deposit requests if pre-Fulu and the Eth1 bridge deposits are not yet applied. + // Support for the former Eth1 bridge deposit mechanism was removed in Fulu. + if !state.fork_name_unchecked().fulu_enabled() + && deposit.slot > spec.genesis_slot && state.eth1_deposit_index() < state.deposit_requests_start_index()? { break; diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 027acfab7f..26f28eda45 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -1127,21 +1127,33 @@ impl BeaconState { // Post-Fulu we must never compute proposer indices using insufficient lookahead. This // would be very dangerous as it would lead to conflicts between the *true* proposer as // defined by `self.proposer_lookahead` and the output of this function. - // With MIN_SEED_LOOKAHEAD=1 (common config), this is equivalent to checking that the - // requested epoch is not the current epoch. // - // We do not run this check if this function is called from `upgrade_to_fulu`, - // which runs *after* the slot is incremented, and needs to compute the proposer - // shuffling for the epoch that was just transitioned into. - if self.fork_name_unchecked().fulu_enabled() - && epoch < current_epoch.safe_add(spec.min_seed_lookahead)? - { - return Err( - BeaconStateError::ComputeProposerIndicesInsufficientLookahead { - current_epoch, - request_epoch: epoch, - }, - ); + // Furthermore, post-Gloas, we must never compute proposers at any slot other than the + // dependent root slot itself, as slashings at subsequent slots have the ability to + // change the shuffling. + // + // For simplicity these two checks are combined into a single check on the dependent + // slot, which is safe for Fulu and Gloas. This function is always called from + // `get_beacon_proposer_indices`, which uses the cached lookahead for `current_epoch` and + // `next_epoch`. The only epoch's shuffling that should ordinarily be computed therefore + // is `next_epoch + 1`, which for Fulu and Gloas is computed during the epoch transition + // in the last slot of `current_epoch` (before the slot is incremented into + // `next_epoch`). + // + // The only case where computation of proposers in `current_epoch` and `next_epoch` is + // directly required is during the fork to Fulu itself + // (`upgrade_to_fulu`/`initialize_proposer_lookahead`), in which case the state is not + // yet the Fulu variant, and we omit the check. + if self.fork_name_unchecked().fulu_enabled() { + let dependent_slot = spec.proposer_shuffling_decision_slot::(epoch); + if self.slot() != dependent_slot { + return Err( + BeaconStateError::ComputeProposerIndicesInsufficientLookahead { + current_epoch, + request_epoch: epoch, + }, + ); + } } } else { // Pre-Fulu the situation is reversed, we *should not* compute proposer indices using @@ -1375,7 +1387,7 @@ impl BeaconState { spec: &ChainSpec, ) -> Result, BeaconStateError> { // This isn't in the spec, but we remove the footgun that is requesting the current epoch - // for a Fulu state. + // or next epoch for a Fulu state. if let Ok(proposer_lookahead) = self.proposer_lookahead() && epoch >= self.current_epoch() && epoch <= self.next_epoch()? @@ -1394,7 +1406,15 @@ impl BeaconState { } // Not using the cached validator indices since they are shuffled. - let indices = self.get_active_validator_indices(epoch, spec)?; + let mut indices = self.get_active_validator_indices(epoch, spec)?; + + // Post-Gloas, slashed validators are excluded from proposer selection + if self.fork_name_unchecked().gloas_enabled() { + let latest_block_slot = self.latest_block_header().slot; + let slashings_cache = self.slashings_cache(); + slashings_cache.check_initialized(latest_block_slot)?; + indices.retain(|&index| !slashings_cache.is_slashed(index)); + } let preimage = self.get_seed(epoch, Domain::BeaconProposer, spec)?; self.compute_proposer_indices(epoch, preimage.as_slice(), &indices, spec) diff --git a/consensus/types/src/state/slashings_cache.rs b/consensus/types/src/state/slashings_cache.rs index b6ed583df8..cdaa2c6c89 100644 --- a/consensus/types/src/state/slashings_cache.rs +++ b/consensus/types/src/state/slashings_cache.rs @@ -64,3 +64,127 @@ impl SlashingsCache { self.latest_block_slot = Some(latest_block_slot); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Epoch, Hash256}; + use bls::PublicKeyBytes; + + /// Build a minimal validator with the given `slashed` flag. The other fields are irrelevant to + /// the slashings cache. + fn validator(slashed: bool) -> Validator { + Validator { + pubkey: PublicKeyBytes::empty(), + withdrawal_credentials: Hash256::ZERO, + effective_balance: 0, + slashed, + activation_eligibility_epoch: Epoch::new(0), + activation_epoch: Epoch::new(0), + exit_epoch: Epoch::new(0), + withdrawable_epoch: Epoch::new(0), + } + } + + /// Validators 1 and 3 are slashed, the rest are not. + fn validators() -> Vec { + vec![ + validator(false), + validator(true), + validator(false), + validator(true), + validator(false), + ] + } + + #[test] + fn new_captures_slashed_indices() { + let validators = validators(); + let cache = SlashingsCache::new(Slot::new(7), validators.iter()); + + // The cache is initialized at the block slot it was built for. + assert!(cache.is_initialized(Slot::new(7))); + assert!(!cache.is_initialized(Slot::new(8))); + + // Each index reports the same `slashed` status as the source validator. + for (index, validator) in validators.iter().enumerate() { + assert_eq!( + cache.is_slashed(index), + validator.slashed, + "validator {index} slashed status mismatch" + ); + } + + // An out-of-bounds index is not slashed. + assert!(!cache.is_slashed(validators.len())); + } + + #[test] + fn default_is_uninitialized() { + let cache = SlashingsCache::default(); + + // A default cache is not initialized at any slot. + assert!(!cache.is_initialized(Slot::new(0))); + assert_eq!( + cache.check_initialized(Slot::new(0)), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: None, + latest_block_slot: Slot::new(0), + }) + ); + + // It reports nothing as slashed. This is exactly why callers must check initialization + // before trusting `is_slashed`. + assert!(!cache.is_slashed(0)); + } + + #[test] + fn check_initialized_matches_block_slot() { + let cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + assert_eq!(cache.check_initialized(Slot::new(3)), Ok(())); + assert_eq!( + cache.check_initialized(Slot::new(4)), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: Some(Slot::new(3)), + latest_block_slot: Slot::new(4), + }) + ); + } + + #[test] + fn record_validator_slashing_requires_matching_slot() { + let mut cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + // Index 0 starts unslashed. + assert!(!cache.is_slashed(0)); + + // Recording at the initialized slot succeeds and marks the validator slashed. + cache.record_validator_slashing(Slot::new(3), 0).unwrap(); + assert!(cache.is_slashed(0)); + + // Recording at a slot the cache is not initialized for errors and leaves the set unchanged. + assert_eq!( + cache.record_validator_slashing(Slot::new(4), 2), + Err(BeaconStateError::SlashingsCacheUninitialized { + initialized_slot: Some(Slot::new(3)), + latest_block_slot: Slot::new(4), + }) + ); + assert!(!cache.is_slashed(2)); + } + + #[test] + fn update_latest_block_slot_preserves_slashed_set() { + let mut cache = SlashingsCache::new(Slot::new(3), validators().iter()); + + cache.update_latest_block_slot(Slot::new(4)); + + // The initialized slot moves forward without clearing the recorded slashings. + assert!(!cache.is_initialized(Slot::new(3))); + assert!(cache.is_initialized(Slot::new(4))); + assert!(cache.is_slashed(1)); + assert!(cache.is_slashed(3)); + assert!(!cache.is_slashed(0)); + } +} diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index f566a89ded..a497fdaeff 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.8 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.10 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 723c5e7e9e..c81f94d8e9 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -84,6 +84,8 @@ excluded_paths = [ "tests/.*/.*/networking/gossip_sync_committee_message/.*", "tests/.*/.*/networking/gossip_sync_committee_contribution_and_proof/.*", "tests/.*/.*/networking/gossip_blob_sidecar/.*", + "tests/.*/.*/networking/gossip_data_column_sidecar/.*", + "tests/.*/.*/networking/gossip_partial_data_column_sidecar/.*", # TODO: fast confirmation rule not merged yet "tests/.*/.*/fast_confirmation", ] diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index ec243f05cc..b89a72ca77 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -423,6 +423,9 @@ impl> Case for EpochProcessing { // Processing requires the committee caches. pre_state.build_all_committee_caches(spec).unwrap(); + // Proposer index computation (e.g. proposer lookahead) requires the slashings cache post-Gloas + pre_state.build_slashings_cache().unwrap(); + let mut state = pre_state.clone(); let mut expected = self.post.clone(); diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index cf122dbd65..9edc5b85c8 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -53,6 +53,9 @@ pub struct PowBlock { pub struct Head { slot: Slot, root: Hash256, + // Post-gloas, the head check also asserts the payload status of the head block + #[serde(default)] + payload_status: Option, } #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] @@ -132,6 +135,10 @@ pub enum Step< }, Attestation { attestation: TAttestation, + // Post-Gloas `on_attestation` tests can assert that an attestation is rejected (e.g. an + // invalid payload-present index). Defaults to `true` for the pre-Gloas tests that omit it. + #[serde(default = "default_true")] + valid: bool, }, AttesterSlashing { attester_slashing: TAttesterSlashing, @@ -169,8 +176,12 @@ fn default_true() -> bool { #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Meta { - #[serde(rename(deserialize = "description"))] - _description: String, + #[serde(default, rename(deserialize = "description"))] + _description: Option, + // Some Gloas fork choice tests carry a `bls_setting` instead of a description. We accept and + // ignore it: the value is always `1` (BLS required), which matches our default behaviour. + #[serde(default, rename(deserialize = "bls_setting"))] + _bls_setting: Option, } #[derive(Debug)] @@ -240,17 +251,19 @@ impl LoadCase for ForkChoiceTest { valid, }) } - Step::Attestation { attestation } => { + Step::Attestation { attestation, valid } => { if fork_name.electra_enabled() { ssz_decode_file(&path.join(format!("{}.ssz_snappy", attestation))).map( |attestation| Step::Attestation { attestation: Attestation::Electra(attestation), + valid, }, ) } else { ssz_decode_file(&path.join(format!("{}.ssz_snappy", attestation))).map( |attestation| Step::Attestation { attestation: Attestation::Base(attestation), + valid, }, ) } @@ -389,7 +402,9 @@ impl Case for ForkChoiceTest { proofs.clone(), *valid, )?, - Step::Attestation { attestation } => tester.process_attestation(attestation)?, + Step::Attestation { attestation, valid } => { + tester.process_attestation(attestation, *valid)? + } Step::AttesterSlashing { attester_slashing } => { tester.process_attester_slashing(attester_slashing.to_ref()) } @@ -673,7 +688,7 @@ impl Tester { if success { for attestation in block.message().body().attestations() { let att = attestation.clone_as_attestation(); - let _ = self.process_attestation(&att); + let _ = self.process_attestation(&att, true); } for attester_slashing in block.message().body().attester_slashings() { self.process_attester_slashing(attester_slashing); @@ -786,7 +801,7 @@ impl Tester { if success { for attestation in block.message().body().attestations() { let att = attestation.clone_as_attestation(); - let _ = self.process_attestation(&att); + let _ = self.process_attestation(&att, true); } for attester_slashing in block.message().body().attester_slashings() { self.process_attester_slashing(attester_slashing); @@ -848,7 +863,6 @@ impl Tester { block_delay, &state, PayloadVerificationStatus::Irrelevant, - block.message().proposer_index(), &self.harness.chain.spec, ); @@ -863,22 +877,41 @@ impl Tester { Ok(()) } - pub fn process_attestation(&self, attestation: &Attestation) -> Result<(), Error> { - let (indexed_attestation, _) = obtain_indexed_attestation_and_committees_per_slot( + pub fn process_attestation( + &self, + attestation: &Attestation, + valid: bool, + ) -> Result<(), Error> { + // Post-Gloas `on_attestation` tests can assert that an attestation is rejected (e.g. an + // invalid same-slot/payload-present index). Treat any failure in either indexing or fork + // choice application as a rejection so it can be compared against the expected `valid` flag. + let result = obtain_indexed_attestation_and_committees_per_slot( &self.harness.chain, attestation.to_ref(), ) - .map_err(|e| Error::InternalError(format!("attestation indexing failed with {:?}", e)))?; - let verified_attestation: ManuallyVerifiedAttestation> = - ManuallyVerifiedAttestation { - attestation, - indexed_attestation, - }; + .map_err(|e| format!("attestation indexing failed with {:?}", e)) + .and_then(|(indexed_attestation, _)| { + let verified_attestation: ManuallyVerifiedAttestation> = + ManuallyVerifiedAttestation { + attestation, + indexed_attestation, + }; - self.harness - .chain - .apply_attestation_to_fork_choice(&verified_attestation) - .map_err(|e| Error::InternalError(format!("attestation import failed with {:?}", e))) + self.harness + .chain + .apply_attestation_to_fork_choice(&verified_attestation) + .map_err(|e| format!("attestation import failed with {:?}", e)) + }); + + if valid { + result.map_err(Error::InternalError) + } else if result.is_ok() { + Err(Error::DidntFail( + "attestation was valid but the test expects it to be rejected".to_string(), + )) + } else { + Ok(()) + } } pub fn process_attester_slashing(&self, attester_slashing: AttesterSlashingRef) { @@ -909,9 +942,17 @@ impl Tester { let chain_head = Head { slot: head.head_slot(), root: head.head_block_root(), + // Compared separately below so the slot/root equality is not affected. + payload_status: expected_head.payload_status, }; - check_equal("head", chain_head, expected_head) + check_equal("head", chain_head, expected_head)?; + + if let Some(expected_status) = expected_head.payload_status { + self.check_head_payload_status(expected_status)?; + } + + Ok(()) } pub fn check_time(&self, expected_time: u64) -> Result<(), Error> { diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index f5c999920d..d851427a95 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -204,7 +204,12 @@ impl Operation for Deposit { ssz_decode_file(path) } - fn is_enabled_for_fork(_: ForkName) -> bool { + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + // The standalone `deposit` operation tests were removed in Fulu (deposits are processed + // via `deposit_request` from Electra onwards). + if fork_name.fulu_enabled() { + return false; + } // Some deposit tests require signature verification but are not marked as such. cfg!(not(feature = "fake_crypto")) } diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index df1ece49dd..b45ea3a230 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -708,13 +708,6 @@ impl Handler for ForkChoiceHandler { return false; } - // No FCU override tests prior to bellatrix, and removed in Gloas. - if self.handler_name == "should_override_forkchoice_update" - && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) - { - return false; - } - // Deposit tests exist only for Electra and later. if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { return false; @@ -725,9 +718,10 @@ impl Handler for ForkChoiceHandler { return false; } - // on_execution_payload_envelope, get_parent_payload_status, and + // on_attestation, on_execution_payload_envelope, get_parent_payload_status, and // on_payload_attestation_message tests exist only for Gloas and later. - if (self.handler_name == "on_execution_payload_envelope" + if (self.handler_name == "on_attestation" + || self.handler_name == "on_execution_payload_envelope" || self.handler_name == "get_parent_payload_status" || self.handler_name == "on_payload_attestation_message") && !fork_name.gloas_enabled() diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 6e1c4fdc10..9af88e0201 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -708,14 +708,18 @@ mod ssz_static { #[test] fn blob_sidecar() { - SszStaticHandler::, MinimalEthSpec>::deneb_and_later().run(); - SszStaticHandler::, MainnetEthSpec>::deneb_and_later().run(); + SszStaticHandler::, MinimalEthSpec>::deneb_only().run(); + SszStaticHandler::, MainnetEthSpec>::deneb_only().run(); + SszStaticHandler::, MinimalEthSpec>::electra_only().run(); + SszStaticHandler::, MainnetEthSpec>::electra_only().run(); } #[test] fn blob_identifier() { - SszStaticHandler::::deneb_and_later().run(); - SszStaticHandler::::deneb_and_later().run(); + SszStaticHandler::::deneb_only().run(); + SszStaticHandler::::deneb_only().run(); + SszStaticHandler::::electra_only().run(); + SszStaticHandler::::electra_only().run(); } #[test] @@ -1025,6 +1029,12 @@ fn fork_choice_get_head() { ForkChoiceHandler::::new("get_head").run(); } +#[test] +fn fork_choice_on_attestation() { + ForkChoiceHandler::::new("on_attestation").run(); + ForkChoiceHandler::::new("on_attestation").run(); +} + #[test] fn fork_choice_on_block() { ForkChoiceHandler::::new("on_block").run(); @@ -1049,12 +1059,6 @@ fn fork_choice_withholding() { // There is no mainnet variant for this test. } -#[test] -fn fork_choice_should_override_forkchoice_update() { - ForkChoiceHandler::::new("should_override_forkchoice_update").run(); - ForkChoiceHandler::::new("should_override_forkchoice_update").run(); -} - #[test] fn fork_choice_get_proposer_head() { ForkChoiceHandler::::new("get_proposer_head").run(); From 41dff2d96523cf16ab3cf1f04993d69447d1ea81 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:53:25 +0200 Subject: [PATCH 02/26] Enable mplex by default and change --enable-mplex to take bool (#9476) Enable Mplex by default, and let `--enable-mplex` take a bool argument. Co-Authored-By: Daniel Knopik Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- beacon_node/lighthouse_network/src/config.rs | 4 +- beacon_node/src/cli.rs | 9 +++-- beacon_node/src/config.rs | 6 +-- book/src/help_bn.md | 7 ++-- lighthouse/tests/beacon_node.rs | 39 ++++++++++++++++++++ 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index 8f7c1dd8de..f54a7ee5b9 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -125,7 +125,7 @@ pub struct Config { /// Whether light client protocols should be enabled. pub enable_light_client_server: bool, - /// Whether to enable the deprecated mplex multiplexer alongside yamux. + /// Whether to enable the mplex multiplexer alongside yamux. Enabled by default. pub enable_mplex: bool, /// Configuration for the outbound rate limiter (requests made by this node). @@ -365,7 +365,7 @@ impl Default for Config { proposer_only: false, metrics_enabled: false, enable_light_client_server: true, - enable_mplex: false, + enable_mplex: true, outbound_rate_limiter_config: None, invalid_block_storage: None, inbound_rate_limiter_config: None, diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index f06d0045b6..942b30120b 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -390,9 +390,12 @@ pub fn cli_app() -> Command { .arg( Arg::new("enable-mplex") .long("enable-mplex") - .action(ArgAction::SetTrue) - .help_heading(FLAG_HEADER) - .help("Enables mplex multiplexer alongside yamux. Yamux is preferred when both are available.") + .value_name("BOOLEAN") + .action(ArgAction::Set) + .num_args(0..=1) + .default_value("true") + .default_missing_value("true") + .help("Enables the mplex multiplexer alongside yamux. Yamux is preferred when both are available. Enabled by default; set to \"false\" to disable.") .display_order(0) ) .arg( diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 1793dfa091..4f42923010 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -7,7 +7,7 @@ use beacon_chain::graffiti_calculator::GraffitiOrigin; use bls::PublicKeyBytes; use clap::{ArgMatches, Id, parser::ValueSource}; use clap_utils::flags::DISABLE_MALLOC_TUNING_FLAG; -use clap_utils::{parse_flag, parse_required}; +use clap_utils::{parse_flag, parse_optional, parse_required}; use client::{ClientConfig, ClientGenesis}; use directory::{DEFAULT_BEACON_NODE_DIR, DEFAULT_NETWORK_DIR, DEFAULT_ROOT_DIR}; use environment::RuntimeContext; @@ -1434,8 +1434,8 @@ pub fn set_network_config( config.disable_quic_support = true; } - if parse_flag(cli_args, "enable-mplex") { - config.enable_mplex = true; + if let Some(enable_mplex) = parse_optional(cli_args, "enable-mplex")? { + config.enable_mplex = enable_mplex; } if parse_flag(cli_args, "disable-upnp") { diff --git a/book/src/help_bn.md b/book/src/help_bn.md index f02f1cb10e..1612d35b0e 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -84,6 +84,10 @@ Options: --discovery-port6 The UDP port that discovery will listen on over IPv6 if listening over both IPv4 and IPv6. Defaults to `port6` + --enable-mplex [] + Enables the mplex multiplexer alongside yamux. Yamux is preferred when + both are available. Enabled by default; set to "false" to disable. + [default: true] --enr-address
... The IP address/ DNS address to broadcast to other peers on how to reach this node. If a DNS address is provided, the enr-address is set @@ -489,9 +493,6 @@ Flags: Sets the local ENR IP address and port to match those set for lighthouse. Specifically, the IP address will be the value of --listen-address and the UDP port will be --discovery-port. - --enable-mplex - Enables mplex multiplexer alongside yamux. Yamux is preferred when - both are available. --enable-partial-columns Enable partial messages for data columns. This can reduce the amount of data sent over the network. Enabled by default on Hoodi and diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index 8bdab48282..d52558da93 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -2785,6 +2785,45 @@ fn invalid_block_roots_default_mainnet() { }) } +#[test] +fn enable_mplex_default() { + CommandLineTest::new() + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_true() { + CommandLineTest::new() + .flag("enable-mplex", Some("true")) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_false() { + CommandLineTest::new() + .flag("enable-mplex", Some("false")) + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_mplex); + }) +} + +#[test] +fn enable_mplex_no_value() { + CommandLineTest::new() + .flag("enable-mplex", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_mplex); + }) +} + #[test] fn partial_columns() { CommandLineTest::new() From e0ff3b5709f9888c0c8fd36a67624d087e6de0a7 Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 16 Jun 2026 10:54:11 +0400 Subject: [PATCH 03/26] Use `hashlink` over `lru` for `LruCache` (#8911) Use the `LruCache` implementation provided by `hashlink` instead of the current `lru` one. This is mostly a 1-to-1 swap with only slight API incompatibilities. I have decided to leave some config files which previously used `NonZeroUsize` but they may not be required anymore and could potentially switch to `usize`. Co-Authored-By: Mac L --- Cargo.lock | 23 +++----- Cargo.toml | 3 +- beacon_node/beacon_chain/Cargo.toml | 2 +- .../beacon_chain/src/beacon_proposer_cache.rs | 13 ++--- .../src/data_availability_checker.rs | 9 ++- .../overflow_lru_cache.rs | 21 +++---- .../beacon_chain/src/fetch_blobs/tests.rs | 3 +- .../src/light_client_server_cache.rs | 13 ++--- .../src/partial_data_column_assembler.rs | 57 ++++++++++--------- .../src/pending_payload_cache/mod.rs | 15 ++--- .../src/pre_finalization_cache.rs | 28 +++++---- beacon_node/client/src/builder.rs | 4 +- beacon_node/execution_layer/Cargo.toml | 2 +- beacon_node/execution_layer/src/engines.rs | 8 +-- .../execution_layer/src/payload_cache.rs | 10 ++-- beacon_node/http_api/Cargo.toml | 2 +- beacon_node/http_api/src/caches.rs | 7 +-- beacon_node/http_api/src/test_utils.rs | 4 +- beacon_node/lighthouse_network/Cargo.toml | 2 +- .../lighthouse_network/src/discovery/mod.rs | 16 +++--- .../service/partial_column_header_tracker.rs | 8 +-- beacon_node/store/Cargo.toml | 2 +- beacon_node/store/src/historic_state_cache.rs | 13 ++--- beacon_node/store/src/hot_cold_store.rs | 36 ++++++------ beacon_node/store/src/state_cache.rs | 43 +++++++------- deny.toml | 1 + slasher/Cargo.toml | 2 +- slasher/src/database.rs | 11 ++-- 28 files changed, 166 insertions(+), 192 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 223a6192c8..c0344bb7c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "lru 0.13.0", + "lru", "parking_lot", "pin-project", "reqwest", @@ -1229,13 +1229,13 @@ dependencies = [ "fork_choice", "futures", "genesis", + "hashlink", "hex", "int_to_bytes", "itertools 0.14.0", "kzg", "lighthouse_version", "logging", - "lru 0.12.5", "maplit", "merkle_proof", "metrics", @@ -3394,13 +3394,13 @@ dependencies = [ "fork_choice", "hash-db", "hash256-std-hasher", + "hashlink", "hex", "jsonwebtoken", "keccak-hash", "kzg", "lighthouse_version", "logging", - "lru 0.12.5", "metrics", "parking_lot", "pretty_reqwest_error", @@ -4240,12 +4240,12 @@ dependencies = [ "fixed_bytes", "futures", "genesis", + "hashlink", "health_metrics", "hex", "lighthouse_network", "lighthouse_version", "logging", - "lru 0.12.5", "metrics", "network", "network_utils", @@ -5549,6 +5549,7 @@ dependencies = [ "fixed_bytes", "fnv", "futures", + "hashlink", "hex", "if-addrs 0.14.0", "itertools 0.14.0", @@ -5556,7 +5557,6 @@ dependencies = [ "libp2p-mplex", "lighthouse_version", "logging", - "lru 0.12.5", "lru_cache", "metrics", "network_utils", @@ -5703,15 +5703,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.13.0" @@ -8415,10 +8406,10 @@ dependencies = [ "filesystem", "fixed_bytes", "flate2", + "hashlink", "libmdbx", "lmdb-rkv", "lmdb-rkv-sys", - "lru 0.12.5", "maplit", "metrics", "parking_lot", @@ -8652,10 +8643,10 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", + "hashlink", "itertools 0.14.0", "leveldb", "logging", - "lru 0.12.5", "metrics", "milhouse", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index 23bae317b4..6e0b1ddf3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,7 @@ fs2 = "0.4" futures = "0.3" genesis = { path = "beacon_node/genesis" } graffiti_file = { path = "validator_client/graffiti_file" } -hashlink = "0.9.0" +hashlink = "0.11" health_metrics = { path = "common/health_metrics" } hex = "0.4" http_api = { path = "beacon_node/http_api" } @@ -170,7 +170,6 @@ lockfile = { path = "common/lockfile" } log = "0.4" logging = { path = "common/logging" } logroller = "0.1.8" -lru = "0.12" lru_cache = { path = "common/lru_cache" } malloc_utils = { path = "common/malloc_utils" } maplit = "1" diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index 47ef4d7a03..236ce42abd 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -35,13 +35,13 @@ fixed_bytes = { workspace = true } fork_choice = { workspace = true } futures = { workspace = true } genesis = { workspace = true } +hashlink = { workspace = true } hex = { workspace = true } int_to_bytes = { workspace = true } itertools = { workspace = true } kzg = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } merkle_proof = { workspace = true } metrics = { workspace = true } milhouse = { workspace = true } diff --git a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs index b258d7471f..d35c9f003f 100644 --- a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs +++ b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs @@ -10,21 +10,19 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; use fork_choice::ExecutionStatus; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use once_cell::sync::OnceCell; use parking_lot::Mutex; use safe_arith::SafeArith; use smallvec::SmallVec; use state_processing::state_advance::partial_state_advance; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{debug, instrument}; use typenum::Unsigned; -use types::new_non_zero_usize; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, Fork, Hash256, Slot}; /// The number of sets of proposer indices that should be cached. -const CACHE_SIZE: NonZeroUsize = new_non_zero_usize(16); +const CACHE_SIZE: usize = 16; /// This value is fairly unimportant, it's used to avoid heap allocations. The result of it being /// incorrect is non-substantial from a consensus perspective (and probably also from a @@ -138,7 +136,8 @@ impl BeaconProposerCache { ) -> Arc> { let key = (epoch, shuffling_decision_block); self.cache - .get_or_insert(key, || Arc::new(OnceCell::new())) + .entry(key) + .or_insert_with(|| Arc::new(OnceCell::new())) .clone() } @@ -155,10 +154,10 @@ impl BeaconProposerCache { fork: Fork, ) -> Result<(), BeaconStateError> { let key = (epoch, shuffling_decision_block); - if !self.cache.contains(&key) { + if !self.cache.contains_key(&key) { let epoch_proposers = EpochBlockProposers::new(epoch, fork, proposers); self.cache - .put(key, Arc::new(OnceCell::with_value(epoch_proposers))); + .insert(key, Arc::new(OnceCell::with_value(epoch_proposers))); } Ok(()) diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index a0b117f072..29cbf84235 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -11,7 +11,6 @@ use slot_clock::SlotClock; use std::collections::HashSet; use std::fmt; use std::fmt::Debug; -use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; @@ -20,7 +19,7 @@ use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, - PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, new_non_zero_usize, + PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, }; mod error; @@ -49,7 +48,7 @@ pub use error::{Error as AvailabilityCheckError, ErrorCategory as AvailabilityCh /// /// `PendingComponents` are now never removed from the cache manually are only removed via LRU /// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. -const OVERFLOW_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); +const OVERFLOW_LRU_CAPACITY: usize = 32; /// Cache to hold fully valid data that can't be imported to fork-choice yet. After Dencun hard-fork /// blocks have a sidecar of data that is received separately from the network. We call the concept @@ -124,13 +123,13 @@ impl DataAvailabilityChecker { enable_partial_columns: bool, ) -> Result { let inner = DataAvailabilityCheckerInner::new( - OVERFLOW_LRU_CAPACITY_NON_ZERO, + OVERFLOW_LRU_CAPACITY, custody_context.clone(), spec.clone(), )?; let partial_assembler = if enable_partial_columns { Some(Arc::new(PartialDataColumnAssembler::new( - OVERFLOW_LRU_CAPACITY_NON_ZERO, + OVERFLOW_LRU_CAPACITY, ))) } else { None 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 2254728850..47740cdf5e 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 @@ -7,11 +7,10 @@ use crate::block_verification_types::{ use crate::data_availability_checker::{Availability, AvailabilityCheckError}; use crate::data_column_verification::KzgVerifiedCustodyDataColumn; use crate::{BeaconChainTypes, BlockProcessStatus}; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use ssz_types::RuntimeFixedVector; use std::cmp::Ordering; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{Span, debug, debug_span}; use types::data::BlobIdentifier; @@ -365,7 +364,7 @@ pub(crate) enum ReconstructColumnsDecision { impl DataAvailabilityCheckerInner { pub fn new( - capacity: NonZeroUsize, + capacity: usize, custody_context: Arc>, spec: Arc, ) -> Result { @@ -565,7 +564,7 @@ impl DataAvailabilityCheckerInner { let mut write_lock = self.critical.write(); { - let pending_components = write_lock.get_or_insert_mut(block_root, || { + let pending_components = write_lock.entry(block_root).or_insert_with(|| { PendingComponents::empty(block_root, self.spec.max_blobs_per_block(epoch) as usize) }); update_fn(pending_components)? @@ -672,7 +671,7 @@ impl DataAvailabilityCheckerInner { if let Some(BlockProcessStatus::NotValidated(_, _)) = self.get_cached_block(block_root) { // If the block is execution invalid, this status is permanent and idempotent to this // block_root. We drop its components (e.g. columns) because they will never be useful. - self.critical.write().pop(block_root); + self.critical.write().remove(block_root); } } @@ -733,7 +732,7 @@ impl DataAvailabilityCheckerInner { } // Now remove keys for key in keys_to_remove { - write_lock.pop(&key); + write_lock.remove(&key); } Ok(()) @@ -765,7 +764,6 @@ mod test { use store::{HotColdDB, ItemStore, StoreConfig, database::interface::BeaconNodeBackend}; use tempfile::{TempDir, tempdir}; use tracing::info; - use types::new_non_zero_usize; use types::{DataColumnSubnetId, MinimalEthSpec}; const LOW_VALIDATOR_COUNT: usize = 32; @@ -930,19 +928,14 @@ mod test { let chain_db_path = tempdir().expect("should get temp dir"); let harness = get_fulu_chain(&chain_db_path).await; let spec = harness.spec.clone(); - let capacity_non_zero = new_non_zero_usize(capacity); let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), &spec, )); let cache = Arc::new( - DataAvailabilityCheckerInner::::new( - capacity_non_zero, - custody_context, - spec.clone(), - ) - .expect("should create cache"), + DataAvailabilityCheckerInner::::new(capacity, custody_context, spec.clone()) + .expect("should create cache"), ); (harness, cache, chain_db_path) } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index 99cb4b5a78..4a37113fd9 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -9,7 +9,6 @@ use eth2::types::BlobsBundle; use execution_layer::json_structures::{BlobAndProof, BlobAndProofV1, BlobAndProofV2}; use execution_layer::test_utils::generate_blobs; use maplit::hashset; -use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use task_executor::test_utils::TestRuntime; use types::{ @@ -339,7 +338,7 @@ fn mock_beacon_adapter(fork_name: ForkName, get_blobs_v3: bool) -> MockFetchBlob let test_runtime = TestRuntime::default(); let spec = Arc::new(fork_name.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); - let partial_assembler = PartialDataColumnAssembler::new(NonZeroUsize::new(32).unwrap()); + let partial_assembler = PartialDataColumnAssembler::new(32); let mut mock_adapter = MockFetchBlobsBeaconAdapter::default(); mock_adapter.expect_spec().return_const(spec.clone()); 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 5b405234e7..d9d3fdd63e 100644 --- a/beacon_node/beacon_chain/src/light_client_server_cache.rs +++ b/beacon_node/beacon_chain/src/light_client_server_cache.rs @@ -1,15 +1,14 @@ use crate::errors::BeaconChainError; use crate::{BeaconChainTypes, BeaconStore, metrics}; +use hashlink::lru_cache::LruCache; use parking_lot::{Mutex, RwLock}; use safe_arith::SafeArith; use ssz::Decode; -use std::num::NonZeroUsize; use std::sync::Arc; use store::DBColumn; use store::KeyValueStore; use tracing::debug; use tree_hash::TreeHash; -use types::new_non_zero_usize; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, EthSpec, ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, @@ -19,7 +18,7 @@ use types::{ /// A prev block cache miss requires to re-generate the state of the post-parent block. Items in the /// prev block cache are very small 32 * (6 + 1) = 224 bytes. 32 is an arbitrary number that /// represents unlikely re-orgs, while keeping the cache very small. -const PREV_BLOCK_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(32); +const PREV_BLOCK_CACHE_SIZE: usize = 32; /// This cache computes light client messages ahead of time, required to satisfy p2p and API /// requests. These messages include proofs on historical states, so on-demand computation is @@ -39,7 +38,7 @@ pub struct LightClientServerCache { /// Caches the current sync committee, latest_written_current_sync_committee: RwLock>>>, /// Caches state proofs by block root - prev_block_cache: Mutex>>, + prev_block_cache: Mutex>>, /// Tracks the latest broadcasted finality update latest_broadcasted_finality_update: RwLock>>, /// Tracks the latest broadcasted optimistic update @@ -55,7 +54,7 @@ impl LightClientServerCache { latest_written_current_sync_committee: None.into(), latest_broadcasted_finality_update: None.into(), latest_broadcasted_optimistic_update: None.into(), - prev_block_cache: lru::LruCache::new(PREV_BLOCK_CACHE_SIZE).into(), + prev_block_cache: LruCache::new(PREV_BLOCK_CACHE_SIZE).into(), } } @@ -74,7 +73,7 @@ impl LightClientServerCache { if fork_name.altair_enabled() { // Persist in memory cache for a descendent block let cached_data = LightClientCachedData::from_state(block_post_state)?; - self.prev_block_cache.lock().put(block_root, cached_data); + self.prev_block_cache.lock().insert(block_root, cached_data); } Ok(()) @@ -335,7 +334,7 @@ impl LightClientServerCache { // Insert value and return owned self.prev_block_cache .lock() - .put(*block_root, new_value.clone()); + .insert(*block_root, new_value.clone()); Ok(new_value) } diff --git a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs index ee59102cfd..f8580352b2 100644 --- a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs +++ b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs @@ -1,10 +1,9 @@ use crate::data_column_verification::{ KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, }; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::RwLock; use std::collections::HashMap; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::error; use types::core::{Epoch, EthSpec, Hash256}; @@ -44,7 +43,7 @@ pub struct PartialMergeResult { } impl PartialDataColumnAssembler { - pub fn new(capacity: NonZeroUsize) -> Self { + pub fn new(capacity: usize) -> Self { Self { assemblies: RwLock::new(LruCache::new(capacity)), } @@ -55,7 +54,7 @@ impl PartialDataColumnAssembler { pub fn init(&self, block_root: Hash256, header: Arc>) -> bool { let mut assemblies = self.assemblies.write(); - if assemblies.contains(&block_root) { + if assemblies.contains_key(&block_root) { return false; } @@ -65,7 +64,7 @@ impl PartialDataColumnAssembler { columns: HashMap::new(), }; - assemblies.put(block_root, assembly); + assemblies.insert(block_root, assembly); true } @@ -79,11 +78,13 @@ impl PartialDataColumnAssembler { header: Arc>, ) -> Option> { let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: header.clone(), - has_local_blobs: false, - columns: HashMap::new(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: header.clone(), + has_local_blobs: false, + columns: HashMap::new(), + }); let mut full_columns = Vec::new(); let mut updated_partials = Vec::new(); @@ -165,15 +166,17 @@ impl PartialDataColumnAssembler { }; let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: Arc::new(PartialDataColumnHeader { - kzg_commitments: fulu.kzg_commitments.clone(), - signed_block_header: fulu.signed_block_header.clone(), - kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), - }), - has_local_blobs: false, - columns: Default::default(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: Arc::new(PartialDataColumnHeader { + kzg_commitments: fulu.kzg_commitments.clone(), + signed_block_header: fulu.signed_block_header.clone(), + kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), + }), + has_local_blobs: false, + columns: Default::default(), + }); let prev = assembly .columns .insert(column.index(), AssemblyColumn::Complete(column.clone())); @@ -215,11 +218,13 @@ impl PartialDataColumnAssembler { header: &Arc>, ) -> Vec> { let mut assemblies = self.assemblies.write(); - let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { - header: header.clone(), - has_local_blobs: true, - columns: Default::default(), - }); + let assembly = assemblies + .entry(block_root) + .or_insert_with(|| PartialAssembly { + header: header.clone(), + has_local_blobs: true, + columns: Default::default(), + }); assembly.has_local_blobs = true; @@ -253,7 +258,7 @@ impl PartialDataColumnAssembler { } for root in to_remove { - assemblies.pop(&root); + assemblies.remove(&root); } } } @@ -362,7 +367,7 @@ mod tests { } fn make_assembler() -> PartialDataColumnAssembler { - PartialDataColumnAssembler::new(NonZeroUsize::new(16).unwrap()) + PartialDataColumnAssembler::new(16) } // -- init and get_header tests -- diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs index 2100a5fe9f..c5c97418c7 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs @@ -15,13 +15,12 @@ use crate::payload_envelope_verification::{ AvailabilityPendingExecutedEnvelope, AvailableExecutedEnvelope, }; use crate::{BeaconChainTypes, CustodyContext, metrics}; +use hashlink::lru_cache::LruCache; use kzg::Kzg; -use lru::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::collections::HashMap; use std::fmt; use std::fmt::Debug; -use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{Span, debug, error, instrument}; use types::{ @@ -41,7 +40,6 @@ use crate::metrics::{ use crate::observed_data_sidecars::ObservationStrategy; use pending_components::{PendingComponents, ReconstructColumnsDecision}; use types::SignedExecutionPayloadBid; -use types::new_non_zero_usize; /// The LRU Cache stores `PendingComponents`, which store the block root, the execution payload bid, and its associated column data. /// The execution payload bid stores the kzg commitments which we use to verify against incoming column data. @@ -49,7 +47,7 @@ use types::new_non_zero_usize; /// /// `PendingComponents` are now never removed from the cache manually and are only removed via LRU /// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. -const AVAILABILITY_CACHE_CAPACITY: NonZeroUsize = new_non_zero_usize(32); +const AVAILABILITY_CACHE_CAPACITY: usize = 32; /// This type is returned after adding a bid / column to the `DataAvailabilityChecker`. /// @@ -206,7 +204,9 @@ impl PendingPayloadCache { /// This will silently drop the bid if a bid for this block root already exists in the cache. pub fn insert_bid(&self, block_root: Hash256, bid: Arc>) { let mut write_lock = self.availability_cache.write(); - write_lock.get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + write_lock + .entry(block_root) + .or_insert_with(|| PendingComponents::new(block_root, bid)); } /// Perform KZG verification on RPC custody columns and insert them into the cache. @@ -423,7 +423,8 @@ impl PendingPayloadCache { { let pending_components = write_lock - .get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + .entry(block_root) + .or_insert_with(|| PendingComponents::new(block_root, bid)); update_fn(pending_components) } @@ -496,7 +497,7 @@ impl PendingPayloadCache { } } for key in keys_to_remove { - write_lock.pop(&key); + write_lock.remove(&key); } Ok(()) diff --git a/beacon_node/beacon_chain/src/pre_finalization_cache.rs b/beacon_node/beacon_chain/src/pre_finalization_cache.rs index 54bd5c1940..b90d02db5e 100644 --- a/beacon_node/beacon_chain/src/pre_finalization_cache.rs +++ b/beacon_node/beacon_chain/src/pre_finalization_cache.rs @@ -1,15 +1,13 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use hashlink::lru_cache::LruCache; use itertools::process_results; -use lru::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use std::time::Duration; use tracing::debug; use types::Hash256; -use types::new_non_zero_usize; -const BLOCK_ROOT_CACHE_LIMIT: NonZeroUsize = new_non_zero_usize(512); -const LOOKUP_LIMIT: NonZeroUsize = new_non_zero_usize(8); +const BLOCK_ROOT_CACHE_LIMIT: usize = 512; +const LOOKUP_LIMIT: usize = 8; const METRICS_TIMEOUT: Duration = Duration::from_millis(100); /// Cache for rejecting attestations to blocks from before finalization. @@ -49,13 +47,13 @@ impl BeaconChain { let mut cache = self.pre_finalization_block_cache.cache.lock(); // Check the cache to see if we already know this pre-finalization block root. - if cache.block_roots.contains(&block_root) { + if cache.block_roots.contains_key(&block_root) { return Ok(true); } // Avoid repeating the disk lookup for blocks that are already subject to a network lookup. // Sync will take care of de-duplicating the single block lookups. - if cache.in_progress_lookups.contains(&block_root) { + if cache.in_progress_lookups.contains_key(&block_root) { return Ok(false); } @@ -68,19 +66,19 @@ impl BeaconChain { .map_err(BeaconChainError::BeaconStateError) })?; if is_recent_finalized_block { - cache.block_roots.put(block_root, ()); + cache.block_roots.insert(block_root, ()); return Ok(true); } // 2. Check on disk. if self.store.get_blinded_block(&block_root)?.is_some() { - cache.block_roots.put(block_root, ()); + cache.block_roots.insert(block_root, ()); return Ok(true); } // 3. Check the network with a single block lookup. - cache.in_progress_lookups.put(block_root, ()); - if cache.in_progress_lookups.len() == LOOKUP_LIMIT.get() { + cache.in_progress_lookups.insert(block_root, ()); + if cache.in_progress_lookups.len() == LOOKUP_LIMIT { // NOTE: we expect this to occur sometimes if a lot of blocks that we look up fail to be // imported for reasons other than being pre-finalization. The cache will eventually // self-repair in this case by replacing old entries with new ones until all the failed @@ -95,8 +93,8 @@ impl BeaconChain { pub fn pre_finalization_block_rejected(&self, block_root: Hash256) { // Future requests can know that this block is invalid without having to look it up again. let mut cache = self.pre_finalization_block_cache.cache.lock(); - cache.in_progress_lookups.pop(&block_root); - cache.block_roots.put(block_root, ()); + cache.in_progress_lookups.remove(&block_root); + cache.block_roots.insert(block_root, ()); } } @@ -104,11 +102,11 @@ impl PreFinalizationBlockCache { pub fn block_processed(&self, block_root: Hash256) { // Future requests will find this block in fork choice, so no need to cache it in the // ongoing lookup cache any longer. - self.cache.lock().in_progress_lookups.pop(&block_root); + self.cache.lock().in_progress_lookups.remove(&block_root); } pub fn contains(&self, block_root: Hash256) -> bool { - self.cache.lock().block_roots.contains(&block_root) + self.cache.lock().block_roots.contains_key(&block_root) } pub fn metrics(&self) -> Option<(usize, usize)> { diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 0a3c414632..1624f73e9b 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -36,7 +36,6 @@ use rand::SeedableRng; use rand::rngs::{OsRng, StdRng}; use slasher::Slasher; use slasher_service::SlasherService; -use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -641,8 +640,7 @@ where beacon_processor_send: Some(beacon_processor_channels.beacon_processor_tx.clone()), sse_logging_components: runtime_context.sse_logging_components.clone(), historical_committee_cache: Arc::new(http_api::HistoricalCommitteeCache::new( - NonZeroUsize::new(self.http_api_config.historical_committee_cache_size) - .unwrap_or(NonZeroUsize::MIN), + self.http_api_config.historical_committee_cache_size, )), }); diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index a23ea948e4..91eb74e621 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -20,13 +20,13 @@ fixed_bytes = { workspace = true } fork_choice = { workspace = true } hash-db = "0.15.2" hash256-std-hasher = "0.15.2" +hashlink = { workspace = true } hex = { workspace = true } jsonwebtoken = "9" keccak-hash = "0.10.0" kzg = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } parking_lot = { workspace = true } pretty_reqwest_error = { workspace = true } diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index 3e6f78abbe..aac170d48c 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -5,9 +5,8 @@ use crate::engine_api::{ PayloadId, }; use crate::{ClientVersionV1, HttpJsonRpc}; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use std::future::Future; -use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; @@ -15,12 +14,11 @@ use tokio::sync::{Mutex, RwLock, watch}; use tokio_stream::wrappers::WatchStream; use tracing::{debug, error, info, warn}; use types::ExecutionBlockHash; -use types::new_non_zero_usize; /// The number of payload IDs that will be stored for each `Engine`. /// /// Since the size of each value is small (~800 bytes) a large number is used for safety. -const PAYLOAD_ID_LRU_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(512); +const PAYLOAD_ID_LRU_CACHE_SIZE: usize = 512; const CACHED_RESPONSE_AGE_LIMIT: Duration = Duration::from_secs(900); // 15 minutes /// Stores the remembered state of a engine. @@ -175,7 +173,7 @@ impl Engine { if let Some(key) = payload_attributes .map(|pa| PayloadIdCacheKey::new(&forkchoice_state.head_block_hash, &pa)) { - self.payload_id_cache.lock().await.put(key, payload_id); + self.payload_id_cache.lock().await.insert(key, payload_id); } else { debug!(?payload_id, "Engine returned unexpected payload_id"); } diff --git a/beacon_node/execution_layer/src/payload_cache.rs b/beacon_node/execution_layer/src/payload_cache.rs index ce65a53ef1..958bf12dc2 100644 --- a/beacon_node/execution_layer/src/payload_cache.rs +++ b/beacon_node/execution_layer/src/payload_cache.rs @@ -1,12 +1,10 @@ use eth2::types::FullPayloadContents; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use tree_hash::TreeHash; -use types::new_non_zero_usize; use types::{EthSpec, Hash256}; -pub const DEFAULT_PAYLOAD_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(10); +pub const DEFAULT_PAYLOAD_CACHE_SIZE: usize = 10; /// A cache mapping execution payloads by tree hash roots. pub struct PayloadCache { @@ -27,11 +25,11 @@ impl Default for PayloadCache { impl PayloadCache { pub fn put(&self, payload: FullPayloadContents) -> Option> { let root = payload.payload_ref().tree_hash_root(); - self.payloads.lock().put(PayloadCacheId(root), payload) + self.payloads.lock().insert(PayloadCacheId(root), payload) } pub fn pop(&self, root: &Hash256) -> Option> { - self.payloads.lock().pop(&PayloadCacheId(*root)) + self.payloads.lock().remove(&PayloadCacheId(*root)) } pub fn get(&self, hash: &Hash256) -> Option> { diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index dd15a76c7a..fb01f655d9 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -20,12 +20,12 @@ ethereum_ssz = { workspace = true } execution_layer = { workspace = true } fixed_bytes = { workspace = true } futures = { workspace = true } +hashlink = { workspace = true } health_metrics = { workspace = true } hex = { workspace = true } lighthouse_network = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } network = { workspace = true } network_utils = { workspace = true } diff --git a/beacon_node/http_api/src/caches.rs b/beacon_node/http_api/src/caches.rs index d92571594a..0f8c0ee6e0 100644 --- a/beacon_node/http_api/src/caches.rs +++ b/beacon_node/http_api/src/caches.rs @@ -1,6 +1,5 @@ -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; -use std::num::NonZeroUsize; use std::sync::Arc; use types::{AttestationShufflingId, CommitteeCache, Epoch}; @@ -25,7 +24,7 @@ pub struct HistoricalCommitteeCache { } impl HistoricalCommitteeCache { - pub fn new(size: NonZeroUsize) -> Self { + pub fn new(size: usize) -> Self { Self { committees: Mutex::new(LruCache::new(size)), } @@ -38,6 +37,6 @@ impl HistoricalCommitteeCache { } pub fn insert(&self, id: HistoricalShufflingId, cache: Arc) { - self.committees.lock().put(id, cache); + self.committees.lock().insert(id, cache); } } diff --git a/beacon_node/http_api/src/test_utils.rs b/beacon_node/http_api/src/test_utils.rs index 467a5216b1..9a705e4162 100644 --- a/beacon_node/http_api/src/test_utils.rs +++ b/beacon_node/http_api/src/test_utils.rs @@ -22,10 +22,10 @@ use lighthouse_network::{ }; use network::{NetworkReceivers, NetworkSenders}; use sensitive_url::SensitiveUrl; +use std::future::Future; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use std::{future::Future, num::NonZeroUsize}; use store::MemoryStore; use task_executor::test_utils::TestRuntime; use types::{ChainSpec, EthSpec}; @@ -294,7 +294,7 @@ pub async fn create_api_server_with_config( beacon_processor_send: Some(beacon_processor_send), sse_logging_components: None, historical_committee_cache: Arc::new(HistoricalCommitteeCache::new( - NonZeroUsize::new(http_config.historical_committee_cache_size).unwrap(), + http_config.historical_committee_cache_size, )), }); diff --git a/beacon_node/lighthouse_network/Cargo.toml b/beacon_node/lighthouse_network/Cargo.toml index 659886f0f1..f69f13612a 100644 --- a/beacon_node/lighthouse_network/Cargo.toml +++ b/beacon_node/lighthouse_network/Cargo.toml @@ -21,6 +21,7 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } fnv = { workspace = true } futures = { workspace = true } +hashlink = { workspace = true } hex = { workspace = true } if-addrs = "0.14" itertools = { workspace = true } @@ -28,7 +29,6 @@ libp2p = { workspace = true } libp2p-mplex = { git = "https://github.com/libp2p/rust-libp2p.git" } lighthouse_version = { workspace = true } logging = { workspace = true } -lru = { workspace = true } lru_cache = { workspace = true } metrics = { workspace = true } network_utils = { workspace = true } diff --git a/beacon_node/lighthouse_network/src/discovery/mod.rs b/beacon_node/lighthouse_network/src/discovery/mod.rs index 21b1146aff..964c0cd5fb 100644 --- a/beacon_node/lighthouse_network/src/discovery/mod.rs +++ b/beacon_node/lighthouse_network/src/discovery/mod.rs @@ -18,6 +18,7 @@ use alloy_rlp::bytes::Bytes; use enr::{ATTESTATION_BITFIELD_ENR_KEY, ETH2_ENR_KEY, SYNC_COMMITTEE_BITFIELD_ENR_KEY}; use futures::prelude::*; use futures::stream::FuturesUnordered; +use hashlink::lru_cache::LruCache; use libp2p::core::transport::PortUse; use libp2p::multiaddr::Protocol; use libp2p::swarm::THandlerInEvent; @@ -31,10 +32,8 @@ pub use libp2p::{ }, }; use logging::crit; -use lru::LruCache; use network_utils::discovery_metrics; use ssz::Encode; -use std::num::NonZeroUsize; use std::{ collections::{HashMap, VecDeque}, net::{IpAddr, SocketAddr}, @@ -51,7 +50,6 @@ use types::{ChainSpec, EnrForkId, EthSpec}; mod subnet_predicate; use crate::discovery::enr::{NEXT_FORK_DIGEST_ENR_KEY, PEERDAS_CUSTODY_GROUP_COUNT_ENR_KEY}; pub use subnet_predicate::subnet_predicate; -use types::new_non_zero_usize; /// Local ENR storage filename. pub const ENR_FILENAME: &str = "enr.dat"; @@ -74,7 +72,7 @@ pub const FIND_NODE_QUERY_CLOSEST_PEERS: usize = 16; /// The threshold for updating `min_ttl` on a connected peer. const DURATION_DIFFERENCE: Duration = Duration::from_millis(1); /// The capacity of the Discovery ENR cache. -const ENR_CACHE_CAPACITY: NonZeroUsize = new_non_zero_usize(50); +const ENR_CACHE_CAPACITY: usize = 50; /// A query has completed. This result contains a mapping of discovered peer IDs to the `min_ttl` /// of the peer if it is specified. @@ -358,7 +356,7 @@ impl Discovery { /// Removes a cached ENR from the list. pub fn remove_cached_enr(&mut self, peer_id: &PeerId) -> Option { - self.cached_enrs.pop(peer_id) + self.cached_enrs.remove(peer_id) } /// This adds a new `FindPeers` query to the queue if one doesn't already exist. @@ -394,7 +392,7 @@ impl Discovery { /// Add an ENR to the routing table of the discovery mechanism. pub fn add_enr(&mut self, enr: Enr) { // add the enr to seen caches - self.cached_enrs.put(enr.peer_id(), enr.clone()); + self.cached_enrs.insert(enr.peer_id(), enr.clone()); if let Err(e) = self.discv5.add_enr(enr) { debug!( @@ -665,7 +663,7 @@ impl Discovery { } // Remove the peer from the cached list, to prevent redialing disconnected // peers. - self.cached_enrs.pop(peer_id); + self.cached_enrs.remove(peer_id); } /* Internal Functions */ @@ -875,7 +873,7 @@ impl Discovery { .into_iter() .map(|enr| { // cache the found ENR's - self.cached_enrs.put(enr.peer_id(), enr.clone()); + self.cached_enrs.insert(enr.peer_id(), enr.clone()); (enr, None) }) .collect(); @@ -910,7 +908,7 @@ impl Discovery { // cache the found ENR's for enr in r.iter().cloned() { - self.cached_enrs.put(enr.peer_id(), enr); + self.cached_enrs.insert(enr.peer_id(), enr); } // Map each subnet query's min_ttl to the set of ENR's returned for that subnet. diff --git a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs index bb588fe3d8..309674a885 100644 --- a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs +++ b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs @@ -1,12 +1,11 @@ use crate::types::HeaderSentSet; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use parking_lot::Mutex; use std::collections::HashSet; -use std::num::NonZeroUsize; use std::sync::Arc; use types::core::Hash256; -const MAX_BLOCKS: NonZeroUsize = NonZeroUsize::new(4).unwrap(); +const MAX_BLOCKS: usize = 4; pub struct PartialColumnHeaderTracker { blocks: LruCache, @@ -22,7 +21,8 @@ impl PartialColumnHeaderTracker { pub fn get_for_block(&mut self, hash: Hash256) -> HeaderSentSet { Arc::clone( self.blocks - .get_or_insert(hash, || Arc::new(Mutex::new(HashSet::new()))), + .entry(hash) + .or_insert_with(|| Arc::new(Mutex::new(HashSet::new()))), ) } } diff --git a/beacon_node/store/Cargo.toml b/beacon_node/store/Cargo.toml index 50028fe73f..5f810ea76b 100644 --- a/beacon_node/store/Cargo.toml +++ b/beacon_node/store/Cargo.toml @@ -16,10 +16,10 @@ directory = { workspace = true } ethereum_ssz = { workspace = true } ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } +hashlink = { workspace = true } itertools = { workspace = true } leveldb = { version = "0.8.6", optional = true, default-features = false } logging = { workspace = true } -lru = { workspace = true } metrics = { workspace = true } milhouse = { workspace = true } parking_lot = { workspace = true } diff --git a/beacon_node/store/src/historic_state_cache.rs b/beacon_node/store/src/historic_state_cache.rs index e5abb04c07..02eb68ad70 100644 --- a/beacon_node/store/src/historic_state_cache.rs +++ b/beacon_node/store/src/historic_state_cache.rs @@ -1,7 +1,6 @@ use crate::hdiff::{Error, HDiffBuffer}; use crate::metrics; -use lru::LruCache; -use std::num::NonZeroUsize; +use hashlink::lru_cache::LruCache; use types::{BeaconState, ChainSpec, EthSpec, Slot}; /// Holds a combination of finalized states in two formats: @@ -25,7 +24,7 @@ pub struct Metrics { } impl HistoricStateCache { - pub fn new(hdiff_buffer_cache_size: NonZeroUsize, state_cache_size: NonZeroUsize) -> Self { + pub fn new(hdiff_buffer_cache_size: usize, state_cache_size: usize) -> Self { Self { hdiff_buffers: LruCache::new(hdiff_buffer_cache_size), states: LruCache::new(state_cache_size), @@ -47,7 +46,7 @@ impl HistoricStateCache { ); let cloned = buffer.clone(); drop(_timer); - self.hdiff_buffers.put(slot, cloned); + self.hdiff_buffers.insert(slot, cloned); Some(buffer) } else { None @@ -63,7 +62,7 @@ impl HistoricStateCache { Ok(Some(state.clone())) } else if let Some(buffer) = self.hdiff_buffers.get(&slot) { let state = buffer.as_state(spec)?; - self.states.put(slot, state.clone()); + self.states.insert(slot, state.clone()); Ok(Some(state)) } else { Ok(None) @@ -71,11 +70,11 @@ impl HistoricStateCache { } pub fn put_state(&mut self, slot: Slot, state: BeaconState) { - self.states.put(slot, state); + self.states.insert(slot, state); } pub fn put_hdiff_buffer(&mut self, slot: Slot, buffer: HDiffBuffer) { - self.hdiff_buffers.put(slot, buffer); + self.hdiff_buffers.insert(slot, buffer); } pub fn put_both(&mut self, slot: Slot, state: BeaconState, buffer: HDiffBuffer) { diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index a625a97004..7484c271ae 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -19,8 +19,8 @@ use crate::{ parse_data_column_key, }; use fixed_bytes::FixedBytesExtended; +use hashlink::lru_cache::LruCache; use itertools::{Itertools, process_results}; -use lru::LruCache; use parking_lot::{Mutex, RwLock}; use safe_arith::SafeArith; use serde::{Deserialize, Serialize}; @@ -34,7 +34,6 @@ use std::cmp::{Ordering, min}; use std::collections::{HashMap, HashSet}; use std::io::{Read, Write}; use std::marker::PhantomData; -use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -97,7 +96,7 @@ struct BlockCache { } impl BlockCache { - pub fn new(size: NonZeroUsize) -> Self { + pub fn new(size: usize) -> Self { Self { block_cache: LruCache::new(size), blob_cache: LruCache::new(size), @@ -106,14 +105,15 @@ impl BlockCache { } } pub fn put_block(&mut self, block_root: Hash256, block: SignedBeaconBlock) { - self.block_cache.put(block_root, block); + self.block_cache.insert(block_root, block); } pub fn put_blobs(&mut self, block_root: Hash256, blobs: BlobSidecarList) { - self.blob_cache.put(block_root, blobs); + self.blob_cache.insert(block_root, blobs); } pub fn put_data_column(&mut self, block_root: Hash256, data_column: Arc>) { self.data_column_cache - .get_or_insert_mut(block_root, Default::default) + .entry(block_root) + .or_insert_with(Default::default) .insert(*data_column.index(), data_column); } pub fn put_data_column_custody_info( @@ -143,13 +143,13 @@ impl BlockCache { self.data_column_custody_info_cache.clone() } pub fn delete_block(&mut self, block_root: &Hash256) { - let _ = self.block_cache.pop(block_root); + let _ = self.block_cache.remove(block_root); } pub fn delete_blobs(&mut self, block_root: &Hash256) { - let _ = self.blob_cache.pop(block_root); + let _ = self.blob_cache.remove(block_root); } pub fn delete_data_columns(&mut self, block_root: &Hash256) { - let _ = self.data_column_cache.pop(block_root); + let _ = self.data_column_cache.remove(block_root); } pub fn delete(&mut self, block_root: &Hash256) { self.delete_block(block_root); @@ -236,17 +236,16 @@ impl HotColdDB { cold_db: MemoryStore::open(), blobs_db: MemoryStore::open(), hot_db: MemoryStore::open(), - block_cache: NonZeroUsize::new(config.block_cache_size) - .map(BlockCache::new) - .map(Mutex::new), + block_cache: (config.block_cache_size > 0) + .then(|| Mutex::new(BlockCache::new(config.block_cache_size))), state_cache: Mutex::new(StateCache::new( config.state_cache_size, config.state_cache_headroom, config.hot_hdiff_buffer_cache_size, )), historic_state_cache: Mutex::new(HistoricStateCache::new( - config.cold_hdiff_buffer_cache_size, - config.historic_state_cache_size, + config.cold_hdiff_buffer_cache_size.get(), + config.historic_state_cache_size.get(), )), config, hierarchy, @@ -290,17 +289,16 @@ impl HotColdDB { blobs_db: BeaconNodeBackend::open(&config, blobs_db_path)?, cold_db: BeaconNodeBackend::open(&config, cold_path)?, hot_db, - block_cache: NonZeroUsize::new(config.block_cache_size) - .map(BlockCache::new) - .map(Mutex::new), + block_cache: (config.block_cache_size > 0) + .then(|| Mutex::new(BlockCache::new(config.block_cache_size))), state_cache: Mutex::new(StateCache::new( config.state_cache_size, config.state_cache_headroom, config.hot_hdiff_buffer_cache_size, )), historic_state_cache: Mutex::new(HistoricStateCache::new( - config.cold_hdiff_buffer_cache_size, - config.historic_state_cache_size, + config.cold_hdiff_buffer_cache_size.get(), + config.historic_state_cache_size.get(), )), config, hierarchy, diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 6d159c9361..9a1f2524c1 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -3,7 +3,7 @@ use crate::{ Error, metrics::{self, HOT_METRIC}, }; -use lru::LruCache; +use hashlink::lru_cache::LruCache; use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroUsize; use tracing::instrument; @@ -86,9 +86,9 @@ impl StateCache { ) -> Self { StateCache { finalized_state: None, - states: LruCache::new(state_capacity), + states: LruCache::new(state_capacity.get()), block_map: BlockMap::default(), - hdiff_buffers: HotHDiffBufferCache::new(hdiff_capacity), + hdiff_buffers: HotHDiffBufferCache::new(hdiff_capacity.get()), max_epoch: Epoch::new(0), head_block_root: Hash256::ZERO, headroom, @@ -100,7 +100,7 @@ impl StateCache { } pub fn capacity(&self) -> usize { - self.states.cap().get() + self.states.capacity() } pub fn num_hdiff_buffers(&self) -> usize { @@ -154,7 +154,7 @@ impl StateCache { // preferences older slots. // NOTE: This isn't perfect as it prunes by slot: there could be multiple buffers // at some slots in the case of long forks without finality. - let new_hdiff_cache = HotHDiffBufferCache::new(self.hdiff_buffers.cap()); + let new_hdiff_cache = HotHDiffBufferCache::new(self.hdiff_buffers.capacity()); let old_hdiff_cache = std::mem::replace(&mut self.hdiff_buffers, new_hdiff_cache); for (state_root, (slot, buffer)) in old_hdiff_cache.hdiff_buffers { if pre_finalized_slots_to_retain.contains(&slot) { @@ -164,7 +164,7 @@ impl StateCache { // Delete states. for state_root in state_roots_to_prune { - if let Some((_, state)) = self.states.pop(&state_root) { + if let Some((_, state)) = self.states.remove(&state_root) { // Add the hdiff buffer for this state to the hdiff cache if it is now part of // the pre-finalized grid. The `put` method will take care of keeping the most // useful buffers. @@ -260,7 +260,7 @@ impl StateCache { // Insert the full state into the cache. if let Some((deleted_state_root, _)) = - self.states.put(state_root, (state_root, state.clone())) + self.states.insert(state_root, (state_root, state.clone())) { deleted_states.push(deleted_state_root); } @@ -334,14 +334,14 @@ impl StateCache { } pub fn delete_state(&mut self, state_root: &Hash256) { - self.states.pop(state_root); + self.states.remove(state_root); self.block_map.delete(state_root); } pub fn delete_block_states(&mut self, block_root: &Hash256) { if let Some(slot_map) = self.block_map.delete_block_states(block_root) { for state_root in slot_map.slots.values() { - self.states.pop(state_root); + self.states.remove(state_root); } } } @@ -366,9 +366,10 @@ impl StateCache { let mut old_boundary_state_roots = vec![]; let mut good_boundary_state_roots = vec![]; - // Skip the `cull_exempt` most-recently used, then reverse the iterator to start at - // least-recently used states. - for (&state_root, (_, state)) in self.states.iter().skip(cull_exempt).rev() { + // Start at the least-recently used states, excluding the `cull_exempt` most-recently + // used (which are the final entries of the iterator). + let num_cull_candidates = self.states.len().saturating_sub(cull_exempt); + for (&state_root, (_, state)) in self.states.iter().take(num_cull_candidates) { let is_advanced = state.slot() > state.latest_block_header().slot; let is_boundary = state.slot() % E::slots_per_epoch() == 0; let could_finalize = @@ -450,7 +451,7 @@ impl BlockMap { } impl HotHDiffBufferCache { - pub fn new(capacity: NonZeroUsize) -> Self { + pub fn new(capacity: usize) -> Self { Self { hdiff_buffers: LruCache::new(capacity), } @@ -467,8 +468,8 @@ impl HotHDiffBufferCache { /// If the value was inserted then `true` is returned. pub fn put(&mut self, state_root: Hash256, slot: Slot, buffer: HDiffBuffer) -> bool { // If the cache is not full, simply insert the value. - if self.hdiff_buffers.len() != self.hdiff_buffers.cap().get() { - self.hdiff_buffers.put(state_root, (slot, buffer)); + if self.hdiff_buffers.len() != self.hdiff_buffers.capacity() { + self.hdiff_buffers.insert(state_root, (slot, buffer)); return true; } @@ -484,23 +485,23 @@ impl HotHDiffBufferCache { return false; }; - if self.hdiff_buffers.cap().get() > 1 || slot < min_slot { + if self.hdiff_buffers.capacity() > 1 || slot < min_slot { // Remove LRU value. Cache is now at size `cap - 1`. let Some((removed_state_root, (removed_slot, removed_buffer))) = - self.hdiff_buffers.pop_lru() + self.hdiff_buffers.remove_lru() else { // Unreachable: cache is full so should have at least one entry to pop. return false; }; // Insert new value. Cache size is now at size `cap`. - self.hdiff_buffers.put(state_root, (slot, buffer)); + self.hdiff_buffers.insert(state_root, (slot, buffer)); // If the removed value had the min slot and we didn't intend to replace it (cap=1) // then we reinsert it. if removed_slot == min_slot && slot >= min_slot { self.hdiff_buffers - .put(removed_state_root, (removed_slot, removed_buffer)); + .insert(removed_state_root, (removed_slot, removed_buffer)); } true } else { @@ -509,8 +510,8 @@ impl HotHDiffBufferCache { } } - pub fn cap(&self) -> NonZeroUsize { - self.hdiff_buffers.cap() + pub fn capacity(&self) -> usize { + self.hdiff_buffers.capacity() } #[allow(clippy::len_without_is_empty)] diff --git a/deny.toml b/deny.toml index 015f2ec88b..5a8691fd5c 100644 --- a/deny.toml +++ b/deny.toml @@ -21,6 +21,7 @@ deny = [ { crate = "scrypt", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "syn", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "uuid", deny-multiple-versions = true, reason = "dependency hygiene" }, + { crate = "lru", deny-multiple-versions = true, reason = "use hashlink instead" }, ] [sources] diff --git a/slasher/Cargo.toml b/slasher/Cargo.toml index a068b2e885..83cfb4861e 100644 --- a/slasher/Cargo.toml +++ b/slasher/Cargo.toml @@ -22,9 +22,9 @@ ethereum_ssz_derive = { workspace = true } filesystem = { workspace = true } fixed_bytes = { workspace = true } flate2 = { version = "1.0.14", features = ["zlib"], default-features = false } +hashlink = { workspace = true } lmdb-rkv = { git = "https://github.com/sigp/lmdb-rs", rev = "f33845c6469b94265319aac0ed5085597862c27e", optional = true } lmdb-rkv-sys = { git = "https://github.com/sigp/lmdb-rs", rev = "f33845c6469b94265319aac0ed5085597862c27e", optional = true } -lru = { workspace = true } # MDBX is pinned at the last version with Windows and macOS support. mdbx = { package = "libmdbx", git = "https://github.com/sigp/libmdbx-rs", rev = "e6ff4b9377c1619bcf0bfdf52bee5a980a432a1a", optional = true } diff --git a/slasher/src/database.rs b/slasher/src/database.rs index 80d073a81c..ed8079689b 100644 --- a/slasher/src/database.rs +++ b/slasher/src/database.rs @@ -9,8 +9,8 @@ use crate::{ }; use bls::AggregateSignature; use byteorder::{BigEndian, ByteOrder}; +use hashlink::lru_cache::LruCache; use interface::{Environment, OpenDatabases, RwTransaction}; -use lru::LruCache; use parking_lot::Mutex; use serde::de::DeserializeOwned; use ssz::{Decode, Encode}; @@ -305,7 +305,8 @@ impl SlasherDB { } } - let attestation_root_cache = Mutex::new(LruCache::new(config.attestation_root_cache_size)); + let attestation_root_cache = + Mutex::new(LruCache::new(config.attestation_root_cache_size.get())); let mut db = Self { env, @@ -559,7 +560,7 @@ impl SlasherDB { let indexed_attestation = self.get_indexed_attestation(txn, indexed_id)?; let attestation_data_root = indexed_attestation.data().tree_hash_root(); - cache.put(indexed_id, attestation_data_root); + cache.insert(indexed_id, attestation_data_root); Ok((attestation_data_root, Some(indexed_attestation))) } @@ -570,13 +571,13 @@ impl SlasherDB { attestation_data_root: Hash256, ) { let mut cache = self.attestation_root_cache.lock(); - cache.put(indexed_attestation_id, attestation_data_root); + cache.insert(indexed_attestation_id, attestation_data_root); } fn delete_attestation_data_roots(&self, ids: impl IntoIterator) { let mut cache = self.attestation_root_cache.lock(); for indexed_id in ids { - cache.pop(&indexed_id); + cache.remove(&indexed_id); } } From 9de2e9e6e1c148d90009130afcf5a11d51f3350a Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:54:00 +0200 Subject: [PATCH 04/26] Make single block lookup respect `earliest_available_slot` for column requests (#9447) Single block lookups do not respect the `earliest_available_slot` peers sent. This causes us to potentially request columns from peers that do not custody columns yet (but will soon). Pass down the block's slot and only consider peers where `earliest_available_slot <= block_slot` for custody column requests. Co-Authored-By: Daniel Knopik --- .../src/peer_manager/peerdb.rs | 113 ++++++++++++++---- .../src/peer_manager/peerdb/peer_info.rs | 10 +- .../lighthouse_network/src/types/globals.rs | 19 +-- .../network/src/sync/network_context.rs | 5 +- .../src/sync/network_context/custody.rs | 7 +- 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs index 0a338bb011..693fdebb69 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs @@ -257,17 +257,9 @@ impl PeerDB { .iter() .filter(move |(_, info)| { info.is_connected() - && match info.sync_status() { - SyncStatus::Synced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::Advanced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::IrrelevantPeer - | SyncStatus::Behind { .. } - | SyncStatus::Unknown => false, - } + && info.is_synced_or_advanced_with_available_slot( + epoch.start_slot(E::slots_per_epoch()), + ) }) .map(|(peer_id, _)| peer_id) } @@ -301,10 +293,11 @@ impl PeerDB { } /// Returns an iterator of all good gossipsub peers that are supposed to be custodying - /// the given subnet id. + /// the given subnet id, with data available at the given slot. pub fn good_custody_subnet_peer( &self, subnet: DataColumnSubnetId, + slot: Slot, ) -> impl Iterator { self.peers .iter() @@ -314,7 +307,7 @@ impl PeerDB { info.is_connected() && info.is_good_gossipsub_peer() && is_custody_subnet_peer - && info.is_synced_or_advanced() + && info.is_synced_or_advanced_with_available_slot(slot) }) .map(|(peer_id, _)| peer_id) } @@ -330,14 +323,9 @@ impl PeerDB { let good_sync_peers_for_epoch = self.peers.values().filter(|&info| { info.is_connected() - && match info.sync_status() { - SyncStatus::Synced { info } | SyncStatus::Advanced { info } => { - info.has_slot(epoch.start_slot(E::slots_per_epoch())) - } - SyncStatus::IrrelevantPeer - | SyncStatus::Behind { .. } - | SyncStatus::Unknown => false, - } + && info.is_synced_or_advanced_with_available_slot( + epoch.start_slot(E::slots_per_epoch()), + ) }); for info in good_sync_peers_for_epoch { @@ -2211,6 +2199,89 @@ mod tests { ); } + #[test] + fn test_good_custody_subnet_peer_respects_earliest_available_slot() { + let mut pdb = get_db(); + let subnet = DataColumnSubnetId::new(0); + let request_slot = Slot::new(10); + + fn sync_info(earliest_available_slot: Option) -> SyncInfo { + SyncInfo { + head_slot: Slot::new(100), + head_root: Hash256::ZERO, + finalized_epoch: Epoch::new(0), + finalized_root: Hash256::ZERO, + earliest_available_slot, + } + } + + let add_custody_peer = |pdb: &mut PeerDB, sync_status: SyncStatus| { + let peer_id = PeerId::random(); + pdb.connect_ingoing(&peer_id, "/ip4/0.0.0.0".parse().unwrap(), None); + pdb.__set_custody_subnets(&peer_id, HashSet::from([subnet])) + .unwrap(); + pdb.update_sync_status(&peer_id, sync_status); + peer_id + }; + + let peer_with_data = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(Slot::new(5))), + }, + ); + let peer_at_boundary = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(request_slot)), + }, + ); + let peer_pruned = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(Some(Slot::new(11))), + }, + ); + let peer_no_eas = add_custody_peer( + &mut pdb, + SyncStatus::Synced { + info: sync_info(None), + }, + ); + let peer_behind = add_custody_peer( + &mut pdb, + SyncStatus::Behind { + info: sync_info(Some(Slot::new(0))), + }, + ); + + let good_peers = pdb + .good_custody_subnet_peer(subnet, request_slot) + .copied() + .collect::>(); + + assert!( + good_peers.contains(&peer_with_data), + "peer with earliest_available_slot before the request slot should be returned" + ); + assert!( + good_peers.contains(&peer_at_boundary), + "peer with earliest_available_slot equal to the request slot should be returned" + ); + assert!( + !good_peers.contains(&peer_pruned), + "peer with earliest_available_slot after the request slot should be excluded" + ); + assert!( + good_peers.contains(&peer_no_eas), + "peer without an advertised earliest_available_slot should be returned" + ); + assert!( + !good_peers.contains(&peer_behind), + "behind peer should be excluded regardless of earliest_available_slot" + ); + } + #[test] fn test_disable_peer_scoring() { let peer = PeerId::random(); diff --git a/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs b/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs index 8ad7d10a88..658a6355e3 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/peerdb/peer_info.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use std::net::IpAddr; use std::time::Instant; use strum::AsRefStr; -use types::{DataColumnSubnetId, EthSpec}; +use types::{DataColumnSubnetId, EthSpec, Slot}; /// Information about a given connected peer. #[derive(Clone, Debug, Serialize)] @@ -339,6 +339,14 @@ impl PeerInfo { ) } + /// Checks if the peer is synced or advanced, and has data available for the given slot. + pub fn is_synced_or_advanced_with_available_slot(&self, slot: Slot) -> bool { + match &self.sync_status { + SyncStatus::Synced { info } | SyncStatus::Advanced { info } => info.has_slot(slot), + SyncStatus::IrrelevantPeer | SyncStatus::Behind { .. } | SyncStatus::Unknown => false, + } + } + /// Checks if the status is connected. pub fn is_dialing(&self) -> bool { matches!(self.connection_status, PeerConnectionStatus::Dialing { .. }) diff --git a/beacon_node/lighthouse_network/src/types/globals.rs b/beacon_node/lighthouse_network/src/types/globals.rs index df8dbdc559..1f770a5847 100644 --- a/beacon_node/lighthouse_network/src/types/globals.rs +++ b/beacon_node/lighthouse_network/src/types/globals.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, error}; use types::data::{compute_subnets_from_custody_group, get_custody_groups}; -use types::{ChainSpec, ColumnIndex, DataColumnSubnetId, EthSpec}; +use types::{ChainSpec, ColumnIndex, DataColumnSubnetId, EthSpec, Slot}; pub struct NetworkGlobals { /// The current local ENR. @@ -196,14 +196,19 @@ impl NetworkGlobals { /// Returns a connected peer that: /// 1. is connected /// 2. assigned to custody the column based on it's `custody_subnet_count` from ENR or metadata - /// 3. has a good score - pub fn custody_peers_for_column(&self, column_index: ColumnIndex) -> Vec { + /// 3. has data available past the given slot + /// 4. has a good score + pub fn custody_peers_for_column( + &self, + column_index: ColumnIndex, + block_slot: Slot, + ) -> Vec { self.peers .read() - .good_custody_subnet_peer(DataColumnSubnetId::from_column_index( - column_index, - &self.spec, - )) + .good_custody_subnet_peer( + DataColumnSubnetId::from_column_index(column_index, &self.spec), + block_slot, + ) .cloned() .collect::>() } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 8e8abd4fa6..d2ced9fd9d 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -397,9 +397,9 @@ impl SyncNetworkContext { .collect() } - pub fn get_custodial_peers(&self, column_index: ColumnIndex) -> Vec { + pub fn get_custodial_peers(&self, column_index: ColumnIndex, block_slot: Slot) -> Vec { self.network_globals() - .custody_peers_for_column(column_index) + .custody_peers_for_column(column_index, block_slot) } pub fn network_globals(&self) -> &NetworkGlobals { @@ -1161,6 +1161,7 @@ impl SyncNetworkContext { let requester = CustodyRequester(id); let mut request = ActiveCustodyRequest::new( block_root, + block_slot, CustodyId { requester }, &custody_indexes_to_fetch, lookup_peers, diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index b1a4b52867..3518eecf09 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -13,7 +13,7 @@ use std::hash::{BuildHasher, RandomState}; use std::time::{Duration, Instant}; use std::{collections::HashMap, marker::PhantomData, sync::Arc}; use tracing::{Span, debug, debug_span, warn}; -use types::{DataColumnSidecar, Hash256, data::ColumnIndex}; +use types::{DataColumnSidecar, Hash256, Slot, data::ColumnIndex}; use types::{DataColumnSidecarList, EthSpec}; use super::{LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext}; @@ -22,6 +22,7 @@ const MAX_STALE_NO_PEERS_DURATION: Duration = Duration::from_secs(30); pub struct ActiveCustodyRequest { block_root: Hash256, + block_slot: Slot, custody_id: CustodyId, /// List of column indices this request needs to download to complete successfully column_requests: FnvHashMap>, @@ -62,6 +63,7 @@ pub type CustodyRequestResult = Result ActiveCustodyRequest { pub(crate) fn new( block_root: Hash256, + block_slot: Slot, custody_id: CustodyId, column_indices: &[ColumnIndex], lookup_peers: Arc>>, @@ -73,6 +75,7 @@ impl ActiveCustodyRequest { ); Self { block_root, + block_slot, custody_id, column_requests: HashMap::from_iter( column_indices @@ -365,7 +368,7 @@ impl ActiveCustodyRequest { // We draw from the total set of peers, but prioritize those peers who we have // received an attestation or a block from (`lookup_peers`), as the `lookup_peers` may take // time to build up and we are likely to not find any column peers initially. - let custodial_peers = cx.get_custodial_peers(column_index); + let custodial_peers = cx.get_custodial_peers(column_index, self.block_slot); let mut prioritized_peers = custodial_peers .iter() .filter(|peer| { From e8472b9d77e160418884056f07565c202aca2383 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:45:30 +0200 Subject: [PATCH 05/26] Remove --disable-partial-columns in favour of bool argument to `--enable...` (#9478) Similar to https://github.com/sigp/lighthouse/pull/9476, partial columns is a feature expected to become the default soon, so instead of introducing a CLI option that will be removed again soon, consolidate into `--enable-partial-columns` which now takes a boolean argument. Co-Authored-By: Daniel Knopik --- beacon_node/src/cli.rs | 20 ++++++------------- beacon_node/src/config.rs | 6 ++---- book/src/help_bn.md | 11 ++++------- lighthouse/tests/beacon_node.rs | 34 ++++++++++++++++----------------- 4 files changed, 29 insertions(+), 42 deletions(-) diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 942b30120b..3cf6a6efd2 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -684,21 +684,13 @@ pub fn cli_app() -> Command { .arg( Arg::new("enable-partial-columns") .long("enable-partial-columns") + .value_name("BOOLEAN") .help("Enable partial messages for data columns. This can reduce the amount of \ - data sent over the network. Enabled by default on Hoodi and Sepolia; use \ - --disable-partial-columns to opt out.") - .action(ArgAction::SetTrue) - .help_heading(FLAG_HEADER) - .display_order(0) - ) - .arg( - Arg::new("disable-partial-columns") - .long("disable-partial-columns") - .help("Disable partial messages for data columns. Use this on Hoodi or Sepolia \ - to opt out of the default-enabled behavior.") - .action(ArgAction::SetTrue) - .conflicts_with("enable-partial-columns") - .help_heading(FLAG_HEADER) + data sent over the network. Enabled by default on Hoodi and Sepolia; set to \ + \"false\" to opt out.") + .action(ArgAction::Set) + .num_args(0..=1) + .default_missing_value("true") .display_order(0) ) /* diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 4f42923010..d27909bddf 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -112,10 +112,8 @@ pub fn get_config( .config_name .as_ref() .is_some_and(|name| matches!(name.as_str(), "hoodi" | "sepolia")); - let user_disable_partial_columns = parse_flag(cli_args, "disable-partial-columns"); - let user_enable_partial_columns = parse_flag(cli_args, "enable-partial-columns"); - let enable_partial_columns = !user_disable_partial_columns - && (user_enable_partial_columns || default_partial_columns_enabled); + let enable_partial_columns = clap_utils::parse_optional(cli_args, "enable-partial-columns")? + .unwrap_or(default_partial_columns_enabled); if enable_partial_columns { // Partial messages assume that each subnet maps to exactly one column. diff --git a/book/src/help_bn.md b/book/src/help_bn.md index 1612d35b0e..45e10b0d11 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -88,6 +88,10 @@ Options: Enables the mplex multiplexer alongside yamux. Yamux is preferred when both are available. Enabled by default; set to "false" to disable. [default: true] + --enable-partial-columns [] + Enable partial messages for data columns. This can reduce the amount + of data sent over the network. Enabled by default on Hoodi and + Sepolia; set to "false" to opt out. --enr-address
... The IP address/ DNS address to broadcast to other peers on how to reach this node. If a DNS address is provided, the enr-address is set @@ -475,9 +479,6 @@ Flags: --disable-packet-filter Disables the discovery packet filter. Useful for testing in smaller networks - --disable-partial-columns - Disable partial messages for data columns. Use this on Hoodi or - Sepolia to opt out of the default-enabled behavior. --disable-proposer-reorgs Do not attempt to reorg late blocks from other validators when proposing. @@ -493,10 +494,6 @@ Flags: Sets the local ENR IP address and port to match those set for lighthouse. Specifically, the IP address will be the value of --listen-address and the UDP port will be --discovery-port. - --enable-partial-columns - Enable partial messages for data columns. This can reduce the amount - of data sent over the network. Enabled by default on Hoodi and - Sepolia; use --disable-partial-columns to opt out. --enable-private-discovery Lighthouse by default does not discover private IP addresses. Set this flag to enable connection attempts to local addresses. diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index d52558da93..b8fd978ac5 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -2827,7 +2827,7 @@ fn enable_mplex_no_value() { #[test] fn partial_columns() { CommandLineTest::new() - .flag("enable-partial-columns", None) + .flag("enable-partial-columns", Some("true")) .run_with_zero_port() .with_config(|config| { assert!(config.network.enable_partial_columns); @@ -2842,6 +2842,18 @@ fn partial_columns() { }) } +#[test] +fn partial_columns_no_value() { + // Passing the flag without a value should enable partial columns. + CommandLineTest::new() + .flag("enable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); +} + #[test] fn partial_columns_default_hoodi() { CommandLineTest::new() @@ -2865,10 +2877,10 @@ fn partial_columns_default_sepolia() { } #[test] -fn partial_columns_disable_overrides_hoodi_default() { +fn partial_columns_false_overrides_hoodi_default() { CommandLineTest::new() .flag("network", Some("hoodi")) - .flag("disable-partial-columns", None) + .flag("enable-partial-columns", Some("false")) .run_with_zero_port() .with_config(|config| { assert!(!config.network.enable_partial_columns); @@ -2877,24 +2889,12 @@ fn partial_columns_disable_overrides_hoodi_default() { } #[test] -fn partial_columns_disable_on_mainnet_no_op() { +fn partial_columns_false_on_mainnet() { CommandLineTest::new() - .flag("disable-partial-columns", None) + .flag("enable-partial-columns", Some("false")) .run_with_zero_port() .with_config(|config| { assert!(!config.network.enable_partial_columns); assert!(!config.chain.enable_partial_columns); }); } - -#[test] -fn partial_columns_enable_disable_conflict() { - let mut cmd = base_cmd(); - cmd.arg("--enable-partial-columns") - .arg("--disable-partial-columns"); - let output = cmd.output().expect("should run command"); - assert!( - !output.status.success(), - "expected clap to reject --enable-partial-columns and --disable-partial-columns together", - ); -} From eb0da57044d15768673b5df1789aa8752c8799d5 Mon Sep 17 00:00:00 2001 From: Vansh Sahay Date: Wed, 17 Jun 2026 06:46:09 +0530 Subject: [PATCH 06/26] fix(http_api): ignore committee_index in attestation data endpoint (#9437) Co-Authored-By: Vansh Sahay Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- beacon_node/http_api/src/validator/mod.rs | 4 +++- beacon_node/http_api/tests/tests.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 8639914774..ee699b3adc 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -277,8 +277,10 @@ pub fn get_validator_attestation_data( ))); } + // Always use committee_index 0 regardless of the query parameter, since + // attestation data does not depend on the committee index post-Electra. chain - .produce_unaggregated_attestation(query.slot, query.committee_index) + .produce_unaggregated_attestation(query.slot, 0) .map(|attestation| attestation.data().clone()) .map(GenericResponse::from) .map_err(warp_utils::reject::unhandled_error) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 843e055827..4867852645 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4856,6 +4856,19 @@ impl ApiTester { assert_eq!(result, expected); } + // The committee_index in the response must always be 0 post-Electra, + // regardless of the query parameter. + let committee_count = state.get_committee_count_at_slot(slot).unwrap(); + if committee_count > 0 { + let result = self + .client + .get_validator_attestation_data(slot, 1) + .await + .unwrap() + .data; + assert_eq!(result.index, 0); + } + self } From 3bc9148e0e90f40eabb9496659a15d965292b4f7 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:17:10 +0800 Subject: [PATCH 07/26] SSZ fallback to JSON in `proposer_preferences` (#9475) Co-Authored-By: Tan Chee Keong Co-Authored-By: Eitan Seri-Levi --- .../src/proposer_preferences_service.rs | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/validator_client/validator_services/src/proposer_preferences_service.rs b/validator_client/validator_services/src/proposer_preferences_service.rs index fc17a1bce6..5d5c40a6cd 100644 --- a/validator_client/validator_services/src/proposer_preferences_service.rs +++ b/validator_client/validator_services/src/proposer_preferences_service.rs @@ -182,24 +182,33 @@ impl ProposerPreferencesSer .first_success(|beacon_node| { let signed = signed.clone(); async move { - match beacon_node + beacon_node .post_validator_proposer_preferences_ssz(&signed, fork_name) .await - { - Ok(()) => Ok(()), - Err(ssz_err) => { - debug!(error = ?ssz_err, "SSZ publish failed, falling back to JSON"); + .map_err(|e| format!("Failed to publish proposer preferences (SSZ): {e:?}")) + } + }) + .await; + + let result = match result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let signed = signed.clone(); + async move { beacon_node .post_validator_proposer_preferences(&signed, fork_name) .await .map_err(|e| { - format!("Failed to publish proposer preferences: {e:?}") + format!("Failed to publish proposer preferences (JSON): {e:?}") }) } - } - } - }) - .await; + }) + .await + } + }; match result { Ok(()) => { From a46620155b1d6ed1ec2a775a0f4fa10e53cebd3f Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:44:21 +0200 Subject: [PATCH 08/26] Gloas attestation payload reprocess (#9440) Handle payload-present attestations before the payload is seen (gloas) A gloas beacon_attestation with index == 1 claims a past block's payload is already present. If we haven't seen that block's payload envelope yet, we shouldn't reject it the envelope may just be in flight. So instead we IGNORE it (new AttnError::UnknownPayloadEnvelope), ask sync to fetch the envelope, and park the attestation in the reprocess queue. When the envelope is imported, the parked attestations are released and re-verified. The envelope lookup itself is stubbed here and wired up in #9155 or a follow up PR Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Eitan Seri-Levi --- .../src/attestation_verification.rs | 30 ++ beacon_node/beacon_chain/src/beacon_chain.rs | 17 +- .../beacon_chain/src/fetch_blobs/tests.rs | 10 +- .../src/payload_bid_verification/tests.rs | 1 + .../payload_envelope_verification/import.rs | 7 +- .../tests/attestation_verification.rs | 170 ++++++++ .../beacon_chain/tests/column_verification.rs | 5 +- .../src/scheduler/work_reprocessing_queue.rs | 391 ++++++++++++++---- .../src/beacon/execution_payload_envelopes.rs | 4 +- .../http_api/src/publish_attestations.rs | 40 ++ beacon_node/http_api/src/publish_blocks.rs | 4 +- .../http_api/tests/interactive_tests.rs | 6 +- beacon_node/network/src/metrics.rs | 7 + .../gossip_methods.rs | 286 +++++++++++-- .../src/network_beacon_processor/mod.rs | 22 +- .../network_beacon_processor/sync_methods.rs | 22 +- beacon_node/network/src/sync/manager.rs | 28 ++ beacon_node/network/src/sync/tests/lookups.rs | 2 +- consensus/fork_choice/src/fork_choice.rs | 2 + consensus/proto_array/benches/find_head.rs | 1 + .../src/fork_choice_test_definition.rs | 1 + .../src/proto_array_fork_choice.rs | 7 + 22 files changed, 893 insertions(+), 170 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index 635ca3a2ae..90ac7d68cf 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -174,6 +174,14 @@ pub enum Error { /// The attestation points to a block we have not yet imported. It's unclear if the attestation /// is valid or not. UnknownHeadBlock { beacon_block_root: Hash256 }, + /// An attestation indicating the presence of a payload (`index == 1`) references a block whose + /// execution payload envelope has not been seen yet. + /// + /// ## Peer scoring + /// + /// The attestation may be valid once the payload envelope is retrieved; it's unclear if the + /// attestation is valid or not, so it is ignored (not penalized) pending the envelope. + UnknownPayloadEnvelope { beacon_block_root: Hash256 }, /// The `attestation.data.beacon_block_root` block is from before the finalized checkpoint. /// /// ## Peer scoring @@ -612,6 +620,18 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { )); } + // [New in Gloas]: `index == 1` claims the block's execution payload is present. Ignore the + // attestation until we have seen the block's payload envelope, so it can be re-processed + // (and the envelope retrieved) once the payload is received. + if fork_name.gloas_enabled() + && attestation.data().index == 1 + && !head_block.payload_received + { + return Err(Error::UnknownPayloadEnvelope { + beacon_block_root: attestation.data().beacon_block_root, + }); + } + // Check the attestation target root is consistent with the head root. // // This check is not in the specification, however we guard against it since it opens us up @@ -923,6 +943,16 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { )); } + // [New in Gloas]: `index == 1` claims the block's execution payload is present. Ignore the + // attestation until we have seen the block's payload envelope, so it can be re-processed + // (and the envelope retrieved) once the payload is received. + if fork_name.gloas_enabled() && attestation.data.index == 1 && !head_block.payload_received + { + return Err(Error::UnknownPayloadEnvelope { + beacon_block_root: attestation.data.beacon_block_root, + }); + } + // Check the attestation target root is consistent with the head root. verify_attestation_target_root::(&head_block, &attestation.data)?; diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 5a521d18e6..f09b9b0520 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -196,7 +196,7 @@ pub enum WhenSlotSkipped { #[derive(Copy, Clone, Debug, PartialEq)] pub enum AvailabilityProcessingStatus { MissingComponents(Slot, Hash256), - Imported(Hash256), + Imported(Slot, Hash256), } impl TryInto for AvailabilityProcessingStatus { @@ -204,7 +204,7 @@ impl TryInto for AvailabilityProcessingStatus { fn try_into(self) -> Result { match self { - AvailabilityProcessingStatus::Imported(hash) => Ok(hash.into()), + AvailabilityProcessingStatus::Imported(_, hash) => Ok(hash.into()), _ => Err(()), } } @@ -215,7 +215,7 @@ impl TryInto for AvailabilityProcessingStatus { fn try_into(self) -> Result { match self { - AvailabilityProcessingStatus::Imported(hash) => Ok(hash), + AvailabilityProcessingStatus::Imported(_, hash) => Ok(hash), _ => Err(()), } } @@ -3159,9 +3159,9 @@ impl BeaconChain { { Ok(status) => { match status { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { // The block was imported successfully. - imported_blocks.push((block_root, block_slot)); + imported_blocks.push((block_root, slot)); } AvailabilityProcessingStatus::MissingComponents(slot, block_root) => { warn!( @@ -3808,10 +3808,10 @@ impl BeaconChain { // Verify and import the block. match import_block.await { // The block was successfully verified and imported. Yay. - Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + Ok(status @ AvailabilityProcessingStatus::Imported(slot, block_root)) => { debug!( ?block_root, - %block_slot, + %slot, source = %block_source, "Beacon block imported" ); @@ -4149,6 +4149,7 @@ impl BeaconChain { payload_verification_outcome, } = *block; + let slot = block.slot(); let BlockImportData { block_root, state, @@ -4183,7 +4184,7 @@ impl BeaconChain { .await?? }; - Ok(AvailabilityProcessingStatus::Imported(block_root)) + Ok(AvailabilityProcessingStatus::Imported(slot, block_root)) } /// Accepts a fully-verified and available block and imports it into the chain without performing any diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index 4a37113fd9..3c0f43fef0 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -200,7 +200,10 @@ mod get_blobs_v2 { .returning(|_, _| None); mock_process_engine_blobs_result( &mut mock_adapter, - Ok(AvailabilityProcessingStatus::Imported(block_root)), + Ok(AvailabilityProcessingStatus::Imported( + block.slot(), + block_root, + )), ); // Trigger fetch blobs on the block @@ -217,7 +220,10 @@ mod get_blobs_v2 { assert_eq!( processing_status, - Some(AvailabilityProcessingStatus::Imported(block_root)) + Some(AvailabilityProcessingStatus::Imported( + block.slot(), + block_root + )) ); let published_columns = extract_published_blobs(publish_fn_args); diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 04eb875bd9..e764f0beb5 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -223,6 +223,7 @@ impl TestContext { execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), proposer_index: Some(0), + payload_received: false, }, Slot::new(1), &self.spec, diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 90cdb4fe97..29782b3294 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -108,10 +108,10 @@ impl BeaconChain { // Verify and import the payload envelope. match import_envelope.await { // The payload envelope was successfully verified and imported. - Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + Ok(status @ AvailabilityProcessingStatus::Imported(slot, block_root)) => { info!( ?block_root, - %block_slot, + %slot, source = %envelope_source, "Execution payload envelope imported" ); @@ -195,6 +195,7 @@ impl BeaconChain { block_root, payload_verification_outcome, } = *envelope; + let slot = envelope.envelope.slot(); let block_root = { let chain = self.clone(); @@ -211,7 +212,7 @@ impl BeaconChain { .await?? }; - Ok(AvailabilityProcessingStatus::Imported(block_root)) + Ok(AvailabilityProcessingStatus::Imported(slot, block_root)) } /// Accepts a fully-verified and available envelope and imports it into the chain without performing any diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 03b8ae58ac..ad369c79ee 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1970,6 +1970,176 @@ async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { ); } +/// [New in Gloas]: An unaggregated attestation claiming payload-present (`data.index == 1`) for a +/// block whose payload envelope has not yet been seen (`payload_received == false`) must be +/// rejected with `UnknownPayloadEnvelope`, so it can be parked for re-processing once the envelope +/// arrives. +#[tokio::test] +async fn gloas_unaggregated_attestation_unknown_payload_envelope() { + if !test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + let harness = get_harness(VALIDATOR_COUNT); + + // Build some chain depth. `extend_chain` imports each block's payload envelope, so every block + // produced so far has `payload_received == true`. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce one more block but do NOT import its payload envelope, leaving the head block with + // `payload_received == false`. + let head = harness.chain.head_snapshot(); + let block_slot = head.beacon_block.slot() + 1; + let ((signed_block, blobs), _envelope, _post_state) = harness + .make_block_with_envelope(head.beacon_state.clone(), block_slot) + .await; + let block_root = signed_block.canonical_root(); + harness + .process_block(block_slot, block_root, (signed_block, blobs)) + .await + .expect("payload-less block should import"); + + // The block should be the head, and its payload envelope should not be recorded. + assert!( + !harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .expect("block should be in fork choice") + .payload_received, + "block should not have its payload envelope recorded" + ); + + // Advance a slot so the attestation slot is later than the (payload-less) head block's slot, + // which avoids the same-slot `index == 0` requirement. + harness.advance_slot(); + + // Produce a valid attestation for the head block, then claim payload-present (`index == 1`). + // The gloas payload-envelope check runs before signature verification, so mutating the index + // is sufficient to exercise the arm. + let (mut attestation, _attester_sk, subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + assert_eq!( + attestation.data.beacon_block_root, block_root, + "attestation should be for the payload-less head block" + ); + attestation.data.index = 1; + + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&attestation, Some(subnet_id)); + assert!( + matches!( + result, + Err(AttnError::UnknownPayloadEnvelope { beacon_block_root }) + if beacon_block_root == block_root + ), + "gloas: payload-present attestation for a block with an unseen payload envelope should be \ + rejected with UnknownPayloadEnvelope, got {:?}", + result.err() + ); +} + +/// [New in Gloas]: The aggregate counterpart of +/// `gloas_unaggregated_attestation_unknown_payload_envelope`. An aggregate claiming payload-present +/// (`data.index == 1`) for a block whose payload envelope has not been seen must be rejected with +/// `UnknownPayloadEnvelope`. +#[tokio::test] +async fn gloas_aggregated_attestation_unknown_payload_envelope() { + // Skip unless running with the gloas fork, before paying for harness setup. + if !test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + let harness = get_harness(VALIDATOR_COUNT); + + // Build some chain depth. `extend_chain` imports each block's payload envelope, so every block + // produced so far has `payload_received == true`. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce one more block but do NOT import its payload envelope, leaving the head block with + // `payload_received == false`. + let head = harness.chain.head_snapshot(); + let block_slot = head.beacon_block.slot() + 1; + let ((signed_block, blobs), _envelope, _post_state) = harness + .make_block_with_envelope(head.beacon_state.clone(), block_slot) + .await; + let block_root = signed_block.canonical_root(); + harness + .process_block(block_slot, block_root, (signed_block, blobs)) + .await + .expect("payload-less block should import"); + + // Advance a slot so the attestation slot is later than the (payload-less) head block's slot, + // which avoids the same-slot `index == 0` requirement. + harness.advance_slot(); + + let head = harness.chain.head_snapshot(); + let current_slot = harness.chain.slot().expect("should get slot"); + + // Build a valid aggregate for the head block, then claim payload-present (`index == 1`). The + // gloas payload-envelope check runs before signature verification, so mutating the index is + // sufficient to exercise the arm. + let (valid_attestation, _, _) = get_valid_unaggregated_attestation(&harness.chain); + assert_eq!( + valid_attestation.data.beacon_block_root, block_root, + "attestation should be for the payload-less head block" + ); + let committee = head + .beacon_state + .get_beacon_committee(current_slot, valid_attestation.committee_index) + .expect("should get committee"); + let fork_name = harness + .spec + .fork_name_at_slot::(valid_attestation.data.slot); + let aggregate_attestation = + single_attestation_to_attestation(&valid_attestation, committee.committee, fork_name) + .unwrap(); + let (mut valid_aggregate, _, _) = + get_valid_aggregated_attestation(&harness.chain, aggregate_attestation); + + valid_aggregate + .as_electra_mut() + .unwrap() + .message + .aggregate + .data + .index = 1; + + let result = harness + .chain + .verify_aggregated_attestation_for_gossip(&valid_aggregate); + assert!( + matches!( + result, + Err(AttnError::UnknownPayloadEnvelope { beacon_block_root }) + if beacon_block_root == block_root + ), + "gloas: payload-present aggregate for a block with an unseen payload envelope should be \ + rejected with UnknownPayloadEnvelope, got {:?}", + result.err() + ); +} + /// Regression test: a SingleAttestation with a huge bogus attester_index must not be forwarded to /// the slasher. Previously the slasher received the IndexedAttestation before committee-membership /// validation, causing an OOM when the slasher tried to allocate based on the untrusted index. diff --git a/beacon_node/beacon_chain/tests/column_verification.rs b/beacon_node/beacon_chain/tests/column_verification.rs index 06a5f44e5f..180e187e90 100644 --- a/beacon_node/beacon_chain/tests/column_verification.rs +++ b/beacon_node/beacon_chain/tests/column_verification.rs @@ -274,5 +274,8 @@ async fn verify_header_signature_fork_block_bug() { .process_rpc_custody_columns(data_column_sidecars) .await .unwrap(); - assert_eq!(status, AvailabilityProcessingStatus::Imported(block_root)); + assert_eq!( + status, + AvailabilityProcessingStatus::Imported(signed_block.slot(), block_root) + ); } diff --git a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs index 62ed86fbad..dddf2a740d 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs @@ -115,10 +115,10 @@ pub enum ReprocessQueueMessage { RpcBlock(QueuedRpcBlock), /// A block that was successfully processed. We use this to handle attestations updates /// for unknown blocks. - BlockImported { - block_root: Hash256, - parent_root: Hash256, - }, + BlockImported { block_root: Hash256 }, + /// A block's execution payload envelope was imported. We use this to release attestations that + /// claim payload-present (`index == 1`) for a block whose payload had not yet been seen. + PayloadEnvelopeImported { block_root: Hash256 }, /// A new `LightClientOptimisticUpdate` has been produced. We use this to handle light client /// updates for unknown parent blocks. NewLightClientOptimisticUpdate { parent_root: Hash256 }, @@ -126,6 +126,12 @@ pub enum ReprocessQueueMessage { UnknownBlockUnaggregate(QueuedUnaggregate), /// An aggregated attestation that references an unknown block. UnknownBlockAggregate(QueuedAggregate), + /// An unaggregated attestation (`index == 1`) whose block's execution payload envelope has not + /// been seen yet. + UnknownPayloadUnaggregate(QueuedUnaggregate), + /// An aggregated attestation (`index == 1`) whose block's execution payload envelope has not + /// been seen yet. + UnknownPayloadAggregate(QueuedAggregate), /// A light client optimistic update that references a parent root that has not been seen as a parent. UnknownLightClientOptimisticUpdate(QueuedLightClientUpdate), /// A new backfill batch that needs to be scheduled for processing. @@ -296,6 +302,9 @@ struct ReprocessQueue { queued_unaggregates: FnvHashMap, /// Attestations (aggregated and unaggregated) per root. awaiting_attestations_per_root: HashMap>, + /// Attestations (aggregated and unaggregated) awaiting a block's execution payload envelope, + /// keyed by block root. Released on `PayloadEnvelopeImported`. + awaiting_attestations_per_payload: HashMap>, /// Queued Light Client Updates. queued_lc_updates: FnvHashMap, /// Light Client Updates per parent_root. @@ -331,6 +340,20 @@ enum QueuedAttestationId { Unaggregate(usize), } +/// An attestation queued for re-processing, of either aggregation kind. +enum QueuedAttestation { + Aggregate(QueuedAggregate), + Unaggregate(QueuedUnaggregate), +} + +/// The component an attestation is waiting on before it can be re-processed. +enum AwaitingComponent { + /// The attestation's head block has not been seen. + Block, + /// The block's execution payload envelope has not been seen (`index == 1`, post-Gloas). + Payload, +} + impl QueuedAggregate { pub fn beacon_block_root(&self) -> &Hash256 { &self.beacon_block_root @@ -494,6 +517,7 @@ impl ReprocessQueue { queued_aggregates: FnvHashMap::default(), queued_unaggregates: FnvHashMap::default(), awaiting_attestations_per_root: HashMap::new(), + awaiting_attestations_per_payload: HashMap::new(), awaiting_lc_updates_per_parent_root: HashMap::new(), queued_backfill_batches: Vec::new(), queued_column_reconstructions: HashMap::new(), @@ -512,6 +536,65 @@ impl ReprocessQueue { } } + /// Queue an attestation for re-processing once the component it is waiting on (`awaiting`) is + /// imported. Shared by the unknown-block and unknown-payload paths for both aggregate and + /// unaggregate attestations. + fn queue_awaiting_attestation( + &mut self, + attestation: QueuedAttestation, + awaiting: AwaitingComponent, + ) { + if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { + if self.attestation_delay_debounce.elapsed() { + error!( + queue_size = MAXIMUM_QUEUED_ATTESTATIONS, + msg = "system resources may be saturated", + "Attestation delay queue is full" + ); + } + // Drop the attestation. + return; + } + + let id = self.next_attestation; + let (att_id, beacon_block_root) = match &attestation { + QueuedAttestation::Aggregate(a) => { + (QueuedAttestationId::Aggregate(id), *a.beacon_block_root()) + } + QueuedAttestation::Unaggregate(u) => { + (QueuedAttestationId::Unaggregate(id), *u.beacon_block_root()) + } + }; + + // Register the delay. + let delay_key = self + .attestations_delay_queue + .insert(att_id, QUEUED_ATTESTATION_DELAY); + + // Register this attestation against the component it awaits. + match awaiting { + AwaitingComponent::Block => &mut self.awaiting_attestations_per_root, + AwaitingComponent::Payload => &mut self.awaiting_attestations_per_payload, + } + .entry(beacon_block_root) + .or_default() + .push(att_id); + + // Store the attestation and its info. + match attestation { + QueuedAttestation::Aggregate(queued_aggregate) => { + self.queued_aggregates + .insert(id, (queued_aggregate, delay_key)); + } + QueuedAttestation::Unaggregate(queued_unaggregate) => { + self.queued_unaggregates + .insert(id, (queued_unaggregate, delay_key)); + } + } + + self.next_attestation += 1; + } + fn handle_message(&mut self, msg: InboundEvent) { use ReprocessQueueMessage::*; match msg { @@ -654,70 +737,26 @@ impl ReprocessQueue { error!("Failed to send rpc block to beacon processor"); } } - InboundEvent::Msg(UnknownBlockAggregate(queued_aggregate)) => { - if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { - if self.attestation_delay_debounce.elapsed() { - error!( - queue_size = MAXIMUM_QUEUED_ATTESTATIONS, - msg = "system resources may be saturated", - "Aggregate attestation delay queue is full" - ); - } - // Drop the attestation. - return; - } - - let att_id = QueuedAttestationId::Aggregate(self.next_attestation); - - // Register the delay. - let delay_key = self - .attestations_delay_queue - .insert(att_id, QUEUED_ATTESTATION_DELAY); - - // Register this attestation for the corresponding root. - self.awaiting_attestations_per_root - .entry(*queued_aggregate.beacon_block_root()) - .or_default() - .push(att_id); - - // Store the attestation and its info. - self.queued_aggregates - .insert(self.next_attestation, (queued_aggregate, delay_key)); - - self.next_attestation += 1; - } - InboundEvent::Msg(UnknownBlockUnaggregate(queued_unaggregate)) => { - if self.attestations_delay_queue.len() >= MAXIMUM_QUEUED_ATTESTATIONS { - if self.attestation_delay_debounce.elapsed() { - error!( - queue_size = MAXIMUM_QUEUED_ATTESTATIONS, - msg = "system resources may be saturated", - "Attestation delay queue is full" - ); - } - // Drop the attestation. - return; - } - - let att_id = QueuedAttestationId::Unaggregate(self.next_attestation); - - // Register the delay. - let delay_key = self - .attestations_delay_queue - .insert(att_id, QUEUED_ATTESTATION_DELAY); - - // Register this attestation for the corresponding root. - self.awaiting_attestations_per_root - .entry(*queued_unaggregate.beacon_block_root()) - .or_default() - .push(att_id); - - // Store the attestation and its info. - self.queued_unaggregates - .insert(self.next_attestation, (queued_unaggregate, delay_key)); - - self.next_attestation += 1; - } + InboundEvent::Msg(UnknownBlockAggregate(queued_aggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Aggregate(queued_aggregate), + AwaitingComponent::Block, + ), + InboundEvent::Msg(UnknownBlockUnaggregate(queued_unaggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Unaggregate(queued_unaggregate), + AwaitingComponent::Block, + ), + InboundEvent::Msg(UnknownPayloadAggregate(queued_aggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Aggregate(queued_aggregate), + AwaitingComponent::Payload, + ), + InboundEvent::Msg(UnknownPayloadUnaggregate(queued_unaggregate)) => self + .queue_awaiting_attestation( + QueuedAttestation::Unaggregate(queued_unaggregate), + AwaitingComponent::Payload, + ), InboundEvent::Msg(UnknownBlockDataColumn(queued_data_column)) => { let block_root = queued_data_column.beacon_block_root; @@ -785,10 +824,7 @@ impl ReprocessQueue { self.next_lc_update += 1; } - InboundEvent::Msg(BlockImported { - block_root, - parent_root, - }) => { + InboundEvent::Msg(BlockImported { block_root }) => { // Unqueue the envelope we have for this root, if any. if let Some((envelope, delay_key)) = self.awaiting_envelopes_per_root.remove(&block_root) @@ -853,7 +889,6 @@ impl ReprocessQueue { if failed_to_send_count > 0 { error!( hint = "system may be overloaded", - ?parent_root, ?block_root, failed_count = failed_to_send_count, sent_count, @@ -881,6 +916,59 @@ impl ReprocessQueue { } } } + InboundEvent::Msg(PayloadEnvelopeImported { block_root }) => { + // Release attestations that were awaiting this block's execution payload envelope. + if let Some(queued_ids) = self.awaiting_attestations_per_payload.remove(&block_root) + { + let mut failed_to_send_count = 0; + + for id in queued_ids { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_MATCHED_ATTESTATIONS, + ); + + if let Some((work, delay_key)) = match id { + QueuedAttestationId::Aggregate(id) => self + .queued_aggregates + .remove(&id) + .map(|(aggregate, delay_key)| { + (ReadyWork::Aggregate(aggregate), delay_key) + }), + QueuedAttestationId::Unaggregate(id) => self + .queued_unaggregates + .remove(&id) + .map(|(unaggregate, delay_key)| { + (ReadyWork::Unaggregate(unaggregate), delay_key) + }), + } { + // Remove the delay. + self.attestations_delay_queue.remove(&delay_key); + + // Send the work. + if self.ready_work_tx.try_send(work).is_err() { + failed_to_send_count += 1; + } + } else { + // There is a mismatch between the attestation ids registered for this + // root and the queued attestations. This should never happen. + error!( + ?block_root, + att_id = ?id, + "Unknown queued attestation for payload envelope" + ); + } + } + + if failed_to_send_count > 0 { + error!( + hint = "system may be overloaded", + ?block_root, + failed_count = failed_to_send_count, + "Ignored scheduled attestation(s) for payload envelope" + ); + } + } + } InboundEvent::Msg(NewLightClientOptimisticUpdate { parent_root }) => { // Unqueue the light client optimistic updates we have for this root, if any. if let Some(queued_lc_id) = self @@ -1033,18 +1121,25 @@ impl ReprocessQueue { ); } - if let Entry::Occupied(mut queued_atts) = - self.awaiting_attestations_per_root.entry(root) - && let Some(index) = - queued_atts.get().iter().position(|&id| id == queued_id) - { - let queued_atts_mut = queued_atts.get_mut(); - queued_atts_mut.swap_remove(index); + // The attestation is awaiting either its block or its payload envelope; prune it + // from whichever map holds it (the other lookup is a no-op) to avoid leaking the + // entry on expiry. + for awaiting in [ + &mut self.awaiting_attestations_per_root, + &mut self.awaiting_attestations_per_payload, + ] { + if let Entry::Occupied(mut queued_atts) = awaiting.entry(root) + && let Some(index) = + queued_atts.get().iter().position(|&id| id == queued_id) + { + let queued_atts_mut = queued_atts.get_mut(); + queued_atts_mut.swap_remove(index); - // If the vec is empty after this attestation's removal, we need to delete - // the entry to prevent bloating the hashmap indefinitely. - if queued_atts_mut.is_empty() { - queued_atts.remove_entry(); + // If the vec is empty after this attestation's removal, we need to + // delete the entry to prevent bloating the hashmap indefinitely. + if queued_atts_mut.is_empty() { + queued_atts.remove_entry(); + } } } } @@ -1412,6 +1507,131 @@ mod tests { assert!(queue.awaiting_attestations_per_root.is_empty()); } + // Regression test for the same memory leak as `prune_awaiting_attestations_per_root`, but for + // attestations awaiting a block's execution payload envelope. + #[tokio::test] + async fn prune_awaiting_attestations_per_payload() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert a payload-present attestation awaiting its payload envelope. + let att = ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + + // Check that it is queued. + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + assert!( + queue + .awaiting_attestations_per_payload + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the attestation. + advance_time(&queue.slot_clock, 2 * QUEUED_ATTESTATION_DELAY).await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyAttestation(_))); + queue.handle_message(ready_msg); + + // The entry should be pruned on expiry. + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // The payload envelope import releases attestations awaiting that block's payload. + #[tokio::test] + async fn release_awaiting_attestations_on_payload_envelope_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + + // Importing the payload envelope drains the awaiting attestations for that root. + queue.handle_message(InboundEvent::Msg( + ReprocessQueueMessage::PayloadEnvelopeImported { + block_root: beacon_block_root, + }, + )); + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // As `prune_awaiting_attestations_per_payload`, but for an aggregated payload-present + // attestation (`UnknownPayloadAggregate`). + #[tokio::test] + async fn prune_awaiting_attestations_per_payload_aggregate() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + assert!( + queue + .awaiting_attestations_per_payload + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the attestation. + advance_time(&queue.slot_clock, 2 * QUEUED_ATTESTATION_DELAY).await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyAttestation(_))); + queue.handle_message(ready_msg); + + // The entry should be pruned on expiry. + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + + // As `release_awaiting_attestations_on_payload_envelope_imported`, but for an aggregated + // payload-present attestation (`UnknownPayloadAggregate`). + #[tokio::test] + async fn release_awaiting_aggregate_on_payload_envelope_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + let att = ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root, + process_fn: Box::new(|| {}), + }); + queue.handle_message(InboundEvent::Msg(att)); + assert_eq!(queue.awaiting_attestations_per_payload.len(), 1); + + // Importing the payload envelope drains the awaiting attestations for that root. + queue.handle_message(InboundEvent::Msg( + ReprocessQueueMessage::PayloadEnvelopeImported { + block_root: beacon_block_root, + }, + )); + assert!(queue.awaiting_attestations_per_payload.is_empty()); + } + // This is a regression test for a memory leak in `awaiting_lc_updates_per_parent_root`. // See: https://github.com/sigp/lighthouse/pull/8065 #[tokio::test] @@ -1622,7 +1842,6 @@ mod tests { tokio::time::pause(); let beacon_block_root = Hash256::repeat_byte(0xaf); - let parent_root = Hash256::repeat_byte(0xab); // Insert an envelope. let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { @@ -1640,7 +1859,6 @@ mod tests { // Simulate block import. let imported = ReprocessQueueMessage::BlockImported { block_root: beacon_block_root, - parent_root, }; queue.handle_message(InboundEvent::Msg(imported)); @@ -1716,7 +1934,6 @@ mod tests { // Simulate block import. queue.handle_message(InboundEvent::Msg(ReprocessQueueMessage::BlockImported { block_root: beacon_block_root, - parent_root: Hash256::repeat_byte(0x00), })); // Internal state should be cleaned up. diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index b6b681e091..d058f66001 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -163,7 +163,7 @@ pub async fn publish_execution_payload_envelope( .await; let mut envelope_imported = match &import_result { - Ok(AvailabilityProcessingStatus::Imported(_)) => true, + Ok(AvailabilityProcessingStatus::Imported(_, _)) => true, Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => false, Err(e) => { warn!(%slot, error = ?e, "Failed to import execution payload envelope"); @@ -210,7 +210,7 @@ pub async fn publish_execution_payload_envelope( if !sampling_columns.is_empty() { match Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await { - Ok(AvailabilityProcessingStatus::Imported(_)) => envelope_imported = true, + Ok(AvailabilityProcessingStatus::Imported(_, _)) => envelope_imported = true, Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => {} Err(e) => { error!( diff --git a/beacon_node/http_api/src/publish_attestations.rs b/beacon_node/http_api/src/publish_attestations.rs index b93f2a0b7b..c1ea241b79 100644 --- a/beacon_node/http_api/src/publish_attestations.rs +++ b/beacon_node/http_api/src/publish_attestations.rs @@ -189,6 +189,46 @@ pub async fn publish_attestations( PublishAttestationResult::Reprocessing(rx) } } + Err(Error::Validation(AttestationError::UnknownPayloadEnvelope { + beacon_block_root, + })) => { + if !allow_reprocess { + return PublishAttestationResult::Failure(Error::ReprocessDisabled); + }; + // Re-process once the block's payload envelope is seen (Gloas). + let (tx, rx) = oneshot::channel(); + let reprocess_chain = chain.clone(); + let reprocess_network_tx = network_tx.clone(); + let reprocess_fn = move || { + let result = verify_and_publish_attestation( + &reprocess_chain, + &attestation, + seen_timestamp, + &reprocess_network_tx, + ); + // Ignore failure on the oneshot that reports the result. This + // shouldn't happen unless some catastrophe befalls the waiting + // thread which causes it to drop. + let _ = tx.send(result); + }; + let reprocess_msg = ReprocessQueueMessage::UnknownPayloadUnaggregate( + QueuedUnaggregate { + beacon_block_root, + process_fn: Box::new(reprocess_fn), + }, + ); + if task_spawner + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(reprocess_msg), + }) + .is_err() + { + PublishAttestationResult::Failure(Error::ReprocessFull) + } else { + PublishAttestationResult::Reprocessing(rx) + } + } Err(Error::Validation(AttestationError::PriorAttestationKnown { .. })) => PublishAttestationResult::AlreadyKnown, diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index b46576ddad..8b45a4b04c 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -266,7 +266,7 @@ pub async fn publish_block>( Err(BlockError::DuplicateFullyImported(root)) => { if publish_fn_completed.load(Ordering::SeqCst) { post_block_import_logging_and_response( - Ok(AvailabilityProcessingStatus::Imported(root)), + Ok(AvailabilityProcessingStatus::Imported(slot, root)), validation_level, block, is_locally_built_block, @@ -474,7 +474,7 @@ async fn post_block_import_logging_and_response( // result of the block being imported from gossip, OR it could be that it finished importing // after processing of a gossip blob. In the latter case we MUST run fork choice to // re-compute the head. - Ok(AvailabilityProcessingStatus::Imported(root)) + Ok(AvailabilityProcessingStatus::Imported(_, root)) | Err(BlockError::DuplicateFullyImported(root)) => { let delay = get_block_delay_ms(seen_timestamp, block.message(), &chain.slot_clock); info!( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index e6135a81c7..9258dab1af 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -876,7 +876,6 @@ async fn queue_attestations_from_http() { // In parallel, apply the block. We need to manually notify the reprocess queue, because the // `beacon_chain` does not know about the queue and will not update it for us. - let parent_root = block.0.parent_root(); harness .process_block(attestation_slot, block_root, block) .await @@ -888,10 +887,7 @@ async fn queue_attestations_from_http() { .unwrap() .try_send(WorkEvent { drop_during_sync: false, - work: Work::Reprocess(ReprocessQueueMessage::BlockImported { - block_root, - parent_root, - }), + work: Work::Reprocess(ReprocessQueueMessage::BlockImported { block_root }), }) .unwrap(); diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index c043133cee..1a664662df 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -22,6 +22,13 @@ pub(crate) enum BlockSource { Rpc, } +/// The path through which a payload envelope was imported. +#[derive(Debug, Clone, Copy, AsRefStr)] +pub(crate) enum EnvelopeSource { + Gossip, + Rpc, +} + pub static BEACON_BLOCK_MESH_PEERS_PER_CLIENT: LazyLock> = LazyLock::new(|| { try_create_int_gauge_vec( diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index b52732000e..20342c1aa9 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -1,5 +1,5 @@ use crate::{ - metrics::{self, register_process_result_metrics}, + metrics::{self, EnvelopeSource, register_process_result_metrics}, network_beacon_processor::{InvalidBlockStorage, NetworkBeaconProcessor}, service::NetworkMessage, sync::SyncMessage, @@ -70,6 +70,45 @@ use beacon_processor::{ /// messages. const STRICT_LATE_MESSAGE_PENALTIES: bool = false; +/// Tracks which kinds of attestation re-processing are still permitted for a gossip attestation +/// or aggregate. +/// +/// A new attestation may be re-queued for an unknown block, then (post-Gloas) for an unknown +/// payload envelope, and finally not at all. Each re-queue narrows the allowance to the next +/// variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReprocessAllowance { + /// Re-queue for either an unknown block or an unknown payload envelope. + BlockAndPayload, + /// Re-queue only for an unknown payload envelope (already re-queued once for the block). + PayloadOnly, + /// Do not re-queue again. + None, +} + +impl ReprocessAllowance { + /// Whether the attestation may be re-queued for an unknown block. + fn allows_block(self) -> bool { + matches!(self, ReprocessAllowance::BlockAndPayload) + } + + /// Whether the attestation may be re-queued for an unknown payload envelope. + fn allows_payload(self) -> bool { + matches!( + self, + ReprocessAllowance::BlockAndPayload | ReprocessAllowance::PayloadOnly + ) + } + + /// Re-queuing always narrows the allowance so a message can't loop indefinitely. + fn next_requeue(self) -> Self { + match self { + ReprocessAllowance::BlockAndPayload => ReprocessAllowance::PayloadOnly, + ReprocessAllowance::PayloadOnly | ReprocessAllowance::None => ReprocessAllowance::None, + } + } +} + /// An attestation that has been validated by the `BeaconChain`. /// /// Since this struct implements `beacon_chain::VerifiedAttestation`, it would be a logic error to @@ -233,7 +272,7 @@ impl NetworkBeaconProcessor { attestation: Box, subnet_id: SubnetId, should_import: bool, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { let result = match self @@ -256,7 +295,7 @@ impl NetworkBeaconProcessor { message_id, peer_id, subnet_id, - allow_reprocess, + reprocess_allowance, should_import, seen_timestamp, ); @@ -265,7 +304,7 @@ impl NetworkBeaconProcessor { pub fn process_gossip_attestation_batch( self: Arc, packages: GossipAttestationBatch, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, ) { let attestations_and_subnets = packages .iter() @@ -326,7 +365,7 @@ impl NetworkBeaconProcessor { package.message_id, package.peer_id, package.subnet_id, - allow_reprocess, + reprocess_allowance, package.should_import, package.seen_timestamp, ); @@ -342,7 +381,7 @@ impl NetworkBeaconProcessor { message_id: MessageId, peer_id: PeerId, subnet_id: SubnetId, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, should_import: bool, seen_timestamp: Duration, ) { @@ -426,7 +465,7 @@ impl NetworkBeaconProcessor { should_import, seen_timestamp, }, - allow_reprocess, + reprocess_allowance, error, seen_timestamp, ); @@ -446,7 +485,7 @@ impl NetworkBeaconProcessor { message_id: MessageId, peer_id: PeerId, aggregate: Box>, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { let beacon_block_root = aggregate.message().aggregate().data().beacon_block_root; @@ -470,7 +509,7 @@ impl NetworkBeaconProcessor { beacon_block_root, message_id, peer_id, - allow_reprocess, + reprocess_allowance, seen_timestamp, ); } @@ -478,7 +517,7 @@ impl NetworkBeaconProcessor { pub fn process_gossip_aggregate_batch( self: Arc, packages: Vec>, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, ) { let aggregates = packages.iter().map(|package| package.aggregate.as_ref()); @@ -532,7 +571,7 @@ impl NetworkBeaconProcessor { package.beacon_block_root, package.message_id, package.peer_id, - allow_reprocess, + reprocess_allowance, package.seen_timestamp, ); } @@ -544,7 +583,7 @@ impl NetworkBeaconProcessor { beacon_block_root: Hash256, message_id: MessageId, peer_id: PeerId, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, seen_timestamp: Duration, ) { match result { @@ -624,7 +663,7 @@ impl NetworkBeaconProcessor { attestation: signed_aggregate, seen_timestamp, }, - allow_reprocess, + reprocess_allowance, error, seen_timestamp, ); @@ -918,12 +957,13 @@ impl NetworkBeaconProcessor { match result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { debug!( %block_root, "Gossipsub data column processed, imported fully available block" ); self.chain.recompute_head_at_current_slot().await; + self.notify_import_after_column(slot, block_root); metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, @@ -1311,12 +1351,13 @@ impl NetworkBeaconProcessor { match &result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(block_root) => { + AvailabilityProcessingStatus::Imported(slot, block_root) => { debug!( %block_root, "Data column from partial processed, imported fully available block" ); self.chain.recompute_head_at_current_slot().await; + self.notify_import_after_column(*slot, *block_root); metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, @@ -1784,24 +1825,8 @@ impl NetworkBeaconProcessor { register_process_result_metrics(&result, metrics::BlockSource::Gossip, "block"); match &result { - Ok(AvailabilityProcessingStatus::Imported(block_root)) => { - if self - .beacon_processor_send - .try_send(WorkEvent { - drop_during_sync: false, - work: Work::Reprocess(ReprocessQueueMessage::BlockImported { - block_root: *block_root, - parent_root: block.message().parent_root(), - }), - }) - .is_err() - { - error!( - source = "gossip", - ?block_root, - "Failed to inform block import" - ) - }; + Ok(AvailabilityProcessingStatus::Imported(_, block_root)) => { + self.notify_block_imported(*block_root); debug!( ?block_root, @@ -2458,7 +2483,7 @@ impl NetworkBeaconProcessor { peer_id: PeerId, message_id: MessageId, failed_att: FailedAtt, - allow_reprocess: bool, + reprocess_allowance: ReprocessAllowance, error: AttnError, seen_timestamp: Duration, ) { @@ -2717,7 +2742,7 @@ impl NetworkBeaconProcessor { block = ?beacon_block_root, "Attestation for unknown block" ); - if allow_reprocess { + if reprocess_allowance.allows_block() { // We don't know the block, get the sync manager to handle the block lookup, and // send the attestation to be scheduled for re-processing. self.send_sync_message(SyncMessage::UnknownBlockHashFromAttestation( @@ -2740,7 +2765,7 @@ impl NetworkBeaconProcessor { message_id, peer_id, attestation, - false, // Do not allow this attestation to be re-processed beyond this point. + reprocess_allowance.next_requeue(), seen_timestamp, ) }), @@ -2765,7 +2790,7 @@ impl NetworkBeaconProcessor { attestation, subnet_id, should_import, - false, // Do not allow this attestation to be re-processed beyond this point. + reprocess_allowance.next_requeue(), seen_timestamp, ) }), @@ -2797,6 +2822,89 @@ impl NetworkBeaconProcessor { return; } + AttnError::UnknownPayloadEnvelope { beacon_block_root } => { + trace!( + %peer_id, + block = ?beacon_block_root, + "Payload-present attestation for block with unseen payload envelope" + ); + if reprocess_allowance.allows_payload() { + // We haven't seen the block's payload envelope yet. Ask the sync manager to + // retrieve it, and schedule the attestation for re-processing once it arrives. + self.send_sync_message(SyncMessage::UnknownPayloadEnvelopeFromAttestation( + peer_id, + *beacon_block_root, + )); + let msg = match failed_att { + FailedAtt::Aggregate { + attestation, + seen_timestamp, + } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_AGGREGATED_ATTESTATION_REQUEUED_TOTAL, + ); + let processor = self.clone(); + ReprocessQueueMessage::UnknownPayloadAggregate(QueuedAggregate { + beacon_block_root: *beacon_block_root, + process_fn: Box::new(move || { + processor.process_gossip_aggregate( + message_id, + peer_id, + attestation, + reprocess_allowance.next_requeue(), + seen_timestamp, + ) + }), + }) + } + FailedAtt::Unaggregate { + attestation, + subnet_id, + should_import, + seen_timestamp, + } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_UNAGGREGATED_ATTESTATION_REQUEUED_TOTAL, + ); + let processor = self.clone(); + ReprocessQueueMessage::UnknownPayloadUnaggregate(QueuedUnaggregate { + beacon_block_root: *beacon_block_root, + process_fn: Box::new(move || { + processor.process_gossip_attestation( + message_id, + peer_id, + attestation, + subnet_id, + should_import, + reprocess_allowance.next_requeue(), + seen_timestamp, + ) + }), + }) + } + }; + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(msg), + }) + .is_err() + { + error!("Failed to send attestation for re-processing") + } + } else { + // We shouldn't make any further attempts to process this attestation. + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + + return; + } AttnError::UnknownTargetRoot(_) => { /* * The block indicated by the target root is not known to us. @@ -3796,10 +3904,13 @@ impl NetworkBeaconProcessor { // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); match &result { - Ok(AvailabilityProcessingStatus::Imported(_)) => { + Ok(AvailabilityProcessingStatus::Imported(_, block_root)) => { self.chain.recompute_head_at_current_slot().await; + // The payload envelope is imported (`is_payload_received` is now true); release any + // attestations awaiting this block's payload so they can be re-processed. + self.notify_payload_envelope_imported(*block_root, EnvelopeSource::Gossip); } - Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => {} + Ok(_) => {} Err(e) => { debug!( ?beacon_block_root, @@ -3811,6 +3922,64 @@ impl NetworkBeaconProcessor { } } + /// Inform the reprocess queue that a fully available block (or its payload envelope, post-gloas) + /// has been imported, so any attestations waiting on it can be released. + fn notify_import_after_column(&self, slot: Slot, block_root: Hash256) { + if self + .chain + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + self.notify_payload_envelope_imported(block_root, EnvelopeSource::Gossip); + } else { + self.notify_block_imported(block_root); + } + } + + /// Inform the reprocess queue that `block_root` has been imported as a full block. + fn notify_block_imported(&self, block_root: Hash256) { + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::BlockImported { block_root }), + }) + .is_err() + { + error!( + source = "gossip", + ?block_root, + "Failed to inform block import" + ) + }; + } + + /// Inform the reprocess queue that `block_root`'s payload envelope has been imported, releasing + /// any attestations awaiting the payload. `source` identifies the import path for logging. + pub(crate) fn notify_payload_envelope_imported( + &self, + block_root: Hash256, + source: EnvelopeSource, + ) { + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::PayloadEnvelopeImported { + block_root, + }), + }) + .is_err() + { + error!( + source = source.as_ref(), + ?block_root, + "Failed to inform payload envelope import" + ) + }; + } + #[instrument( name = "lh_process_execution_payload_bid", parent = None, @@ -4059,3 +4228,42 @@ impl NetworkBeaconProcessor { } } } + +#[cfg(test)] +mod tests { + use super::ReprocessAllowance::{BlockAndPayload, None, PayloadOnly}; + + #[test] + fn reprocess_allowance_gates() { + // A block re-queue is only permitted for a freshly received attestation. + assert!(BlockAndPayload.allows_block()); + assert!(!PayloadOnly.allows_block()); + assert!(!None.allows_block()); + + // A payload-envelope re-queue is permitted until we've already re-queued for it. + assert!(BlockAndPayload.allows_payload()); + assert!(PayloadOnly.allows_payload()); + assert!(!None.allows_payload()); + } + + #[test] + fn reprocess_allowance_progression() { + // Each re-queue narrows the allowance to the next variant in the progression. + assert_eq!(BlockAndPayload.next_requeue(), PayloadOnly); + assert_eq!(PayloadOnly.next_requeue(), None); + assert_eq!(None.next_requeue(), None); + } + + #[test] + fn reprocess_allowance_is_bounded() { + // Safety property: from any starting state, re-queuing twice reaches the terminal `None`, + // so an attestation can never loop indefinitely. + for start in [BlockAndPayload, PayloadOnly, None] { + assert_eq!( + start.next_requeue().next_requeue(), + None, + "re-queuing twice from {start:?} should be terminal" + ); + } + } +} diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index a9579caaeb..7619f706cc 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -39,6 +39,8 @@ use { pub use sync_methods::{BlockProcessingResult, ChainSegmentProcessId}; +use gossip_methods::ReprocessAllowance; + pub type Error = TrySendError>; mod gossip_methods; @@ -93,15 +95,17 @@ impl NetworkBeaconProcessor { package.attestation, package.subnet_id, package.should_import, - true, + ReprocessAllowance::BlockAndPayload, package.seen_timestamp, ) }; // Define a closure for processing batches of attestations. let processor = self.clone(); - let process_batch = - move |attestations| processor.process_gossip_attestation_batch(attestations, true); + let process_batch = move |attestations| { + processor + .process_gossip_attestation_batch(attestations, ReprocessAllowance::BlockAndPayload) + }; self.try_send(BeaconWorkEvent { drop_during_sync: true, @@ -135,15 +139,17 @@ impl NetworkBeaconProcessor { package.message_id, package.peer_id, package.aggregate, - true, + ReprocessAllowance::BlockAndPayload, package.seen_timestamp, ) }; // Define a closure for processing batches of attestations. let processor = self.clone(); - let process_batch = - move |aggregates| processor.process_gossip_aggregate_batch(aggregates, true); + let process_batch = move |aggregates| { + processor + .process_gossip_aggregate_batch(aggregates, ReprocessAllowance::BlockAndPayload) + }; let beacon_block_root = aggregate.message().aggregate().data().beacon_block_root; self.try_send(BeaconWorkEvent { @@ -932,7 +938,7 @@ impl NetworkBeaconProcessor { .await { Ok(Some(availability)) => match availability { - AvailabilityProcessingStatus::Imported(_) => { + AvailabilityProcessingStatus::Imported(..) => { debug!( result = "imported block and custody columns", %block_root, @@ -1020,7 +1026,7 @@ impl NetworkBeaconProcessor { Ok(Some((availability_processing_status, data_columns_to_publish))) => { self.publish_data_columns_gradually(data_columns_to_publish, block_root); match &availability_processing_status { - AvailabilityProcessingStatus::Imported(hash) => { + AvailabilityProcessingStatus::Imported(_, hash) => { debug!( result = "imported block and custody columns", block_hash = %hash, diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index caf718732b..35437e1a2e 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -1,4 +1,4 @@ -use crate::metrics::{self, register_process_result_metrics}; +use crate::metrics::{self, EnvelopeSource, register_process_result_metrics}; use crate::network_beacon_processor::{FUTURE_SLOT_TOLERANCE, NetworkBeaconProcessor}; use crate::sync::BatchProcessResult; use crate::sync::manager::CustodyBatchProcessResult; @@ -158,8 +158,6 @@ impl NetworkBeaconProcessor { return; }; - let slot = block.slot(); - let parent_root = block.message().parent_root(); let commitments_formatted = block.as_block().commitments_formatted(); debug!( @@ -186,17 +184,14 @@ impl NetworkBeaconProcessor { // RPC block imported, regardless of process type match result.as_ref() { - Ok(AvailabilityProcessingStatus::Imported(hash)) => { + Ok(AvailabilityProcessingStatus::Imported(slot, hash)) => { info!( %slot, %hash, "New RPC block received", ); // Trigger processing for work referencing this block. - let reprocess_msg = ReprocessQueueMessage::BlockImported { - block_root: *hash, - parent_root, - }; + let reprocess_msg = ReprocessQueueMessage::BlockImported { block_root: *hash }; if self .beacon_processor_send .try_send(WorkEvent { @@ -213,7 +208,7 @@ impl NetworkBeaconProcessor { }; self.chain.block_times_cache.write().set_time_observed( *hash, - slot, + *slot, seen_timestamp, None, None, @@ -294,7 +289,7 @@ impl NetworkBeaconProcessor { match &result { Ok(availability) => match availability { - AvailabilityProcessingStatus::Imported(hash) => { + AvailabilityProcessingStatus::Imported(_, hash) => { debug!( result = "imported block and custody columns", block_hash = %hash, @@ -376,8 +371,11 @@ impl NetworkBeaconProcessor { let result: Result = result.map_err(|e| BlockError::InternalError(format!("envelope: {e}"))); - if matches!(result, Ok(AvailabilityProcessingStatus::Imported(_))) { + // The payload envelope is imported; release any attestations awaiting this block's payload + // so they can be re-processed (parity with the gossip import path). + if let Ok(AvailabilityProcessingStatus::Imported(_, block_root)) = &result { self.chain.recompute_head_at_current_slot().await; + self.notify_payload_envelope_imported(*block_root, EnvelopeSource::Rpc); } self.send_sync_message(SyncMessage::BlockComponentProcessed { @@ -1022,7 +1020,7 @@ impl From> for BlockProcessingR )) } match result { - Ok(AvailabilityProcessingStatus::Imported(_)) => Self::Imported(true, "imported"), + Ok(AvailabilityProcessingStatus::Imported(..)) => Self::Imported(true, "imported"), Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { Self::Imported(false, "missing_components") } diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 8e7b8cd05a..3282f7f083 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -156,6 +156,11 @@ pub enum SyncMessage { /// manager to attempt to find the block matching the unknown hash. UnknownBlockHashFromAttestation(PeerId, Hash256), + /// A peer has sent a payload-present attestation (`index == 1`) for a block whose execution + /// payload envelope we have not seen. This triggers the manager to fetch the payload envelope + /// for `block_root` via `ExecutionPayloadEnvelopesByRoot`. + UnknownPayloadEnvelopeFromAttestation(PeerId, Hash256), + /// A peer has disconnected. Disconnect(PeerId), @@ -260,6 +265,10 @@ pub struct SyncManager { /// may forward us thousands of a attestations, each one triggering an individual event. Only /// one event is useful, the rest generating log noise and wasted cycles notified_unknown_roots: LRUTimeCache<(PeerId, Hash256)>, + /// Debounce duplicated `UnknownPayloadEnvelopeFromAttestation` for the same root/peer tuple, + /// for the same reason as `notified_unknown_roots`: a peer may forward many payload-present + /// attestations for a block whose execution payload envelope we have not yet seen. + notified_unknown_payload_roots: LRUTimeCache<(PeerId, Hash256)>, } /// Spawns a new `SyncManager` thread which has a weak reference to underlying beacon @@ -320,6 +329,9 @@ impl SyncManager { notified_unknown_roots: LRUTimeCache::new(Duration::from_secs( NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, )), + notified_unknown_payload_roots: LRUTimeCache::new(Duration::from_secs( + NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, + )), } } @@ -895,6 +907,22 @@ impl SyncManager { self.handle_unknown_block_root(peer_id, block_root); } } + SyncMessage::UnknownPayloadEnvelopeFromAttestation(peer_id, block_root) => { + if !self + .notified_unknown_payload_roots + .contains(&(peer_id, block_root)) + { + self.notified_unknown_payload_roots + .insert((peer_id, block_root)); + // TODO(gloas): trigger a payload-envelope lookup for `block_root` via + // `ExecutionPayloadEnvelopesByRoot`. Wired up in the gloas lookup-sync PR (#9155). + debug!( + ?block_root, + ?peer_id, + "Received unknown payload envelope from attestation" + ); + } + } SyncMessage::Disconnect(peer_id) => { debug!(%peer_id, "Received disconnected message"); self.peer_disconnect(&peer_id); diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 13eeaee9aa..621824c7d2 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -1989,7 +1989,7 @@ impl TestRig { block: Arc>, ) { match self.import_block_to_da_checker(block).await { - AvailabilityProcessingStatus::Imported(_) => { + AvailabilityProcessingStatus::Imported(..) => { panic!("block removed from da_checker, available") } AvailabilityProcessingStatus::MissingComponents(_, block_root) => { diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 90f2bb9a67..e648d5669b 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1064,6 +1064,8 @@ where execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(block.proposer_index()), + // Set on payload-envelope import, not block import. + payload_received: false, }, current_slot, spec, diff --git a/consensus/proto_array/benches/find_head.rs b/consensus/proto_array/benches/find_head.rs index 98077a7f97..07edc4d46f 100644 --- a/consensus/proto_array/benches/find_head.rs +++ b/consensus/proto_array/benches/find_head.rs @@ -68,6 +68,7 @@ fn build_chain(num_blocks: u64, gloas: bool) -> (ProtoArrayForkChoice, types::Ch }, execution_payload_block_hash: if is_gloas { Some(get_hash(i)) } else { None }, proposer_index: Some(0), + payload_received: false, }; fork_choice diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 7ffa763308..d9acda1258 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -330,6 +330,7 @@ impl ForkChoiceTestDefinition { execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(0), + payload_received: false, }; fork_choice .process_block::(block, slot, &spec, Duration::ZERO) diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 69202486a7..90143f1dd1 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -242,6 +242,8 @@ pub struct Block { pub execution_payload_parent_hash: Option, pub execution_payload_block_hash: Option, pub proposer_index: Option, + /// Whether the block's execution payload envelope has been received. Always `false` pre-Gloas. + pub payload_received: bool, } impl Block { @@ -502,6 +504,7 @@ impl ProtoArrayForkChoice { execution_payload_parent_hash, execution_payload_block_hash, proposer_index: Some(proposer_index), + payload_received: false, }; proto_array @@ -959,6 +962,7 @@ impl ProtoArrayForkChoice { execution_payload_parent_hash: block.execution_payload_parent_hash().ok(), execution_payload_block_hash: block.execution_payload_block_hash().ok(), proposer_index: block.proposer_index().ok(), + payload_received: block.payload_received().unwrap_or(false), }) } @@ -1383,6 +1387,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, genesis_slot + 1, &spec, @@ -1411,6 +1416,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, genesis_slot + 1, &spec, @@ -1547,6 +1553,7 @@ mod test_compute_deltas { execution_payload_parent_hash: None, execution_payload_block_hash: None, proposer_index: Some(0), + payload_received: false, }, Slot::from(block.slot), &spec, From 716528f44e25ca9bac031c7d7e1662eab01bb78f Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 17 Jun 2026 23:40:07 +1000 Subject: [PATCH 09/26] Correct unrealized justification for blocks with slashings (#9471) Fix a bug in fork choice whereby the unrealized justified and finalized checkpoints from a parent block would be incorrectly carried over to a child block in the same epoch. The documented optimisation in `fork_choice.rs` was wrong because it failed to account for slashings. This bug is not considered to be sensitive due to the difficulty of triggering it, and the low payoff for doing so (fleeting divergence). Keep the optimisation, updating it to correctly skip reusing the parent checkpoints when slashings are present. A more minimal alternative would be to scrap the optimisation altogether (always compute the checkpoints), however this would come with a minor performance downside. I think the updated optimisation is simple enough to be worth retaining. There are 3 regression tests added which confirm the correct behaviour. Temporarily setting `has_slashings` to `false` causes all 3 tests to fail. Co-Authored-By: Michael Sproul Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/tests/main.rs | 1 + .../tests/unrealized_checkpoints.rs | 342 ++++++++++++++++++ consensus/fork_choice/src/fork_choice.rs | 15 +- 3 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 beacon_node/beacon_chain/tests/unrealized_checkpoints.rs diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index d31db128c5..e84f561fac 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -12,4 +12,5 @@ mod schema_stability; mod store_tests; mod sync_committee_verification; mod tests; +mod unrealized_checkpoints; mod validator_monitor; diff --git a/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs b/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs new file mode 100644 index 0000000000..01ae86c44d --- /dev/null +++ b/beacon_node/beacon_chain/tests/unrealized_checkpoints.rs @@ -0,0 +1,342 @@ +#![cfg(not(debug_assertions))] + +//! This file contains regression tests for a bug in fork choice whereby the unrealized justified +//! and finalized checkpoints of a block were assumed to carry over to its child. This is NOT TRUE +//! in general, as the child block may contain slashings which invalidate the +//! justification/finalization from the parent. The tests in this file reproduce this scenario using +//! both attester slashings and proposer slashings. + +use beacon_chain::{ + StateSkipConfig, + test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, + }, +}; +use state_processing::per_epoch_processing; +use std::sync::Arc; +use types::{Checkpoint, Epoch, EthSpec, MinimalEthSpec, consts::altair::TIMELY_TARGET_FLAG_INDEX}; + +type E = MinimalEthSpec; + +// Proposer slashings are limited to MaxProposerSlashings (16) per block. With 32 validators, +// dropping below the 2/3 justification threshold requires only ~11 slashes, which fits. +const VALIDATOR_COUNT: usize = 32; + +fn ceil_two_thirds(value: u64) -> u64 { + (2 * value).div_ceil(3) +} + +struct SameEpochSlashingChild { + harness: BeaconChainHarness>, + stored_parent_justified: Checkpoint, + stored_parent_finalized: Checkpoint, + stored_child_justified: Checkpoint, + stored_child_finalized: Checkpoint, + expected_child_justified: Checkpoint, + expected_child_finalized: Checkpoint, + parent_epoch: Epoch, +} + +/// Basic test checking that the child has correct unrealized justified/finalized checkpoints in the +/// case where the slashings are attester slashings. +#[tokio::test] +async fn child_unrealized_checkpoints_recomputed_after_same_epoch_slashing() { + let scenario = same_epoch_attester_slashing_child().await; + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); +} + +/// Variation on the attester slashing test, checking that the inferior justification of the child +/// results in fork choice correctly reverting to the justified checkpoint once the child block is +/// no longer considered viable. +#[tokio::test] +async fn child_with_stale_voting_source_not_head_at_epoch_plus_two() { + let scenario = same_epoch_attester_slashing_child().await; + let slots_per_epoch = E::slots_per_epoch(); + let divergence_slot = scenario + .parent_epoch + .saturating_add(2u64) + .start_slot(slots_per_epoch); + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); + assert!( + scenario.expected_child_justified.epoch.saturating_add(2u64) + < divergence_slot.epoch(slots_per_epoch), + "with the spec-computed checkpoint the child is outside the viability window at epoch N + 2" + ); + + while scenario.harness.get_current_slot() < divergence_slot { + scenario.harness.advance_slot(); + } + + let mut fork_choice = scenario + .harness + .chain + .canonical_head + .fork_choice_write_lock(); + let head_result = fork_choice.get_head(divergence_slot, &scenario.harness.chain.spec); + + assert_eq!( + fork_choice.justified_checkpoint(), + scenario.stored_parent_justified, + "the store should realize the parent's unrealized justification at the epoch boundary" + ); + assert_eq!( + fork_choice.finalized_checkpoint(), + scenario.stored_parent_finalized, + "the store should realize the parent's unrealized finalization at the epoch boundary" + ); + + // No epoch N + 1 blocks were produced after the slashing child. Under the spec-computed child + // checkpoint, the child is the only leaf below the justified root and is outside the viability + // window. The spec-correct result is to set the justified checkpoint as the head. + assert_eq!( + head_result.unwrap().0, + fork_choice.justified_checkpoint().root + ); +} + +/// Basic test checking child checkpoints but with proposer slashings instead of attester slashings. +#[tokio::test] +async fn child_unrealized_checkpoints_recomputed_after_same_epoch_proposer_slashing() { + let scenario = same_epoch_proposer_slashing_child().await; + + assert_eq!( + scenario.stored_child_justified, scenario.expected_child_justified, + "child unrealized justified checkpoint should be recomputed from the child state" + ); + assert_eq!( + scenario.stored_child_finalized, scenario.expected_child_finalized, + "child unrealized finalized checkpoint should be recomputed from the child state" + ); +} + +async fn same_epoch_attester_slashing_child() -> SameEpochSlashingChild { + same_epoch_slashing_child(VALIDATOR_COUNT, |harness, slash_indices| { + harness + .add_attester_slashing(slash_indices.to_vec()) + .expect("should add attester slashing to operation pool"); + }) + .await +} + +async fn same_epoch_proposer_slashing_child() -> SameEpochSlashingChild { + same_epoch_slashing_child(VALIDATOR_COUNT, |harness, slash_indices| { + for &index in slash_indices { + harness + .add_proposer_slashing(index) + .expect("should add proposer slashing to operation pool"); + } + }) + .await +} + +/// Generic scenario builder with `inject_slashings` capable of injecting attester or proposer +/// slashings. +async fn same_epoch_slashing_child( + validator_count: usize, + inject_slashings: F, +) -> SameEpochSlashingChild +where + F: FnOnce(&BeaconChainHarness>, &[u64]), +{ + let spec = test_spec::(); + + let harness: BeaconChainHarness> = + BeaconChainHarness::builder(E::default()) + .spec(Arc::new(spec)) + .deterministic_keypairs(validator_count) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let slots_per_epoch = E::slots_per_epoch(); + + // Minimum warm-up for the parent to reach FFG steady state (justified == epoch, finalized == + // epoch - 1); 2 epochs is too few. + let warmup_epochs: u64 = 3; + harness.advance_slot(); + harness + .extend_chain( + slots_per_epoch as usize * warmup_epochs as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let parent_epoch = Epoch::new(warmup_epochs); + // ceil(Minimal::slots_per_epoch() * 2/3) = 6 + let parent_slot = parent_epoch.start_slot(slots_per_epoch) + 6; + let parent_root = harness.extend_to_slot(parent_slot).await; + + let mut parent_state = harness + .chain + .state_at_slot(parent_slot, StateSkipConfig::WithStateRoots) + .expect("should load parent state"); + parent_state + .build_caches(&harness.chain.spec) + .expect("should build parent state caches"); + + let total_active_balance = parent_state + .get_total_active_balance() + .expect("should get parent total active balance"); + let required_balance = ceil_two_thirds(total_active_balance); + let effective_balance = parent_state + .validators() + .get(0) + .expect("validator 0 should exist") + .effective_balance; + let slash_count_needed = total_active_balance + .checked_sub(required_balance) + .expect("total active balance should be at least the required balance") + / effective_balance + + 1; + + let child_slot = parent_slot + 1; + let child_proposer = parent_state + .get_beacon_proposer_index(child_slot, &harness.chain.spec) + .expect("should get child proposer") as u64; + + let (_, _, _, current_participation, _, _, _, _) = parent_state + .mutable_validator_fields() + .expect("parent state should have Altair validator fields"); + // Slash this epoch's timely-target voters (they count toward the current-epoch target balance), + // excluding the child proposer, to drop target balance below the 2/3 justification threshold. + let slash_indices = current_participation + .iter() + .enumerate() + .filter_map(|(index, flags)| { + flags + .has_flag(TIMELY_TARGET_FLAG_INDEX) + .ok() + .and_then(|has_flag| has_flag.then_some(index as u64)) + }) + .filter(|index| *index != child_proposer) + .take(slash_count_needed as usize) + .collect::>(); + + assert_eq!( + slash_indices.len(), + slash_count_needed as usize, + "should have enough current target attesters to slash" + ); + + inject_slashings(&harness, &slash_indices); + + harness.advance_slot(); + let child_root = harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let ( + stored_parent_justified, + stored_parent_finalized, + stored_child_justified, + stored_child_finalized, + child_slot, + ) = { + let fork_choice = harness.chain.canonical_head.fork_choice_read_lock(); + let parent_block = fork_choice + .get_block(&parent_root) + .expect("parent should be in fork choice"); + let child_block = fork_choice + .get_block(&child_root) + .expect("child should be in fork choice"); + + let parent_justified = parent_block + .unrealized_justified_checkpoint + .expect("parent should have unrealized justified checkpoint"); + let parent_finalized = parent_block + .unrealized_finalized_checkpoint + .expect("parent should have unrealized finalized checkpoint"); + + assert_eq!(parent_block.slot, parent_slot); + assert_eq!(parent_justified.epoch, parent_epoch); + assert_eq!(parent_finalized.epoch.saturating_add(1u64), parent_epoch); + assert_eq!(child_block.slot, parent_slot + 1); + assert_eq!(child_block.slot.epoch(slots_per_epoch), parent_epoch); + + ( + parent_justified, + parent_finalized, + child_block + .unrealized_justified_checkpoint + .expect("child should have unrealized justified checkpoint"), + child_block + .unrealized_finalized_checkpoint + .expect("child should have unrealized finalized checkpoint"), + child_block.slot, + ) + }; + + let mut child_state = harness + .chain + .state_at_slot(child_slot, StateSkipConfig::WithStateRoots) + .expect("should load child state"); + child_state + .build_caches(&harness.chain.spec) + .expect("should build child state caches"); + + let slashed = child_state + .validators() + .iter() + .enumerate() + .filter_map(|(index, validator)| validator.slashed.then_some(index as u64)) + .collect::>(); + let child_total_active_balance = child_state + .get_total_active_balance() + .expect("should get child total active balance"); + let child_current_target_balance = child_state + .progressive_balances_cache() + .current_epoch_target_attesting_balance() + .expect("should get child current target balance"); + let child_justification_and_finalization = + per_epoch_processing::altair::process_justification_and_finalization(&child_state) + .expect("should recompute child justification and finalization"); + let expected_child_justified = + child_justification_and_finalization.current_justified_checkpoint(); + let expected_child_finalized = child_justification_and_finalization.finalized_checkpoint(); + + assert_eq!(slashed, slash_indices); + assert!( + child_current_target_balance < ceil_two_thirds(child_total_active_balance), + "slashings should reduce current target balance below the justification threshold" + ); + assert_ne!( + expected_child_justified, stored_parent_justified, + "test setup should make the child justified checkpoint differ from the parent's" + ); + assert_ne!( + expected_child_finalized, stored_parent_finalized, + "test setup should make the child finalized checkpoint differ from the parent's" + ); + + SameEpochSlashingChild { + harness, + stored_parent_justified, + stored_parent_finalized, + stored_child_justified, + stored_child_finalized, + expected_child_justified, + expected_child_finalized, + parent_epoch, + } +} diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index e648d5669b..aca5ab7851 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -894,22 +894,29 @@ where // Update unrealized justified/finalized checkpoints. let block_epoch = block.slot().epoch(E::slots_per_epoch()); - // If the parent checkpoints are already at the same epoch as the block being imported, - // it's impossible for the unrealized checkpoints to differ from the parent's. This - // holds true because: + // If the block has no slashings and the parent checkpoints are already at the same epoch as + // the block being imported, it's impossible for the unrealized checkpoints to differ from + // the parent's. This holds true because: // // 1. A child block cannot have lower FFG checkpoints than its parent. // 2. A block in epoch `N` cannot contain attestations which would justify an epoch higher than `N`. // 3. A block in epoch `N` cannot contain attestations which would finalize an epoch higher than `N - 1`. // + // Slashings are excluded from this optimization because they can reduce unslashed + // participation in the child state and therefore lower the child's unrealized checkpoints. + // // This is an optimization. It should reduce the amount of times we run // `process_justification_and_finalization` by approximately 1/3rd when the chain is // performing optimally. + let has_slashings = !block.body().proposer_slashings().is_empty() + || block.body().attester_slashings_len() > 0; let parent_checkpoints = parent_block .unrealized_justified_checkpoint .zip(parent_block.unrealized_finalized_checkpoint) .filter(|(parent_justified, parent_finalized)| { - parent_justified.epoch == block_epoch && parent_finalized.epoch + 1 == block_epoch + !has_slashings + && parent_justified.epoch == block_epoch + && parent_finalized.epoch.saturating_add(1u64) == block_epoch }); let (unrealized_justified_checkpoint, unrealized_finalized_checkpoint) = From d2ccae1715c9a480f012ae2ed3c228dfff5218fc Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 17 Jun 2026 08:06:19 -0700 Subject: [PATCH 10/26] Refactor payload attestation service (#9492) Refactors the payload attestation service - Returns a `Result`. we need this for #9434 so we can keep track if we've succeeded with producing payload attestation earlier than the deadline - Separates getting payload attestation data and signing + publishing. This mimics what we do in attestation service and also is needed for #9434 to surface the error while still keeping the same spawning mechanism. In #9434 we want to broadcast payload attestations early if we've already seen an avail envelope. If the SSE event fires, but for some reason getting the payload attestation data from the BN fails, we still want to retry at the deadline. If signing + publishing fails we wont retry at the deadline (similar to the attestation service). Co-Authored-By: Eitan Seri-Levi --- .../src/payload_attestation_service.rs | 167 ++++++++++++------ 1 file changed, 110 insertions(+), 57 deletions(-) diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs index f4cd26552a..34b9f96f7f 100644 --- a/validator_client/validator_services/src/payload_attestation_service.rs +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -1,5 +1,6 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::BeaconNodeFallback; +use eth2::types::PtcDuty; use logging::crit; use slot_clock::SlotClock; use std::ops::Deref; @@ -7,7 +8,7 @@ use std::sync::Arc; use task_executor::TaskExecutor; use tokio::time::sleep; use tracing::{debug, error, info}; -use types::{ChainSpec, EthSpec, Slot}; +use types::{ChainSpec, EthSpec, PayloadAttestationData, Slot}; use validator_store::ValidatorStore; pub struct Inner { @@ -74,7 +75,9 @@ where let interval_fut = async move { loop { - self.run_update().await; + if let Err(e) = self.spawn_payload_attestation_tasks().await { + error!(error = e, "Failed to produce payload attestations"); + } } }; @@ -82,18 +85,32 @@ where Ok(()) } - async fn run_update(&self) { + async fn spawn_payload_attestation_tasks(&self) -> Result<(), String> { let Some(attestation_slot) = self.wait_for_attestation_slot().await else { - return; + return Ok(()); + }; + + let Some((duties, attestation_data)) = self + .produce_payload_attestation_data(attestation_slot) + .await? + else { + return Ok(()); }; let service = self.clone(); self.executor.spawn( async move { - service.produce_and_publish(attestation_slot).await; + if let Err(e) = service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + { + crit!(error = e, %attestation_slot, "Failed to publish payload attestations"); + } }, "payload_attestation_producer", ); + + Ok(()) } async fn wait_for_attestation_slot(&self) -> Option { @@ -136,11 +153,18 @@ where Some(attestation_slot) } - async fn produce_and_publish(&self, slot: types::Slot) { + /// Produce the payload attestation data for `slot`, returned alongside the duties to sign. + /// + /// Returns `Ok(None)` when there is nothing to publish (no duties, or no block for the slot) + /// and `Err` when data production failed. + async fn produce_payload_attestation_data( + &self, + slot: Slot, + ) -> Result, PayloadAttestationData)>, String> { let duties = self.duties_service.get_ptc_duties_for_slot(slot); if duties.is_empty() { - return; + return Ok(None); } debug!( @@ -167,15 +191,10 @@ where %slot, "No block received for slot, skipping payload attestation" ); - return; + return Ok(None); } Err(e) => { - error!( - error = %e, - %slot, - "Failed to produce payload attestation data" - ); - return; + return Err(e.to_string()); } }; @@ -186,6 +205,17 @@ where "Received payload attestation data" ); + Ok(Some((duties, attestation_data))) + } + + /// Sign `attestation_data` for each duty and publish the resulting messages, preferring SSZ + /// and falling back to JSON. + async fn sign_and_publish( + &self, + slot: Slot, + duties: Vec, + attestation_data: PayloadAttestationData, + ) -> Result<(), String> { let mut messages = Vec::with_capacity(duties.len()); for duty in &duties { @@ -209,7 +239,7 @@ where } if messages.is_empty() { - return; + return Ok(()); } let count = messages.len(); @@ -227,42 +257,31 @@ where }) .await; - let result = match result { - Ok(()) => Ok(()), - Err(_) => { - debug!(%slot, "SSZ publish failed, falling back to JSON"); - self.beacon_nodes - .first_success(|beacon_node| { - let messages = messages.clone(); - async move { - beacon_node - .post_beacon_pool_payload_attestations(&messages, fork_name) - .await - .map_err(|e| { - format!("Failed to publish payload attestations (JSON): {e:?}") - }) - } - }) - .await - } - }; - - match result { - Ok(()) => { - info!( - %slot, - %count, - "Successfully published payload attestations" - ); - } - Err(e) => { - crit!( - error = %e, - %slot, - "Failed to publish payload attestations" - ); - } + if result.is_err() { + debug!(%slot, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations(&messages, fork_name) + .await + .map_err(|e| { + format!("Failed to publish payload attestations (JSON): {e:?}") + }) + } + }) + .await + .map_err(|e| e.to_string())?; } + + info!( + %slot, + %count, + "Successfully published payload attestations" + ); + + Ok(()) } } @@ -529,7 +548,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); let messages = harness .mock_beacon_node_1 @@ -591,7 +618,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); // first_success function tries both beacon nodes for SSZ post payload attestation: // first pass: both fail (mock_ssz returns 500, mock_json does not support SSZ) @@ -625,9 +660,16 @@ mod tests { let service = harness.service; - // when there is no duty, produce_and_publish should return early + // when there is no duty, data production returns `None` so there is nothing to publish // therefore, the beacon node is not called, expected to hit 0 - service.produce_and_publish(Slot::new(1)).await; + let data = service + .produce_payload_attestation_data(Slot::new(1)) + .await + .unwrap(); + assert!( + data.is_none(), + "Expected no data to be produced without duties" + ); mock.expect(0).assert(); assert!( @@ -665,8 +707,11 @@ mod tests { .mock_post_beacon_pool_payload_attestations(); let service = harness.service; - // The produce_and_publish() should return early before reaching the POST endpoint - service.produce_and_publish(attestation_slot).await; + // Data production should error before any signing/publishing happens. + let result = service + .produce_payload_attestation_data(attestation_slot) + .await; + assert!(result.is_err()); // Both beacon nodes should not be called at all mock_ssz.expect(0).assert(); @@ -712,7 +757,15 @@ mod tests { .mock_post_beacon_pool_payload_attestations_ssz(Duration::from_secs(0)); let service = harness.service; - service.produce_and_publish(attestation_slot).await; + let (duties, attestation_data) = service + .produce_payload_attestation_data(attestation_slot) + .await + .unwrap() + .unwrap(); + service + .sign_and_publish(attestation_slot, duties, attestation_data) + .await + .unwrap(); let messages = harness .mock_beacon_node_1 From 446f5b5c1636f26b5a326d45491930ff0f15dba2 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 18 Jun 2026 12:37:05 +1000 Subject: [PATCH 11/26] Update DB docs for v8.2.0/schema v29 (#9489) - Move info about pre-8.0 schema migrations to the historical migrations section. - Include info about v8.1.x and v8.2.x. Co-Authored-By: Michael Sproul --- book/src/advanced_database_migrations.md | 15 ++++++++------- wordlist.txt | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/book/src/advanced_database_migrations.md b/book/src/advanced_database_migrations.md index 115a885878..59a8b7cff5 100644 --- a/book/src/advanced_database_migrations.md +++ b/book/src/advanced_database_migrations.md @@ -17,11 +17,9 @@ validator client or the slasher**. | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|----------------------| -| v8.0.0 | Nov 2025 | v28 | yes before Fulu | -| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu | -| v7.1.0 | Jul 2025 | v26 | yes | -| v7.0.0 | Apr 2025 | v22 | no | -| v6.0.0 | Nov 2024 | v22 | no | +| v8.2.0 | Jun 2026 | v29 | yes before Gloas | +| v8.1.0 | Feb 2026 | v28 | no | +| v8.0.0 | Nov 2025 | v28 | no | > **Note**: All point releases (e.g. v4.4.1) are schema-compatible with the prior minor release > (e.g. v4.4.0). @@ -209,8 +207,11 @@ Here are the steps to prune historic states: | Lighthouse version | Release date | Schema version | Downgrade available? | |--------------------|--------------|----------------|-------------------------------------| -| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu | -| v7.1.0 | Jul 2025 | v26 | yes | +| v8.2.0 | Jun 2026 | v29 | yes before Gloas | +| v8.1.0 | Feb 2026 | v28 | yes before Fulu using <= v8.1.3 | +| v8.0.0 | Nov 2025 | v28 | yes before Fulu using <= v8.1.3 | +| v8.0.0-rc.0 | Sep 2025 | v28 | yes before Fulu using <= v8.1.3 | +| v7.1.0 | Jul 2025 | v26 | yes using <= v8.1.3 | | 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/wordlist.txt b/wordlist.txt index 822e336146..f0076e6332 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -45,6 +45,7 @@ Fusaka Geth GiB Gitcoin +Gloas Gnosis Goerli Grafana From ddfc26512308aa23a80f701b03f8fc0adfbcdd34 Mon Sep 17 00:00:00 2001 From: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:57:13 -0400 Subject: [PATCH 12/26] Enable late re-org and re-org interactive tests (#9405) https://github.com/sigp/lighthouse/issues/8959 WIP still working on adding more re-org tests and refactoring existing. Co-Authored-By: hopinheimer Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 21 +- .../beacon_chain/src/block_production/mod.rs | 79 +- .../payload_attestation_verification/tests.rs | 167 +++- beacon_node/beacon_chain/src/test_utils.rs | 298 +++++- .../http_api/tests/gloas_reorg_tests.rs | 946 ++++++++++++++++++ .../http_api/tests/interactive_tests.rs | 13 +- beacon_node/http_api/tests/main.rs | 1 + consensus/proto_array/src/proto_array.rs | 4 + .../src/proto_array_fork_choice.rs | 11 +- 9 files changed, 1480 insertions(+), 60 deletions(-) create mode 100644 beacon_node/http_api/tests/gloas_reorg_tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f09b9b0520..d175c54be7 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5138,7 +5138,6 @@ impl BeaconChain { }) } - // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5169,6 +5168,11 @@ impl BeaconChain { ) .map_err(|e| e.map_inner_error(Error::ProposerHeadForkChoiceError))?; + // We don't need to override fork choice updates for Gloas. + if info.head_node.is_gloas() { + return Ok(*canonical_forkchoice_params); + } + // The slot of our potential re-org block is always 1 greater than the head block because we // only attempt single-slot re-orgs. let head_slot = info.head_node.slot(); @@ -5302,9 +5306,7 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // TODO(gloas): V29 nodes don't carry execution_status, so this returns - // None for post-Gloas re-orgs. Need to source the EL block hash from - // the bid's block_hash instead. Re-org is disabled for Gloas for now. + // This only works pre-Gloas, but we don't run this code for Gloas anyway. let parent_head_hash = info .parent_node .execution_status() @@ -6341,8 +6343,15 @@ impl BeaconChain { } let canonical_fcu_params = cached_head.forkchoice_update_parameters(); - let fcu_params = - chain.overridden_forkchoice_update_params(canonical_fcu_params)?; + let fcu_params = if chain + .spec + .fork_name_at_slot::(head_slot) + .gloas_enabled() + { + canonical_fcu_params + } else { + chain.overridden_forkchoice_update_params(canonical_fcu_params)? + }; let pre_payload_attributes = chain.get_pre_payload_attributes( prepare_slot, fcu_params.head_root, diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 84fb886b8f..1f29a47f69 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -4,7 +4,7 @@ use fork_choice::PayloadStatus; use proto_array::{ProposerHeadError, ReOrgThreshold}; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot}; +use types::{BeaconState, Epoch, EthSpec, Hash256, SignedExecutionPayloadEnvelope, Slot}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -14,13 +14,21 @@ use crate::{ mod gloas; /// State loaded from the database for block production. -pub(crate) struct BlockProductionState { +pub(crate) struct BlockProductionState { pub state: BeaconState, pub state_root: Option, pub parent_payload_status: PayloadStatus, pub parent_envelope: Option>>, } +/// Inputs assembled for producing a block via a proposer re-org. +struct ReOrgInputs { + state: BeaconState, + state_root: Hash256, + parent_payload_status: PayloadStatus, + parent_envelope: Option>>, +} + impl BeaconChain { /// Load a beacon state from the database for block production. This is a long-running process /// that should not be performed in an `async` context. @@ -50,39 +58,32 @@ impl BeaconChain { head.snapshot.execution_envelope.clone(), ) }; + let result = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. - // TODO(gloas): re-enable reorgs - let gloas_enabled = self - .spec - .fork_name_at_slot::(slot) - .gloas_enabled(); - if !gloas_enabled - && let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) - { + if let Some(inputs) = self.get_state_for_re_org(slot, head_slot, head_block_root) { info!( %slot, head_to_reorg = %head_block_root, "Proposing block to re-org current head" ); - // TODO(gloas): ensure we use a sensible payload status when we enable reorgs - // for Gloas BlockProductionState { - state: re_org_state, - state_root: Some(re_org_state_root), - parent_payload_status: PayloadStatus::Pending, - parent_envelope: None, + state: inputs.state, + state_root: Some(inputs.state_root), + parent_payload_status: inputs.parent_payload_status, + parent_envelope: inputs.parent_envelope, } } else { - // Fetch the head state advanced through to `slot`, which should be present in the - // state cache thanks to the state advance timer. + // Continuation: the new block builds on the current head. Fetch the head state + // advanced through to `slot`, which should be present in the state cache thanks to + // the state advance timer. let parent_state_root = head_state_root; let (state_root, state) = self .store .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; + BlockProductionState { state, state_root: Some(state_root), @@ -100,13 +101,11 @@ impl BeaconChain { .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - // TODO(gloas): update this to read payload canonicity from fork choice once ready - let parent_payload_status = PayloadStatus::Pending; BlockProductionState { state, state_root: None, - parent_payload_status, - parent_envelope: None, + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, } }; @@ -173,7 +172,7 @@ impl BeaconChain { slot: Slot, head_slot: Slot, canonical_head: Hash256, - ) -> Option<(BeaconState, Hash256)> { + ) -> Option> { let re_org_head_threshold = ReOrgThreshold(self.spec.reorg_head_weight_threshold); let re_org_parent_threshold = ReOrgThreshold(self.spec.reorg_parent_weight_threshold); let re_org_max_epochs_since_finalization = @@ -237,9 +236,15 @@ impl BeaconChain { } }) .ok()?; + drop(proposer_head_timer); let re_org_parent_block = proposer_head.parent_node.root(); + // The head uniquely determines the parent payload status for the re-org block, whichever + // variant (full or empty) it builds on must have more weight, or else we would have already + // re-orged away from this block naturally, and it would not be the head, by definition. + let parent_payload_status = proposer_head.head_node.get_parent_payload_status(); + let (state_root, state) = self .store .get_advanced_hot_state_from_cache(re_org_parent_block, slot) @@ -248,6 +253,25 @@ impl BeaconChain { None })?; + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + let envelope = self + .store + .get_payload_envelope(&re_org_parent_block) + .ok() + .flatten() + .map(Arc::new) + .or_else(|| { + warn!( + reason = "missing execution payload envelope", + "Not attempting re-org" + ); + None + })?; + Some(envelope) + } else { + None + }; + info!( weak_head = ?canonical_head, parent = ?re_org_parent_block, @@ -256,6 +280,11 @@ impl BeaconChain { "Attempting re-org due to weak head" ); - Some((state, state_root)) + Some(ReOrgInputs { + state, + state_root, + parent_payload_status, + parent_envelope, + }) } } diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index 6c52e5ce2d..01cee2cdb6 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -15,7 +15,10 @@ use crate::{ GossipVerificationContext, VerifiedPayloadAttestationMessage, }, }, - test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, + test_utils::{ + BeaconChainHarness, EphemeralHarnessType, MakePayloadAttestationOptions, + PayloadAttestationVote, fork_name_from_env, test_spec, + }, }; type E = MinimalEthSpec; @@ -30,6 +33,10 @@ struct TestContext { impl TestContext { fn new() -> Self { + Self::with_validator_count(NUM_VALIDATORS) + } + + fn with_validator_count(num_validators: usize) -> Self { let spec = Arc::new(test_spec::()); let slot_clock = TestingSlotClock::new( Slot::new(0), @@ -38,8 +45,9 @@ impl TestContext { ); let harness = BeaconChainHarness::builder(E::default()) .spec(spec) - .deterministic_keypairs(NUM_VALIDATORS) + .deterministic_keypairs(num_validators) .fresh_ephemeral_store() + .mock_execution_layer() .testing_slot_clock(slot_clock) .build(); @@ -289,6 +297,161 @@ fn duplicate_after_valid() { )); } +#[tokio::test] +async fn harness_builds_and_imports_payload_attestation_messages() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; + let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + for validator_index in ptc.0.iter().copied() { + *ptc_weights.entry(validator_index).or_insert(0usize) += 1; + } + let votes = vec![ + PayloadAttestationVote { + validator_count: 2, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: 3, + payload_present: false, + blob_data_available: false, + }, + ]; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { + votes, + fork: state.fork(), + }, + ); + + assert_eq!(messages.len(), attesters.len()); + assert_eq!( + attesters + .iter() + .copied() + .collect::>() + .len(), + attesters.len() + ); + assert_eq!( + messages + .iter() + .filter(|message| message.data.payload_present && message.data.blob_data_available) + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), + 2 + ); + assert_eq!( + messages + .iter() + .filter(|message| !message.data.payload_present && !message.data.blob_data_available) + .map(|message| ptc_weights[&(message.validator_index as usize)]) + .sum::(), + 3 + ); + + let pool_count_before = ctx.harness.chain.op_pool.num_payload_attestation_messages(); + ctx.harness + .import_payload_attestation_messages(messages) + .expect("payload attestation messages should import"); + assert_eq!( + ctx.harness.chain.op_pool.num_payload_attestation_messages(), + pool_count_before + attesters.len() + ); +} + +#[tokio::test] +async fn harness_packs_payload_attestation_messages_by_ptc_weight() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let beacon_block_root = ctx.harness.extend_to_slot(slot).await; + let state = &ctx.harness.chain.head_snapshot().beacon_state; + assert_eq!(state.slot(), slot); + let ptc = state.get_ptc(slot, &ctx.harness.spec).unwrap(); + let mut ptc_weights = std::collections::HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc.0.iter().copied() { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + let mut sorted_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| (validator_index, ptc_weights[&validator_index], order)) + .collect::>(); + sorted_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); + let first_weight = sorted_ptc_validators + .first() + .map(|(_, weight, _)| *weight) + .expect("PTC should have at least one validator"); + assert!(first_weight > 1, "test requires a duplicate PTC member"); + let second_weight = sorted_ptc_validators + .iter() + .skip(1) + .map(|(_, weight, _)| *weight) + .next() + .expect("PTC should have at least two distinct validators"); + let requested_weight = first_weight + second_weight; + + let (messages, attesters) = ctx.harness.make_payload_attestation_messages_with_opts( + &ctx.harness.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { + votes: vec![PayloadAttestationVote { + validator_count: requested_weight, + payload_present: true, + blob_data_available: true, + }], + fork: state.fork(), + }, + ); + + assert!( + messages.len() < requested_weight, + "duplicate PTC positions should pack into fewer messages" + ); + assert_eq!(messages.len(), attesters.len()); + assert_eq!( + attesters + .iter() + .map(|validator_index| ptc_weights[validator_index]) + .sum::(), + requested_weight + ); + assert!( + attesters + .iter() + .any(|validator_index| ptc_weights[validator_index] > 1) + ); + + ctx.harness + .import_payload_attestation_messages(messages) + .expect("weighted payload attestation messages should import"); +} + #[tokio::test] async fn ptc_cache_is_primed_at_gloas_fork_boundary() { // Only run this test once, when FORK_NAME=gloas exactly. diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 62c7fb3a45..2adfe26d4b 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -43,6 +43,7 @@ use logging::create_test_tracing_subscriber; use merkle_proof::MerkleTree; use operation_pool::ReceivedPreCapella; use parking_lot::{Mutex, RwLockWriteGuard}; +use proto_array::PayloadStatus; use rand::Rng; use rand::SeedableRng; use rand::rngs::StdRng; @@ -752,11 +753,37 @@ pub type HarnessSingleAttestations = Vec<( Option>, )>; +pub type HarnessPayloadAttestationMessages = Vec; + pub type HarnessSyncContributions = Vec<( Vec<(SyncCommitteeMessage, usize)>, Option>, )>; +fn pack_payload_attestation_vote( + available_ptc_validators: &[(usize, usize, usize)], + requested_weight: usize, +) -> Option> { + let mut packs = vec![None::>; requested_weight.checked_add(1)?]; + packs[0] = Some(vec![]); + + for (offset, (_, weight, _)) in available_ptc_validators.iter().enumerate() { + if *weight > requested_weight { + continue; + } + + for weight_so_far in (0..=requested_weight - *weight).rev() { + if packs[weight_so_far].is_some() && packs[weight_so_far + *weight].is_none() { + let mut pack = packs[weight_so_far].as_ref()?.clone(); + pack.push(offset); + packs[weight_so_far + *weight] = Some(pack); + } + } + } + + packs.pop().flatten() +} + impl BeaconChainHarness> where E: EthSpec, @@ -1164,9 +1191,33 @@ where /// /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. pub async fn make_block_with_envelope( + &self, + state: BeaconState, + slot: Slot, + ) -> ( + SignedBlockContentsTuple, + Option>, + BeaconState, + ) { + let parent_payload_status = self + .chain + .canonical_head + .cached_head() + .head_payload_status(); + self.make_block_with_envelope_on(state, slot, parent_payload_status) + .await + } + + /// Returns a newly created block built with the given parent payload status, + /// signed by the proposer for the given slot, along with the execution + /// payload envelope (for Gloas) and the post-block state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope_on( &self, mut state: BeaconState, slot: Slot, + parent_payload_status: PayloadStatus, ) -> ( SignedBlockContentsTuple, Option>, @@ -1189,15 +1240,21 @@ where GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); - // Load the parent's payload envelope and status from the cached head. - // TODO(gloas): we may want to pass these as arguments to support cases where we build - // on alternate chains to the head. - let (parent_payload_status, parent_envelope) = { - let head = self.chain.canonical_head.cached_head(); - ( - head.head_payload_status(), - head.snapshot.execution_envelope.clone(), - ) + let parent_envelope = if parent_payload_status == PayloadStatus::Full { + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .expect("should get parent block root") + } else { + state.latest_block_header().canonical_root() + }; + self.chain + .store + .get_payload_envelope(&parent_root) + .expect("should load parent payload envelope") + .map(Arc::new) + } else { + None }; let (block, post_block_state, _consensus_block_value) = self @@ -2151,6 +2208,169 @@ where ) } + pub fn make_payload_attestation_message( + &self, + validator_index: usize, + data: PayloadAttestationData, + fork: &Fork, + ) -> PayloadAttestationMessage { + let epoch = data.slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::PTCAttester, + fork, + self.chain.genesis_validators_root, + ); + let signing_root = data.signing_root(domain); + let signature = self.validator_keypairs[validator_index] + .sk + .sign(signing_root); + + PayloadAttestationMessage { + validator_index: validator_index as u64, + data, + signature, + } + } + + pub fn make_payload_attestation_messages( + &self, + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + votes: Vec, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let fork = self.spec.fork_at_epoch(slot.epoch(E::slots_per_epoch())); + self.make_payload_attestation_messages_with_opts( + &self.get_all_validators(), + state, + beacon_block_root, + slot, + MakePayloadAttestationOptions { votes, fork }, + ) + } + + pub fn make_payload_attestation_messages_with_opts( + &self, + attesting_validators: &[usize], + state: &BeaconState, + beacon_block_root: Hash256, + slot: Slot, + opts: MakePayloadAttestationOptions, + ) -> (HarnessPayloadAttestationMessages, Vec) { + let MakePayloadAttestationOptions { votes, fork } = opts; + let ptc = state + .get_ptc(slot, &self.spec) + .expect("should get payload timeliness committee"); + + debug!("PTC is {:?}", ptc.0.to_vec()); + + let attesting_validators = attesting_validators.iter().copied().collect::>(); + let mut ptc_weights = HashMap::new(); + let mut ptc_validator_order = vec![]; + for validator_index in ptc + .0 + .iter() + .copied() + .filter(|validator_index| attesting_validators.contains(validator_index)) + { + if let Some(weight) = ptc_weights.get_mut(&validator_index) { + *weight += 1; + } else { + ptc_weights.insert(validator_index, 1usize); + ptc_validator_order.push(validator_index); + } + } + + let mut available_ptc_validators = ptc_validator_order + .into_iter() + .enumerate() + .map(|(order, validator_index)| { + let weight = ptc_weights[&validator_index]; + (validator_index, weight, order) + }) + .collect::>(); + available_ptc_validators.sort_by(|(_, weight_a, order_a), (_, weight_b, order_b)| { + weight_b.cmp(weight_a).then(order_a.cmp(order_b)) + }); + + let mut messages = Vec::new(); + let mut attesters = Vec::new(); + + for vote in votes { + let data = PayloadAttestationData { + beacon_block_root, + slot, + payload_present: vote.payload_present, + blob_data_available: vote.blob_data_available, + }; + + let Some(packed_validator_offsets) = + pack_payload_attestation_vote(&available_ptc_validators, vote.validator_count) + else { + let available_weights = available_ptc_validators + .iter() + .map(|(validator_index, weight, _)| (*validator_index, *weight)) + .collect::>(); + panic!( + "requested packing couldn't be formed for payload attestation vote {vote:?}; \ + requested PTC weight {}, available PTC weights {:?}", + vote.validator_count, available_weights + ); + }; + + for &offset in &packed_validator_offsets { + let validator_index = available_ptc_validators[offset].0; + messages.push(self.make_payload_attestation_message( + validator_index, + data.clone(), + &fork, + )); + attesters.push(validator_index); + } + + for offset in packed_validator_offsets.into_iter().rev() { + available_ptc_validators.remove(offset); + } + } + + (messages, attesters) + } + + pub fn import_payload_attestation_message( + &self, + message: PayloadAttestationMessage, + ) -> Result<(), PayloadAttestationImportError> { + let verified = self + .chain + .verify_payload_attestation_message_for_gossip(message) + .map_err(PayloadAttestationImportError::Verification)?; + + self.chain + .apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) + .map_err(|e| PayloadAttestationImportError::ForkChoice(Box::new(e)))?; + + self.chain + .add_payload_attestation_to_pool(&verified) + .map_err(|e| PayloadAttestationImportError::Pool(Box::new(e)))?; + + Ok(()) + } + + pub fn import_payload_attestation_messages( + &self, + messages: impl IntoIterator, + ) -> Result<(), PayloadAttestationImportError> { + for message in messages { + self.import_payload_attestation_message(message)?; + } + + Ok(()) + } + pub fn make_sync_contributions( &self, state: &BeaconState, @@ -2158,6 +2378,21 @@ where slot: Slot, relative_sync_committee: RelativeSyncCommittee, ) -> HarnessSyncContributions { + // Resolve the committee for aggregator selection using the same relative committee as the + // messages. Selecting from `current_sync_committee` unconditionally would pick an + // aggregator outside the verifying committee at sync committee period boundaries (where + // `Next` is used), causing `AggregatorNotInCommittee`. + let sync_committee: Arc> = match relative_sync_committee { + RelativeSyncCommittee::Current => state + .current_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + RelativeSyncCommittee::Next => state + .next_sync_committee() + .expect("should be called on altair beacon state") + .clone(), + }; + let sync_messages = self.make_sync_committee_messages(state, block_hash, slot, relative_sync_committee); @@ -2167,10 +2402,7 @@ where .map(|(subnet_id, committee_messages)| { // If there are any sync messages in this committee, create an aggregate. if let Some((sync_message, subcommittee_position)) = committee_messages.first() { - let sync_committee: Arc> = state - .current_sync_committee() - .expect("should be called on altair beacon state") - .clone(); + let sync_committee = sync_committee.clone(); let aggregator_index = sync_committee .get_subcommittee_pubkeys(subnet_id) @@ -3239,14 +3471,24 @@ where if sync_committee_strategy == SyncCommitteeStrategy::AllValidators && new_state.current_sync_committee().is_ok() { + // A sync message for `slot` is verified against the committee of `epoch(slot + 1)` + // (see `BeaconChain::sync_committee_at_next_slot`), so we must sign with `Next` only + // when `slot + 1` crosses into a new sync committee period, not for the whole first + // epoch of the period. + let slots_per_epoch = E::slots_per_epoch(); + let crosses_period = slot + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap() + != (slot + 1) + .epoch(slots_per_epoch) + .sync_committee_period(&self.spec) + .unwrap(); self.sync_committee_sign_block( &new_state, block_hash.into(), slot, - if (slot + 1).epoch(E::slots_per_epoch()) - % self.spec.epochs_per_sync_committee_period - == 0 - { + if crosses_period { RelativeSyncCommittee::Next } else { RelativeSyncCommittee::Current @@ -3806,6 +4048,28 @@ pub struct MakeAttestationOptions { pub payload_present_override: Option, } +#[derive(Debug, Clone, Copy)] +pub struct PayloadAttestationVote { + /// Amount of PTC weight to produce messages for this vote. + pub validator_count: usize, + pub payload_present: bool, + pub blob_data_available: bool, +} + +pub struct MakePayloadAttestationOptions { + /// Vote groups to produce. Each group becomes `validator_count` individual messages. + pub votes: Vec, + /// Fork to use for signing payload attestation messages. + pub fork: Fork, +} + +#[derive(Debug)] +pub enum PayloadAttestationImportError { + Verification(crate::payload_attestation_verification::Error), + ForkChoice(Box), + Pool(Box), +} + pub enum NumBlobs { Random, Number(usize), diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs new file mode 100644 index 0000000000..6bbca727f9 --- /dev/null +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -0,0 +1,946 @@ +//! post-gloas payload re-org tests. +//! +//! These tests are deliberately kept separate from `interactive_tests.rs` because they exercise +//! post-gloas fork-choice behaviour: the head is a `ForkChoiceNode` = (block root, payload status), +//! and a block's *payload* can be re-orged (head flips `FULL` -> `EMPTY`) independently of the +//! beacon block, when later-slot voters attest the block with `payload_present = false`. +//! +use beacon_chain::{ + ChainConfig, + chain_config::DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, + custody_context::NodeCustodyType, + test_utils::{ + AttestationStrategy, BlockStrategy, LightClientStrategy, MakeAttestationOptions, + MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, + }, +}; +use eth2::types::ProduceBlockV3Response; +use execution_layer::{ForkchoiceState, PayloadAttributes}; +use fixed_bytes::FixedBytesExtended; +use http_api::test_utils::InteractiveTester; +use parking_lot::Mutex; +use proto_array::PayloadStatus; +use slot_clock::SlotClock; +use state_processing::{ + per_block_processing::get_expected_withdrawals, state_advance::complete_state_advance, +}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use types::{ + Address, BeaconBlockRef, EthSpec, ExecPayload, ExecutionBlockHash, Hash256, MinimalEthSpec, + ProposerPreparationData, Slot, +}; + +type E = MinimalEthSpec; + +// Must be at least PTC size to simplify PTC reasoning (unique PTC members per slot). +const ATTESTERS_PER_SLOT: usize = 20; + +/// Data structure for tracking fork choice updates received by the mock execution layer. +#[derive(Debug, Default)] +struct ForkChoiceUpdates { + updates: HashMap>, +} + +#[derive(Debug, Clone)] +struct ForkChoiceUpdateMetadata { + received_at: Duration, + state: ForkchoiceState, + payload_attributes: Option, +} + +impl ForkChoiceUpdates { + fn insert(&mut self, update: ForkChoiceUpdateMetadata) { + self.updates + .entry(update.state.head_block_hash) + .or_default() + .push(update); + } + + fn contains_update_for(&self, block_hash: ExecutionBlockHash) -> bool { + self.updates.contains_key(&block_hash) + } + + /// Find the first fork choice update for `head_block_hash` with payload attributes matching + /// the proposal and parent being tested. + fn first_update_with_payload_attributes( + &self, + head_block_hash: ExecutionBlockHash, + proposal_timestamp: u64, + parent_beacon_block_root: Option, + slot_number: Option, + ) -> Option { + self.updates + .get(&head_block_hash)? + .iter() + .find(|update| { + update + .payload_attributes + .as_ref() + .is_some_and(|payload_attributes| { + if payload_attributes.timestamp() != proposal_timestamp { + return false; + } + + if let Some(parent_beacon_block_root) = parent_beacon_block_root + && payload_attributes.parent_beacon_block_root().ok() + != Some(parent_beacon_block_root) + { + return false; + } + + if let Some(slot_number) = slot_number + && payload_attributes.slot_number().ok() != Some(slot_number) + { + return false; + } + + true + }) + }) + .cloned() + } +} + +#[derive(Clone, Copy)] +enum ExpectedFirstUpdateLookahead { + Payload, + ForkChoice, + BlockProduction, +} + +pub struct ReOrgTest { + head_slot: Slot, + /// Number of slots between parent block and canonical head. + parent_distance: u64, + /// Number of slots between head block and block proposal slot. + head_distance: u64, + /// Fraction of parent (A)'s committee that votes for A (always with payload_present=0). + percent_parent_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=0. + percent_skip_empty_votes: usize, + /// Fraction of B's committee that votes for A with payload_present=1. + percent_skip_full_votes: usize, + /// Fraction of B's committee that votes for B (always with payload_present=0). + percent_head_votes: usize, + /// Parent payload status of block B. + head_parent_payload_status: PayloadStatus, + /// Fraction of A's PTC that vote for A's payload being present. + percent_parent_ptc_present_votes: usize, + /// Fraction of A's PTC that vote for A's payload being absent. + percent_parent_ptc_absent_votes: usize, + /// Expected parent payload status of our proposed block (C). + /// + /// This can be the payload status of A or B depending on whether we reorged or not. + expected_parent_payload_status: PayloadStatus, + should_re_org: bool, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead, + /// Whether to expect withdrawals to change on epoch boundaries. + expect_withdrawals_change_on_epoch: bool, +} + +impl Default for ReOrgTest { + /// Default config represents a regular easy re-org. + fn default() -> Self { + Self { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 1, + head_distance: 1, + percent_parent_votes: 100, + percent_skip_empty_votes: 0, + percent_skip_full_votes: 100, + percent_head_votes: 0, + head_parent_payload_status: PayloadStatus::Full, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + should_re_org: true, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::Payload, + expect_withdrawals_change_on_epoch: false, + } + } +} + +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_easy() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 100, + percent_skip_full_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, + ..Default::default() + }) + .await; +} + +// A-Empty chain has 55% of one committee supporting it A-Full chain has 45% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Empty. +// +// This test doesn't actually exercise the re-org code path because the chain just naturally +// re-orgs to A-empty at the start of slot C anyway. That only happens after the 500ms +// pre-slot fork choice recompute. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_empty_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::ForkChoice, + ..Default::default() + }) + .await; +} + +// A-Empty chain has 45% of one committee supporting it A-Full chain has 55% of one committee +// supporting it, including 15% for descendant B that is late and re-orgable. +// +// A-Full has 100% PTC support, but this should be completely ignored. +// +// We should re-org B and build on A-Full. +// Since Gloas fork choice updates are not overridden for proposer re-orgs, the first fcU for this +// parent is sent during block production. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn re_org_parent_is_full_marginal_win() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 45, + percent_skip_full_votes: 40, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + expected_parent_payload_status: PayloadStatus::Full, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_empty() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_empty_votes: 55, + percent_skip_full_votes: 30, + percent_head_votes: 15, + percent_parent_ptc_present_votes: 100, + percent_parent_ptc_absent_votes: 0, + head_parent_payload_status: PayloadStatus::Empty, + expected_parent_payload_status: PayloadStatus::Empty, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +// Test that the beacon node will try to perform proposer boost re-orgs on late blocks when +// configured. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_zero_weight() { + proposer_boost_re_org_test(ReOrgTest { + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +// Since Fulu, proposer shuffling is stable across epoch boundaries, so re-orgs of the last block +// in an epoch are permitted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + should_re_org: true, + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip1() { + // Proposing a block on a boundary after a skip will change the set of expected withdrawals + // sent in the payload attributes. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(2 * E::slots_per_epoch() - 2), + head_distance: 2, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_epoch_boundary_skip32() { + // Propose a block at 64 after a whole epoch of skipped slots. + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + head_distance: E::slots_per_epoch() + 1, + should_re_org: false, + expect_withdrawals_change_on_epoch: true, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_slot_after_epoch_boundary() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(33), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_bad_ffg() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(64 + 22), + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_no_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(96), + percent_parent_votes: 100, + percent_skip_full_votes: 0, + percent_head_votes: 100, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_finality() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(129), + expected_first_update_lookahead: ExpectedFirstUpdateLookahead::BlockProduction, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_parent_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 2), + parent_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_distance() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 3), + head_distance: 2, + should_re_org: false, + ..Default::default() + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_very_unhealthy() { + proposer_boost_re_org_test(ReOrgTest { + head_slot: Slot::new(E::slots_per_epoch() - 1), + parent_distance: 2, + head_distance: 2, + percent_parent_votes: 10, + percent_skip_full_votes: 10, + percent_head_votes: 10, + should_re_org: false, + ..Default::default() + }) + .await; +} + +/// The head block is late but still receives 30% of the committee vote, making it strong enough +/// that we do not re-org it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +pub async fn proposer_boost_re_org_head_too_strong() { + proposer_boost_re_org_test(ReOrgTest { + percent_skip_full_votes: 70, + percent_head_votes: 30, + should_re_org: false, + ..Default::default() + }) + .await; +} + +/// Run a proposer boost re-org test. +/// +/// - `head_slot`: the slot of the canonical head to be reorged +/// - `reorg_threshold`: committee percentage value for reorging +/// - `num_empty_votes`: percentage of comm of attestations for the parent block +/// - `num_head_votes`: number of attestations for the head block +/// - `should_re_org`: whether the proposer should build on the parent rather than the head +#[allow(clippy::large_stack_frames)] +pub async fn proposer_boost_re_org_test( + ReOrgTest { + head_slot, + parent_distance, + head_distance, + percent_parent_votes, + percent_skip_empty_votes, + percent_skip_full_votes, + percent_head_votes, + head_parent_payload_status, + percent_parent_ptc_present_votes, + percent_parent_ptc_absent_votes, + expected_parent_payload_status, + should_re_org, + expected_first_update_lookahead, + expect_withdrawals_change_on_epoch, + }: ReOrgTest, +) { + assert!(head_slot > 0); + + let spec = test_spec::(); + + if !spec.is_gloas_scheduled() { + return; + } + + // Ensure there are enough validators to have `ATTESTERS_PER_SLOT`. + assert!(ATTESTERS_PER_SLOT >= E::ptc_size()); + let validator_count = E::slots_per_epoch() as usize * ATTESTERS_PER_SLOT; + let all_validators = (0..validator_count).collect::>(); + let num_initial = head_slot.as_u64().checked_sub(parent_distance + 1).unwrap(); + + // Check that the required vote percentages can be satisfied exactly using `ATTESTERS_PER_SLOT`. + assert_eq!(100 % ATTESTERS_PER_SLOT, 0); + let percent_per_attester = 100 / ATTESTERS_PER_SLOT; + assert_eq!(percent_parent_votes % percent_per_attester, 0); + assert_eq!(percent_skip_empty_votes % percent_per_attester, 0); + assert_eq!(percent_skip_full_votes % percent_per_attester, 0); + assert_eq!(percent_head_votes % percent_per_attester, 0); + let num_parent_votes = Some(ATTESTERS_PER_SLOT * percent_parent_votes / 100); + let num_skip_empty_votes = Some(ATTESTERS_PER_SLOT * percent_skip_empty_votes / 100); + let num_skip_full_votes = Some(ATTESTERS_PER_SLOT * percent_skip_full_votes / 100); + let num_head_votes = Some(ATTESTERS_PER_SLOT * percent_head_votes / 100); + + assert_eq!((percent_parent_ptc_present_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_present_votes = percent_parent_ptc_present_votes * E::ptc_size() / 100; + assert_eq!((percent_parent_ptc_absent_votes * E::ptc_size()) % 100, 0); + let num_parent_ptc_absent_votes = percent_parent_ptc_absent_votes * E::ptc_size() / 100; + + // We must configure the prepare payload lookahead so it scales with the minimal config, + // otherwise the late block reveal for A halfway through the slot can end up being *after* + // the payload lookahead, which messes up our measurement of timings. + let chain_config = ChainConfig { + prepare_payload_lookahead: spec.get_slot_duration() + / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, + ..Default::default() + }; + + let tester = InteractiveTester::::new_with_initializer_and_mutator( + Some(spec), + validator_count, + None, + Some(Box::new(move |builder| builder.chain_config(chain_config))), + Default::default(), + false, + NodeCustodyType::Fullnode, + ) + .await; + let harness = &tester.harness; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let execution_ctx = mock_el.server.ctx.clone(); + let slot_clock = &harness.chain.slot_clock; + + mock_el.server.all_payloads_valid(); + + // Send proposer preparation data for all validators. + let proposer_preparation_data = all_validators + .iter() + .map(|i| { + ( + ProposerPreparationData { + validator_index: *i as u64, + fee_recipient: Address::from_low_u64_be(*i as u64), + }, + None, + ) + }) + .collect::>(); + harness + .chain + .execution_layer + .as_ref() + .unwrap() + .update_proposer_preparation( + head_slot.epoch(E::slots_per_epoch()) + 1, + proposer_preparation_data.iter().map(|(a, b)| (a, b)), + ) + .await; + + // Create some chain depth. Sign sync committee signatures so validator balances don't dip + // below 32 ETH and become ineligible for withdrawals. + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; + + // Start collecting fork choice updates. + let forkchoice_updates = Arc::new(Mutex::new(ForkChoiceUpdates::default())); + let forkchoice_updates_inner = forkchoice_updates.clone(); + let chain_inner = harness.chain.clone(); + + execution_ctx + .hook + .lock() + .set_forkchoice_updated_hook(Box::new(move |state, payload_attributes| { + let received_at = chain_inner.slot_clock.now_duration().unwrap(); + let state = ForkchoiceState::from(state); + let payload_attributes = payload_attributes.map(Into::into); + let update = ForkChoiceUpdateMetadata { + received_at, + state, + payload_attributes, + }; + forkchoice_updates_inner.lock().insert(update); + None + })); + + // We set up the following block graph, where B is a block that arrives late and is re-orged + // by C. + // + // A | B | - | + // ^ | - | C | + + let slot_a = Slot::new(num_initial + 1); + let slot_b = slot_a + parent_distance; + let slot_c = slot_b + head_distance; + + // We need to transition to at least epoch 2 in order to trigger + // `process_rewards_and_penalties`. This allows us to test withdrawals changes at epoch + // boundaries. + if expect_withdrawals_change_on_epoch { + assert!( + slot_c.epoch(E::slots_per_epoch()) >= 2, + "for withdrawals to change, test must end at an epoch >= 2" + ); + } + + harness.advance_slot(); + let (block_a_root, block_a, mut state_a) = harness + .add_block_at_slot(slot_a, harness.get_current_state()) + .await + .unwrap(); + let state_a_root = state_a.canonical_root().unwrap(); + + // Attest to block A during slot A. + let (block_a_parent_votes, _) = harness.make_attestations_with_limit( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_a, + num_parent_votes, + ); + harness.process_attestations(block_a_parent_votes, &state_a); + + // Produce PTC messages for slot A. + let a_ptc_votes = vec![ + PayloadAttestationVote { + validator_count: num_parent_ptc_present_votes, + payload_present: true, + blob_data_available: true, + }, + PayloadAttestationVote { + validator_count: num_parent_ptc_absent_votes, + payload_present: false, + blob_data_available: false, + }, + ]; + let (a_ptc_messages, _) = harness.make_payload_attestation_messages_with_opts( + &all_validators, + &state_a, + block_a_root.into(), + slot_a, + MakePayloadAttestationOptions { + votes: a_ptc_votes, + fork: state_a.fork(), + }, + ); + harness + .import_payload_attestation_messages(a_ptc_messages) + .unwrap(); + + // Attest to block A during slot B. + for _ in 0..parent_distance { + harness.advance_slot(); + } + let (block_a_empty_votes, block_a_empty_attesters) = harness.make_attestations_with_opts( + &all_validators, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_empty_votes, + fork: state_a.fork(), + payload_present_override: Some(false), + }, + ); + harness.process_attestations(block_a_empty_votes, &state_a); + let remaining_attesters_after_empty = all_validators + .iter() + .copied() + .filter(|index| !block_a_empty_attesters.contains(index)) + .collect::>(); + let (block_a_full_votes, block_a_full_attesters) = harness.make_attestations_with_opts( + &remaining_attesters_after_empty, + &state_a, + state_a_root, + block_a_root, + slot_b, + MakeAttestationOptions { + limit: num_skip_full_votes, + fork: state_a.fork(), + payload_present_override: Some(true), + }, + ); + harness.process_attestations(block_a_full_votes, &state_a); + + let remaining_attesters = remaining_attesters_after_empty + .iter() + .copied() + .filter(|index| !block_a_full_attesters.contains(index)) + .collect::>(); + + // Produce block B and process it halfway through the slot. + // When B is expected to remain canonical (no re-org), capture its Gloas payload envelope so we + // can reveal B's execution payload to fork choice below. Without this, B's payload status stays + // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's + // own execution block hash. We skip this when B will be re-orged, since the execution layer + // must never be told about a block that is about to be re-orged away. + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_b) + .gloas_enabled(); + let reveal_block_b_payload = is_gloas && !should_re_org; + let (block_b, block_b_envelope, mut state_b) = if is_gloas { + let (block_b, block_b_envelope, state_b) = harness + .make_block_with_envelope_on(state_a.clone(), slot_b, head_parent_payload_status) + .await; + let block_b_envelope = if reveal_block_b_payload { + block_b_envelope + } else { + None + }; + (block_b, block_b_envelope, state_b) + } else { + let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; + (block_b, None, state_b) + }; + let state_b_root = state_b.canonical_root().unwrap(); + let block_b_root = block_b.0.canonical_root(); + + let obs_time = slot_clock.start_of(slot_b).unwrap() + slot_clock.slot_duration() / 2; + slot_clock.set_current_time(obs_time); + harness.chain.block_times_cache.write().set_time_observed( + block_b_root, + slot_b, + obs_time, + None, + None, + ); + harness.process_block_result(block_b.clone()).await.unwrap(); + + // Reveal B's execution payload so fork choice marks the payload as received and the + // forkchoiceUpdated head hash references B's own execution block hash. + if let Some(block_b_envelope) = block_b_envelope { + harness + .process_envelope(block_b_root, block_b_envelope, &state_b, state_b_root) + .await; + } + + // Add attestations to block B. + let (block_b_head_votes, _) = harness.make_attestations_with_limit( + &remaining_attesters, + &state_b, + state_b_root, + block_b_root.into(), + slot_b, + num_head_votes, + ); + harness.process_attestations(block_b_head_votes, &state_b); + + let payload_lookahead = harness.chain.config.prepare_payload_lookahead; + let fork_choice_lookahead = Duration::from_millis(500); + while harness.get_current_slot() != slot_c { + let current_slot = harness.get_current_slot(); + let next_slot = current_slot + 1; + + // Simulate the scheduled call to prepare proposers at 8 seconds into the slot. + harness.advance_to_slot_lookahead(next_slot, payload_lookahead); + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Simulate the scheduled call to fork choice + prepare proposers 500ms before the + // next slot. + harness.advance_to_slot_lookahead(next_slot, fork_choice_lookahead); + harness.chain.recompute_head_at_slot(next_slot).await; + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + harness.advance_slot(); + harness.chain.per_slot_task().await; + } + + // Produce block C. + // Advance state_b so we can get the proposer. + assert_eq!(state_b.slot(), slot_b); + let pre_advance_withdrawals = get_expected_withdrawals(&state_b, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec(); + complete_state_advance(&mut state_b, None, slot_c, &harness.chain.spec).unwrap(); + + let proposer_index = state_b + .get_beacon_proposer_index(slot_c, &harness.chain.spec) + .unwrap(); + let randao_reveal = harness + .sign_randao_reveal(&state_b, proposer_index, slot_c) + .into(); + let is_gloas = harness + .chain + .spec + .fork_name_at_slot::(slot_c) + .gloas_enabled(); + + let (block_c, block_c_blobs) = if is_gloas { + let (response, _) = tester + .client + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .await + .unwrap(); + ( + Arc::new(harness.sign_beacon_block(response.data, &state_b)), + None, + ) + } else { + let (unsigned_block_type, _) = tester + .client + .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) + .await + .unwrap(); + + let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { + ProduceBlockV3Response::Full(unsigned_block_contents_c) => { + unsigned_block_contents_c.deconstruct() + } + ProduceBlockV3Response::Blinded(_) => { + panic!("Should not be a blinded block"); + } + }; + ( + Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)), + block_c_blobs, + ) + }; + + // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash + // lives in the execution payload bid, and the payload timestamp is derived from the slot. + let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + if is_gloas { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .block_hash + } else { + block.execution_payload().unwrap().block_hash() + } + }; + let exec_parent_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { + if is_gloas { + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .parent_block_hash + } else { + block.execution_payload().unwrap().parent_hash() + } + }; + + let block_a_exec_hash = exec_block_hash(block_a.0.message()); + let block_b_exec_hash = exec_block_hash(block_b.0.message()); + + if is_gloas { + assert_eq!( + block_b.0.is_parent_block_full(block_a_exec_hash), + head_parent_payload_status == PayloadStatus::Full + ); + } + + if should_re_org { + // Block C should build on A. + assert_eq!(block_c.parent_root(), Hash256::from(block_a_root)); + + if is_gloas { + assert_eq!( + block_c.is_parent_block_full(block_a_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } + } else { + // Block C should build on B. + assert_eq!(block_c.parent_root(), block_b_root); + + if is_gloas { + assert_eq!( + block_c.is_parent_block_full(block_b_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); + } + } + + // Applying block C should cause it to become head regardless (re-org or continuation). + let block_root_c = Hash256::from( + harness + .process_block_result((block_c.clone(), block_c_blobs)) + .await + .unwrap(), + ); + + assert_eq!(harness.head_block_root(), block_root_c); + + // Check the fork choice updates that were sent. + let forkchoice_updates = forkchoice_updates.lock(); + + let block_c_timestamp = if is_gloas { + harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs() + } else { + block_c.message().execution_payload().unwrap().timestamp() + }; + + // If we re-orged then no fork choice update for B should have been sent. + assert_eq!( + should_re_org, + !forkchoice_updates.contains_update_for(block_b_exec_hash), + "{block_b_exec_hash:?}" + ); + + // Check the timing of the first fork choice update with payload attributes for block C. + let c_parent_block = if should_re_org { + block_a.0.message() + } else { + block_b.0.message() + }; + let c_parent_hash = if expected_parent_payload_status == PayloadStatus::Full { + exec_block_hash(c_parent_block) + } else { + exec_parent_hash(c_parent_block) + }; + let first_update = forkchoice_updates + .first_update_with_payload_attributes( + c_parent_hash, + block_c_timestamp, + is_gloas.then(|| block_c.parent_root()), + is_gloas.then(|| slot_c.as_u64()), + ) + .unwrap(); + let payload_attribs = first_update.payload_attributes.as_ref().unwrap(); + + // Check that withdrawals from the payload attributes match those computed from the state used + // by the path that produced the matching fcU. + let parent_state_advanced = if should_re_org { + let mut state = state_a.clone(); + complete_state_advance(&mut state, None, slot_c, &harness.chain.spec).unwrap(); + state + } else { + state_b.clone() + }; + let expected_withdrawals = if is_gloas + && matches!( + expected_first_update_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction + ) + && expected_parent_payload_status == PayloadStatus::Empty + { + parent_state_advanced + .payload_expected_withdrawals() + .unwrap() + .to_vec() + } else { + get_expected_withdrawals(&parent_state_advanced, &harness.chain.spec) + .unwrap() + .withdrawals() + .to_vec() + }; + let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); + assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); + // The validator withdrawal sweep is positional: it scans a rotating window of + // `max_validators_per_withdrawals_sweep` validators starting at `next_withdrawal_validator_index`. + // For a given proposal slot that window can legitimately contain no withdrawal-eligible + // validators (with empty partial/builder withdrawal queues), so an empty withdrawals list is + // valid. Withdrawal correctness is covered by the equality check above; we only assert the + // re-org/epoch-boundary withdrawals change when there are withdrawals to compare. + if !expected_withdrawals.is_empty() + && (should_re_org + || expect_withdrawals_change_on_epoch + && slot_c.epoch(E::slots_per_epoch()) != slot_b.epoch(E::slots_per_epoch())) + { + assert_ne!(expected_withdrawals, pre_advance_withdrawals); + } + + // Check that the `parent_beacon_block_root` of the payload attributes are correct. + if let Ok(parent_beacon_block_root) = payload_attribs.parent_beacon_block_root() { + assert_eq!(parent_beacon_block_root, block_c.parent_root()); + } + + let lookahead = slot_clock + .start_of(slot_c) + .unwrap() + .checked_sub(first_update.received_at) + .unwrap(); + + let expected_lookahead = match expected_first_update_lookahead { + ExpectedFirstUpdateLookahead::Payload => payload_lookahead, + ExpectedFirstUpdateLookahead::ForkChoice => fork_choice_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction => Duration::ZERO, + }; + assert_eq!( + lookahead, + expected_lookahead, + "observed_lookahead={lookahead:?}, expected={expected_lookahead:?}, timestamp={}, prev_randao={:?}", + payload_attribs.timestamp(), + payload_attribs.prev_randao(), + ); +} diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 9258dab1af..d2a28da0f5 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -3,7 +3,8 @@ use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ ChainConfig, test_utils::{ - AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, test_spec, + AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, + fork_name_from_env, test_spec, }, }; use beacon_processor::{Work, WorkEvent, work_reprocessing_queue::ReprocessQueueMessage}; @@ -366,9 +367,13 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - // TODO(EIP-7732): extend test for Gloas — `get_validator_blocks_v3` is missing the - // `Eth-Execution-Payload-Blinded` header for Gloas block production responses. - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + // We don't run these test for post-Gloas forks because of the FcU changes that were + // applied in the gloas. Gloas adopted tests can be found in `gloas_re_org_test.rs` + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let spec = test_spec::(); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; diff --git a/beacon_node/http_api/tests/main.rs b/beacon_node/http_api/tests/main.rs index e0636424e4..35400a912e 100644 --- a/beacon_node/http_api/tests/main.rs +++ b/beacon_node/http_api/tests/main.rs @@ -2,6 +2,7 @@ pub mod broadcast_validation_tests; pub mod fork_tests; +pub mod gloas_reorg_tests; pub mod interactive_tests; pub mod status_tests; pub mod tests; diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index d45412c608..04113e2c0e 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -174,6 +174,10 @@ pub struct ProtoNode { } impl ProtoNode { + pub fn is_gloas(&self) -> bool { + self.as_v29().is_ok() + } + /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by /// considering their parents Empty. pub fn get_parent_payload_status(&self) -> PayloadStatus { diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 90143f1dd1..c6a7829c27 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -723,15 +723,14 @@ impl ProtoArrayForkChoice { .into()); } - // Spec: `is_parent_strong`. Use payload-aware weight matching the - // payload path the head node is on from its parent. - let parent_payload_status = info.head_node.get_parent_payload_status(); - let parent_weight = info.parent_node.attestation_score(parent_payload_status); + // Spec: `is_parent_strong`. Use `PayloadStatus::Pending` to avoid weight split + // between payload statuses. https://github.com/ethereum/consensus-specs/issues/5305 + let parent_pending_weight = info.parent_node.attestation_score(PayloadStatus::Pending); let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; - let parent_strong = parent_weight > re_org_parent_weight_threshold; + let parent_strong = parent_pending_weight > re_org_parent_weight_threshold; if !parent_strong { return Err(DoNotReOrg::ParentNotStrong { - parent_weight, + parent_weight: parent_pending_weight, re_org_parent_weight_threshold, } .into()); From 560f90611ee810dcab0f055daf7dcc50fab891b4 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:50:24 +0200 Subject: [PATCH 13/26] Fix and improve handling of empty columns after getBlobs response (#9361) This PR fixes two issues: 1. This condition is inverted: https://github.com/sigp/lighthouse/blob/dfb259171a65cacd6db57b8874af8f543cabcb7a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs#L1507-L1508 We are supposed to filter out incomplete columns when we DON'T have local blobs yet! 2. When the EL returns no blobs, we never store a partial in the assembler, and this code fails to publish our need to the network, as no partials are returned: https://github.com/sigp/lighthouse/blob/dfb259171a65cacd6db57b8874af8f543cabcb7a/beacon_node/network/src/network_beacon_processor/mod.rs#L1038-L1050 The simple fix for 1 would be to invert the condition, but we can improve the flow here: Instead of not publishing anything, we can publish what we got, but not request anything. This ties into the fix for 2: After get blobs completes, we not only publish anything in the partial assembler, but also for every missing custody column in there, publish an empty column and a request for all cells. In particular: - When sending a partial message to `network`, allow specifying a request bitmap instead of hardcoding an all-ones bitmap. - For clarity and to prepare for Gloas integration, add a `PubsubPartialMessage` enum with a `DataColumnFulu` variant. - On republishing after merging a gossip column: always publish, but only request cells if local blobs are known or get blobs is disabled. This also prepares us to request only *some* cells, e.g. in cases where we are aware of the blobs that the EL is going to send us, e.g. via `engine_hasBlobs`. - Move guards in `fetch_engine_blobs_and_publish` to ensure everything works fine if there are no blobs or if get_blobs is disabled. Co-Authored-By: Daniel Knopik --- beacon_node/beacon_chain/src/builder.rs | 2 + .../src/data_availability_checker.rs | 3 + .../beacon_chain/src/fetch_blobs/tests.rs | 2 +- .../src/partial_data_column_assembler.rs | 14 +- beacon_node/beacon_chain/src/test_utils.rs | 1 + beacon_node/http_api/src/publish_blocks.rs | 20 ++- beacon_node/lighthouse_network/src/lib.rs | 4 +- .../lighthouse_network/src/service/mod.rs | 117 ++++++++------- .../lighthouse_network/src/types/mod.rs | 2 +- .../lighthouse_network/src/types/partial.rs | 44 +++++- .../lighthouse_network/src/types/pubsub.rs | 24 ++- .../gossip_methods.rs | 75 ++++++---- .../src/network_beacon_processor/mod.rs | 138 +++++++++++++----- .../network_beacon_processor/sync_methods.rs | 2 +- beacon_node/network/src/service.rs | 13 +- 15 files changed, 313 insertions(+), 148 deletions(-) diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index c24c7b6fc6..91a9dcc7c8 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -908,6 +908,7 @@ where let shuffling_cache_size = self.chain_config.shuffling_cache_size; let complete_blob_backfill = self.chain_config.complete_blob_backfill; let enable_partial_columns = self.chain_config.enable_partial_columns; + let disable_get_blobs = self.chain_config.disable_get_blobs; // Calculate the weak subjectivity point in which to backfill blocks to. let genesis_backfill_slot = if self.chain_config.genesis_backfill { @@ -1043,6 +1044,7 @@ where custody_context.clone(), self.spec.clone(), enable_partial_columns, + disable_get_blobs, ) .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index 29cbf84235..e559dc7689 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -121,6 +121,7 @@ impl DataAvailabilityChecker { custody_context: Arc>, spec: Arc, enable_partial_columns: bool, + disable_get_blobs: bool, ) -> Result { let inner = DataAvailabilityCheckerInner::new( OVERFLOW_LRU_CAPACITY, @@ -130,6 +131,7 @@ impl DataAvailabilityChecker { let partial_assembler = if enable_partial_columns { Some(Arc::new(PartialDataColumnAssembler::new( OVERFLOW_LRU_CAPACITY, + disable_get_blobs, ))) } else { None @@ -1432,6 +1434,7 @@ mod test { custody_context, spec, true, + false, ) .expect("should initialise data availability checker") } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index 3c0f43fef0..8b805a8da3 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -344,7 +344,7 @@ fn mock_beacon_adapter(fork_name: ForkName, get_blobs_v3: bool) -> MockFetchBlob let test_runtime = TestRuntime::default(); let spec = Arc::new(fork_name.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); - let partial_assembler = PartialDataColumnAssembler::new(32); + let partial_assembler = PartialDataColumnAssembler::new(32, false); let mut mock_adapter = MockFetchBlobsBeaconAdapter::default(); mock_adapter.expect_spec().return_const(spec.clone()); diff --git a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs index f8580352b2..0d21cb7621 100644 --- a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs +++ b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs @@ -13,6 +13,9 @@ use types::data::{ColumnIndex, PartialDataColumnHeader}; pub struct PartialDataColumnAssembler { /// Cache of assemblies keyed by block root assemblies: RwLock>>, + /// Whether getBlobs is disabled. If so, always set `has_local_blobs` to true, as we will never + /// retrieve blobs from the EL and therefore should immediately request cells from the network. + disable_get_blobs: bool, } /// Tracks partial columns being assembled for a single block @@ -43,9 +46,10 @@ pub struct PartialMergeResult { } impl PartialDataColumnAssembler { - pub fn new(capacity: usize) -> Self { + pub fn new(capacity: usize, disable_get_blobs: bool) -> Self { Self { assemblies: RwLock::new(LruCache::new(capacity)), + disable_get_blobs, } } @@ -60,7 +64,7 @@ impl PartialDataColumnAssembler { let assembly = PartialAssembly { header, - has_local_blobs: false, + has_local_blobs: self.disable_get_blobs, columns: HashMap::new(), }; @@ -82,7 +86,7 @@ impl PartialDataColumnAssembler { .entry(block_root) .or_insert_with(|| PartialAssembly { header: header.clone(), - has_local_blobs: false, + has_local_blobs: self.disable_get_blobs, columns: HashMap::new(), }); @@ -174,7 +178,7 @@ impl PartialDataColumnAssembler { signed_block_header: fulu.signed_block_header.clone(), kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), }), - has_local_blobs: false, + has_local_blobs: self.disable_get_blobs, columns: Default::default(), }); let prev = assembly @@ -367,7 +371,7 @@ mod tests { } fn make_assembler() -> PartialDataColumnAssembler { - PartialDataColumnAssembler::new(16) + PartialDataColumnAssembler::new(16, false) } // -- init and get_header tests -- diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 2adfe26d4b..deaae6cba5 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -246,6 +246,7 @@ pub fn test_da_checker( custody_context, spec, true, + false, ) .expect("should initialise data availability checker") } diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 8b45a4b04c..e3e9839b2d 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -14,7 +14,7 @@ use eth2::types::{ }; use execution_layer::{ProvenancedPayload, SubmitBlindedBlockResponse}; use futures::TryFutureExt; -use lighthouse_network::PubsubMessage; +use lighthouse_network::{PubsubMessage, PubsubPartialMessage}; use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; @@ -442,12 +442,22 @@ pub(crate) fn publish_column_sidecars( // Publish partial messages if !partial_columns.is_empty() { if let Some(header) = partial_header { + let header = Arc::new(header); + let messages = partial_columns + .into_iter() + .map(|column| { + let mut request_cells = column.sidecar.cells_present_bitmap.clone(); + request_cells.not_inplace(); + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header: header.clone(), + } + }) + .collect(); crate::utils::publish_network_message( sender_clone, - NetworkMessage::PublishPartialColumns { - columns: partial_columns, - header: Arc::new(header), - }, + NetworkMessage::PublishPartialColumns { messages }, ) .map_err(|_| { BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) diff --git a/beacon_node/lighthouse_network/src/lib.rs b/beacon_node/lighthouse_network/src/lib.rs index fdb6ff095e..7719ee8540 100644 --- a/beacon_node/lighthouse_network/src/lib.rs +++ b/beacon_node/lighthouse_network/src/lib.rs @@ -98,8 +98,8 @@ impl std::fmt::Display for ClearDialError<'_> { } pub use crate::types::{ - Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, Subnet, - SubnetDiscovery, decode_partial, + Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, + PubsubPartialMessage, Subnet, SubnetDiscovery, decode_partial, }; pub use prometheus_client; diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 862281c910..9037975a0e 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -20,7 +20,9 @@ use crate::types::{ SubnetDiscovery, all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, subnet_from_topic_hash, }; -use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, decode_partial, metrics}; +use crate::{ + Enr, NetworkGlobals, PubsubMessage, PubsubPartialMessage, TopicHash, decode_partial, metrics, +}; use api_types::{AppRequestId, Response}; use futures::stream::StreamExt; use gossipsub_scoring_parameters::{PeerScoreSettings, lighthouse_gossip_thresholds}; @@ -43,8 +45,9 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, trace, warn}; use types::{ - ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, PartialDataColumn, - PartialDataColumnHeader, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, + CellBitmap, ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, + PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, + consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, }; use utils::{Context as ServiceContext, build_transport, strip_peer_id}; @@ -920,65 +923,73 @@ impl Network { } /// Publishes partial data column sidecars to the gossipsub network. - pub fn publish_partial( - &mut self, - columns: Vec>>, - header: Arc>, - ) { + pub fn publish_partial(&mut self, messages: Vec>) { if !self.network_globals.config.enable_partial_columns { return; } - debug!( - count = columns.len(), - "Sending partial data column sidecars" - ); + debug!(count = messages.len(), "Sending partial messages"); - for column in columns { - let subnet = - DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); - let topic = GossipTopic::new( - GossipKind::DataColumnSidecar(subnet), - GossipEncoding::default(), - self.enr_fork_id.fork_digest, - ); - let header_sent_set = self - .partial_column_header_tracker - .get_for_block(column.block_root); - let partial_message = OutgoingPartialColumn::new(column, &header, header_sent_set); - let publish_topic: Topic = topic.clone().into(); - - if let Err(e) = self - .gossipsub_mut() - .publish_partial(publish_topic, partial_message) - { - match e { - PublishError::NoPeersSubscribedToTopic => { - debug!( - kind = %topic.kind(), - "No peers supporting partial messages" - ); - } - ref e => { - warn!( - error = ?e, - kind = %topic.kind(), - "Could not publish partial message" - ); - } - } - - // add to metrics - if let Some(v) = metrics::get_int_gauge( - &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, - &[&format!("{:?}", topic.kind())], - ) { - v.inc() - }; + for message in messages { + match message { + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header, + } => self.publish_partial_data_column_fulu(column, request_cells, header), } } } + fn publish_partial_data_column_fulu( + &mut self, + column: Arc>, + request_cells: CellBitmap, + header: Arc>, + ) { + let subnet = DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); + let topic = GossipTopic::new( + GossipKind::DataColumnSidecar(subnet), + GossipEncoding::default(), + self.enr_fork_id.fork_digest, + ); + let header_sent_set = self + .partial_column_header_tracker + .get_for_block(column.block_root); + let partial_message = + OutgoingPartialColumn::new(column, &header, header_sent_set, request_cells); + let publish_topic: Topic = topic.clone().into(); + + if let Err(e) = self + .gossipsub_mut() + .publish_partial(publish_topic, partial_message) + { + match e { + PublishError::NoPeersSubscribedToTopic => { + debug!( + kind = %topic.kind(), + "No peers supporting partial messages" + ); + } + ref e => { + warn!( + error = ?e, + kind = %topic.kind(), + "Could not publish partial message" + ); + } + } + + // add to metrics + if let Some(v) = metrics::get_int_gauge( + &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, + &[&format!("{:?}", topic.kind())], + ) { + v.inc() + }; + } + } + /// Informs the gossipsub about the result of a message validation. /// If the message is valid it will get propagated by gossipsub. pub fn report_message_validation_result( diff --git a/beacon_node/lighthouse_network/src/types/mod.rs b/beacon_node/lighthouse_network/src/types/mod.rs index d0173e5b9a..8a20e9da94 100644 --- a/beacon_node/lighthouse_network/src/types/mod.rs +++ b/beacon_node/lighthouse_network/src/types/mod.rs @@ -16,7 +16,7 @@ pub use eth2::lighthouse::sync_state::{BackFillState, CustodyBackFillState, Sync pub use globals::NetworkGlobals; pub use partial::HeaderSentSet; pub use partial::OutgoingPartialColumn; -pub use pubsub::{PubsubMessage, SnappyTransform, decode_partial}; +pub use pubsub::{PubsubMessage, PubsubPartialMessage, SnappyTransform, decode_partial}; pub use subnet::{Subnet, SubnetDiscovery}; pub use topics::{ GossipEncoding, GossipKind, GossipTopic, TopicConfig, all_topics_at_fork, diff --git a/beacon_node/lighthouse_network/src/types/partial.rs b/beacon_node/lighthouse_network/src/types/partial.rs index 4b5dcd8ad6..0dd5c58ac6 100644 --- a/beacon_node/lighthouse_network/src/types/partial.rs +++ b/beacon_node/lighthouse_network/src/types/partial.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use tracing::{error, trace}; use types::core::{EthSpec, Hash256}; use types::data::{ - PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, PartialDataColumnSidecar, PartialDataColumnSidecarRef, }; @@ -30,10 +30,29 @@ impl OutgoingPartialColumn { partial_column: Arc>, header: &PartialDataColumnHeader, header_sent_set: HeaderSentSet, + requests: CellBitmap, ) -> Self { - // For now, always request all cells - let mut requests = partial_column.sidecar.cells_present_bitmap.clone_zeroed(); - requests.not_inplace(); + // Always set the request bit for available cells. + // + // Gossipsub applys certain optimisations to avoid sending redundant messages. This + // requires that we stay consistent with our metadata. Gossipsub uses the `Metadata` trait + // impl below to determine whether it can perform these optimisations. + // + // If we request a cell and then receive it, un-setting the request bit in the next + // published message may cause issues: + // Gossipsub tries to avoid the impact of application race conditions by checking newly + // published metadata against previously published metadata. This no longer functions + // correctly if request bits are unset between calls, as Gossipsub will consider a message + // with new requests as new info to be propagated, possibly overwriting previous messages + // with more cells (but fewer request bits). This is because gossipsub will see that both + // metadata have some bits that are not set in the other metadata and therefore cannot + // decide which actually carries more data. By always setting request bits for available + // cells, we avoid this issue, as requests will never be unset between calls. + // + // In other words, gossipsub relies on the fact that metadata is additive. The request bit + // is, therefore, to be seen as a "request if not available" bit. + let requests = requests.union(&partial_column.sidecar.cells_present_bitmap); + let metadata = PartialDataColumnPartsMetadata:: { available: partial_column.sidecar.cells_present_bitmap.clone(), requests, @@ -322,6 +341,14 @@ mod tests { }) } + fn make_all_one_bitmap(len: usize) -> CellBitmap { + let mut request_cells = CellBitmap::::with_capacity(len).unwrap(); + for idx in 0..request_cells.len() { + request_cells.set(idx, true).unwrap(); + } + request_cells + } + fn random_peer_id() -> PeerId { let keypair = Keypair::generate_ed25519(); PeerId::from(keypair.public()) @@ -422,7 +449,8 @@ mod tests { let header = make_header(4); let partial = make_partial_column(root, 4, &[0, 1]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); @@ -442,7 +470,8 @@ mod tests { // We have cells [0, 2, 3] let partial = make_partial_column(root, 4, &[0, 2, 3]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); @@ -474,7 +503,8 @@ mod tests { // We have cells [0] let partial = make_partial_column(root, 4, &[0]); let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); - let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + let requests = make_all_one_bitmap(4); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set, requests); let peer = random_peer_id(); diff --git a/beacon_node/lighthouse_network/src/types/pubsub.rs b/beacon_node/lighthouse_network/src/types/pubsub.rs index d486ca5129..00b2c42629 100644 --- a/beacon_node/lighthouse_network/src/types/pubsub.rs +++ b/beacon_node/lighthouse_network/src/types/pubsub.rs @@ -7,10 +7,10 @@ use ssz::{Decode, Encode}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use types::{ - AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, DataColumnSidecar, + AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, CellBitmap, DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, Hash256, LightClientFinalityUpdate, - LightClientOptimisticUpdate, PartialDataColumn, PartialDataColumnSidecar, - PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + LightClientOptimisticUpdate, PartialDataColumn, PartialDataColumnHeader, + PartialDataColumnSidecar, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, SignedAggregateAndProofBase, SignedAggregateAndProofElectra, SignedBeaconBlock, SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella, SignedBeaconBlockDeneb, SignedBeaconBlockElectra, @@ -56,6 +56,24 @@ pub enum PubsubMessage { LightClientOptimisticUpdate(Box>), } +/// A message published via the partial gossipsub protocol. +#[derive(Debug, Clone)] +pub enum PubsubPartialMessage { + /// A partial data column sidecar from the Fulu fork. + DataColumnFulu { + /// The column to publish. Libp2p will cache it and treat it as the data to send if any peer + /// asks for data within it. + column: Arc>, + /// The cells we are requesting. Usually, this will be all-ones, as we need all cells. + /// However, while get_blobs is still in progress, blobs we expect from the EL should not be + /// requested to conserve bandwidth. + request_cells: CellBitmap, + /// The header associated with the column above. This is set separately here, as the column + /// to be published does not contain the header - it is stored without. + header: Arc>, + }, +} + // Implements the `DataTransform` trait of gossipsub to employ snappy compression pub struct SnappyTransform { /// Sets the maximum size we allow gossipsub messages to decompress to. diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 20342c1aa9..346e621879 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -33,7 +33,7 @@ use beacon_chain::{ use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{ Client, GossipTopic, MessageAcceptance, MessageId, PeerAction, PeerId, PubsubMessage, - ReportSource, + PubsubPartialMessage, ReportSource, }; use logging::crit; use operation_pool::ReceivedPreCapella; @@ -937,9 +937,15 @@ impl NetworkBeaconProcessor { Ok(mut column) => { let header = column.sidecar.header.take(); if let Some(header) = header { + // Requesting cells is irrelevant as all cells are available, simply clone + // the `cells_present_bitmap`. + let request_cells = column.sidecar.cells_present_bitmap.clone(); self.send_network_message(NetworkMessage::PublishPartialColumns { - columns: vec![Arc::new(column)], - header: Arc::new(header), + messages: vec![PubsubPartialMessage::DataColumnFulu { + column: Arc::new(column), + request_cells, + header: Arc::new(header), + }], }); } else { crit!("Converting from full to partial yielded headerless partial") @@ -1077,8 +1083,10 @@ impl NetworkBeaconProcessor { debug!(block = %block_root, "Triggering getBlobs after receiving partial header"); // We want to publish immediately when this finishes let publish_blobs = true; - self.fetch_engine_blobs_and_publish(header.into_header(), block_root, publish_blobs) - .await + let header = header.into_header(); + self.fetch_engine_blobs_and_publish_full(header.clone(), block_root, publish_blobs) + .await; + self.publish_partial_data_columns(header, block_root).await; } } } @@ -1311,28 +1319,31 @@ impl NetworkBeaconProcessor { }); } - let only_send_completed_partials = - merge_result.local_blobs || self.chain.config.disable_get_blobs; - let columns = merge_result - .updated_partials - .into_iter() - .map(|partial| partial.into_inner()) - .filter(|partial| { - !only_send_completed_partials || partial.sidecar.is_complete() - }) - .collect::>(); - - if !columns.is_empty() { - if only_send_completed_partials { - debug!( - block = %block_root, - "Not publishing incomplete partials before getBlobs" - ); - } - self.send_network_message(NetworkMessage::PublishPartialColumns { - columns, - header: verified_header.into_header(), - }); + if !merge_result.updated_partials.is_empty() { + let header = verified_header.into_header(); + let messages = merge_result + .updated_partials + .into_iter() + .map(|partial| { + let column = partial.into_inner(); + let present_cells = &column.sidecar.cells_present_bitmap; + let request_cells = if merge_result.local_blobs { + // Request all cells that are not available locally. + let mut all_one = present_cells.clone_zeroed(); + all_one.not_inplace(); + all_one + } else { + // Do not request cells if we don't know the local blobs yet. + present_cells.clone_zeroed() + }; + PubsubPartialMessage::DataColumnFulu { + column, + request_cells, + header: header.clone(), + } + }) + .collect(); + self.send_network_message(NetworkMessage::PublishPartialColumns { messages }); } Ok(avail) } @@ -1803,8 +1814,16 @@ impl NetworkBeaconProcessor { self.executor.spawn( async move { if let Ok(header) = PartialDataColumnHeader::try_from(block_clone.as_ref()) { + let header = Arc::new(header); self_clone - .fetch_engine_blobs_and_publish(Arc::new(header), block_root, publish_blobs) + .fetch_engine_blobs_and_publish_full( + header.clone(), + block_root, + publish_blobs, + ) + .await; + self_clone + .publish_partial_data_columns(header, block_root) .await } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 7619f706cc..c5023ed5f4 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -22,9 +22,13 @@ use lighthouse_network::rpc::methods::{ use lighthouse_network::service::api_types::CustodyBackfillBatchId; use lighthouse_network::{ Client, GossipTopic, MessageId, NetworkConfig, NetworkGlobals, PeerId, PubsubMessage, + PubsubPartialMessage, rpc::{BlocksByRangeRequest, BlocksByRootRequest, LightClientBootstrapRequest, StatusMessage}, }; +use logging::crit; use rand::prelude::SliceRandom; +use ssz_types::VariableList; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -907,7 +911,7 @@ impl NetworkBeaconProcessor { }); } - pub async fn fetch_engine_blobs_and_publish( + pub async fn fetch_engine_blobs_and_publish_full( self: &Arc, header: Arc>, block_root: Hash256, @@ -931,7 +935,7 @@ impl NetworkBeaconProcessor { match fetch_and_process_engine_blobs( self.chain.clone(), block_root, - header.clone(), + header, custody_columns, publish_fn, ) @@ -975,44 +979,108 @@ impl NetworkBeaconProcessor { ); } } + } - // Publish partial columns without eager send - // TODO(gloas): implement publish partial columns without eager send - if let Some(assembler) = self.chain.data_availability_checker.partial_assembler() { - let columns = assembler.get_columns_and_mark_as_local_fetched(block_root, &header); + pub async fn publish_partial_data_columns( + self: &Arc, + header: Arc>, + block_root: Hash256, + ) { + if header.kzg_commitments.is_empty() { + return; + } + + // TODO(gloas): implement publish partial columns + let Some(assembler) = self.chain.data_availability_checker.partial_assembler() else { + // Partials are disabled. + return; + }; + let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); + let custody_columns = self.chain.sampling_columns_for_epoch(epoch); + let columns = assembler.get_columns_and_mark_as_local_fetched(block_root, &header); + + let mut present_indices: HashSet = HashSet::with_capacity(columns.len()); + let mut messages: Vec> = Vec::with_capacity(columns.len()); + for column in columns { // Republish both complete and incomplete columns as partials - let columns: Vec<_> = columns - .into_iter() - .filter_map(|column| match column { - AssemblyColumn::Incomplete(partial) => Some(partial.into_inner()), - AssemblyColumn::Complete(full) => { - let DataColumnSidecar::Fulu(fulu) = full.as_data_column() else { - return None; - }; - match fulu.to_partial() { - Ok(partial) => Some(Arc::new(partial)), - Err(err) => { - error!( - %block_root, - column_index = %full.index(), - ?err, - "Failed to convert complete column to partial for re-seeding" - ); - None - } + let partial_column = match column { + AssemblyColumn::Incomplete(partial) => partial.into_inner(), + AssemblyColumn::Complete(full) => { + let DataColumnSidecar::Fulu(fulu) = full.as_data_column() else { + continue; + }; + match fulu.to_partial() { + Ok(partial) => Arc::new(partial), + Err(err) => { + error!( + %block_root, + column_index = %full.index(), + ?err, + "Failed to convert complete column to partial for re-seeding" + ); + continue; } } - }) - .collect(); - if !columns.is_empty() { - debug!(block = %block_root, "Publishing all partials after getBlobs"); - self.send_network_message(NetworkMessage::PublishPartialColumns { - columns, - header, - }); - } else { - debug!(block = %block_root, "No partials to publish after getBlobs"); + } + }; + + present_indices.insert(partial_column.index); + let mut request_cells = partial_column.sidecar.cells_present_bitmap.clone_zeroed(); + request_cells.not_inplace(); + messages.push(PubsubPartialMessage::DataColumnFulu { + column: partial_column, + request_cells, + header: header.clone(), + }); + } + + // For each custody column without any local partial, send an empty placeholder + // that requests all cells. + let num_cells = header.kzg_commitments.len(); + for col_idx in custody_columns { + if present_indices.contains(col_idx) { + continue; } + // `kzg_commitments.len()` is bounded by `MaxBlobCommitmentsPerBlock`, so the + // bitmap constructor is infallible. + let Ok(cells_present_bitmap) = CellBitmap::::with_capacity(num_cells) + else { + crit!( + %block_root, + num_cells, + column_index = %col_idx, + "CellBitmap construction failed despite being bounded by MaxBlobCommitmentsPerBlock" + ); + continue; + }; + let request_cells = cells_present_bitmap.not(); + messages.push(PubsubPartialMessage::DataColumnFulu { + column: Arc::new(PartialDataColumn { + block_root, + index: *col_idx, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap, + column: VariableList::empty(), + kzg_proofs: VariableList::empty(), + header: None.into(), + }, + }), + request_cells, + header: header.clone(), + }); + } + + if !messages.is_empty() { + debug!( + block = %block_root, + count = messages.len(), + "Publishing all partials" + ); + self.send_network_message(NetworkMessage::PublishPartialColumns { messages }); + } else { + // This should not happen, as any custody columns will have at least an empty + // partial published. + warn!(block = %block_root, "No partials to publish"); } } diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index 35437e1a2e..b9e07743eb 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -222,7 +222,7 @@ impl NetworkBeaconProcessor { // to be sent from the peers if we already have them. if let Ok(header) = signed_beacon_block.as_ref().try_into() { let publish_blobs = false; - self.fetch_engine_blobs_and_publish( + self.fetch_engine_blobs_and_publish_full( Arc::new(header), block_root, publish_blobs, diff --git a/beacon_node/network/src/service.rs b/beacon_node/network/src/service.rs index c2e79fe9e8..ba4aada352 100644 --- a/beacon_node/network/src/service.rs +++ b/beacon_node/network/src/service.rs @@ -19,7 +19,7 @@ use lighthouse_network::rpc::methods::RpcResponse; use lighthouse_network::service::Network; use lighthouse_network::types::GossipKind; use lighthouse_network::{ - Context, PeerAction, PubsubMessage, ReportSource, Response, Subnet, + Context, PeerAction, PubsubMessage, PubsubPartialMessage, ReportSource, Response, Subnet, rpc::{GoodbyeReason, RpcErrorResponse}, }; use lighthouse_network::{MessageAcceptance, prometheus_client::registry::Registry}; @@ -39,8 +39,8 @@ use tokio::time::Sleep; use tracing::{debug, error, info, trace, warn}; use typenum::Unsigned; use types::{ - EthSpec, ForkContext, PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, - SyncCommitteeSubscription, SyncSubnetId, ValidatorSubscription, + EthSpec, ForkContext, Slot, SubnetId, SyncCommitteeSubscription, SyncSubnetId, + ValidatorSubscription, }; mod tests; @@ -85,8 +85,7 @@ pub enum NetworkMessage { Publish { messages: Vec> }, /// Publish partial data column sidecars via the partial gossipsub protocol. PublishPartialColumns { - columns: Vec>>, - header: Arc>, + messages: Vec>, }, /// Validates a received gossipsub message. This will propagate the message on the network. ValidationResult { @@ -683,8 +682,8 @@ impl NetworkService { ); self.libp2p.publish(messages); } - NetworkMessage::PublishPartialColumns { columns, header } => { - self.libp2p.publish_partial(columns, header); + NetworkMessage::PublishPartialColumns { messages } => { + self.libp2p.publish_partial(messages); } NetworkMessage::ReportPeer { peer_id, From 477c25db9f39bef5b3afb29643a257a8aa5d249b Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sat, 20 Jun 2026 12:41:30 -0700 Subject: [PATCH 14/26] Fix race condition between validator duties service and proposer preferences (#9309) The proposer preferences service was attempting to publish preferences at the start of each epoch. This caused it to race with the validator duties service, it wouldn't calculate validator duties in time for the proposer preference service. This PR first updates the validator duties service to calculate proposer duties for the current epoch and the next epoch. After Fulu we have the ability to look ahead one epoch for proposer duties, but we never updated the vc to leverage this feature. This PR also updates the proposer preferences service to fire at every slot. We have an `(Epoch, DependentRoot)` map that prevents us from publishing the same preferences twice. The changes here should prevent the race condition between the two services and make the proposer preferences service more robust in general. Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul --- book/src/help_vc.md | 5 + lighthouse/tests/validator_client.rs | 15 +++ validator_client/src/cli.rs | 11 ++ validator_client/src/config.rs | 4 + validator_client/src/lib.rs | 1 + .../validator_services/src/duties_service.rs | 127 +++++++++++------- .../src/proposer_preferences_service.rs | 99 +++++++++----- 7 files changed, 182 insertions(+), 80 deletions(-) diff --git a/book/src/help_vc.md b/book/src/help_vc.md index f1a342197c..719b02a5a5 100644 --- a/book/src/help_vc.md +++ b/book/src/help_vc.md @@ -200,6 +200,11 @@ Flags: If present, do not configure the system allocator. Providing this flag will generally increase memory usage, it should only be provided when debugging specific memory allocation issues. + --disable-proposer-duties-v2 + Fetch proposer duties using the v1 beacon node endpoint instead of v2. + The v1 endpoint reports an incorrect dependent root which causes + spurious proposer duty re-org warnings. Only enable this flag if your + beacon node does not serve the v2 proposer duties endpoint. --disable-slashing-protection-web3signer Disable Lighthouse's slashing protection for all web3signer keys. This can reduce the I/O burden on the VC but is only safe if slashing diff --git a/lighthouse/tests/validator_client.rs b/lighthouse/tests/validator_client.rs index 945e363ab5..2cbf2aaef0 100644 --- a/lighthouse/tests/validator_client.rs +++ b/lighthouse/tests/validator_client.rs @@ -130,6 +130,21 @@ fn disable_auto_discover_flag() { .with_config(|config| assert!(config.disable_auto_discover)); } +#[test] +fn disable_proposer_duties_v2_default() { + CommandLineTest::new() + .run() + .with_config(|config| assert!(!config.disable_proposer_duties_v2)); +} + +#[test] +fn disable_proposer_duties_v2_flag() { + CommandLineTest::new() + .flag("disable-proposer-duties-v2", None) + .run() + .with_config(|config| assert!(config.disable_proposer_duties_v2)); +} + #[test] fn init_slashing_protections_flag() { CommandLineTest::new() diff --git a/validator_client/src/cli.rs b/validator_client/src/cli.rs index e5fe1580da..cf21e276d7 100644 --- a/validator_client/src/cli.rs +++ b/validator_client/src/cli.rs @@ -105,6 +105,17 @@ pub struct ValidatorClient { )] pub disable_attesting: bool, + #[clap( + long, + help = "Fetch proposer duties using the v1 beacon node endpoint instead of v2. The v1 \ + endpoint reports an incorrect dependent root which causes spurious proposer duty \ + re-org warnings. Only enable this flag if your beacon node does not serve the v2 \ + proposer duties endpoint.", + display_order = 0, + help_heading = FLAG_HEADER + )] + pub disable_proposer_duties_v2: bool, + #[clap( long, help = "If present, the validator client will use longer timeouts for requests \ diff --git a/validator_client/src/config.rs b/validator_client/src/config.rs index 418cd385da..3e5abebc68 100644 --- a/validator_client/src/config.rs +++ b/validator_client/src/config.rs @@ -92,6 +92,8 @@ pub struct Config { #[serde(flatten)] pub initialized_validators: InitializedValidatorsConfig, pub disable_attesting: bool, + /// Fetch proposer duties using the v1 endpoint instead of v2. + pub disable_proposer_duties_v2: bool, } impl Default for Config { @@ -139,6 +141,7 @@ impl Default for Config { distributed: false, initialized_validators: <_>::default(), disable_attesting: false, + disable_proposer_duties_v2: false, } } } @@ -402,6 +405,7 @@ impl Config { }; config.disable_attesting = validator_client_config.disable_attesting; + config.disable_proposer_duties_v2 = validator_client_config.disable_proposer_duties_v2; Ok(config) } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 71d9333493..9680189b1a 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -502,6 +502,7 @@ impl ProductionValidatorClient { .attestation_selection_proof_config(attestation_selection_proof_config) .sync_selection_proof_config(sync_selection_proof_config) .disable_attesting(config.disable_attesting) + .disable_proposer_duties_v2(config.disable_proposer_duties_v2) .build()?, ); diff --git a/validator_client/validator_services/src/duties_service.rs b/validator_client/validator_services/src/duties_service.rs index 2a371abf62..5fe413a216 100644 --- a/validator_client/validator_services/src/duties_service.rs +++ b/validator_client/validator_services/src/duties_service.rs @@ -305,6 +305,7 @@ pub struct DutiesServiceBuilder { /// Create sync selection proof config sync_selection_proof_config: SelectionProofConfig, disable_attesting: bool, + disable_proposer_duties_v2: bool, } impl Default for DutiesServiceBuilder { @@ -325,6 +326,7 @@ impl DutiesServiceBuilder { attestation_selection_proof_config: SelectionProofConfig::default(), sync_selection_proof_config: SelectionProofConfig::default(), disable_attesting: false, + disable_proposer_duties_v2: false, } } @@ -382,6 +384,11 @@ impl DutiesServiceBuilder { self } + pub fn disable_proposer_duties_v2(mut self, disable_proposer_duties_v2: bool) -> Self { + self.disable_proposer_duties_v2 = disable_proposer_duties_v2; + self + } + pub fn build(self) -> Result, String> { Ok(DutiesService { attesters: Default::default(), @@ -405,6 +412,7 @@ impl DutiesServiceBuilder { enable_high_validator_count_metrics: self.enable_high_validator_count_metrics, selection_proof_config: self.attestation_selection_proof_config, disable_attesting: self.disable_attesting, + disable_proposer_duties_v2: self.disable_proposer_duties_v2, }) } } @@ -437,6 +445,11 @@ pub struct DutiesService { /// Pass the config for distributed or non-distributed mode. pub selection_proof_config: SelectionProofConfig, pub disable_attesting: bool, + /// Use the v1 proposer duties endpoint instead of v2. The v1 endpoint reports an incorrect + /// dependent root, causing spurious "Proposer duties re-org" warnings. This flag exists for + /// compatibility with beacon nodes that do not yet serve the v2 endpoint and can be removed + /// after Gloas. + pub disable_proposer_duties_v2: bool, } impl DutiesService { @@ -1660,54 +1673,8 @@ async fn poll_beacon_proposers( // Only download duties and push out additional block production events if we have some // validators. if !local_pubkeys.is_empty() { - let download_result = duties_service - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::DUTIES_SERVICE_TIMES, - &[validator_metrics::PROPOSER_DUTIES_HTTP_GET], - ); - beacon_node - .get_validator_duties_proposer(current_epoch) - .await - }) - .await; - - match download_result { - Ok(response) => { - let dependent_root = response.dependent_root; - - let relevant_duties = response - .data - .into_iter() - .filter(|proposer_duty| local_pubkeys.contains(&proposer_duty.pubkey)) - .collect::>(); - - debug!( - %dependent_root, - num_relevant_duties = relevant_duties.len(), - "Downloaded proposer duties" - ); - - if let Some((prior_dependent_root, _)) = duties_service - .proposers - .write() - .insert(current_epoch, (dependent_root, relevant_duties)) - && dependent_root != prior_dependent_root - { - warn!( - %prior_dependent_root, - %dependent_root, - msg = "this may happen from time to time", - "Proposer duties re-org" - ) - } - } - // Don't return early here, we still want to try and produce blocks using the cached values. - Err(e) => error!( - err = %e, - "Failed to download proposer duties" - ), + for epoch in [current_epoch, current_epoch + 1] { + fetch_and_store_proposer_duties(duties_service, epoch, &local_pubkeys).await; } // Compute the block proposers for this slot again, now that we've received an update from @@ -1750,6 +1717,70 @@ async fn poll_beacon_proposers( Ok(()) } +async fn fetch_and_store_proposer_duties( + duties_service: &DutiesService, + epoch: Epoch, + local_pubkeys: &HashSet, +) { + let use_v2 = !duties_service.disable_proposer_duties_v2; + let download_result = duties_service + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::PROPOSER_DUTIES_HTTP_GET], + ); + // Prefer the v2 endpoint, which reports the correct dependent root. The v1 endpoint + // returns an incorrect dependent root, leading to spurious "Proposer duties re-org" + // warnings. + if use_v2 { + beacon_node.get_validator_duties_proposer_v2(epoch).await + } else { + beacon_node.get_validator_duties_proposer(epoch).await + } + }) + .await; + + match download_result { + Ok(response) => { + let dependent_root = response.dependent_root; + + let relevant_duties = response + .data + .into_iter() + .filter(|proposer_duty| local_pubkeys.contains(&proposer_duty.pubkey)) + .collect::>(); + + debug!( + %dependent_root, + %epoch, + num_relevant_duties = relevant_duties.len(), + "Downloaded proposer duties" + ); + + if let Some((prior_dependent_root, _)) = duties_service + .proposers + .write() + .insert(epoch, (dependent_root, relevant_duties)) + && dependent_root != prior_dependent_root + { + warn!( + %prior_dependent_root, + %dependent_root, + %epoch, + msg = "this may happen from time to time", + "Proposer duties re-org" + ) + } + } + Err(e) => error!( + err = %e, + %epoch, + "Failed to download proposer duties" + ), + } +} + /// Query the beacon node for ptc duties for any known validators. async fn poll_beacon_ptc_attesters( duties_service: &Arc>, diff --git a/validator_client/validator_services/src/proposer_preferences_service.rs b/validator_client/validator_services/src/proposer_preferences_service.rs index 5d5c40a6cd..330517482e 100644 --- a/validator_client/validator_services/src/proposer_preferences_service.rs +++ b/validator_client/validator_services/src/proposer_preferences_service.rs @@ -1,12 +1,14 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::BeaconNodeFallback; +use eth2::types::ProposerData; use slot_clock::SlotClock; +use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; use task_executor::TaskExecutor; use tokio::time::sleep; use tracing::{debug, error, info, warn}; -use types::{ChainSpec, Epoch, EthSpec, ForkName, ProposerPreferences}; +use types::{ChainSpec, Epoch, EthSpec, ForkName, Hash256, ProposerPreferences}; use validator_store::ValidatorStore; pub struct Inner { @@ -66,6 +68,8 @@ impl ProposerPreferencesSer let executor = self.executor.clone(); let interval_fut = async move { + let mut published_preferences: HashMap = HashMap::new(); + loop { let Some(current_slot) = self.slot_clock.now() else { error!("Failed to read slot clock"); @@ -73,29 +77,16 @@ impl ProposerPreferencesSer continue; }; - if !self - .chain_spec - .fork_name_at_slot::(current_slot) - .gloas_enabled() - { - let duration_to_next_epoch = self - .slot_clock - .duration_to_next_epoch(S::E::slots_per_epoch()) - .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); - sleep(duration_to_next_epoch).await; - continue; - } - let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); - let fork_name = self.chain_spec.fork_name_at_slot::(current_slot); - self.publish_proposer_preferences(current_epoch, fork_name) + + self.poll_and_publish_preferences(current_epoch, &mut published_preferences) .await; - let duration_to_next_epoch = self + let duration_to_next_slot = self .slot_clock - .duration_to_next_epoch(S::E::slots_per_epoch()) - .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); - sleep(duration_to_next_epoch).await; + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; } }; @@ -103,15 +94,57 @@ impl ProposerPreferencesSer Ok(()) } - async fn publish_proposer_preferences(&self, current_epoch: Epoch, fork_name: ForkName) { - let (dependent_root, duties) = { - let proposers = self.duties_service.proposers.read(); - match proposers.get(¤t_epoch) { - Some((root, duties)) => (*root, duties.clone()), - None => return, + /// Publish proposer preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish preferences for a given epoch once per dependent root. + async fn poll_and_publish_preferences( + &self, + current_epoch: Epoch, + published_preferences: &mut HashMap, + ) { + for (epoch, fork_name) in [ + ( + current_epoch, + self.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; } - }; + let (dependent_root, duties) = { + let proposers = self.duties_service.proposers.read(); + match proposers.get(&epoch) { + Some((root, duties)) => (*root, duties.clone()), + None => continue, + } + }; + + if published_preferences.get(&epoch) == Some(&dependent_root) { + continue; + } + + if self + .publish_proposer_preferences(epoch, fork_name, dependent_root, duties) + .await + { + published_preferences.insert(epoch, dependent_root); + } + } + + published_preferences.retain(|epoch, _| *epoch >= current_epoch); + } + + async fn publish_proposer_preferences( + &self, + epoch: Epoch, + fork_name: ForkName, + dependent_root: Hash256, + duties: Vec, + ) -> bool { let preferences_to_sign: Vec<_> = { let mut result = vec![]; for duty in &duties { @@ -144,11 +177,11 @@ impl ProposerPreferencesSer }; if preferences_to_sign.is_empty() { - return; + return false; } debug!( - %current_epoch, + %epoch, count = preferences_to_sign.len(), "Signing proposer preferences" ); @@ -172,7 +205,7 @@ impl ProposerPreferencesSer } if signed.is_empty() { - return; + return false; } let count = signed.len(); @@ -213,17 +246,19 @@ impl ProposerPreferencesSer match result { Ok(()) => { info!( - %current_epoch, + %epoch, %count, "Successfully published proposer preferences" ); + true } Err(e) => { error!( error = %e, - %current_epoch, + %epoch, "Failed to publish proposer preferences" ); + false } } } From 10568b139b3f2fc02c3dab2f8de5165349c97b88 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sat, 20 Jun 2026 12:41:34 -0700 Subject: [PATCH 15/26] Add proposer preferences SSE event (#9308) This is needed to connect to buildoor (Kurtosis package, acts as a trustless builder) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/events.rs | 15 ++++ .../gossip_verified_proposer_preferences.rs | 14 ++++ beacon_node/beacon_chain/tests/events.rs | 83 ++++++++++++++++++- beacon_node/http_api/src/lib.rs | 3 + common/eth2/src/types.rs | 11 +++ .../types/src/builder/proposer_preferences.rs | 22 +++++ 6 files changed, 145 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/events.rs b/beacon_node/beacon_chain/src/events.rs index 80667cd399..dd08c59c76 100644 --- a/beacon_node/beacon_chain/src/events.rs +++ b/beacon_node/beacon_chain/src/events.rs @@ -29,6 +29,7 @@ pub struct ServerSentEventHandler { execution_payload_gossip_tx: Sender>, execution_payload_available_tx: Sender>, execution_payload_bid_tx: Sender>, + proposer_preferences_tx: Sender>, payload_attestation_message_tx: Sender>, } @@ -60,6 +61,7 @@ impl ServerSentEventHandler { let (execution_payload_gossip_tx, _) = broadcast::channel(capacity); let (execution_payload_available_tx, _) = broadcast::channel(capacity); let (execution_payload_bid_tx, _) = broadcast::channel(capacity); + let (proposer_preferences_tx, _) = broadcast::channel(capacity); let (payload_attestation_message_tx, _) = broadcast::channel(capacity); Self { @@ -85,6 +87,7 @@ impl ServerSentEventHandler { execution_payload_gossip_tx, execution_payload_available_tx, execution_payload_bid_tx, + proposer_preferences_tx, payload_attestation_message_tx, } } @@ -186,6 +189,10 @@ impl ServerSentEventHandler { .execution_payload_bid_tx .send(kind) .map(|count| log_count("execution payload bid", count)), + EventKind::ProposerPreferences(_) => self + .proposer_preferences_tx + .send(kind) + .map(|count| log_count("proposer preferences", count)), EventKind::PayloadAttestationMessage(_) => self .payload_attestation_message_tx .send(kind) @@ -284,6 +291,10 @@ impl ServerSentEventHandler { self.execution_payload_bid_tx.subscribe() } + pub fn subscribe_proposer_preferences(&self) -> Receiver> { + self.proposer_preferences_tx.subscribe() + } + pub fn subscribe_payload_attestation_message(&self) -> Receiver> { self.payload_attestation_message_tx.subscribe() } @@ -368,6 +379,10 @@ impl ServerSentEventHandler { self.execution_payload_bid_tx.receiver_count() > 0 } + pub fn has_proposer_preferences_subscribers(&self) -> bool { + self.proposer_preferences_tx.receiver_count() > 0 + } + pub fn has_payload_attestation_message_subscribers(&self) -> bool { self.payload_attestation_message_tx.receiver_count() > 0 } diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index 586721d8c1..4dc1646ec4 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -6,6 +6,7 @@ use crate::{ ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache, }, }; +use eth2::types::{EventKind, ForkVersionedResponse}; use slot_clock::SlotClock; use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set}; use tracing::debug; @@ -145,6 +146,19 @@ impl BeaconChain { %validator_index, "Successfully verified gossip proposer preferences" ); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_proposer_preferences_subscribers() + { + event_handler.register(EventKind::ProposerPreferences(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::(proposal_slot), + metadata: Default::default(), + data: (*verified.signed_preferences).clone(), + }, + ))); + } + Ok(verified) } Err(e) => { diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index baa6975303..9f0b3675f3 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -7,9 +7,9 @@ use eth2::types::{EventKind, SseBlobSidecar, SseDataColumnSidecar}; use std::sync::Arc; use types::data::FixedBlobSidecarList; use types::{ - BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, - MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedExecutionPayloadBid, - SignedRoot, Slot, + Address, BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, + EthSpec, Hash256, MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, + ProposerPreferences, SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, }; type E = MinimalEthSpec; @@ -401,3 +401,80 @@ async fn payload_attestation_message_event_on_gossip_verification() { panic!("Expected PayloadAttestationMessage event, got {:?}", event); } } + +/// Verifies that a `proposer_preferences` SSE event is emitted when signed proposer preferences +/// pass gossip verification. +#[tokio::test] +async fn proposer_preferences_event_on_gossip_verification() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let head = harness.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + let genesis_validators_root = harness.chain.genesis_validators_root; + + // Pick a proposal slot in the next epoch so it is always a valid, future slot. The lookahead + // covers 2 epochs: index = epoch_offset * slots_per_epoch + slot_in_epoch. + let slots_per_epoch = E::slots_per_epoch() as usize; + let proposer_lookahead = head_state + .proposer_lookahead() + .expect("gloas state should have proposer lookahead"); + let next_epoch_start = (head_state.current_epoch() + 1).start_slot(E::slots_per_epoch()); + let proposal_slot = next_epoch_start + 1; + let lookahead_index = slots_per_epoch + 1; + let validator_index = *proposer_lookahead + .get(lookahead_index) + .expect("lookahead index should be in range"); + + // Build and sign proposer preferences for the proposer of `proposal_slot`. + let preferences = ProposerPreferences { + dependent_root: Hash256::ZERO, + proposal_slot, + validator_index, + fee_recipient: Address::repeat_byte(0xaa), + target_gas_limit: 30_000_000, + }; + let domain = harness.spec.get_domain( + proposal_slot.epoch(E::slots_per_epoch()), + Domain::ProposerPreferences, + &head_state.fork(), + genesis_validators_root, + ); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(preferences.signing_root(domain)); + let signed = SignedProposerPreferences { + message: preferences.clone(), + signature, + }; + + // Subscribe before verification. + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut receiver = event_handler.subscribe_proposer_preferences(); + + // Verify the preferences through the gossip path. + harness + .chain + .verify_proposer_preferences_for_gossip(Arc::new(signed)) + .expect("verification should succeed"); + + // Assert the event was emitted with the expected data. + let event = receiver.try_recv().expect("should receive event"); + if let EventKind::ProposerPreferences(versioned) = event { + assert_eq!(versioned.data.message, preferences); + assert_eq!( + versioned.version, + harness.spec.fork_name_at_slot::(proposal_slot) + ); + } else { + panic!("Expected ProposerPreferences event, got {:?}", event); + } +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 94f2e3f1df..7c0959acb9 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3273,6 +3273,9 @@ pub fn serve( api_types::EventTopic::ExecutionPayloadBid => { event_handler.subscribe_execution_payload_bid() } + api_types::EventTopic::ProposerPreferences => { + event_handler.subscribe_proposer_preferences() + } api_types::EventTopic::PayloadAttestationMessage => { event_handler.subscribe_payload_attestation_message() } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 049ffba657..4d0bb48f54 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1164,6 +1164,7 @@ pub struct SseExtendedPayloadAttributesGeneric { pub type SseExtendedPayloadAttributes = SseExtendedPayloadAttributesGeneric; pub type VersionedSsePayloadAttributes = ForkVersionedResponse; pub type VersionedSseExecutionPayloadBid = ForkVersionedResponse>; +pub type VersionedSseProposerPreferences = ForkVersionedResponse; pub type VersionedSsePayloadAttestationMessage = ForkVersionedResponse; impl<'de> ContextDeserialize<'de, ForkName> for SsePayloadAttributes { @@ -1245,6 +1246,7 @@ pub enum EventKind { ExecutionPayloadGossip(SseExecutionPayloadGossip), ExecutionPayloadAvailable(SseExecutionPayloadAvailable), ExecutionPayloadBid(Box>), + ProposerPreferences(Box), PayloadAttestationMessage(Box), } @@ -1273,6 +1275,7 @@ impl EventKind { EventKind::ExecutionPayloadGossip(_) => "execution_payload_gossip", EventKind::ExecutionPayloadAvailable(_) => "execution_payload_available", EventKind::ExecutionPayloadBid(_) => "execution_payload_bid", + EventKind::ProposerPreferences(_) => "proposer_preferences", EventKind::PayloadAttestationMessage(_) => "payload_attestation_message", } } @@ -1389,6 +1392,11 @@ impl EventKind { ServerError::InvalidServerSentEvent(format!("Execution Payload Bid: {:?}", e)) })?, ))), + "proposer_preferences" => Ok(EventKind::ProposerPreferences(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Proposer Preferences: {:?}", e)) + })?, + ))), "payload_attestation_message" => Ok(EventKind::PayloadAttestationMessage(Box::new( serde_json::from_str(data).map_err(|e| { ServerError::InvalidServerSentEvent(format!( @@ -1436,6 +1444,7 @@ pub enum EventTopic { ExecutionPayloadGossip, ExecutionPayloadAvailable, ExecutionPayloadBid, + ProposerPreferences, PayloadAttestationMessage, } @@ -1466,6 +1475,7 @@ impl FromStr for EventTopic { "execution_payload_gossip" => Ok(EventTopic::ExecutionPayloadGossip), "execution_payload_available" => Ok(EventTopic::ExecutionPayloadAvailable), "execution_payload_bid" => Ok(EventTopic::ExecutionPayloadBid), + "proposer_preferences" => Ok(EventTopic::ProposerPreferences), "payload_attestation_message" => Ok(EventTopic::PayloadAttestationMessage), _ => Err("event topic cannot be parsed.".to_string()), } @@ -1499,6 +1509,7 @@ impl fmt::Display for EventTopic { write!(f, "execution_payload_available") } EventTopic::ExecutionPayloadBid => write!(f, "execution_payload_bid"), + EventTopic::ProposerPreferences => write!(f, "proposer_preferences"), EventTopic::PayloadAttestationMessage => { write!(f, "payload_attestation_message") } diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index 4f27020105..97ba980d6c 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -14,8 +14,10 @@ use tree_hash_derive::TreeHash; pub struct ProposerPreferences { pub dependent_root: Hash256, pub proposal_slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] pub validator_index: u64, pub fee_recipient: Address, + #[serde(with = "serde_utils::quoted_u64")] pub target_gas_limit: u64, } @@ -45,4 +47,24 @@ mod tests { use super::*; ssz_and_tree_hash_tests!(ProposerPreferences); + + /// `validator_index` and `target_gas_limit` must serialize as quoted JSON strings (Beacon API + /// convention) and round-trip back to their numeric values. + #[test] + fn quoted_u64_json_serde() { + let preferences = ProposerPreferences { + dependent_root: Hash256::ZERO, + proposal_slot: Slot::new(7), + validator_index: 42, + fee_recipient: Address::ZERO, + target_gas_limit: 30_000_000, + }; + + let value = serde_json::to_value(&preferences).unwrap(); + assert_eq!(value["validator_index"], serde_json::json!("42")); + assert_eq!(value["target_gas_limit"], serde_json::json!("30000000")); + + let decoded: ProposerPreferences = serde_json::from_value(value).unwrap(); + assert_eq!(decoded, preferences); + } } From b05badb5f2e3aa91bc28fe3ce80fcf4d5cb665ea Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:34:29 +0200 Subject: [PATCH 16/26] Gate sync peer selection on per-protocol concurrent-request limit (#9456) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/lighthouse_network/src/rpc/mod.rs | 2 +- .../network/src/sync/network_context.rs | 119 ++++++++---------- .../src/sync/network_context/custody.rs | 15 ++- 3 files changed, 59 insertions(+), 77 deletions(-) diff --git a/beacon_node/lighthouse_network/src/rpc/mod.rs b/beacon_node/lighthouse_network/src/rpc/mod.rs index 7c43018af8..e9177586cb 100644 --- a/beacon_node/lighthouse_network/src/rpc/mod.rs +++ b/beacon_node/lighthouse_network/src/rpc/mod.rs @@ -46,7 +46,7 @@ mod response_limiter; mod self_limiter; // Maximum number of concurrent requests per protocol ID that a client may issue. -const MAX_CONCURRENT_REQUESTS: usize = 2; +pub const MAX_CONCURRENT_REQUESTS: usize = 2; /// Composite trait for a request id. pub trait ReqId: Send + 'static + std::fmt::Debug + Copy + Clone {} diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index d2ced9fd9d..0db172a21a 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -27,7 +27,9 @@ use fnv::FnvHashMap; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, DataColumnsByRangeRequest, PayloadEnvelopesByRangeRequest, }; -use lighthouse_network::rpc::{BlocksByRangeRequest, GoodbyeReason, RPCError, RequestType}; +use lighthouse_network::rpc::{ + BlocksByRangeRequest, GoodbyeReason, MAX_CONCURRENT_REQUESTS, RPCError, RequestType, +}; pub use lighthouse_network::service::api_types::RangeRequestId; use lighthouse_network::service::api_types::{ AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, @@ -40,8 +42,8 @@ use lighthouse_network::{Client, NetworkGlobals, PeerAction, PeerId, ReportSourc use parking_lot::RwLock; pub use requests::LookupVerifyError; use requests::{ - ActiveRequests, BlobsByRangeRequestItems, BlocksByRangeRequestItems, BlocksByRootRequestItems, - DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, + ActiveRequestItems, ActiveRequests, BlobsByRangeRequestItems, BlocksByRangeRequestItems, + BlocksByRootRequestItems, DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, PayloadEnvelopesByRangeRequestItems, PayloadEnvelopesByRootRequestItems, }; #[cfg(test)] @@ -100,6 +102,30 @@ pub type RpcResponseResult = Result<(T, Duration), RpcResponseError>; pub type CustodyByRootResult = Result>, RpcResponseError>; +/// Per-peer count of active requests for a single protocol, to keep peer selection within +/// `MAX_CONCURRENT_REQUESTS` concurrent requests per protocol ID. +struct ActiveRequestsPerPeer { + count_by_peer: HashMap, +} + +impl ActiveRequestsPerPeer { + fn new(requests: &ActiveRequests) -> Self + where + K: Copy + Eq + std::hash::Hash + std::fmt::Display, + T: ActiveRequestItems, + { + let mut count_by_peer = HashMap::::new(); + for peer_id in requests.iter_request_peers() { + *count_by_peer.entry(peer_id).or_default() += 1; + } + Self { count_by_peer } + } + + fn at_concurrency_limit(&self, peer_id: &PeerId) -> bool { + self.count_by_peer.get(peer_id).copied().unwrap_or(0) >= MAX_CONCURRENT_REQUESTS + } +} + #[derive(Debug)] #[allow(private_interfaces)] pub enum RpcResponseError { @@ -440,47 +466,6 @@ impl SyncNetworkContext { } } - fn active_request_count_by_peer(&self) -> HashMap { - let Self { - network_send: _, - request_id: _, - blocks_by_root_requests, - payload_envelopes_by_root_requests, - data_columns_by_root_requests, - blocks_by_range_requests, - blobs_by_range_requests, - data_columns_by_range_requests, - payload_envelopes_by_range_requests, - // custody_by_root_requests is a meta request of data_columns_by_root_requests - custody_by_root_requests: _, - // components_by_range_requests is a meta request of various _by_range requests - components_by_range_requests: _, - custody_backfill_data_column_batch_requests: _, - execution_engine_state: _, - network_beacon_processor: _, - chain: _, - fork_context: _, - // Don't use a fallback match. We want to be sure that all requests are considered when - // adding new ones - } = self; - - let mut active_request_count_by_peer = HashMap::::new(); - - for peer_id in blocks_by_root_requests - .iter_request_peers() - .chain(payload_envelopes_by_root_requests.iter_request_peers()) - .chain(data_columns_by_root_requests.iter_request_peers()) - .chain(blocks_by_range_requests.iter_request_peers()) - .chain(blobs_by_range_requests.iter_request_peers()) - .chain(data_columns_by_range_requests.iter_request_peers()) - .chain(payload_envelopes_by_range_requests.iter_request_peers()) - { - *active_request_count_by_peer.entry(peer_id).or_default() += 1; - } - - active_request_count_by_peer - } - /// Retries only the specified failed columns by requesting them again. /// /// Note: This function doesn't retry the whole batch, but retries specific requests within @@ -507,8 +492,6 @@ impl SyncNetworkContext { return Err("request id not present".to_string()); }; - let active_request_count_by_peer = self.active_request_count_by_peer(); - debug!( ?failed_columns, ?id, @@ -518,12 +501,7 @@ impl SyncNetworkContext { // Attempt to find all required custody peers to request the failed columns from let columns_by_range_peers_to_request = self - .select_columns_by_range_peers_to_request( - failed_columns, - peers, - active_request_count_by_peer, - peers_to_deprioritize, - ) + .select_columns_by_range_peers_to_request(failed_columns, peers, peers_to_deprioritize) .map_err(|e| format!("{:?}", e))?; // Reuse the id for the request that received partially correct responses @@ -581,7 +559,7 @@ impl SyncNetworkContext { column_peers = column_peers.len() ); let _guard = range_request_span.clone().entered(); - let active_request_count_by_peer = self.active_request_count_by_peer(); + let blocks_by_range_per_peer = ActiveRequestsPerPeer::new(&self.blocks_by_range_requests); let Some(block_peer) = block_peers .iter() @@ -589,8 +567,8 @@ impl SyncNetworkContext { ( // If contains -> 1 (order after), not contains -> 0 (order first) peers_to_deprioritize.contains(peer), - // Prefer peers with less overall requests - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + blocks_by_range_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -620,7 +598,6 @@ impl SyncNetworkContext { Some(self.select_columns_by_range_peers_to_request( &column_indexes, column_peers, - active_request_count_by_peer, peers_to_deprioritize, )?) } else { @@ -692,6 +669,9 @@ impl SyncNetworkContext { let payloads_req_id = if matches!(batch_type, ByRangeRequestType::BlocksAndEnvelopesAndColumns) { Some(self.send_payload_envelopes_by_range_request( + // Peer selection: for a given peer, the count of sent blocks_by_range requests + // equals the count of sent payloads_by_range requests. So we are under the + // concurrency limit for payloads_by_range requests block_peer, PayloadEnvelopesByRangeRequest { start_slot: *request.start_slot(), @@ -731,10 +711,11 @@ impl SyncNetworkContext { &self, custody_indexes: &HashSet, peers: &HashSet, - active_request_count_by_peer: HashMap, peers_to_deprioritize: &HashSet, ) -> Result>, RpcRequestSendError> { let mut columns_to_request_by_peer = HashMap::>::new(); + let data_columns_by_range_per_peer = + ActiveRequestsPerPeer::new(&self.data_columns_by_range_requests); for column_index in custody_indexes { // Strictly consider peers that are custodials of this column AND are part of this @@ -750,12 +731,10 @@ impl SyncNetworkContext { ( // If contains -> 1 (order after), not contains -> 0 (order first) peers_to_deprioritize.contains(peer), - // Prefer peers with less overall requests - // Also account for requests that are not yet issued tracked in peer_id_to_request_map - // We batch requests to the same peer, so count existance in the - // `columns_to_request_by_peer` as a single 1 request. - active_request_count_by_peer.get(peer).copied().unwrap_or(0) - + columns_to_request_by_peer.get(peer).map(|_| 1).unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + // Note: do not account for to-be-sent requests on + // `data_columns_by_range_by_peer` as we always send at most one request + data_columns_by_range_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -881,14 +860,14 @@ impl SyncNetworkContext { lookup_peers: Arc>>, block_root: Hash256, ) -> Result>>, RpcRequestSendError> { - let active_request_count_by_peer = self.active_request_count_by_peer(); + let blocks_by_root_per_peer = ActiveRequestsPerPeer::new(&self.blocks_by_root_requests); let Some(peer_id) = lookup_peers .read() .iter() .map(|peer| { ( - // Prefer peers with less overall requests - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + blocks_by_root_per_peer.at_concurrency_limit(peer), // Random factor to break ties, otherwise the PeerID breaks ties rand::random::(), peer, @@ -1001,13 +980,15 @@ impl SyncNetworkContext { )); } - let active_request_count_by_peer = self.active_request_count_by_peer(); + let payload_envelopes_by_root_per_peer = + ActiveRequestsPerPeer::new(&self.payload_envelopes_by_root_requests); let Some(peer_id) = lookup_peers .read() .iter() .map(|peer| { ( - active_request_count_by_peer.get(peer).copied().unwrap_or(0), + // Strictly de-prioritize peers already at the per-protocol concurrency limit + payload_envelopes_by_root_per_peer.at_concurrency_limit(peer), rand::random::(), peer, ) @@ -1757,7 +1738,6 @@ impl SyncNetworkContext { peers: &HashSet, peers_to_deprioritize: &HashSet, ) -> Result { - let active_request_count_by_peer = self.active_request_count_by_peer(); // Attempt to find all required custody peers before sending any request or creating an ID let columns_by_range_peers_to_request = { let column_indexes = self @@ -1770,7 +1750,6 @@ impl SyncNetworkContext { self.select_columns_by_range_peers_to_request( &column_indexes, peers, - active_request_count_by_peer, peers_to_deprioritize, )? }; diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index 3518eecf09..3dd3683c42 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -16,7 +16,9 @@ use tracing::{Span, debug, debug_span, warn}; use types::{DataColumnSidecar, Hash256, Slot, data::ColumnIndex}; use types::{DataColumnSidecarList, EthSpec}; -use super::{LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext}; +use super::{ + ActiveRequestsPerPeer, LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext, +}; const MAX_STALE_NO_PEERS_DURATION: Duration = Duration::from_secs(30); @@ -237,7 +239,8 @@ impl ActiveCustodyRequest { ))); } - let active_request_count_by_peer = cx.active_request_count_by_peer(); + let data_columns_by_root_per_peer = + ActiveRequestsPerPeer::new(&cx.data_columns_by_root_requests); let mut columns_to_request_by_peer = HashMap::>::new(); let mut columns_without_peers = vec![]; let lookup_peers = self.lookup_peers.read(); @@ -255,7 +258,7 @@ impl ActiveCustodyRequest { let peer_to_request = self.select_column_peer( cx, - &active_request_count_by_peer, + &data_columns_by_root_per_peer, &lookup_peers, *column_index, &random_state, @@ -360,7 +363,7 @@ impl ActiveCustodyRequest { fn select_column_peer( &self, cx: &mut SyncNetworkContext, - active_request_count_by_peer: &HashMap, + data_columns_by_root_per_peer: &ActiveRequestsPerPeer, lookup_peers: &HashSet, column_index: ColumnIndex, random_state: &RandomState, @@ -377,12 +380,12 @@ impl ActiveCustodyRequest { }) .map(|peer| { ( + // Strictly de-prioritize peers already at the per-protocol concurrency limit + data_columns_by_root_per_peer.at_concurrency_limit(peer), // Prioritize peers that claim to know have imported this block if lookup_peers.contains(peer) { 0 } else { 1 }, // De-prioritize peers that we have already attempted to download from self.peer_attempts.get(peer).copied().unwrap_or(0), - // Prefer peers with fewer requests to load balance across peers. - active_request_count_by_peer.get(peer).copied().unwrap_or(0), // The hash ensures consistent peer ordering within this request // to avoid fragmentation while varying selection across different requests. random_state.hash_one(peer), From e9f55a5e512db62536db04f30728201ffc8a3c85 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 18 Jun 2026 13:43:28 +1000 Subject: [PATCH 17/26] Update deps --- Cargo.lock | 135 +++++++++++++++++++++++++---------------------------- Cargo.toml | 4 ++ 2 files changed, 68 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0344bb7c8..0bc97653d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,7 +447,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.14.0", + "itertools 0.13.0", "serde", "serde_json", "serde_with", @@ -1368,7 +1368,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "lazy_static", "lazycell", "proc-macro2", @@ -1969,7 +1969,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -2475,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -3114,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3441,6 +3441,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.2", + "siphasher", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -3654,8 +3666,8 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b604752cefc5aa3ab98992a107a8bd99465d2825c1584e0b60cb6957b21e19d7" dependencies = [ + "futures-timer", "futures-util", - "tokio", ] [[package]] @@ -4409,7 +4421,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -4783,15 +4795,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -5086,7 +5089,7 @@ dependencies = [ [[package]] name = "libp2p" version = "0.57.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "bytes", "either", @@ -5117,7 +5120,7 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5127,7 +5130,7 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5137,7 +5140,7 @@ dependencies = [ [[package]] name = "libp2p-core" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "either", "fnv", @@ -5161,7 +5164,7 @@ dependencies = [ [[package]] name = "libp2p-dns" version = "0.45.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "hickory-resolver", @@ -5175,7 +5178,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5205,7 +5208,7 @@ dependencies = [ [[package]] name = "libp2p-identify" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "either", @@ -5245,7 +5248,7 @@ dependencies = [ [[package]] name = "libp2p-mdns" version = "0.49.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "hickory-proto", @@ -5263,7 +5266,7 @@ dependencies = [ [[package]] name = "libp2p-metrics" version = "0.18.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "libp2p-core", @@ -5279,7 +5282,7 @@ dependencies = [ [[package]] name = "libp2p-mplex" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -5297,7 +5300,7 @@ dependencies = [ [[package]] name = "libp2p-noise" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -5319,7 +5322,7 @@ dependencies = [ [[package]] name = "libp2p-quic" version = "0.14.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5328,6 +5331,7 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", + "quinn-proto", "rand 0.8.5", "ring", "rustls 0.23.40", @@ -5340,7 +5344,7 @@ dependencies = [ [[package]] name = "libp2p-swarm" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "either", "fnv", @@ -5363,7 +5367,7 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" version = "0.36.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "heck", "quote", @@ -5373,7 +5377,7 @@ dependencies = [ [[package]] name = "libp2p-tcp" version = "0.45.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5388,7 +5392,7 @@ dependencies = [ [[package]] name = "libp2p-tls" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-rustls", @@ -5406,7 +5410,7 @@ dependencies = [ [[package]] name = "libp2p-upnp" version = "0.7.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "futures-timer", @@ -5420,15 +5424,13 @@ dependencies = [ [[package]] name = "libp2p-yamux" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ - "either", "futures", "libp2p-core", "thiserror 2.0.17", "tracing", - "yamux 0.12.1", - "yamux 0.13.10", + "yamux", ] [[package]] @@ -6090,7 +6092,7 @@ dependencies = [ [[package]] name = "multistream-select" version = "0.14.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "bytes", "futures", @@ -6296,7 +6298,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7118,7 +7120,7 @@ dependencies = [ [[package]] name = "prost-codec" version = "0.4.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "asynchronous-codec", "bytes", @@ -7134,7 +7136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7147,7 +7149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7206,9 +7208,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -7218,7 +7220,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.40", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -7227,11 +7229,12 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", + "fastbloom", "getrandom 0.3.4", "lru-slab", "rand 0.9.2", @@ -7255,9 +7258,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7802,7 +7805,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7908,7 +7911,7 @@ dependencies = [ [[package]] name = "rw-stream-sink" version = "0.5.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#3e72d4c071d5ec8815d2f6f7ee3602600ff51798" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=3563de5ed20e509885592b391aa816992eef55d4#3563de5ed20e509885592b391aa816992eef55d4" dependencies = [ "futures", "pin-project", @@ -8387,6 +8390,12 @@ dependencies = [ "types", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.11" @@ -8870,7 +8879,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10234,7 +10243,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10844,31 +10853,15 @@ dependencies = [ [[package]] name = "yamux" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +checksum = "767113d8f66a81e461362462aa71d8d0108cbf3430d4442fb88a04e31be81165" dependencies = [ "futures", "log", "nohash-hasher", "parking_lot", "pin-project", - "rand 0.8.5", - "static_assertions", -] - -[[package]] -name = "yamux" -version = "0.13.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" -dependencies = [ - "futures", - "log", - "nohash-hasher", - "parking_lot", - "pin-project", - "rand 0.9.2", "static_assertions", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 6e0b1ddf3a..f4dba84ff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -272,3 +272,7 @@ incremental = false inherits = "release" debug = true +[patch."https://github.com/libp2p/rust-libp2p.git"] +libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } +libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } +libp2p-quic = { git = "https://github.com/sigp/rust-libp2p.git", rev = "3563de5ed20e509885592b391aa816992eef55d4" } From 120c3c6dac9df8ee4d83f055919bd3488abae4f6 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 16 Jun 2026 17:19:52 +1000 Subject: [PATCH 18/26] Release v8.2.0 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0bc97653d0..b88b949957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "bls", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_chain", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.3" +version = "8.2.0" dependencies = [ "beacon_node", "bytes", @@ -4984,7 +4984,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_chain", @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_manager", "account_utils", @@ -5614,7 +5614,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.3" +version = "8.2.0" dependencies = [ "regex", ] @@ -9688,7 +9688,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.3" +version = "8.2.0" dependencies = [ "account_utils", "beacon_node_fallback", diff --git a/Cargo.toml b/Cargo.toml index f4dba84ff4..f43786efe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.3" +version = "8.2.0" [workspace.dependencies] account_utils = { path = "common/account_utils" } From 8c4b21c3dbfab4319513b608e5484b0a646794fd Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 22 Jun 2026 17:06:26 -0700 Subject: [PATCH 19/26] Fix ci issues (#9514) - Remove deprecated geth flag - Remove duplicate entries for crate `syn` Co-Authored-By: Eitan Seri-Levi --- Cargo.lock | 2 +- testing/execution_engine_integration/src/geth.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b88b949957..d4a531d26d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2475,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] diff --git a/testing/execution_engine_integration/src/geth.rs b/testing/execution_engine_integration/src/geth.rs index 4b62e68e94..a676cb1a4c 100644 --- a/testing/execution_engine_integration/src/geth.rs +++ b/testing/execution_engine_integration/src/geth.rs @@ -108,7 +108,6 @@ impl GenericExecutionEngine for GethEngine { .arg(http_auth_port.to_string()) .arg("--port") .arg(network_port.to_string()) - .arg("--allow-insecure-unlock") .arg("--authrpc.jwtsecret") .arg(jwt_secret_path.as_path().to_str().unwrap()) // This flag is required to help Geth perform reliably when feeding it blocks From 34e14fd1bc33e5cabaeb3a1fd3eb5e375dbc7092 Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 23 Jun 2026 04:09:31 +0400 Subject: [PATCH 20/26] Forbid removed `execution_payload_envelope.rs` file (#9506) I noticed that `beacon_node/http_api/src/beacon/execution_payload_envelope.rs` was recently removed but not added to the forbidden-files.txt. Add the removed file to the forbidden list to ensure it isn't accidentally re-added by a merge or rebase. Co-Authored-By: Mac L --- .github/forbidden-files.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index 1c5e9acab9..eca611585b 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -9,6 +9,7 @@ beacon_node/beacon_chain/src/block_reward.rs beacon_node/http_api/src/attestation_performance.rs beacon_node/http_api/src/block_packing_efficiency.rs beacon_node/http_api/src/block_rewards.rs +beacon_node/http_api/src/beacon/execution_payload_envelope.rs common/eth2/src/lighthouse/attestation_performance.rs common/eth2/src/lighthouse/block_packing_efficiency.rs common/eth2/src/lighthouse/block_rewards.rs From e7c027cfa8bd1da66700c51b9486d98cfe3f32ca Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:21:25 +0200 Subject: [PATCH 21/26] Run Gloas sync tests instead of skipping them (#9446) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Pawan Dhananjay --- .../src/sync/block_sidecar_coupling.rs | 343 ++++++++++-------- beacon_node/network/src/sync/tests/lookups.rs | 123 ++++--- beacon_node/network/src/sync/tests/mod.rs | 6 +- beacon_node/network/src/sync/tests/range.rs | 38 +- 4 files changed, 276 insertions(+), 234 deletions(-) diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index b64ae4a4c5..93c0699ded 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -594,15 +594,113 @@ mod tests { } } - /// The custody-column coupling tests below build Fulu data-column sidecars directly, which is - /// incompatible with a Gloas genesis (Gloas columns have a different structure). Skip them when - /// `FORK_NAME` schedules Gloas at genesis. TODO(gloas): port the harness to build Gloas columns. - fn skip_under_gloas() -> bool { + /// Returns true when `FORK_NAME` schedules Gloas at genesis. Used to make the custody-column + /// coupling tests fork-aware: under Gloas the columns are coupled into the payload envelope, so + /// these tests build Gloas blocks/columns/envelopes and complete the payloads request. + fn is_gloas_env() -> bool { test_spec::() .fork_name_at_epoch(Epoch::new(0)) .gloas_enabled() } + /// The fork to build blocks/columns for in the custody-column coupling tests. Under a Gloas + /// genesis we must build Gloas columns (and matching envelopes); otherwise we use Fulu. + fn custody_test_fork() -> ForkName { + if is_gloas_env() { + ForkName::Gloas + } else { + ForkName::Fulu + } + } + + /// A spec with custody-column (PeerDAS) coupling enabled at genesis, matching the env fork. + /// Under a Gloas env this enables Gloas at genesis (so envelopes are coupled); otherwise it + /// enables Fulu at genesis. + fn custody_test_spec() -> ChainSpec { + let mut spec = test_spec::(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + if is_gloas_env() { + spec.gloas_fork_epoch = Some(Epoch::new(0)); + } + spec + } + + /// A block, its data columns, and (under Gloas) its matching payload envelope. + type BlockColumnsEnvelope = ( + Arc>, + DataColumnSidecarList, + Option>>, + ); + + /// Builds `count` blocks with their data columns, plus a matching payload envelope under Gloas. + /// Under Fulu the envelope is `None`. + fn make_blocks_and_columns( + count: usize, + num_blobs: NumBlobs, + spec: &ChainSpec, + ) -> Vec { + let fork = custody_test_fork(); + let mut u = types::test_utils::test_unstructured(); + (0..count) + .map(|_| { + // `NumBlobs` isn't `Clone`, so rebuild a fresh value for each block. + let num_blobs = match &num_blobs { + NumBlobs::Random => NumBlobs::Random, + NumBlobs::Number(n) => NumBlobs::Number(*n), + NumBlobs::None => NumBlobs::None, + }; + let (block, data_columns) = + generate_rand_block_and_data_columns::(fork, num_blobs, &mut u, spec) + .unwrap(); + let block = Arc::new(block); + let envelope = is_gloas_env().then(|| matching_envelope(&block)); + (block, data_columns, envelope) + }) + .collect() + } + + /// Under Gloas, completes the payloads request with the envelopes from `blocks`. Under Fulu this + /// is a no-op (there is no payloads request). Pass the subset of blocks whose envelopes should + /// be supplied. + fn add_envelopes_if_gloas( + info: &mut RangeBlockComponentsRequest, + payloads_req_id: Option, + blocks: &[BlockColumnsEnvelope], + ) { + if let Some(payloads_req_id) = payloads_req_id { + info.add_payload_envelopes( + payloads_req_id, + blocks + .iter() + .filter_map(|(_, _, envelope)| envelope.clone()) + .collect(), + ) + .unwrap(); + } + } + + /// Asserts the coupled `responses` carry the expected data. Pre-Gloas only the count is checked; + /// under Gloas each block must additionally wrap an envelope holding `expected_columns` columns. + fn assert_custody_columns_coupled( + responses: &[RangeSyncBlock], + expected_blocks: usize, + expected_columns: usize, + ) { + assert_eq!(responses.len(), expected_blocks); + if is_gloas_env() { + for response in responses { + match response { + RangeSyncBlock::Gloas { + envelope: Some(envelope), + .. + } => assert_eq!(envelope.columns.len(), expected_columns), + other => panic!("expected Gloas block with envelope, got {other:?}"), + } + } + } + } + fn blocks_id(parent_request_id: ComponentsByRangeRequestId) -> BlocksByRangeRequestId { BlocksByRangeRequestId { id: 1, @@ -798,38 +896,33 @@ mod tests { #[test] fn no_blobs_into_responses() { - // This exercises the pre-Gloas blobs/no-data coupling path. Gloas coupling is covered - // by the dedicated `setup_gloas_coupling` tests below. - if skip_under_gloas() { - return; - } - let spec = Arc::new(test_spec::()); - - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_blobs::( - spec.fork_name_at_epoch(Epoch::new(0)), - NumBlobs::None, - &mut u, - ) - .unwrap() - .0 - .into() - }) - .collect::>>>(); - - let blocks_req_id = blocks_id(components_id()); - let mut info = - RangeBlockComponentsRequest::::new(blocks_req_id, None, None, None, Span::none()); - - // Send blocks and complete terminate response - info.add_blocks(blocks_req_id, blocks).unwrap(); - + // Coupling of blocks that carry no data. Pre-Gloas there is simply no data request; under + // Gloas each block still couples to its (empty-column) payload envelope, so the envelope + // request is driven too. + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let blocks = make_blocks_and_columns(4, NumBlobs::None, &spec); - // Assert response is finished and RpcBlocks can be constructed - info.responses(da_checker, spec).unwrap().unwrap(); + let components_id = components_id(); + let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); + let mut info = RangeBlockComponentsRequest::::new( + blocks_req_id, + None, + is_gloas_env().then(|| (vec![], vec![])), + payloads_req_id, + Span::none(), + ); + + info.add_blocks( + blocks_req_id, + blocks.iter().map(|(block, _, _)| block.clone()).collect(), + ) + .unwrap(); + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); + + let responses = info.responses(da_checker, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), 0); } #[test] @@ -875,33 +968,17 @@ mod tests { #[test] fn rpc_block_with_custody_columns() { - if skip_under_gloas() { - return; - } - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); let expects_custody_columns = da_checker .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(4, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expects_custody_columns .iter() .enumerate() @@ -919,13 +996,13 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expects_custody_columns.clone())), - None, + payloads_req_id, Span::none(), ); // Send blocks and complete terminate response info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); // Assert response is not finished @@ -938,7 +1015,12 @@ mod tests { *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) + .flat_map(|(_, columns, _)| { + columns + .iter() + .filter(|d| *d.index() == column_index) + .cloned() + }) .collect(), ) .unwrap(); @@ -951,19 +1033,18 @@ mod tests { } } + // Under Gloas the columns are coupled into the payload envelope; supply the envelopes so + // the request can complete. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); + // All completed construct response - info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(da_checker, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), expects_custody_columns.len()); } #[test] fn rpc_block_with_custody_columns_batched() { - if skip_under_gloas() { - return; - } - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); let expected_sampling_columns = da_checker .custody_context() @@ -981,6 +1062,7 @@ mod tests { let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = batched_column_requests .iter() .enumerate() @@ -999,27 +1081,16 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..4) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(4, NumBlobs::Number(1), &spec); // Send blocks and complete terminate response info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); // Assert response is not finished @@ -1032,8 +1103,9 @@ mod tests { *req, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| column_indices.contains(d.index())) .cloned() }) @@ -1049,8 +1121,13 @@ mod tests { } } + // Under Gloas the columns are coupled into the payload envelope; supply the envelopes so + // the request can complete. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); + // All completed construct response - info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(da_checker, spec).unwrap().unwrap(); + assert_custody_columns_coupled(&responses, blocks.len(), expected_sampling_columns.len()); } #[test] @@ -1164,31 +1241,18 @@ mod tests { #[test] fn missing_custody_columns_from_faulty_peers() { - if skip_under_gloas() { - return; - } // GIVEN: A request expecting sampling columns from multiple peers - let spec = Arc::new(test_spec::()); + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); let expected_sampling_columns = da_checker .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..2) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1206,16 +1270,19 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received successfully info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed; the + // faulty-peer detection happens before the envelope wrap and is fork-independent. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only the first 2 sampling columns are received successfully for (i, &column_index) in expected_sampling_columns.iter().take(2).enumerate() { @@ -1224,7 +1291,12 @@ mod tests { *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) + .flat_map(|(_, columns, _)| { + columns + .iter() + .filter(|d| *d.index() == column_index) + .cloned() + }) .collect(), ) .unwrap(); @@ -1263,34 +1335,18 @@ mod tests { #[test] fn retry_logic_after_peer_failures() { - if skip_under_gloas() { - return; - } // GIVEN: A request expecting sampling columns where some peers initially fail - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); let expected_sampling_columns = da_checker .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..2) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1308,16 +1364,18 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only partial sampling columns are received (first column but not others) let (req0, _) = columns_req_id.first().unwrap(); @@ -1325,8 +1383,9 @@ mod tests { *req0, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| *d.index() == expected_sampling_columns[0]) .cloned() }) @@ -1363,8 +1422,9 @@ mod tests { new_columns_req_id, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| failed_column_indices.contains(d.index())) .cloned() }) @@ -1383,34 +1443,18 @@ mod tests { #[test] fn max_retries_exceeded_behavior() { - if skip_under_gloas() { - return; - } // GIVEN: A request where peers consistently fail to provide required columns - let mut spec = test_spec::(); - spec.deneb_fork_epoch = Some(Epoch::new(0)); - spec.fulu_fork_epoch = Some(Epoch::new(0)); - let spec = Arc::new(spec); + let spec = Arc::new(custody_test_spec()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); let expected_sampling_columns = da_checker .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut u = types::test_utils::test_unstructured(); - let blocks = (0..1) - .map(|_| { - generate_rand_block_and_data_columns::( - ForkName::Fulu, - NumBlobs::Number(1), - &mut u, - &spec, - ) - .unwrap() - }) - .collect::>(); + let blocks = make_blocks_and_columns(1, NumBlobs::Number(1), &spec); let components_id = components_id(); let blocks_req_id = blocks_id(components_id); + let payloads_req_id = is_gloas_env().then(|| payloads_id(components_id)); let columns_req_id = expected_sampling_columns .iter() .enumerate() @@ -1428,16 +1472,18 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), - None, + payloads_req_id, Span::none(), ); // AND: All blocks are received info.add_blocks( blocks_req_id, - blocks.iter().map(|b| b.0.clone().into()).collect(), + blocks.iter().map(|(block, _, _)| block.clone()).collect(), ) .unwrap(); + // Under Gloas the payloads request must be completed for `responses` to proceed. + add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // AND: Only the first sampling column is provided successfully let (req0, _) = columns_req_id.first().unwrap(); @@ -1445,8 +1491,9 @@ mod tests { *req0, blocks .iter() - .flat_map(|b| { - b.1.iter() + .flat_map(|(_, columns, _)| { + columns + .iter() .filter(|d| *d.index() == expected_sampling_columns[0]) .cloned() }) diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 621824c7d2..deaf421e39 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -45,6 +45,17 @@ use types::{ const D: Duration = Duration::new(0, 0); +/// Extract the Gloas payload envelope (if any) carried by a stored `RangeSyncBlock`. +fn envelope_of(block: &RangeSyncBlock) -> Option>> { + match block { + RangeSyncBlock::Gloas { + envelope: Some(envelope), + .. + } => Some(envelope.envelope().clone()), + _ => None, + } +} + /// Gloas genesis needs enough validators to populate `proposer_lookahead`. const TEST_RIG_VALIDATOR_COUNT: usize = 8; @@ -331,7 +342,6 @@ impl TestRig { fork_name, network_blocks_by_root: <_>::default(), network_blocks_by_slot: <_>::default(), - network_envelopes_by_root: <_>::default(), penalties: <_>::default(), seen_lookups: <_>::default(), requests: <_>::default(), @@ -669,7 +679,10 @@ impl TestRig { if self.complete_strategy.hold_envelope_for_block == Some(block_root) { return; } - let envelope = self.network_envelopes_by_root.get(&block_root).cloned(); + let envelope = self + .network_blocks_by_root + .get(&block_root) + .and_then(envelope_of); self.send_rpc_envelope_response(req_id, peer_id, envelope); } @@ -843,7 +856,7 @@ impl TestRig { if self.complete_strategy.hold_envelope_for_block == Some(block_root) { return None; } - self.network_envelopes_by_root.get(&block_root).cloned() + envelope_of(block) }) .collect::>(); self.send_rpc_envelopes_response(req_id, peer_id, &envelopes); @@ -1057,13 +1070,7 @@ impl TestRig { .await; let block = external_harness.get_full_block(&block_root); let block_slot = block.slot(); - self.insert_external_block( - block, - external_harness - .chain - .get_payload_envelope(&block_root) - .unwrap(), - ); + self.insert_external_block(block); blocks.push((block_slot, block_root)); } @@ -1171,8 +1178,7 @@ impl TestRig { // Cache every block through the single `get_full_block` + `insert_external_block2` path. for root in [g_root, a_root, c_root, b_root] { let block = external_harness.get_full_block(&root); - let envelope = external_harness.chain.get_payload_envelope(&root).unwrap(); - self.insert_external_block(block, envelope); + self.insert_external_block(block); } self.harness.set_current_slot(child_slot); @@ -1200,21 +1206,12 @@ impl TestRig { Some((r, fork)) } - fn insert_external_block( - &mut self, - block: RangeSyncBlock, - envelope: Option>, - ) { + fn insert_external_block(&mut self, block: RangeSyncBlock) { let block_root = block.canonical_root(); let block_slot = block.slot(); self.network_blocks_by_root .insert(block_root, block.clone()); self.network_blocks_by_slot.insert(block_slot, block); - // Cache Gloas envelopes for lookup RPCs. - if let Some(envelope) = envelope { - self.network_envelopes_by_root - .insert(block_root, envelope.into()); - } self.log(&format!( "Produced block {block_root:?} slot {block_slot} in external harness", )); @@ -1300,9 +1297,9 @@ impl TestRig { let range_sync_block = if block.fork_name_unchecked().gloas_enabled() { // Gloas carries data columns in the payload envelope, not in `block_data`. let envelope = self - .network_envelopes_by_root + .network_blocks_by_root .get(&block_root) - .cloned() + .and_then(envelope_of) .map(|envelope| AvailableEnvelope::new(envelope, columns.unwrap_or_default())); RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { @@ -1370,6 +1367,8 @@ impl TestRig { .unwrap_or_else(|| panic!("No block at slot {slot}")) .clone(); let block_root = rpc_block.canonical_root(); + let block_state_root = rpc_block.as_block().state_root(); + let envelope = envelope_of(&rpc_block); self.harness .chain .process_block( @@ -1381,6 +1380,18 @@ impl TestRig { ) .await .unwrap(); + // Gloas: import the payload envelope so the block counts as full for its children. + if let Some(envelope) = envelope { + let state = self + .harness + .chain + .get_state(&block_state_root, Some(Slot::new(slot)), false) + .expect("should load state") + .expect("state should exist"); + self.harness + .process_envelope(block_root, (*envelope).clone(), &state, block_state_root) + .await; + } } self.harness.chain.recompute_head_at_current_slot().await; } @@ -1495,6 +1506,17 @@ impl TestRig { panic!("Some downscore events: {:?}", self.penalties); } } + + fn assert_no_block_requests(&self) { + assert_eq!( + self.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) + .collect::>(), + Vec::<&(RequestType, AppRequestId)>::new(), + "There should be no block requests" + ); + } fn assert_failed_lookup_sync(&mut self) { assert!(self.created_lookups() > 0, "no created lookups"); assert_eq!(self.completed_lookups(), 0, "some completed lookups"); @@ -1623,6 +1645,10 @@ impl TestRig { genesis_fork().fulu_enabled().then(Self::default) } + fn new_after_gloas() -> Option { + genesis_fork().gloas_enabled().then(Self::default) + } + pub fn new_fulu_peer_test(fulu_test_type: FuluTestType) -> Option { genesis_fork().fulu_enabled().then(|| { Self::new(TestRigConfig { @@ -2129,7 +2155,8 @@ async fn happy_path_unknown_data_parent(depth: usize) { let Some(mut r) = TestRig::new_after_fulu() else { return; }; - // No unknown-parent data-column trigger post-Gloas. + // Fulu-only: the `UnknownDataColumnParent` trigger doesn't exist post-Gloas (columns ride in + // the payload envelope, not as standalone data columns). if r.is_after_gloas() { return; } @@ -2359,10 +2386,6 @@ async fn test_single_block_lookup_ignored_response() { /// Assert that if the beacon processor returns DuplicateFullyImported, the lookup completes successfully async fn test_single_block_lookup_duplicate_response() { let mut r = TestRig::default(); - // The mock only covers block processing; Gloas also needs real envelope/column results. - if r.is_after_gloas() { - return; - } r.build_chain_and_trigger_last_block(1).await; // Send a DuplicateFullyImported response, the lookup should complete successfully r.simulate( @@ -2427,10 +2450,6 @@ async fn lookups_form_chain() { /// Assert that if a lookup chain (by appending ancestors) is too long we drop it async fn test_parent_lookup_too_deep_grow_ancestor_one() { let mut r = TestRig::default(); - // TODO(gloas): range sync does not fetch payload envelopes yet. - if r.is_after_gloas() { - return; - } r.build_chain(PARENT_DEPTH_TOLERANCE + 1).await; r.trigger_with_last_block(); r.simulate(SimulateConfig::happy_path()).await; @@ -2575,13 +2594,13 @@ async fn test_same_chain_race_condition() { } #[tokio::test] -/// Assert that if the lookup's block is in the da_checker we don't download it again -async fn block_in_da_checker_skips_download() { - // Only post-Fulu, as the block needs custody columns to remain in the da_checker +/// Assert that if the lookup's block is in the da_checker we don't download it again (pre-Gloas). +async fn block_in_da_checker_skips_download_fulu() { + // Only post-Fulu, as the block needs custody columns to remain in the da_checker. let Some(mut r) = TestRig::new_after_fulu() else { return; }; - // TODO(gloas): the helper does not populate the envelope missing-component path yet. + // Pre-Gloas only; the Gloas equivalent is `block_in_da_checker_skips_download_gloas`. if r.is_after_gloas() { return; } @@ -2594,14 +2613,28 @@ async fn block_in_da_checker_skips_download() { r.trigger_with_block_at_slot(1); r.simulate(SimulateConfig::happy_path()).await; r.assert_successful_lookup_sync(); - assert_eq!( - r.requests - .iter() - .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) - .collect::>(), - Vec::<&(RequestType, AppRequestId)>::new(), - "There should be no block requests" - ); + r.assert_no_block_requests(); +} + +#[tokio::test] +/// Assert that if the lookup's block is in the da_checker we don't download it again (Gloas). +async fn block_in_da_checker_skips_download_gloas() { + let Some(mut r) = TestRig::new_after_gloas() else { + return; + }; + // A Gloas block carries no inline DA, so a lone block never sits in the da_checker awaiting + // components: only a FULL *child* proves the block published a payload and supplies the peers + // that serve its columns/envelope. Build a parent + FULL child, insert the PARENT into the + // da_checker, then trigger via the child (which is provided by the trigger, not downloaded). + // The parent lookup must then skip the parent's block download. + r.build_chain(2).await; + let parent = r.block_at_slot(1); + let child = r.block_at_slot(2); + r.import_block_to_da_checker(parent).await; + r.trigger_unknown_parent_blocks_from_all_peers(&[child]); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + r.assert_no_block_requests(); } macro_rules! fulu_peer_matrix_tests { diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 2f318bfb9a..4e185cc081 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -21,7 +21,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ForkName, Hash256, MinimalEthSpec as E, SignedExecutionPayloadEnvelope, Slot}; +use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; mod lookups; mod range; @@ -77,10 +77,6 @@ struct TestRig { /// Blocks that will be used in the test but may not be known to `harness` yet. network_blocks_by_root: HashMap>, network_blocks_by_slot: HashMap>, - /// Gloas execution payload envelopes keyed by block root, populated during `build_chain` - /// from the external harness store. The rig serves these when a lookup issues a - /// `PayloadEnvelopesByRoot` request. - network_envelopes_by_root: HashMap>>, penalties: Vec, /// All seen lookups through the test run seen_lookups: HashMap, diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index e6890cf242..1499ae5016 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -34,13 +34,6 @@ use types::{Epoch, EthSpec, Hash256, MinimalEthSpec as E, Slot}; const SLOTS_PER_EPOCH: usize = 8; impl TestRig { - /// Range sync doesn't yet ingest Gloas blocks in these tests: the range harness doesn't serve - /// payload envelopes, so a Gloas block never becomes fully available and sync can't complete. - /// Skip the affected completion tests under a Gloas genesis. TODO(gloas): support range sync. - fn skip_range_sync_under_gloas(&self) -> bool { - self.fork_name.gloas_enabled() - } - fn add_head_peer(&mut self) -> PeerId { let local_info = self.local_info(); self.add_supernode_peer(SyncInfo { @@ -267,9 +260,6 @@ impl TestRig { #[tokio::test] async fn head_sync_completes() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_head_sync_completed(); @@ -281,9 +271,6 @@ async fn head_sync_completes() { #[tokio::test] async fn finalized_to_head_transition() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_and_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -295,9 +282,6 @@ async fn finalized_to_head_transition() { #[tokio::test] async fn finalized_sync_completes() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -309,9 +293,6 @@ async fn finalized_sync_completes() { #[tokio::test] async fn batch_rpc_error_retries() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().return_rpc_error(RPCError::UnsupportedProtocol)) .await; @@ -380,9 +361,6 @@ async fn batch_peer_returns_partial_columns_then_succeeds() { #[tokio::test] async fn batch_non_faulty_failure_retries() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_non_faulty_failures(1)) .await; @@ -394,9 +372,6 @@ async fn batch_non_faulty_failure_retries() { #[tokio::test] async fn batch_faulty_failure_redownloads() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(1)) .await; @@ -453,9 +428,6 @@ async fn late_response_for_removed_chain() { #[tokio::test] async fn ee_offline_then_online_resumes_sync() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_ee_offline_for_n_range_responses(2)) .await; @@ -468,9 +440,6 @@ async fn ee_offline_then_online_resumes_sync() { #[tokio::test] async fn finalized_sync_with_local_head_partial() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } r.setup_finalized_sync_with_local_head(3).await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -481,9 +450,6 @@ async fn finalized_sync_with_local_head_partial() { #[tokio::test] async fn finalized_sync_with_local_head_near_target() { let mut r = TestRig::default(); - if r.skip_range_sync_under_gloas() { - return; - } let target_epochs = 5; let local_slots = (target_epochs * SLOTS_PER_EPOCH) - 1; // all blocks except last r.build_chain(target_epochs * SLOTS_PER_EPOCH).await; @@ -502,7 +468,7 @@ async fn finalized_sync_with_local_head_near_target() { #[tokio::test] async fn not_enough_custody_peers_then_peers_arrive() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { + if !r.fork_name.fulu_enabled() { return; } let remote_info = r.setup_finalized_sync_insufficient_peers().await; @@ -529,7 +495,7 @@ async fn not_enough_custody_peers_then_peers_arrive() { #[tokio::test] async fn finalized_sync_not_enough_custody_peers_resume_after_peer_cgc_update() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { + if !r.fork_name.fulu_enabled() { return; } From 5e54cfbf86f5daed83e315327f4d079fa0c143c8 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:29:39 +0200 Subject: [PATCH 22/26] Remove seen_timestamp tracking from sync (#9454) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Pawan Dhananjay --- beacon_node/network/src/metrics.rs | 16 ----- .../src/network_beacon_processor/mod.rs | 23 ++---- .../network_beacon_processor/sync_methods.rs | 42 ++--------- .../src/network_beacon_processor/tests.rs | 3 - beacon_node/network/src/router.rs | 7 -- .../sync/block_lookups/single_block_lookup.rs | 13 +--- beacon_node/network/src/sync/manager.rs | 72 +++++-------------- .../network/src/sync/network_context.rs | 54 ++++++-------- .../src/sync/network_context/custody.rs | 35 +++------ .../src/sync/network_context/requests.rs | 15 ++-- beacon_node/network/src/sync/tests/lookups.rs | 12 ---- 11 files changed, 70 insertions(+), 222 deletions(-) diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index 1a664662df..07e6d7fdb2 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -665,22 +665,6 @@ pub static BEACON_BLOB_DELAY_FULL_VERIFICATION: LazyLock> = Laz ) }); -pub static BEACON_BLOB_RPC_SLOT_START_DELAY_TIME: LazyLock> = LazyLock::new( - || { - try_create_histogram_with_buckets( - "beacon_blob_rpc_slot_start_delay_time", - "Duration between when a blob is received over rpc and the start of the slot it belongs to.", - // Create a custom bucket list for greater granularity in block delay - Ok(vec![ - 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, - 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, - ]), // NOTE: Previous values, which we may want to switch back to. - // [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50] - //decimal_buckets(-1,2) - ) - }, -); - /* * Light client update reprocessing queue metrics. */ diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index c5023ed5f4..ea5fa3e90b 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -528,15 +528,11 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { - let process_fn = self.clone().generate_lookup_beacon_block_process_fn( - block_root, - block, - seen_timestamp, - process_type, - ); + let process_fn = + self.clone() + .generate_lookup_beacon_block_process_fn(block_root, block, process_type); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcBlock { @@ -552,14 +548,13 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, envelope: Arc>, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { let s = self.clone(); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcEnvelope(Box::pin(async move { - s.process_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + s.process_lookup_envelope(block_root, envelope, process_type) .await; })), }) @@ -571,20 +566,14 @@ impl NetworkBeaconProcessor { self: &Arc, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { let s = self.clone(); self.try_send(BeaconWorkEvent { drop_during_sync: false, work: Work::RpcCustodyColumn(Box::pin(async move { - s.process_rpc_custody_columns( - block_root, - custody_columns, - seen_timestamp, - process_type, - ) - .await; + s.process_rpc_custody_columns(block_root, custody_columns, process_type) + .await; })), }) } diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index b9e07743eb..c376f1844b 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -14,7 +14,7 @@ use beacon_chain::data_availability_checker::{ use beacon_chain::historical_data_columns::HistoricalDataColumnError; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainTypes, BlockError, ChainSegmentResult, - HistoricalBlockError, NotifyExecutionLayer, validator_monitor::get_slot_delay_ms, + HistoricalBlockError, NotifyExecutionLayer, }; use beacon_processor::{ AsyncFn, BlockingFn, DuplicateCache, @@ -26,7 +26,6 @@ use lighthouse_network::PeerId; use lighthouse_network::service::api_types::CustodyBackfillBatchId; use logging::crit; use std::sync::Arc; -use std::time::Duration; use tracing::{debug, debug_span, error, info, instrument, warn}; use types::{BlockImportSource, DataColumnSidecarList, Epoch, ExecutionBlockHash, Hash256}; @@ -56,19 +55,12 @@ impl NetworkBeaconProcessor { self: Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> AsyncFn { let process_fn = async move { let duplicate_cache = self.duplicate_cache.clone(); - self.process_lookup_block( - block_root, - block, - seen_timestamp, - process_type, - duplicate_cache, - ) - .await; + self.process_lookup_block(block_root, block, process_type, duplicate_cache) + .await; }; Box::pin(process_fn) } @@ -78,14 +70,12 @@ impl NetworkBeaconProcessor { self: Arc, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> (AsyncFn, BlockingFn) { // An async closure which will import the block. let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, - seen_timestamp, process_type.clone(), ); // A closure which will ignore the block. @@ -119,7 +109,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, block: LookupBlock, - seen_timestamp: Duration, process_type: BlockProcessType, duplicate_cache: DuplicateCache, ) { @@ -133,12 +122,9 @@ impl NetworkBeaconProcessor { ); // Send message to work reprocess queue to retry the block - let (process_fn, ignore_fn) = self.clone().generate_lookup_beacon_block_fns( - block_root, - block, - seen_timestamp, - process_type, - ); + let (process_fn, ignore_fn) = + self.clone() + .generate_lookup_beacon_block_fns(block_root, block, process_type); let reprocess_msg = ReprocessQueueMessage::RpcBlock(QueuedRpcBlock { beacon_block_root: block_root, process_fn, @@ -206,13 +192,6 @@ impl NetworkBeaconProcessor { "Failed to inform block import" ); }; - self.chain.block_times_cache.write().set_time_observed( - *hash, - *slot, - seen_timestamp, - None, - None, - ); self.chain.recompute_head_at_current_slot().await; } @@ -254,7 +233,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) { // custody_columns must always have at least one element @@ -262,13 +240,6 @@ impl NetworkBeaconProcessor { return; }; - if let Ok(current_slot) = self.chain.slot() - && current_slot == slot - { - let delay = get_slot_delay_ms(seen_timestamp, slot, &self.chain.slot_clock); - metrics::observe_duration(&metrics::BEACON_BLOB_RPC_SLOT_START_DELAY_TIME, delay); - } - let mut indices = custody_columns .iter() .map(|d| *d.index()) @@ -332,7 +303,6 @@ impl NetworkBeaconProcessor { self: Arc>, block_root: Hash256, envelope: Arc>, - _seen_timestamp: Duration, process_type: BlockProcessType, ) { debug!( diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 6b7c623230..89434c878e 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -422,7 +422,6 @@ impl TestRig { .send_lookup_beacon_block( block_root, LookupBlock::new(self.next_block.clone()), - std::time::Duration::default(), BlockProcessType::SingleBlock { id: 0 }, ) .unwrap(); @@ -434,7 +433,6 @@ impl TestRig { .send_lookup_beacon_block( block_root, LookupBlock::new(self.next_block.clone()), - std::time::Duration::default(), BlockProcessType::SingleBlock { id: 1 }, ) .unwrap(); @@ -446,7 +444,6 @@ impl TestRig { .send_rpc_custody_columns( self.next_block.canonical_root(), data_columns, - Duration::default(), BlockProcessType::SingleCustodyColumn(1), ) .unwrap(); diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 315ec9387d..d83068e337 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -659,7 +659,6 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -679,7 +678,6 @@ impl Router { peer_id, sync_request_id, blob_sidecar, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All blobs by range responses should belong to sync"); @@ -716,7 +714,6 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -750,7 +747,6 @@ impl Router { sync_request_id, peer_id, data_column, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -770,7 +766,6 @@ impl Router { peer_id, sync_request_id, data_column, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All data columns by range responses should belong to sync"); @@ -796,7 +791,6 @@ impl Router { sync_request_id, peer_id, envelope, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -819,7 +813,6 @@ impl Router { sync_request_id, peer_id, envelope, - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index f03eed1638..346594c2f5 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -381,7 +381,7 @@ impl SingleBlockLookup { if self.awaiting_parent.is_none() && let Some(data) = self.block_request.state.maybe_start_processing() { - cx.send_block_for_processing(self.id, self.block_root, data.value, data.seen_timestamp) + cx.send_block_for_processing(self.id, self.block_root, data.value) .map_err(LookupRequestError::SendFailedProcessor)?; } @@ -422,7 +422,6 @@ impl SingleBlockLookup { self.id, self.block_root, data.value, - data.seen_timestamp, BlockProcessType::SingleCustodyColumn(self.id), ) .map_err(LookupRequestError::SendFailedProcessor)?; @@ -463,7 +462,6 @@ impl SingleBlockLookup { cx.send_payload_for_processing( self.block_root, data.value, - data.seen_timestamp, BlockProcessType::SinglePayloadEnvelope(self.id), ) .map_err(LookupRequestError::SendFailedProcessor)?; @@ -711,17 +709,12 @@ impl SingleBlockLookup { #[derive(Debug, Clone)] pub struct DownloadResult { pub value: T, - pub seen_timestamp: Duration, pub peer_group: PeerGroup, } impl DownloadResult { - pub fn new(value: T, peer_group: PeerGroup, seen_timestamp: Duration) -> Self { - Self { - value, - seen_timestamp, - peer_group, - } + pub fn new(value: T, peer_group: PeerGroup) -> Self { + Self { value, peer_group } } } diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 3282f7f083..b9bea21b8c 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -113,7 +113,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, beacon_block: Option>>, - seen_timestamp: Duration, }, /// A blob has been received from the RPC. @@ -121,7 +120,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, blob_sidecar: Option>>, - seen_timestamp: Duration, }, /// A data columns has been received from the RPC @@ -129,7 +127,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, data_column: Option>>, - seen_timestamp: Duration, }, /// A payload envelope has been received from the RPC. @@ -137,7 +134,6 @@ pub enum SyncMessage { sync_request_id: SyncRequestId, peer_id: PeerId, envelope: Option>>, - seen_timestamp: Duration, }, /// A block with an unknown parent has been received. @@ -835,35 +831,24 @@ impl SyncManager { sync_request_id, peer_id, beacon_block, - seen_timestamp, } => { - self.rpc_block_received(sync_request_id, peer_id, beacon_block, seen_timestamp); + self.rpc_block_received(sync_request_id, peer_id, beacon_block); } SyncMessage::RpcBlob { sync_request_id, peer_id, blob_sidecar, - seen_timestamp, - } => self.rpc_blob_received(sync_request_id, peer_id, blob_sidecar, seen_timestamp), + } => self.rpc_blob_received(sync_request_id, peer_id, blob_sidecar), SyncMessage::RpcDataColumn { sync_request_id, peer_id, data_column, - seen_timestamp, - } => { - self.rpc_data_column_received(sync_request_id, peer_id, data_column, seen_timestamp) - } + } => self.rpc_data_column_received(sync_request_id, peer_id, data_column), SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, envelope, - seen_timestamp, - } => self.rpc_payload_envelope_received( - sync_request_id, - peer_id, - envelope, - seen_timestamp, - ), + } => self.rpc_payload_envelope_received(sync_request_id, peer_id, envelope), SyncMessage::UnknownParentBlock(peer_id, block, block_root) => { let block_slot = block.slot(); let parent_root = block.parent_root(); @@ -877,7 +862,6 @@ impl SyncManager { block_slot, BlockComponent::Block(DownloadResult { value: block.block_cloned(), - seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -1122,19 +1106,14 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, block: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { - SyncRequestId::SingleBlock { id } => self.on_single_block_response( - id, - peer_id, - RpcEvent::from_chunk(block, seen_timestamp), - ), - SyncRequestId::BlocksByRange(id) => self.on_blocks_by_range_response( - id, - peer_id, - RpcEvent::from_chunk(block, seen_timestamp), - ), + SyncRequestId::SingleBlock { id } => { + self.on_single_block_response(id, peer_id, RpcEvent::from_chunk(block)) + } + SyncRequestId::BlocksByRange(id) => { + self.on_blocks_by_range_response(id, peer_id, RpcEvent::from_chunk(block)) + } _ => { crit!(%peer_id, "bad request id for block"); } @@ -1150,9 +1129,7 @@ impl SyncManager { if let Some(resp) = self.network.on_single_block_response(id, peer_id, block) { self.block_lookups.on_block_download_response( id, - resp.map(|(value, seen_timestamp)| { - DownloadResult::new(value, PeerGroup::from_single(peer_id), seen_timestamp) - }), + resp.map(|value| DownloadResult::new(value, PeerGroup::from_single(peer_id))), &mut self.network, ) } @@ -1163,14 +1140,11 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, blob: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { - SyncRequestId::BlobsByRange(id) => self.on_blobs_by_range_response( - id, - peer_id, - RpcEvent::from_chunk(blob, seen_timestamp), - ), + SyncRequestId::BlobsByRange(id) => { + self.on_blobs_by_range_response(id, peer_id, RpcEvent::from_chunk(blob)) + } _ => { crit!(%peer_id, "bad request id for blob"); } @@ -1182,20 +1156,15 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, envelope: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { SyncRequestId::SinglePayloadEnvelope { id } => self - .on_single_payload_envelope_response( - id, - peer_id, - RpcEvent::from_chunk(envelope, seen_timestamp), - ), + .on_single_payload_envelope_response(id, peer_id, RpcEvent::from_chunk(envelope)), SyncRequestId::PayloadEnvelopesByRange(req_id) => { self.on_payload_envelopes_by_range_response( req_id, peer_id, - RpcEvent::from_chunk(envelope, seen_timestamp), + RpcEvent::from_chunk(envelope), ); } _ => { @@ -1209,21 +1178,20 @@ impl SyncManager { sync_request_id: SyncRequestId, peer_id: PeerId, data_column: Option>>, - seen_timestamp: Duration, ) { match sync_request_id { SyncRequestId::DataColumnsByRoot(req_id) => { self.on_data_columns_by_root_response( req_id, peer_id, - RpcEvent::from_chunk(data_column, seen_timestamp), + RpcEvent::from_chunk(data_column), ); } SyncRequestId::DataColumnsByRange(req_id) => { self.on_data_columns_by_range_response( req_id, peer_id, - RpcEvent::from_chunk(data_column, seen_timestamp), + RpcEvent::from_chunk(data_column), ); } _ => { @@ -1244,9 +1212,7 @@ impl SyncManager { { self.block_lookups.on_payload_download_response( id, - resp.map(|(value, seen_timestamp)| { - DownloadResult::new(value, PeerGroup::from_single(peer_id), seen_timestamp) - }), + resp.map(|value| DownloadResult::new(value, PeerGroup::from_single(peer_id))), &mut self.network, ) } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 0db172a21a..8b4e3c5694 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -52,7 +52,6 @@ use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::Arc; -use std::time::Duration; #[cfg(test)] use task_executor::TaskExecutor; use tokio::sync::mpsc; @@ -83,22 +82,21 @@ pub const MAX_COLUMN_RETRIES: usize = 3; #[derive(Debug)] pub enum RpcEvent { StreamTermination, - Response(T, Duration), + Response(T), RPCError(RPCError), } impl RpcEvent { - pub fn from_chunk(chunk: Option, seen_timestamp: Duration) -> Self { + pub fn from_chunk(chunk: Option) -> Self { match chunk { - Some(item) => RpcEvent::Response(item, seen_timestamp), + Some(item) => RpcEvent::Response(item), None => RpcEvent::StreamTermination, } } } -pub type RpcResponseResult = Result<(T, Duration), RpcResponseError>; +pub type RpcResponseResult = Result; -/// Duration = latest seen timestamp of all received data columns pub type CustodyByRootResult = Result>, RpcResponseError>; @@ -776,14 +774,14 @@ impl SyncNetworkContext { if let Err(e) = { let request = entry.get_mut(); match range_block_component { - RangeBlockComponent::Block(req_id, resp) => resp.and_then(|(blocks, _)| { + RangeBlockComponent::Block(req_id, resp) => resp.and_then(|blocks| { request.add_blocks(req_id, blocks).map_err(|e| { RpcResponseError::BlockComponentCouplingError(CouplingError::InternalError( e, )) }) }), - RangeBlockComponent::Blob(req_id, resp) => resp.and_then(|(blobs, _)| { + RangeBlockComponent::Blob(req_id, resp) => resp.and_then(|blobs| { request.add_blobs(req_id, blobs).map_err(|e| { RpcResponseError::BlockComponentCouplingError(CouplingError::InternalError( e, @@ -791,7 +789,7 @@ impl SyncNetworkContext { }) }), RangeBlockComponent::CustodyColumns(req_id, resp) => { - resp.and_then(|(custody_columns, _)| { + resp.and_then(|custody_columns| { request .add_custody_columns(req_id, custody_columns) .map_err(|e| { @@ -801,17 +799,15 @@ impl SyncNetworkContext { }) }) } - RangeBlockComponent::PayloadEnvelope(req_id, resp) => { - resp.and_then(|(envelopes, _)| { - request - .add_payload_envelopes(req_id, envelopes) - .map_err(|e| { - RpcResponseError::BlockComponentCouplingError( - CouplingError::InternalError(e), - ) - }) - }) - } + RangeBlockComponent::PayloadEnvelope(req_id, resp) => resp.and_then(|envelopes| { + request + .add_payload_envelopes(req_id, envelopes) + .map_err(|e| { + RpcResponseError::BlockComponentCouplingError( + CouplingError::InternalError(e), + ) + }) + }), } } { entry.remove(); @@ -1479,11 +1475,11 @@ impl SyncNetworkContext { ) -> Option>>> { let resp = self.blocks_by_root_requests.on_response(id, rpc_event); let resp = resp.map(|res| { - res.and_then(|(mut blocks, seen_timestamp)| { + res.and_then(|mut blocks| { // Enforce that exactly one chunk = one block is returned. ReqResp behavior limits the // response count to at most 1. match blocks.pop() { - Some(block) => Ok((block, seen_timestamp)), + Some(block) => Ok(block), // Should never happen, `blocks_by_root_requests` enforces that we receive at least // 1 chunk. None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), @@ -1503,9 +1499,9 @@ impl SyncNetworkContext { .payload_envelopes_by_root_requests .on_response(id, rpc_event); let resp = resp.map(|res| { - res.and_then(|(mut envelopes, seen_timestamp)| { + res.and_then(|mut envelopes| { match envelopes.pop() { - Some(envelope) => Ok((envelope, seen_timestamp)), + Some(envelope) => Ok(envelope), // Should never happen, we enforce at least 1 chunk. None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), } @@ -1646,7 +1642,6 @@ impl SyncNetworkContext { id: Id, block_root: Hash256, block: Arc>, - seen_timestamp: Duration, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self .beacon_processor_if_enabled() @@ -1661,7 +1656,6 @@ impl SyncNetworkContext { .send_lookup_beacon_block( block_root, lookup_block, - seen_timestamp, BlockProcessType::SingleBlock { id }, ) .map_err(|e| { @@ -1677,7 +1671,6 @@ impl SyncNetworkContext { &self, block_root: Hash256, envelope: Arc>, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self @@ -1691,7 +1684,7 @@ impl SyncNetworkContext { ); beacon_processor - .send_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + .send_lookup_envelope(block_root, envelope, process_type) .map_err(|e| { error!( error = ?e, @@ -1706,7 +1699,6 @@ impl SyncNetworkContext { _id: Id, block_root: Hash256, custody_columns: DataColumnSidecarList, - seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), SendErrorProcessor> { let beacon_processor = self @@ -1720,7 +1712,7 @@ impl SyncNetworkContext { ); beacon_processor - .send_rpc_custody_columns(block_root, custody_columns, seen_timestamp, process_type) + .send_rpc_custody_columns(block_root, custody_columns, process_type) .map_err(|e| { error!( error = ?e, @@ -1806,7 +1798,7 @@ impl SyncNetworkContext { if let Err(e) = { let request = entry.get_mut(); - data_columns.and_then(|(data_columns, _)| { + data_columns.and_then(|data_columns| { request .add_custody_columns(req_id, data_columns.clone()) .map_err(|e| { diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index 3dd3683c42..29cb0a22e5 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -7,7 +7,6 @@ use fnv::FnvHashMap; use lighthouse_network::PeerId; use lighthouse_network::service::api_types::{CustodyId, DataColumnsByRootRequester}; use parking_lot::RwLock; -use slot_clock::SlotClock; use std::collections::HashSet; use std::hash::{BuildHasher, RandomState}; use std::time::{Duration, Instant}; @@ -119,7 +118,7 @@ impl ActiveCustodyRequest { let _guard = batch_request.span.clone().entered(); match resp { - Ok((data_columns, seen_timestamp)) => { + Ok(data_columns) => { debug!( block_root = ?self.block_root, %req_id, @@ -144,12 +143,7 @@ impl ActiveCustodyRequest { .ok_or(Error::BadState("unknown column_index".to_owned()))?; if let Some(data_column) = data_columns.remove(column_index) { - column_request.on_download_success( - req_id, - peer_id, - data_column, - seen_timestamp, - )?; + column_request.on_download_success(req_id, peer_id, data_column)?; } else { // Peer does not have the requested data. // TODO(das) do not consider this case a success. We know for sure the block has @@ -213,30 +207,20 @@ impl ActiveCustodyRequest { if completed_requests >= total_requests { // All requests have completed successfully. let mut peers = HashMap::>::new(); - let mut seen_timestamps = vec![]; let columns = std::mem::take(&mut self.column_requests) .into_values() .map(|request| { - let (peer, data_column, seen_timestamp) = request.complete()?; + let (peer, data_column) = request.complete()?; peers .entry(peer) .or_default() .push(*data_column.index() as usize); - seen_timestamps.push(seen_timestamp); Ok(data_column) }) .collect::, _>>()?; let peer_group = PeerGroup::from_set(peers); - let max_seen_timestamp = seen_timestamps - .into_iter() - .max() - .unwrap_or_else(|| cx.chain.slot_clock.now_duration().unwrap_or_default()); - return Ok(Some(DownloadResult::new( - columns, - peer_group, - max_seen_timestamp, - ))); + return Ok(Some(DownloadResult::new(columns, peer_group))); } let data_columns_by_root_per_peer = @@ -416,7 +400,7 @@ struct ColumnRequest { enum Status { NotStarted(Instant), Downloading(DataColumnsByRootRequestId), - Downloaded(PeerId, Arc>, Duration), + Downloaded(PeerId, Arc>), } impl ColumnRequest { @@ -485,7 +469,6 @@ impl ColumnRequest { req_id: DataColumnsByRootRequestId, peer_id: PeerId, data_column: Arc>, - seen_timestamp: Duration, ) -> Result<(), Error> { match &self.status { Status::Downloading(expected_req_id) => { @@ -495,7 +478,7 @@ impl ColumnRequest { req_id, }); } - self.status = Status::Downloaded(peer_id, data_column, seen_timestamp); + self.status = Status::Downloaded(peer_id, data_column); Ok(()) } other => Err(Error::BadState(format!( @@ -504,11 +487,9 @@ impl ColumnRequest { } } - fn complete(self) -> Result<(PeerId, Arc>, Duration), Error> { + fn complete(self) -> Result<(PeerId, Arc>), Error> { match self.status { - Status::Downloaded(peer_id, data_column, seen_timestamp) => { - Ok((peer_id, data_column, seen_timestamp)) - } + Status::Downloaded(peer_id, data_column) => Ok((peer_id, data_column)), other => Err(Error::BadState(format!( "bad state complete expected Downloaded got {other:?}" ))), diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index cc74785098..b340064746 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -3,7 +3,6 @@ use std::{collections::hash_map::Entry, hash::Hash}; use fnv::FnvHashMap; use lighthouse_network::PeerId; -use slot_clock::timestamp_now; use strum::IntoStaticStr; use tracing::{Span, debug}; use types::{Hash256, Slot}; @@ -122,7 +121,7 @@ impl ActiveReque let result = match rpc_event { // Handler of a success ReqResp chunk. Adds the item to the request accumulator. // `ActiveRequestItems` validates the item before appending to its internal state. - RpcEvent::Response(item, seen_timestamp) => { + RpcEvent::Response(item) => { let request = &mut entry.get_mut(); let _guard = request.span.clone().entered(); match &mut request.state { @@ -133,7 +132,7 @@ impl ActiveReque Ok(true) => { let items = items.consume(); request.state = State::CompletedEarly; - Some(Ok((items, seen_timestamp, request.start_instant.elapsed()))) + Some(Ok((items, request.start_instant.elapsed()))) } // Received item, but we are still expecting more Ok(false) => None, @@ -170,11 +169,7 @@ impl ActiveReque } .into())) } else { - Some(Ok(( - items.consume(), - timestamp_now(), - request.start_instant.elapsed(), - ))) + Some(Ok((items.consume(), request.start_instant.elapsed()))) } } // Items already returned, ignore stream termination @@ -202,7 +197,7 @@ impl ActiveReque }; result.map(|result| match result { - Ok((items, seen_timestamp, duration)) => { + Ok((items, duration)) => { metrics::inc_counter_vec(&metrics::SYNC_RPC_REQUEST_SUCCESSES, &[self.name]); metrics::observe_timer_vec(&metrics::SYNC_RPC_REQUEST_TIME, &[self.name], duration); debug!( @@ -212,7 +207,7 @@ impl ActiveReque "Sync RPC request completed" ); - Ok((items, seen_timestamp)) + Ok(items) } Err(e) => { let err_str: &'static str = match &e { diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index deaf421e39..d84596cf3c 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -43,8 +43,6 @@ use types::{ SignedExecutionPayloadEnvelope, Slot, }; -const D: Duration = Duration::new(0, 0); - /// Extract the Gloas payload envelope (if any) carried by a stored `RangeSyncBlock`. fn envelope_of(block: &RangeSyncBlock) -> Option>> { match block { @@ -886,14 +884,12 @@ impl TestRig { sync_request_id, peer_id, beacon_block: Some(block.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcBlock { sync_request_id, peer_id, beacon_block: None, - seen_timestamp: D, }); } @@ -917,14 +913,12 @@ impl TestRig { sync_request_id, peer_id, blob_sidecar: Some(blob.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcBlob { sync_request_id, peer_id, blob_sidecar: None, - seen_timestamp: D, }); } @@ -953,14 +947,12 @@ impl TestRig { sync_request_id, peer_id, data_column: Some(column.clone()), - seen_timestamp: D, }); } self.push_sync_message(SyncMessage::RpcDataColumn { sync_request_id, peer_id, data_column: None, - seen_timestamp: D, }); } @@ -979,14 +971,12 @@ impl TestRig { sync_request_id, peer_id, envelope: envelope.clone(), - seen_timestamp: D, }); // Stream termination self.push_sync_message(SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, envelope: None, - seen_timestamp: D, }); } @@ -1006,7 +996,6 @@ impl TestRig { sync_request_id, peer_id, envelope: Some(envelope.clone()), - seen_timestamp: D, }); } // Stream termination @@ -1014,7 +1003,6 @@ impl TestRig { sync_request_id, peer_id, envelope: None, - seen_timestamp: D, }); } From dba5a8e268d9b1e2c6776525023aed18310e90ec Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:28:25 +0200 Subject: [PATCH 23/26] Fix peerless lookup getting stuck while awaiting download (#9516) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Pawan Dhananjay --- beacon_node/network/src/sync/block_lookups/mod.rs | 3 +++ .../src/sync/block_lookups/single_block_lookup.rs | 8 ++++++-- beacon_node/network/src/sync/tests/lookups.rs | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index d403382e9e..ef9807f037 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -114,6 +114,8 @@ pub(crate) struct BlockLookupSummary { pub block_root: Hash256, /// List of peers that claim to have imported this set of block components. pub peers: Vec, + /// Whether the lookup expects some future event to make progress. + pub is_awaiting_event: bool, } impl BlockLookups { @@ -150,6 +152,7 @@ impl BlockLookups { id: *id, block_root: l.block_root(), peers: l.all_peers(), + is_awaiting_event: l.is_awaiting_event(), }) .collect() } diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index 346594c2f5..dbf3604cf0 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -355,12 +355,16 @@ impl SingleBlockLookup { self.awaiting_parent.is_some() || self.block_request.state.is_awaiting_event() || match &self.data_request { - DataRequest::WaitingForBlock => true, + // Not awaiting an event itself; it's blocked on the block request, already covered + // by the `block_request` term above. Returning `true` kept a peerless lookup parked + // in `AwaitingDownload` from being pruned, so it got stuck. + DataRequest::WaitingForBlock => false, DataRequest::Request { state, .. } => state.is_awaiting_event(), DataRequest::NoData => false, } || match &self.payload_request { - PayloadRequest::WaitingForBlock => true, + // See `data_request` above: not awaiting an event itself, the block request covers it. + PayloadRequest::WaitingForBlock => false, PayloadRequest::Request { state, .. } => state.is_awaiting_event(), PayloadRequest::PreGloas => false, } diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index d84596cf3c..835b7546b3 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -2407,6 +2407,21 @@ async fn peer_disconnected_then_rpc_error(depth: usize) { r.assert_single_lookups_count(1); } +#[tokio::test] +/// A lookup that loses its only peer while still waiting to download the block must not report +/// itself as awaiting an event, else `drop_lookups_without_peers` skips it and it gets stuck. +/// Regression for the "Notify the devs a sync lookup is stuck" report. +async fn peerless_lookup_awaiting_download_is_not_awaiting_event() { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(1).await; + r.disconnect_all_peers(); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::Disconnected)) + .await; + let lookup = &r.active_single_lookups()[0]; + assert_eq!(lookup.peers.len(), 0); + assert!(!lookup.is_awaiting_event); +} + #[tokio::test] /// Assert that when creating multiple lookups their parent-child relation is discovered and we add /// peers recursively from child to parent. From af90f6a4961d7a1cfb770e733d52f3b1e95dcb26 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 25 Jun 2026 11:15:13 +1000 Subject: [PATCH 24/26] Remove redundant `is_gloas` checks in reorg tests (#9529) Remove some `is_gloas` checks that are unnecessary in the `gloas_reorg_tests.rs`. I found myself wanting to make this change while tweaking these tests in another PR. Figured it makes sense as a simple standalone PR. Co-Authored-By: Michael Sproul Co-Authored-By: hopinheimer <48147533+hopinheimer@users.noreply.github.com> --- .../http_api/tests/gloas_reorg_tests.rs | 136 ++++++------------ 1 file changed, 40 insertions(+), 96 deletions(-) diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 6bbca727f9..e91413dcdf 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -14,7 +14,6 @@ use beacon_chain::{ MakePayloadAttestationOptions, PayloadAttestationVote, SyncCommitteeStrategy, test_spec, }, }; -use eth2::types::ProduceBlockV3Response; use execution_layer::{ForkchoiceState, PayloadAttributes}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; @@ -28,7 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecPayload, ExecutionBlockHash, Hash256, MinimalEthSpec, + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -639,25 +638,14 @@ pub async fn proposer_boost_re_org_test( // `Empty`/`Pending` and the forkchoiceUpdated head hash falls back to B's parent rather than B's // own execution block hash. We skip this when B will be re-orged, since the execution layer // must never be told about a block that is about to be re-orged away. - let is_gloas = harness - .chain - .spec - .fork_name_at_slot::(slot_b) - .gloas_enabled(); - let reveal_block_b_payload = is_gloas && !should_re_org; - let (block_b, block_b_envelope, mut state_b) = if is_gloas { - let (block_b, block_b_envelope, state_b) = harness - .make_block_with_envelope_on(state_a.clone(), slot_b, head_parent_payload_status) - .await; - let block_b_envelope = if reveal_block_b_payload { - block_b_envelope - } else { - None - }; - (block_b, block_b_envelope, state_b) + let reveal_block_b_payload = !should_re_org; + let (block_b, block_b_envelope, mut state_b) = harness + .make_block_with_envelope_on(state_a.clone(), slot_b, head_parent_payload_status) + .await; + let block_b_envelope = if reveal_block_b_payload { + block_b_envelope } else { - let (block_b, state_b) = harness.make_block(state_a.clone(), slot_b).await; - (block_b, None, state_b) + None }; let state_b_root = state_b.canonical_root().unwrap(); let block_b_root = block_b.0.canonical_root(); @@ -735,13 +723,8 @@ pub async fn proposer_boost_re_org_test( let randao_reveal = harness .sign_randao_reveal(&state_b, proposer_index, slot_c) .into(); - let is_gloas = harness - .chain - .spec - .fork_name_at_slot::(slot_c) - .gloas_enabled(); - let (block_c, block_c_blobs) = if is_gloas { + let (block_c, block_c_blobs) = { let (response, _) = tester .client .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) @@ -751,84 +734,51 @@ pub async fn proposer_boost_re_org_test( Arc::new(harness.sign_beacon_block(response.data, &state_b)), None, ) - } else { - let (unsigned_block_type, _) = tester - .client - .get_validator_blocks_v3::(slot_c, &randao_reveal, None, None, None) - .await - .unwrap(); - - let (unsigned_block_c, block_c_blobs) = match unsigned_block_type.data { - ProduceBlockV3Response::Full(unsigned_block_contents_c) => { - unsigned_block_contents_c.deconstruct() - } - ProduceBlockV3Response::Blinded(_) => { - panic!("Should not be a blinded block"); - } - }; - ( - Arc::new(harness.sign_beacon_block(unsigned_block_c, &state_b)), - block_c_blobs, - ) }; // Post-Gloas the execution payload is decoupled from the beacon block: the payload hash // lives in the execution payload bid, and the payload timestamp is derived from the slot. let exec_block_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { - if is_gloas { - block - .body() - .signed_execution_payload_bid() - .unwrap() - .message - .block_hash - } else { - block.execution_payload().unwrap().block_hash() - } + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .block_hash }; let exec_parent_hash = |block: BeaconBlockRef| -> ExecutionBlockHash { - if is_gloas { - block - .body() - .signed_execution_payload_bid() - .unwrap() - .message - .parent_block_hash - } else { - block.execution_payload().unwrap().parent_hash() - } + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .parent_block_hash }; let block_a_exec_hash = exec_block_hash(block_a.0.message()); let block_b_exec_hash = exec_block_hash(block_b.0.message()); - if is_gloas { - assert_eq!( - block_b.0.is_parent_block_full(block_a_exec_hash), - head_parent_payload_status == PayloadStatus::Full - ); - } + assert_eq!( + block_b.0.is_parent_block_full(block_a_exec_hash), + head_parent_payload_status == PayloadStatus::Full + ); if should_re_org { // Block C should build on A. assert_eq!(block_c.parent_root(), Hash256::from(block_a_root)); - if is_gloas { - assert_eq!( - block_c.is_parent_block_full(block_a_exec_hash), - expected_parent_payload_status == PayloadStatus::Full - ); - } + assert_eq!( + block_c.is_parent_block_full(block_a_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); } else { // Block C should build on B. assert_eq!(block_c.parent_root(), block_b_root); - if is_gloas { - assert_eq!( - block_c.is_parent_block_full(block_b_exec_hash), - expected_parent_payload_status == PayloadStatus::Full - ); - } + assert_eq!( + block_c.is_parent_block_full(block_b_exec_hash), + expected_parent_payload_status == PayloadStatus::Full + ); } // Applying block C should cause it to become head regardless (re-org or continuation). @@ -844,11 +794,7 @@ pub async fn proposer_boost_re_org_test( // Check the fork choice updates that were sent. let forkchoice_updates = forkchoice_updates.lock(); - let block_c_timestamp = if is_gloas { - harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs() - } else { - block_c.message().execution_payload().unwrap().timestamp() - }; + let block_c_timestamp = harness.chain.slot_clock.start_of(slot_c).unwrap().as_secs(); // If we re-orged then no fork choice update for B should have been sent. assert_eq!( @@ -872,8 +818,8 @@ pub async fn proposer_boost_re_org_test( .first_update_with_payload_attributes( c_parent_hash, block_c_timestamp, - is_gloas.then(|| block_c.parent_root()), - is_gloas.then(|| slot_c.as_u64()), + Some(block_c.parent_root()), + Some(slot_c.as_u64()), ) .unwrap(); let payload_attribs = first_update.payload_attributes.as_ref().unwrap(); @@ -887,12 +833,10 @@ pub async fn proposer_boost_re_org_test( } else { state_b.clone() }; - let expected_withdrawals = if is_gloas - && matches!( - expected_first_update_lookahead, - ExpectedFirstUpdateLookahead::BlockProduction - ) - && expected_parent_payload_status == PayloadStatus::Empty + let expected_withdrawals = if matches!( + expected_first_update_lookahead, + ExpectedFirstUpdateLookahead::BlockProduction + ) && expected_parent_payload_status == PayloadStatus::Empty { parent_state_advanced .payload_expected_withdrawals() From 99fb99c941bdec47c7fa8d57d9390ef446297335 Mon Sep 17 00:00:00 2001 From: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:53:38 -0400 Subject: [PATCH 25/26] Fix transient bug in `dequeue_attestation` and optimization (#9524) `dequeue_attestations` released votes by splitting the queue at the first entry with `slot >= current_slot`, which assumes the queue is sorted by slot. It isn't: `on_attestation` pushes attestations in arrival order and never sorts. When a future-slot vote sits ahead of a vote that is already due, the split happens at the future-slot vote and the due vote stays stuck behind it and is never applied to fork choice, even after its slot is in the past. The PR current uses a naive solution to solve the bug and also adds regression tests to exercise the bug. There are other competing solutions which can be used which also optimize this path at the same time. https://github.com/sigp/lighthouse/pull/8378 https://github.com/sigp/lighthouse/pull/8378#discussion_r2543322106 Co-Authored-By: hopinheimer Co-Authored-By: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Co-Authored-By: Michael Sproul --- Cargo.lock | 1 + consensus/fork_choice/Cargo.toml | 5 + consensus/fork_choice/benches/benches.rs | 62 ++++++++++ consensus/fork_choice/src/fork_choice.rs | 108 ++++++++++------ consensus/fork_choice/src/lib.rs | 2 +- consensus/fork_choice/src/metrics.rs | 6 +- consensus/fork_choice/tests/tests.rs | 150 ++++++++++++++++++++--- 7 files changed, 278 insertions(+), 56 deletions(-) create mode 100644 consensus/fork_choice/benches/benches.rs diff --git a/Cargo.lock b/Cargo.lock index d4a531d26d..5d200768cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3600,6 +3600,7 @@ version = "0.1.0" dependencies = [ "beacon_chain", "bls", + "criterion", "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", diff --git a/consensus/fork_choice/Cargo.toml b/consensus/fork_choice/Cargo.toml index df47a5c9d1..03dace1f9f 100644 --- a/consensus/fork_choice/Cargo.toml +++ b/consensus/fork_choice/Cargo.toml @@ -20,5 +20,10 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } bls = { workspace = true } +criterion = { workspace = true } store = { workspace = true } tokio = { workspace = true } + +[[bench]] +name = "benches" +harness = false diff --git a/consensus/fork_choice/benches/benches.rs b/consensus/fork_choice/benches/benches.rs new file mode 100644 index 0000000000..bf01b4c346 --- /dev/null +++ b/consensus/fork_choice/benches/benches.rs @@ -0,0 +1,62 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use fork_choice::{QueuedAttestation, dequeue_attestations}; +use std::collections::BTreeMap; +use types::{Epoch, Hash256, Slot}; + +fn att(slot: Slot) -> QueuedAttestation { + QueuedAttestation { + slot, + attesting_indices: vec![], + block_root: Hash256::ZERO, + target_epoch: Epoch::new(0), + payload_present: false, + } +} + +// Anticipated steady-state workload on mainnet: ~94k attestations spread over a small number of slots, then +// many iterations of dequeue (one slot's worth) + enqueue (one slot's worth for a future slot). +// The queue stays at a constant size, exercising the per-slot dequeue cost. +const NUM_ATTESTATIONS: usize = 1_500_000 / 16; +const UNIQUE_SLOTS: usize = 2; +const NUM_ITERATIONS: usize = 64; +const PER_SLOT: usize = NUM_ATTESTATIONS / UNIQUE_SLOTS; + +fn build_queue() -> BTreeMap> { + let mut queue: BTreeMap> = BTreeMap::new(); + for i in 0..NUM_ATTESTATIONS { + let slot = Slot::from(i / PER_SLOT); + queue.entry(slot).or_default().push(att(slot)); + } + queue +} + +fn all_benches(c: &mut Criterion) { + let initial = build_queue(); + + c.bench_with_input( + BenchmarkId::new("dequeue_attestations", NUM_ATTESTATIONS), + &initial, + |b, initial| { + b.iter(|| { + let mut queue = initial.clone(); + for i in 1..=NUM_ITERATIONS { + let dequeued = dequeue_attestations(Slot::from(i), &mut queue); + let dequeued_count: usize = dequeued.values().map(Vec::len).sum(); + assert_eq!(dequeued_count, PER_SLOT); + + let next_slot = Slot::from(UNIQUE_SLOTS + i - 1); + queue + .entry(next_slot) + .or_default() + .extend(std::iter::repeat_with(|| att(next_slot)).take(PER_SLOT)); + + let total: usize = queue.values().map(Vec::len).sum(); + assert_eq!(total, NUM_ATTESTATIONS); + } + }) + }, + ); +} + +criterion_group!(benches, all_benches); +criterion_main!(benches); diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index aca5ab7851..95c8f70a04 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -11,7 +11,7 @@ use state_processing::{ per_block_processing::errors::AttesterSlashingValidationError, per_epoch_processing, }; use std::cmp::Ordering; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::marker::PhantomData; use std::time::Duration; use superstruct::superstruct; @@ -284,12 +284,12 @@ fn compute_start_slot_at_epoch(epoch: Epoch) -> Slot { /// information about the attestation. #[derive(Clone, PartialEq, Encode, Decode)] pub struct QueuedAttestation { - slot: Slot, - attesting_indices: Vec, - block_root: Hash256, - target_epoch: Epoch, + pub slot: Slot, + pub attesting_indices: Vec, + pub block_root: Hash256, + pub target_epoch: Epoch, /// Per Gloas spec: `payload_present = attestation.data.index == 1`. - payload_present: bool, + pub payload_present: bool, } /// Legacy queued attestation without payload_present (pre-Gloas, schema V28). @@ -313,25 +313,22 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { } } -/// Returns all values in `self.queued_attestations` that have a slot that is earlier than the -/// current slot. Also removes those values from `self.queued_attestations`. -fn dequeue_attestations( +/// Returns all attestations in `queued_attestations` with a slot earlier than the current slot, +/// removing them from the queue. +pub fn dequeue_attestations( current_slot: Slot, - queued_attestations: &mut Vec, -) -> Vec { - let remaining = queued_attestations.split_off( - queued_attestations - .iter() - .position(|a| a.slot >= current_slot) - .unwrap_or(queued_attestations.len()), - ); + queued_attestations: &mut BTreeMap>, +) -> BTreeMap> { + let remaining = queued_attestations.split_off(¤t_slot); + let due = std::mem::replace(queued_attestations, remaining); + let dequeued_count: usize = due.values().map(Vec::len).sum(); metrics::inc_counter_by( &metrics::FORK_CHOICE_DEQUEUED_ATTESTATIONS, - queued_attestations.len() as u64, + dequeued_count as u64, ); - std::mem::replace(queued_attestations, remaining) + due } /// Denotes whether an attestation we are processing was received from a block or from gossip. @@ -376,8 +373,9 @@ pub struct ForkChoice { fc_store: T, /// The underlying representation of the block DAG. proto_array: ProtoArrayForkChoice, - /// Attestations that arrived at the current slot and must be queued for later processing. - queued_attestations: Vec, + /// Attestations that arrived at the current slot and must be queued for later processing, + /// keyed by their slot. + queued_attestations: BTreeMap>, /// Stores a cache of the values required to be sent to the execution layer. forkchoice_update_parameters: ForkchoiceUpdateParameters, _phantom: PhantomData, @@ -474,7 +472,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: vec![], + queued_attestations: BTreeMap::new(), // This will be updated during the next call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1353,8 +1351,11 @@ where // Attestations can only affect the fork choice of subsequent slots. // Delay consideration in the fork choice until their slot is in the past. // ``` + let queued_attestation = QueuedAttestation::from(attestation); self.queued_attestations - .push(QueuedAttestation::from(attestation)); + .entry(queued_attestation.slot) + .or_default() + .push(queued_attestation); } Ok(()) @@ -1546,10 +1547,11 @@ where /// Processes and removes from the queue any queued attestations which may now be eligible for /// processing due to the slot clock incrementing. fn process_attestation_queue(&mut self) -> Result<(), Error> { - for attestation in dequeue_attestations( + let dequeued = dequeue_attestations( self.fc_store.get_current_slot(), &mut self.queued_attestations, - ) { + ); + for attestation in dequeued.into_values().flatten() { for validator_index in attestation.attesting_indices.iter() { self.proto_array.process_attestation( *validator_index as usize, @@ -1795,8 +1797,8 @@ where &self.fc_store } - /// Returns a reference to the currently queued attestations. - pub fn queued_attestations(&self) -> &[QueuedAttestation] { + /// Returns a reference to the currently queued attestations, keyed by slot. + pub fn queued_attestations(&self) -> &BTreeMap> { &self.queued_attestations } @@ -1881,7 +1883,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: vec![], + queued_attestations: BTreeMap::new(), // Will be updated in the following call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1994,20 +1996,33 @@ mod tests { } } - fn get_queued_attestations() -> Vec { - (1..4) - .map(|i| QueuedAttestation { - slot: Slot::new(i), + fn queue_from_slots( + slots: impl IntoIterator, + ) -> BTreeMap> { + let mut queued: BTreeMap> = BTreeMap::new(); + for i in slots { + let slot = Slot::new(i); + queued.entry(slot).or_default().push(QueuedAttestation { + slot, attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), payload_present: false, - }) - .collect() + }); + } + queued } - fn get_slots(queued_attestations: &[QueuedAttestation]) -> Vec { - queued_attestations.iter().map(|a| a.slot.into()).collect() + fn get_queued_attestations() -> BTreeMap> { + queue_from_slots(1..4) + } + + fn get_slots(queued_attestations: &BTreeMap>) -> Vec { + queued_attestations + .values() + .flatten() + .map(|a| a.slot.into()) + .collect() } fn test_queued_attestations(current_time: Slot) -> (Vec, Vec) { @@ -2039,4 +2054,25 @@ mod tests { assert!(queued.is_empty()); assert_eq!(dequeued, vec![1, 2, 3]); } + + #[test] + fn dequeue_attestations_out_of_order() { + // A future-slot vote enqueued before a vote that becomes due sooner must not block the + // due vote from being released. + let mut queued = queue_from_slots([4, 3]); + + // At slot 4, the slot-3 vote is due (3 < 4) and must be released. + let dequeued = dequeue_attestations(Slot::new(4), &mut queued); + + assert_eq!( + get_slots(&dequeued), + vec![3], + "slot-3 vote must be dequeued at slot 4" + ); + assert_eq!( + get_slots(&queued), + vec![4], + "only the not-yet-due slot-4 vote should remain" + ); + } } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index dcc499547b..453926fbee 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -6,7 +6,7 @@ pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, InvalidPayloadAttestation, ParentImportStatus, PayloadVerificationStatus, PersistedForkChoice, PersistedForkChoiceV28, PersistedForkChoiceV29, - QueuedAttestation, ResetPayloadStatuses, + QueuedAttestation, ResetPayloadStatuses, dequeue_attestations, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/fork_choice/src/metrics.rs b/consensus/fork_choice/src/metrics.rs index b5cda2f587..2ad18fc1ed 100644 --- a/consensus/fork_choice/src/metrics.rs +++ b/consensus/fork_choice/src/metrics.rs @@ -49,7 +49,11 @@ pub static FORK_CHOICE_ON_ATTESTER_SLASHING_TIMES: LazyLock> = pub fn scrape_for_metrics, E: EthSpec>(fork_choice: &ForkChoice) { set_gauge( &FORK_CHOICE_QUEUED_ATTESTATIONS, - fork_choice.queued_attestations().len() as i64, + fork_choice + .queued_attestations() + .values() + .map(Vec::len) + .sum::() as i64, ); set_gauge( &FORK_CHOICE_NODES, diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 02229e6f33..9b3f5d8857 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -149,13 +149,17 @@ impl ForkChoiceTest { .fork_choice_write_lock() .update_time(self.harness.chain.slot().unwrap()) .unwrap(); - func( - self.harness - .chain - .canonical_head - .fork_choice_read_lock() - .queued_attestations(), - ); + let queued = self + .harness + .chain + .canonical_head + .fork_choice_read_lock() + .queued_attestations() + .values() + .flatten() + .cloned() + .collect::>(); + func(&queued); self } @@ -415,6 +419,24 @@ impl ForkChoiceTest { async fn apply_attestation_to_chain( self, delay: MutationDelay, + mutation_func: F, + comparison_func: G, + ) -> Self + where + F: FnMut(&mut IndexedAttestation, &BeaconChain>), + G: FnMut(Result<(), BeaconChainError>), + { + self.apply_nth_attestation_to_chain(0, delay, mutation_func, comparison_func) + .await + } + + /// Like `apply_attestation_to_chain`, but attests with the validator at + /// `validator_index_in_committee` within the committee. Lets a test enqueue multiple distinct + /// votes for the same slot without tripping `PriorAttestationKnown`. + async fn apply_nth_attestation_to_chain( + self, + validator_index_in_committee: usize, + delay: MutationDelay, mut mutation_func: F, mut comparison_func: G, ) -> Self @@ -431,18 +453,16 @@ impl ForkChoiceTest { .produce_unaggregated_attestation(current_slot, 0) .expect("should not error while producing attestation"); - let validator_committee_index = 0; + // For these tests we always use committee index 0, which also matches the "dummy" committee + // index used post-Electra. + let committee_index = 0; + let validator_index = *head .beacon_state - .get_beacon_committee( - current_slot, - attestation - .committee_index() - .expect("should get committee index"), - ) + .get_beacon_committee(current_slot, committee_index) .expect("should get committees") .committee - .get(validator_committee_index) + .get(validator_index_in_committee) .expect("there should be an attesting validator"); let committee_count = head @@ -452,7 +472,7 @@ impl ForkChoiceTest { let subnet_id = SubnetId::compute_subnet::( current_slot, - 0, + committee_index, committee_count, &self.harness.chain.spec, ) @@ -463,7 +483,7 @@ impl ForkChoiceTest { attestation .sign( &validator_sk, - validator_committee_index, + committee_index as usize, &head.beacon_state.fork(), self.harness.chain.genesis_validators_root, &self.harness.chain.spec, @@ -472,7 +492,7 @@ impl ForkChoiceTest { let single_attestation = SingleAttestation { attester_index: validator_index as u64, - committee_index: validator_committee_index as u64, + committee_index, data: attestation.data().clone(), signature: attestation.signature().clone(), }; @@ -1039,6 +1059,100 @@ async fn invalid_attestation_delayed_slot() { .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)); } +/// Regression test for dequeuing when votes for two different future slots are queued. +/// +/// With votes queued for consecutive slots, advancing the clock past the earlier one must release +/// only that vote and leave the later one queued until its own slot is in the past. +#[tokio::test] +async fn dequeue_attestations_consecutive_slot_divergence() { + ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)) + // Queue a vote for `slot + 2`. + .apply_nth_attestation_to_chain( + 0, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 2; + }, + |result| assert!(result.is_ok()), + ) + .await + // Queue a vote for `slot + 1`, which becomes due sooner. + // A different committee position avoids `PriorAttestationKnown`. + .apply_nth_attestation_to_chain( + 1, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 1; + }, + |result| assert!(result.is_ok()), + ) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 2)) + // Advance so the slot+1 vote is due (in the past) but the slot+2 vote is not yet. + .skip_slots(2) + .inspect_queued_attestations(|queue| { + assert_eq!( + queue.len(), + 1, + "only the due slot+1 vote should be dequeued" + ); + assert_eq!( + queue[0].slot, + Slot::new(3), + "the surviving vote must be the not-yet-due slot+2 vote" + ); + }); +} + +/// Companion to `dequeue_attestations_consecutive_slot_divergence`: votes for two different slots +/// are queued, but the clock is advanced far enough that *both* are due at dequeue time. +/// +/// When every queued vote is in the past, the whole queue drains in a single dequeue. +#[tokio::test] +async fn dequeue_attestations_conciliation() { + ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 0)) + // Queue a vote for `slot + 2`. + .apply_nth_attestation_to_chain( + 0, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 2; + }, + |result| assert!(result.is_ok()), + ) + .await + // Queue a vote for `slot + 1`. + .apply_nth_attestation_to_chain( + 1, + MutationDelay::NoDelay, + |attestation, _| { + let slot = attestation.data().slot; + attestation.data_mut().slot = slot + 1; + }, + |result| assert!(result.is_ok()), + ) + .await + .inspect_queued_attestations(|queue| assert_eq!(queue.len(), 2)) + // Advance past both votes (to slot + 3) so the whole queue drains. + .skip_slots(3) + .inspect_queued_attestations(|queue| { + assert_eq!( + queue.len(), + 0, + "all votes are due, so the entire queue must drain" + ); + }); +} + /// Tests that the correct target root is used when the attested-to block is in a prior epoch to /// the attestation. #[tokio::test] From a4c4cccf04a2e647ace2a2f9ecdcea91915075ef Mon Sep 17 00:00:00 2001 From: ethDreamer <37123614+ethDreamer@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:53:43 -0500 Subject: [PATCH 26/26] Refactor Custody Context Availability Checks (#9515) Co-Authored-By: Mark Mackey --- beacon_node/beacon_chain/src/beacon_chain.rs | 121 +---- .../beacon_chain/src/block_verification.rs | 3 +- .../src/block_verification_types.rs | 13 +- beacon_node/beacon_chain/src/builder.rs | 23 +- .../beacon_chain/src/canonical_head.rs | 3 +- .../beacon_chain/src/custody_context.rs | 500 +++++++++++------- .../src/data_availability_checker.rs | 160 ++---- .../overflow_lru_cache.rs | 43 +- .../src/historical_data_columns.rs | 3 +- beacon_node/beacon_chain/src/invariants.rs | 6 +- .../src/payload_envelope_verification/mod.rs | 88 ++- .../src/pending_payload_cache/mod.rs | 64 +-- .../pending_components.rs | 54 +- beacon_node/beacon_chain/src/test_utils.rs | 98 ++-- .../beacon_chain/tests/block_verification.rs | 147 ++--- beacon_node/beacon_chain/tests/events.rs | 10 +- beacon_node/beacon_chain/tests/store_tests.rs | 29 +- beacon_node/client/src/notifier.rs | 8 +- .../src/beacon/execution_payload_envelopes.rs | 2 +- beacon_node/http_api/src/block_id.rs | 7 +- beacon_node/http_api/src/custody.rs | 12 +- beacon_node/http_api/src/lib.rs | 5 +- beacon_node/http_api/src/publish_blocks.rs | 2 +- beacon_node/http_api/src/validator/mod.rs | 5 +- .../tests/broadcast_validation_tests.rs | 1 + .../http_api/tests/interactive_tests.rs | 9 +- .../gossip_methods.rs | 8 +- .../src/network_beacon_processor/mod.rs | 4 +- .../network_beacon_processor/rpc_methods.rs | 42 +- .../src/network_beacon_processor/tests.rs | 8 +- beacon_node/network/src/service.rs | 5 +- .../network/src/sync/backfill_sync/mod.rs | 5 +- .../sync/block_lookups/single_block_lookup.rs | 11 +- .../src/sync/block_sidecar_coupling.rs | 134 ++--- .../src/sync/custody_backfill_sync/mod.rs | 53 +- .../network/src/sync/network_context.rs | 23 +- .../network/src/sync/range_sync/chain.rs | 3 + beacon_node/network/src/sync/tests/lookups.rs | 53 +- .../execution/signed_execution_payload_bid.rs | 4 + 39 files changed, 939 insertions(+), 830 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d175c54be7..708a07021d 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -16,7 +16,7 @@ use crate::block_verification_types::{ }; pub use crate::canonical_head::CanonicalHead; use crate::chain_config::ChainConfig; -use crate::custody_context::CustodyContextSsz; +use crate::custody_context::{CustodyContext, CustodyContextSsz}; use crate::data_availability_checker::{ Availability as BlockAvailability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, DataColumnReconstructionResult as DataColumnReconstructionResultV1, @@ -500,6 +500,8 @@ pub struct BeaconChain { pub validator_monitor: RwLock>, /// The slot at which blocks are downloaded back to. pub genesis_backfill_slot: Slot, + /// Contains all the information the node requires to calculate which columns to custody + pub custody_context: Arc>, /// Provides KZG verification and temporary storage for pre-Gloas blocks and blobs. pub data_availability_checker: Arc>, /// Provides KZG verification and temporary storage for post-Gloas payload envelopes. @@ -682,11 +684,7 @@ impl BeaconChain { return Ok(()); } - let custody_context: CustodyContextSsz = self - .data_availability_checker - .custody_context() - .as_ref() - .into(); + let custody_context: CustodyContextSsz = self.custody_context.as_ref().into(); // Pattern match to avoid accidentally missing fields and to ignore deprecated fields. let CustodyContextSsz { @@ -3318,8 +3316,9 @@ impl BeaconChain { ); // Check if we have custody of this column - let sampling_columns = - self.sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); + let sampling_columns = self + .custody_context + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); let verified_partial = if sampling_columns.contains(&partial.index) { KzgVerifiedCustodyPartialDataColumn::from_asserted_custody(verified_partial) } else { @@ -3981,7 +3980,7 @@ impl BeaconChain { )?; let availability = self .data_availability_checker - .put_rpc_blobs(block_root, blobs) + .put_rpc_blobs(block_root, blobs, &self.slot_clock) .map_err(BlockError::from)?; self.process_availability(slot, availability, || Ok(())) @@ -7170,25 +7169,6 @@ impl BeaconChain { }) } - /// The data availability boundary for custodying columns. It will just be the - /// regular data availability boundary unless we are near the Fulu fork epoch. - pub fn column_data_availability_boundary(&self) -> Option { - match self.data_availability_boundary() { - Some(da_boundary_epoch) => { - if let Some(fulu_fork_epoch) = self.spec.fulu_fork_epoch { - if da_boundary_epoch < fulu_fork_epoch { - Some(fulu_fork_epoch) - } else { - Some(da_boundary_epoch) - } - } else { - None // Fulu hasn't been enabled - } - } - None => None, // Deneb hasn't been enabled - } - } - /// Safely update data column custody info by ensuring that: /// - cgc values at the updated epoch and the earliest custodied column epoch are equal /// - we are only decrementing the earliest custodied data column epoch by one epoch @@ -7206,14 +7186,12 @@ impl BeaconChain { } let cgc_at_effective_epoch = self - .data_availability_checker - .custody_context() - .custody_group_count_at_epoch(effective_epoch, &self.spec); + .custody_context + .custody_group_count_at_epoch(effective_epoch); let cgc_at_earliest_data_colum_epoch = self - .data_availability_checker - .custody_context() - .custody_group_count_at_epoch(earliest_data_column_epoch, &self.spec); + .custody_context + .custody_group_count_at_epoch(earliest_data_column_epoch); let can_update_data_column_custody_info = cgc_at_effective_epoch == cgc_at_earliest_data_colum_epoch @@ -7240,16 +7218,16 @@ impl BeaconChain { /// Compare columns custodied for `epoch` versus columns custodied for the head of the chain /// and return any column indices that are missing. pub fn get_missing_columns_for_epoch(&self, epoch: Epoch) -> HashSet { - let custody_context = self.data_availability_checker.custody_context(); - - let columns_required = custody_context - .custody_columns_for_epoch(None, &self.spec) + let columns_required = self + .custody_context + .custody_columns_for_epoch(None) .iter() .cloned() .collect::>(); - let current_columns_at_epoch = custody_context - .custody_columns_for_epoch(Some(epoch), &self.spec) + let current_columns_at_epoch = self + .custody_context + .custody_columns_for_epoch(Some(epoch)) .iter() .cloned() .collect::>(); @@ -7260,24 +7238,6 @@ impl BeaconChain { .collect::>() } - /// The da boundary for custodying columns. It will just be the DA boundary unless we are near the Fulu fork epoch. - pub fn get_column_da_boundary(&self) -> Option { - match self.data_availability_boundary() { - Some(da_boundary_epoch) => { - if let Some(fulu_fork_epoch) = self.spec.fulu_fork_epoch { - if da_boundary_epoch < fulu_fork_epoch { - Some(fulu_fork_epoch) - } else { - Some(da_boundary_epoch) - } - } else { - None - } - } - None => None, // If no DA boundary set, dont try to custody backfill - } - } - /// This method serves to get a sense of the current chain health. It is used in block proposal /// to determine whether we should outsource payload production duties. /// @@ -7480,30 +7440,6 @@ impl BeaconChain { gossip_attested || block_attested || aggregated || produced_block } - /// 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 { - self.data_availability_checker.data_availability_boundary() - } - - /// Returns true if epoch is within the data availability boundary - pub fn da_check_required_for_epoch(&self, epoch: Epoch) -> bool { - self.data_availability_checker - .da_check_required_for_epoch(epoch) - } - - /// Returns true if we should fetch blobs for this block - pub fn should_fetch_blobs(&self, block_epoch: Epoch) -> bool { - self.da_check_required_for_epoch(block_epoch) - && !self.spec.is_peer_das_enabled_for_epoch(block_epoch) - } - - /// Returns true if we should fetch custody columns for this block - pub fn should_fetch_custody_columns(&self, block_epoch: Epoch) -> bool { - self.da_check_required_for_epoch(block_epoch) - && self.spec.is_peer_das_enabled_for_epoch(block_epoch) - } - /// Gets the `LightClientBootstrap` object for a requested block root. /// /// Returns `None` when the state or block is not found in the database. @@ -7542,7 +7478,7 @@ impl BeaconChain { Some(StoreOp::PutBlobs(block_root, blobs)) } AvailableBlockData::DataColumns(mut data_columns) => { - let columns_to_custody = self.custody_columns_for_epoch(Some( + let columns_to_custody = self.custody_context.custody_columns_for_epoch(Some( block_slot.epoch(T::EthSpec::slots_per_epoch()), )); // Supernodes need to persist all sampled custody columns @@ -7588,25 +7524,6 @@ impl BeaconChain { roots.reverse(); roots } - - /// Returns a list of column indices that should be sampled for a given epoch. - /// Used for data availability sampling in PeerDAS. - pub fn sampling_columns_for_epoch(&self, epoch: Epoch) -> &[ColumnIndex] { - self.data_availability_checker - .custody_context() - .sampling_columns_for_epoch(epoch, &self.spec) - } - - /// Returns a list of column indices that the node is expected to custody for a given epoch. - /// i.e. the node must have validated and persisted the column samples and should be able to - /// serve them to peers. - /// - /// If epoch is `None`, this function computes the custody columns at head. - pub fn custody_columns_for_epoch(&self, epoch_opt: Option) -> &[ColumnIndex] { - self.data_availability_checker - .custody_context() - .custody_columns_for_epoch(epoch_opt, &self.spec) - } } impl Drop for BeaconChain { diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 6b1ac3b033..0de9a5cdb1 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1246,8 +1246,7 @@ impl SignatureVerifiedBlock { AvailableBlock::new( block, AvailableBlockData::NoData, - &chain.data_availability_checker, - chain.spec.clone(), + &chain.custody_context, ) .map_err(BlockError::AvailabilityCheck)?, ) diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index 18e95f58f3..51feb12a69 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -1,18 +1,18 @@ -use crate::data_availability_checker::{AvailabilityCheckError, DataAvailabilityChecker}; +use crate::data_availability_checker::AvailabilityCheckError; pub use crate::data_availability_checker::{ AvailableBlock, AvailableBlockData, MaybeAvailableBlock, }; use crate::payload_envelope_verification::AvailableEnvelope; use crate::payload_envelope_verification::gossip_verified_envelope::verify_envelope_consistency; -use crate::{BeaconChainTypes, PayloadVerificationOutcome}; +use crate::{BeaconChainTypes, CustodyContext, PayloadVerificationOutcome}; use state_processing::ConsensusContext; use std::fmt::{Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::sync::Arc; use types::data::BlobIdentifier; use types::{ - BeaconBlockRef, BeaconState, BlindedPayload, ChainSpec, Epoch, EthSpec, Hash256, - SignedBeaconBlock, SignedBeaconBlockHeader, Slot, + BeaconBlockRef, BeaconState, BlindedPayload, Epoch, EthSpec, Hash256, SignedBeaconBlock, + SignedBeaconBlockHeader, Slot, }; /// A wrapper around a `SignedBeaconBlock`. This varaint is constructed @@ -118,8 +118,7 @@ impl RangeSyncBlock { pub fn new( block: Arc>, block_data: AvailableBlockData, - da_checker: &DataAvailabilityChecker, - spec: Arc, + custody_context: &CustodyContext, ) -> Result where T: BeaconChainTypes, @@ -127,7 +126,7 @@ impl RangeSyncBlock { if block.fork_name_unchecked().gloas_enabled() { return Err(AvailabilityCheckError::InvalidVariant); } - let available_block = AvailableBlock::new(block, block_data, da_checker, spec)?; + let available_block = AvailableBlock::new(block, block_data, custody_context)?; Ok(Self::Base(available_block)) } diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 91a9dcc7c8..3b137f6faa 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -951,14 +951,18 @@ where self.node_custody_type, head_epoch, ordered_custody_column_indices, - &self.spec, + slot_clock.clone(), + complete_blob_backfill, + self.spec.clone(), ) } else { ( CustodyContext::new( self.node_custody_type, ordered_custody_column_indices, - &self.spec, + slot_clock.clone(), + complete_blob_backfill, + self.spec.clone(), ), None, ) @@ -1036,10 +1040,9 @@ where slasher: self.slasher.clone(), validator_monitor: RwLock::new(validator_monitor), genesis_backfill_slot, + custody_context: custody_context.clone(), data_availability_checker: Arc::new( DataAvailabilityChecker::new( - complete_blob_backfill, - slot_clock.clone(), self.kzg.clone(), custody_context.clone(), self.spec.clone(), @@ -1049,12 +1052,8 @@ where .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), pending_payload_cache: Arc::new( - PendingPayloadCache::new( - self.kzg.clone(), - custody_context.clone(), - self.spec.clone(), - ) - .map_err(|e| format!("Error initializing PendingPayloadCache: {:?}", e))?, + PendingPayloadCache::new(self.kzg.clone(), custody_context, self.spec.clone()) + .map_err(|e| format!("Error initializing PendingPayloadCache: {:?}", e))?, ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), @@ -1125,7 +1124,9 @@ where } // Prune blobs older than the blob data availability boundary in the background. - if let Some(data_availability_boundary) = beacon_chain.data_availability_boundary() { + if let Some(data_availability_boundary) = + beacon_chain.custody_context.data_availability_boundary() + { beacon_chain .store_migrator .process_prune_blobs(data_availability_boundary); diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1eab7ccf7a..5d7a7f1527 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -1065,7 +1065,8 @@ impl BeaconChain { )?; // Prune blobs in the background. - if let Some(data_availability_boundary) = self.data_availability_boundary() { + if let Some(data_availability_boundary) = self.custody_context.data_availability_boundary() + { self.store_migrator .process_prune_blobs(data_availability_boundary); } diff --git a/beacon_node/beacon_chain/src/custody_context.rs b/beacon_node/beacon_chain/src/custody_context.rs index 72f62db1b4..4a1dfdbe71 100644 --- a/beacon_node/beacon_chain/src/custody_context.rs +++ b/beacon_node/beacon_chain/src/custody_context.rs @@ -1,13 +1,18 @@ +use crate::BeaconChainTypes; +use educe::Educe; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use slot_clock::SlotClock; use ssz_derive::{Decode, Encode}; -use std::marker::PhantomData; use std::{ collections::{BTreeMap, HashMap}, + sync::Arc, sync::atomic::{AtomicU64, Ordering}, }; use tracing::{debug, warn}; -use types::{ChainSpec, ColumnIndex, Epoch, EthSpec, Slot}; +use types::{ + ChainSpec, ColumnIndex, Epoch, EthSpec, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, +}; /// A delay before making the CGC change effective to the data availability checker. pub const CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS: u64 = 30; @@ -236,8 +241,9 @@ impl NodeCustodyType { /// Contains all the information the node requires to calculate the /// number of columns to be custodied when checking for DA. -#[derive(Debug)] -pub struct CustodyContext { +#[derive(Educe)] +#[educe(Debug(bound(T: BeaconChainTypes)))] +pub struct CustodyContext { /// The Number of custody groups required based on the number of validators /// that is attached to this node. /// @@ -250,10 +256,15 @@ pub struct CustodyContext { /// Stores an immutable, ordered list of all data column indices as determined by the node's NodeID /// on startup. This used to determine the node's custody columns. ordered_custody_column_indices: Vec, - _phantom_data: PhantomData, + #[educe(Debug(ignore))] + slot_clock: T::SlotClock, + /// backfill blobs and data columns beyond the data availability window. + complete_blob_backfill: bool, + #[educe(Debug(ignore))] + spec: Arc, } -impl CustodyContext { +impl CustodyContext { /// Create a new custody default custody context object when no persisted object /// exists. /// @@ -261,9 +272,11 @@ impl CustodyContext { pub fn new( node_custody_type: NodeCustodyType, ordered_custody_column_indices: Vec, - spec: &ChainSpec, + slot_clock: T::SlotClock, + complete_blob_backfill: bool, + spec: Arc, ) -> Self { - let cgc_override = node_custody_type.get_custody_count_override(spec); + let cgc_override = node_custody_type.get_custody_count_override(&spec); // If there's no override, we initialise `validator_custody_count` to 0. This has been the // existing behaviour and we maintain this for now to avoid a semantic schema change until // a later release. @@ -271,7 +284,9 @@ impl CustodyContext { validator_custody_count: AtomicU64::new(cgc_override.unwrap_or(0)), validator_registrations: RwLock::new(ValidatorRegistrations::new(cgc_override)), ordered_custody_column_indices, - _phantom_data: PhantomData, + slot_clock, + complete_blob_backfill, + spec, } } @@ -294,7 +309,9 @@ impl CustodyContext { node_custody_type: NodeCustodyType, head_epoch: Epoch, ordered_custody_column_indices: Vec, - spec: &ChainSpec, + slot_clock: T::SlotClock, + complete_blob_backfill: bool, + spec: Arc, ) -> (Self, Option) { let CustodyContextSsz { mut validator_custody_at_head, @@ -304,7 +321,7 @@ impl CustodyContext { let mut custody_count_changed = None; - if let Some(cgc_from_cli) = node_custody_type.get_custody_count_override(spec) { + if let Some(cgc_from_cli) = node_custody_type.get_custody_count_override(&spec) { debug!( ?node_custody_type, persisted_custody_count = validator_custody_at_head, @@ -360,7 +377,9 @@ impl CustodyContext { .collect(), }), ordered_custody_column_indices, - _phantom_data: PhantomData, + slot_clock, + complete_blob_backfill, + spec, }; (custody_context, custody_count_changed) @@ -376,13 +395,15 @@ impl CustodyContext { &self, validators_and_balance: ValidatorsAndBalances, current_slot: Slot, - spec: &ChainSpec, ) -> Option { let Some((effective_epoch, new_validator_custody)) = self .validator_registrations .write() - .register_validators::(validators_and_balance, current_slot, spec) - else { + .register_validators::( + validators_and_balance, + current_slot, + &self.spec, + ) else { return None; }; @@ -397,7 +418,7 @@ impl CustodyContext { self.validator_custody_count .store(new_validator_custody, Ordering::Relaxed); - let updated_cgc = self.custody_group_count_at_head(spec); + let updated_cgc = self.custody_group_count_at_head(); // Send the message to network only if there are more columns subnets to subscribe to if updated_cgc > current_cgc { debug!( @@ -407,7 +428,7 @@ impl CustodyContext { return Some(CustodyCountChanged { new_custody_group_count: updated_cgc, old_custody_group_count: current_cgc, - sampling_count: self.num_of_custody_groups_to_sample(effective_epoch, spec), + sampling_count: self.num_of_custody_groups_to_sample(effective_epoch), effective_epoch, }); } @@ -419,14 +440,14 @@ impl CustodyContext { /// This function is used to determine the custody group count at head ONLY. /// Do NOT use this directly for data availability check, use `self.sampling_size` instead as /// CGC can change over epochs. - pub fn custody_group_count_at_head(&self, spec: &ChainSpec) -> u64 { + pub fn custody_group_count_at_head(&self) -> u64 { let validator_custody_count_at_head = self.validator_custody_count.load(Ordering::Relaxed); // If there are no validators, return the minimum custody_requirement if validator_custody_count_at_head > 0 { validator_custody_count_at_head } else { - spec.custody_requirement + self.spec.custody_requirement } } @@ -436,33 +457,35 @@ impl CustodyContext { /// minimum sampling size which may exceed the custody group count (CGC). /// /// See also: [`Self::num_of_custody_groups_to_sample`]. - pub fn custody_group_count_at_epoch(&self, epoch: Epoch, spec: &ChainSpec) -> u64 { + pub fn custody_group_count_at_epoch(&self, epoch: Epoch) -> u64 { self.validator_registrations .read() .custody_requirement_at_epoch(epoch) - .unwrap_or(spec.custody_requirement) + .unwrap_or(self.spec.custody_requirement) } /// Returns the count of custody groups this node must _sample_ for a block at `epoch` to import. - pub fn num_of_custody_groups_to_sample(&self, epoch: Epoch, spec: &ChainSpec) -> u64 { - let custody_group_count = self.custody_group_count_at_epoch(epoch, spec); - spec.sampling_size_custody_groups(custody_group_count) + pub fn num_of_custody_groups_to_sample(&self, epoch: Epoch) -> u64 { + let custody_group_count = self.custody_group_count_at_epoch(epoch); + self.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. - pub fn num_of_data_columns_to_sample(&self, epoch: Epoch, spec: &ChainSpec) -> usize { - let custody_group_count = self.custody_group_count_at_epoch(epoch, spec); - spec.sampling_size_columns::(custody_group_count) + pub fn num_of_data_columns_to_sample(&self, epoch: Epoch) -> usize { + let custody_group_count = self.custody_group_count_at_epoch(epoch); + self.spec + .sampling_size_columns::(custody_group_count) .expect("should compute node sampling size from valid chain spec") } /// Returns whether the node should attempt reconstruction at a given epoch. - pub fn should_attempt_reconstruction(&self, epoch: Epoch, spec: &ChainSpec) -> bool { - let min_columns_for_reconstruction = E::number_of_columns() / 2; + pub fn should_attempt_reconstruction(&self, epoch: Epoch) -> bool { + let min_columns_for_reconstruction = T::EthSpec::number_of_columns() / 2; // performing reconstruction is not necessary if sampling column count is exactly 50%, // because the node doesn't need the remaining columns. - self.num_of_data_columns_to_sample(epoch, spec) > min_columns_for_reconstruction + self.num_of_data_columns_to_sample(epoch) > min_columns_for_reconstruction } /// Returns the ordered list of column indices that should be sampled for data availability checking at the given epoch. @@ -473,8 +496,8 @@ impl CustodyContext { /// /// # Returns /// A slice of ordered column indices that should be sampled for this epoch based on the node's custody configuration - pub fn sampling_columns_for_epoch(&self, epoch: Epoch, spec: &ChainSpec) -> &[ColumnIndex] { - let num_of_columns_to_sample = self.num_of_data_columns_to_sample(epoch, spec); + pub fn sampling_columns_for_epoch(&self, epoch: Epoch) -> &[ColumnIndex] { + let num_of_columns_to_sample = self.num_of_data_columns_to_sample(epoch); &self.ordered_custody_column_indices[..num_of_columns_to_sample] } @@ -491,19 +514,15 @@ impl CustodyContext { /// /// # Returns /// A slice of ordered custody column indices for this epoch based on the node's custody configuration - pub fn custody_columns_for_epoch( - &self, - epoch_opt: Option, - spec: &ChainSpec, - ) -> &[ColumnIndex] { + pub fn custody_columns_for_epoch(&self, epoch_opt: Option) -> &[ColumnIndex] { let custody_group_count = if let Some(epoch) = epoch_opt { - self.custody_group_count_at_epoch(epoch, spec) as usize + self.custody_group_count_at_epoch(epoch) as usize } else { - self.custody_group_count_at_head(spec) as usize + self.custody_group_count_at_head() as usize }; // This is an unnecessary conversion for spec compliance, basically just multiplying by 1. - let columns_per_custody_group = spec.data_columns_per_group::() as usize; + let columns_per_custody_group = self.spec.data_columns_per_group::() as usize; let custody_column_count = columns_per_custody_group * custody_group_count; &self.ordered_custody_column_indices[..custody_column_count] @@ -528,6 +547,61 @@ impl CustodyContext { .write() .reset_validator_custody_requirements(effective_epoch); } + + /// 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?; + + if self.complete_blob_backfill { + Some(fork_epoch) + } else { + 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. + pub fn da_check_required_for_epoch(&self, block_epoch: Epoch) -> bool { + self.data_availability_boundary() + .is_some_and(|da_epoch| block_epoch >= da_epoch) + } + + /// If the epoch is from prior to the data availability boundary, no blobs are required. + pub fn blobs_required_for_epoch(&self, epoch: Epoch) -> bool { + self.da_check_required_for_epoch(epoch) && !self.spec.is_peer_das_enabled_for_epoch(epoch) + } + + /// If the epoch is from prior to the data availability boundary, no data columns are required. + pub fn data_columns_required_for_epoch(&self, epoch: Epoch) -> bool { + self.da_check_required_for_epoch(epoch) && self.spec.is_peer_das_enabled_for_epoch(epoch) + } + + /// See `Self::blobs_required_for_epoch` + pub fn blobs_required_for_block(&self, block: &SignedBeaconBlock) -> bool { + block.num_expected_blobs() > 0 && self.blobs_required_for_epoch(block.epoch()) + } + + /// See `Self::data_columns_required_for_epoch` + pub fn data_columns_required_for_block(&self, block: &SignedBeaconBlock) -> bool { + block.num_expected_blobs() > 0 && self.data_columns_required_for_epoch(block.epoch()) + } + + pub fn data_columns_required_for_bid( + &self, + bid: &SignedExecutionPayloadBid, + ) -> bool { + bid.num_blobs_expected() > 0 && self.data_columns_required_for_epoch(bid.epoch()) + } + + /// The data availability boundary for custodying columns. It will just be the + /// regular data availability boundary unless we are near the Fulu fork epoch. + pub fn column_data_availability_boundary(&self) -> Option { + let da_boundary = self.data_availability_boundary()?; + let fulu_epoch = self.spec.fulu_fork_epoch?; + Some(da_boundary.max(fulu_epoch)) + } } /// Indicates that the custody group count (CGC) has increased. @@ -553,8 +627,8 @@ pub struct CustodyContextSsz { pub epoch_validator_custody_requirements: Vec<(Epoch, u64)>, } -impl From<&CustodyContext> for CustodyContextSsz { - fn from(context: &CustodyContext) -> Self { +impl From<&CustodyContext> for CustodyContextSsz { + fn from(context: &CustodyContext) -> Self { CustodyContextSsz { validator_custody_at_head: context.validator_custody_count.load(Ordering::Relaxed), // This field is deprecated and has no effect @@ -573,16 +647,27 @@ impl From<&CustodyContext> for CustodyContextSsz { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::generate_data_column_indices_rand_order; + use crate::test_utils::{EphemeralHarnessType, generate_data_column_indices_rand_order}; + use slot_clock::{SlotClock, TestingSlotClock}; + use std::time::Duration; use types::MainnetEthSpec; type E = MainnetEthSpec; + type T = EphemeralHarnessType; + + fn testing_slot_clock(spec: &ChainSpec) -> TestingSlotClock { + TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ) + } fn setup_custody_context( - spec: &ChainSpec, + spec: Arc, head_epoch: Epoch, epoch_and_cgc_tuples: Vec<(Epoch, u64)>, - ) -> CustodyContext { + ) -> CustodyContext { let cgc_at_head = epoch_and_cgc_tuples.last().unwrap().1; let ssz_context = CustodyContextSsz { validator_custody_at_head: cgc_at_head, @@ -590,11 +675,14 @@ mod tests { epoch_validator_custody_requirements: epoch_and_cgc_tuples, }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let complete_blob_backfill = false; + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, head_epoch, generate_data_column_indices_rand_order::(), + testing_slot_clock(&spec), + complete_blob_backfill, spec, ); @@ -602,7 +690,7 @@ mod tests { } fn complete_backfill_for_epochs( - custody_context: &CustodyContext, + custody_context: &CustodyContext, start_epoch: Epoch, end_epoch: Epoch, expected_cgc: u64, @@ -623,26 +711,29 @@ mod tests { target_node_custody_type: NodeCustodyType, expected_new_cgc: u64, head_epoch: Epoch, - spec: &ChainSpec, + spec: Arc, ) { let ssz_context = CustodyContextSsz { validator_custody_at_head: persisted_cgc, persisted_is_supernode: false, epoch_validator_custody_requirements: vec![(Epoch::new(0), persisted_cgc)], }; + let complete_blob_backfill = false; let (custody_context, custody_count_changed) = - CustodyContext::::new_from_persisted_custody_context( + CustodyContext::::new_from_persisted_custody_context( ssz_context, target_node_custody_type, head_epoch, generate_data_column_indices_rand_order::(), - spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify CGC increased assert_eq!( - custody_context.custody_group_count_at_head(spec), + custody_context.custody_group_count_at_head(), expected_new_cgc, "cgc should increase from {} to {}", persisted_cgc, @@ -675,13 +766,13 @@ mod tests { // Verify custody_group_count_at_epoch returns correct values assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch, spec), + custody_context.custody_group_count_at_epoch(head_epoch), persisted_cgc, "current epoch should still use old cgc ({})", persisted_cgc ); assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch + 1, spec), + custody_context.custody_group_count_at_epoch(head_epoch + 1), expected_new_cgc, "next epoch should use new cgc ({})", expected_new_cgc @@ -694,26 +785,29 @@ mod tests { persisted_cgc: u64, target_node_custody_type: NodeCustodyType, head_epoch: Epoch, - spec: &ChainSpec, + spec: Arc, ) { let ssz_context = CustodyContextSsz { validator_custody_at_head: persisted_cgc, persisted_is_supernode: false, epoch_validator_custody_requirements: vec![(Epoch::new(0), persisted_cgc)], }; + let complete_blob_backfill = false; let (custody_context, custody_count_changed) = - CustodyContext::::new_from_persisted_custody_context( + CustodyContext::::new_from_persisted_custody_context( ssz_context, target_node_custody_type, head_epoch, generate_data_column_indices_rand_order::(), - spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify CGC stays at persisted value (no reduction) assert_eq!( - custody_context.custody_group_count_at_head(spec), + custody_context.custody_group_count_at_head(), persisted_cgc, "cgc should remain at {} (reduction not supported)", persisted_cgc @@ -728,66 +822,78 @@ mod tests { #[test] fn no_validators_supernode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.number_of_custody_groups ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.number_of_custody_groups ); } #[test] fn no_validators_semi_supernode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::SemiSupernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.number_of_custody_groups / 2 ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.number_of_custody_groups / 2 ); } #[test] fn no_validators_fullnode_default() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.custody_requirement, "head custody count should be minimum spec custody requirement" ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(0), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(0)), spec.samples_per_slot ); } #[test] fn register_single_validator_should_update_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; let min_val_custody_requirement = spec.validator_custody_requirement; @@ -802,20 +908,22 @@ mod tests { (vec![(0, 10 * bal_per_additional_group)], Some(10)), ]; - register_validators_and_assert_cgc::( + register_validators_and_assert_cgc::( &custody_context, validators_and_expected_cgc_change, - &spec, ); } #[test] fn register_multiple_validators_should_update_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; let min_val_custody_requirement = spec.validator_custody_requirement; @@ -843,20 +951,19 @@ mod tests { ), ]; - register_validators_and_assert_cgc::( - &custody_context, - validators_and_expected_cgc, - &spec, - ); + register_validators_and_assert_cgc::(&custody_context, validators_and_expected_cgc); } #[test] fn register_validators_should_not_update_cgc_for_supernode() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let bal_per_additional_group = spec.balance_per_additional_custody_group; @@ -880,30 +987,28 @@ mod tests { ), ]; - register_validators_and_assert_cgc::( - &custody_context, - validators_and_expected_cgc, - &spec, - ); + register_validators_and_assert_cgc::(&custody_context, validators_and_expected_cgc); let current_epoch = Epoch::new(2); assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch), spec.number_of_custody_groups ); } #[test] fn cgc_change_should_be_effective_to_sampling_after_delay() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let default_sampling_size = - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec); + let default_sampling_size = custody_context.num_of_custody_groups_to_sample(current_epoch); let validator_custody_units = 10; let _cgc_changed = custody_context.register_validators( @@ -912,28 +1017,30 @@ mod tests { validator_custody_units * spec.balance_per_additional_custody_group, )], current_slot, - &spec, ); // CGC update is not applied for `current_epoch`. assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch), default_sampling_size ); // CGC update is applied for the next epoch. assert_eq!( - custody_context.num_of_custody_groups_to_sample(current_epoch + 1, &spec), + custody_context.num_of_custody_groups_to_sample(current_epoch + 1), validator_custody_units ); } #[test] fn validator_dropped_after_no_registrations_within_expiry_should_not_reduce_cgc() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let val_custody_units_1 = 10; @@ -952,7 +1059,6 @@ mod tests { ), ], current_slot, - &spec, ); // WHEN val_1 re-registered, but val_2 did not re-register after `VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1` slots @@ -962,24 +1068,26 @@ mod tests { val_custody_units_1 * spec.balance_per_additional_custody_group, )], current_slot + VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1, - &spec, ); // THEN the reduction from dropping val_2 balance should NOT result in a CGC reduction assert!(cgc_changed_opt.is_none(), "CGC should remain unchanged"); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), val_custody_units_1 + val_custody_units_2 ) } #[test] fn validator_dropped_after_no_registrations_within_expiry() { - let spec = E::default_spec(); - let custody_context = CustodyContext::::new( + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let current_slot = Slot::new(10); let val_custody_units_1 = 10; @@ -999,7 +1107,6 @@ mod tests { ), ], current_slot, - &spec, ); // WHEN val_1 and val_3 registered, but val_3 did not re-register after `VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1` slots @@ -1015,7 +1122,6 @@ mod tests { ), ], current_slot + VALIDATOR_REGISTRATION_EXPIRY_SLOTS + 1, - &spec, ); // THEN CGC should increase, BUT val_2 balance should NOT be included in CGC @@ -1028,10 +1134,9 @@ mod tests { } /// Update the validator every epoch and assert cgc against expected values. - fn register_validators_and_assert_cgc( - custody_context: &CustodyContext, + fn register_validators_and_assert_cgc( + custody_context: &CustodyContext, validators_and_expected_cgc_changed: Vec<(ValidatorsAndBalances, Option)>, - spec: &ChainSpec, ) { for (idx, (validators_and_balance, expected_cgc_change)) in validators_and_expected_cgc_changed.into_iter().enumerate() @@ -1040,8 +1145,7 @@ mod tests { let updated_custody_count_opt = custody_context .register_validators( validators_and_balance, - epoch.start_slot(E::slots_per_epoch()), - spec, + epoch.start_slot(T::EthSpec::slots_per_epoch()), ) .map(|c| c.new_custody_group_count); @@ -1051,44 +1155,53 @@ mod tests { #[test] fn custody_columns_for_epoch_no_validators_fullnode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), spec.custody_requirement as usize ); } #[test] fn custody_columns_for_epoch_no_validators_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Supernode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), spec.number_of_custody_groups as usize ); } #[test] fn custody_columns_for_epoch_with_validators_should_match_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let val_custody_units = 10; @@ -1098,30 +1211,32 @@ mod tests { val_custody_units * spec.balance_per_additional_custody_group, )], Slot::new(10), - &spec, ); assert_eq!( - custody_context.custody_columns_for_epoch(None, &spec).len(), + custody_context.custody_columns_for_epoch(None).len(), val_custody_units as usize ); } #[test] fn custody_columns_for_epoch_specific_epoch_uses_epoch_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); let test_epoch = Epoch::new(5); - let expected_cgc = custody_context.custody_group_count_at_epoch(test_epoch, &spec); + let expected_cgc = custody_context.custody_group_count_at_epoch(test_epoch); assert_eq!( custody_context - .custody_columns_for_epoch(Some(test_epoch), &spec) + .custody_columns_for_epoch(Some(test_epoch)) .len(), expected_cgc as usize ); @@ -1129,23 +1244,26 @@ mod tests { #[test] fn restore_from_persisted_fullnode_no_validators() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let ssz_context = CustodyContextSsz { validator_custody_at_head: 0, // no validators persisted_is_supernode: false, epoch_validator_custody_requirements: vec![], }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, Epoch::new(0), generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), spec.custody_requirement, "restored custody group count should match fullnode default" ); @@ -1155,7 +1273,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_fullnode_then_switch_to_supernode_increases_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let head_epoch = Epoch::new(10); let supernode_cgc = spec.number_of_custody_groups; @@ -1164,7 +1282,7 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1172,17 +1290,20 @@ mod tests { /// Semi-supernode can exceed 64 when validator effective balance increases CGC. #[test] fn restore_semi_supernode_with_validators_can_exceed_64() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); + let complete_blob_backfill = false; let semi_supernode_cgc = spec.number_of_custody_groups / 2; // 64 - let custody_context = CustodyContext::::new( + let custody_context = CustodyContext::::new( NodeCustodyType::SemiSupernode, generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify initial CGC is 64 (semi-supernode) assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), semi_supernode_cgc, "initial cgc should be 64" ); @@ -1196,7 +1317,6 @@ mod tests { validator_custody_units * spec.balance_per_additional_custody_group, )], current_slot, - &spec, ); // Verify CGC increased from 64 to 70 @@ -1216,7 +1336,7 @@ mod tests { // Verify the custody context reflects the new CGC assert_eq!( - custody_context.custody_group_count_at_head(&spec), + custody_context.custody_group_count_at_head(), validator_custody_units, "custody_group_count_at_head should be 70" ); @@ -1226,14 +1346,14 @@ mod tests { /// CGC reduction is not supported - persisted value is retained. #[test] fn restore_supernode_then_switch_to_fullnode_uses_persisted() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let supernode_cgc = spec.number_of_custody_groups; assert_custody_type_switch_unchanged_cgc( supernode_cgc, NodeCustodyType::Fullnode, Epoch::new(0), - &spec, + spec, ); } @@ -1241,7 +1361,7 @@ mod tests { /// CGC reduction is not supported - persisted value is retained. #[test] fn restore_supernode_then_switch_to_semi_supernode_keeps_supernode_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1249,7 +1369,7 @@ mod tests { supernode_cgc, NodeCustodyType::SemiSupernode, head_epoch, - &spec, + spec, ); } @@ -1257,7 +1377,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_fullnode_with_validators_then_switch_to_semi_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let persisted_cgc = 32u64; let semi_supernode_cgc = spec.number_of_custody_groups / 2; let head_epoch = Epoch::new(10); @@ -1267,7 +1387,7 @@ mod tests { NodeCustodyType::SemiSupernode, semi_supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1275,7 +1395,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_semi_supernode_then_switch_to_supernode() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let semi_supernode_cgc = spec.number_of_custody_groups / 2; let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1285,7 +1405,7 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } @@ -1293,7 +1413,7 @@ mod tests { /// CGC should increase and trigger backfill via CustodyCountChanged. #[test] fn restore_with_cli_flag_increases_cgc_from_nonzero() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let persisted_cgc = 32u64; let supernode_cgc = spec.number_of_custody_groups; let head_epoch = Epoch::new(10); @@ -1303,13 +1423,13 @@ mod tests { NodeCustodyType::Supernode, supernode_cgc, head_epoch, - &spec, + spec, ); } #[test] fn restore_with_validator_custody_history_across_epochs() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let increased_cgc = 16u64; let final_cgc = 32u64; @@ -1324,45 +1444,45 @@ mod tests { ], }; - let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( + let complete_blob_backfill = false; + let (custody_context, _) = CustodyContext::::new_from_persisted_custody_context( ssz_context, NodeCustodyType::Fullnode, Epoch::new(20), generate_data_column_indices_rand_order::(), - &spec, + testing_slot_clock(&spec), + complete_blob_backfill, + spec.clone(), ); // Verify head uses latest value - assert_eq!( - custody_context.custody_group_count_at_head(&spec), - final_cgc - ); + assert_eq!(custody_context.custody_group_count_at_head(), final_cgc); // Verify historical epoch lookups work correctly assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(5), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(5)), initial_cgc, "epoch 5 should use initial cgc" ); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), increased_cgc, "epoch 15 should use increased cgc" ); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(25), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(25)), final_cgc, "epoch 25 should use final cgc" ); // Verify sampling size calculation uses correct historical values assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(5), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(5)), spec.samples_per_slot, "sampling at epoch 5 should use spec minimum since cgc is at minimum" ); assert_eq!( - custody_context.num_of_custody_groups_to_sample(Epoch::new(25), &spec), + custody_context.num_of_custody_groups_to_sample(Epoch::new(25)), final_cgc, "sampling at epoch 25 should match final cgc" ); @@ -1370,16 +1490,16 @@ mod tests { #[test] fn backfill_single_cgc_increase_updates_past_epochs() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let final_cgc = 32u64; let default_cgc = spec.custody_requirement; // Setup: Node restart after validators were registered, causing CGC increase to 32 at epoch 20 let head_epoch = Epoch::new(20); let epoch_and_cgc_tuples = vec![(head_epoch, final_cgc)]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), default_cgc, ); @@ -1388,26 +1508,26 @@ mod tests { // After backfilling to epoch 15, it should use latest CGC (32) assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(15), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(15)), final_cgc, ); assert_eq!( custody_context - .custody_columns_for_epoch(Some(Epoch::new(15)), &spec) + .custody_columns_for_epoch(Some(Epoch::new(15))) .len(), final_cgc as usize, ); // Prior epoch should still return the original CGC assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(14), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(14)), default_cgc, ); } #[test] fn backfill_with_multiple_cgc_increases_prunes_map_correctly() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let mid_cgc = 16u64; let final_cgc = 32u64; @@ -1419,7 +1539,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill to epoch 15 (between the two CGC increases) complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(15), final_cgc); @@ -1427,7 +1547,7 @@ mod tests { // Verify epochs 15 - 20 return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1435,7 +1555,7 @@ mod tests { // Verify epochs 10-14 still return mid_cgc (16) for epoch in 10..14 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), mid_cgc, ); } @@ -1443,7 +1563,7 @@ mod tests { #[test] fn attempt_backfill_with_invalid_cgc() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let initial_cgc = 8u64; let mid_cgc = 16u64; let final_cgc = 32u64; @@ -1455,7 +1575,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill to epoch 15 (between the two CGC increases) complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(15), final_cgc); @@ -1463,7 +1583,7 @@ mod tests { // Verify epochs 15 - 20 return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1479,7 +1599,7 @@ mod tests { // Verify epochs 15 - 20 still return latest CGC (32) for epoch in 15..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } @@ -1487,7 +1607,7 @@ mod tests { // Verify epochs 10-14 still return mid_cgc (16) for epoch in 10..14 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), mid_cgc, ); } @@ -1495,7 +1615,7 @@ mod tests { #[test] fn reset_validator_custody_requirements() { - let spec = E::default_spec(); + let spec = Arc::new(E::default_spec()); let minimum_cgc = 4u64; let initial_cgc = 8u64; let mid_cgc = 16u64; @@ -1508,7 +1628,7 @@ mod tests { (Epoch::new(10), mid_cgc), (head_epoch, final_cgc), ]; - let custody_context = setup_custody_context(&spec, head_epoch, epoch_and_cgc_tuples); + let custody_context = setup_custody_context(spec.clone(), head_epoch, epoch_and_cgc_tuples); // Backfill from epoch 20 to 9 complete_backfill_for_epochs(&custody_context, Epoch::new(20), Epoch::new(9), final_cgc); @@ -1519,14 +1639,14 @@ mod tests { // Verify epochs 0 - 19 return the minimum cgc requirement because of the validator custody requirement reset for epoch in 0..=19 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), minimum_cgc, ); } // Verify epoch 20 returns a CGC of 32 assert_eq!( - custody_context.custody_group_count_at_epoch(head_epoch, &spec), + custody_context.custody_group_count_at_epoch(head_epoch), final_cgc ); @@ -1536,7 +1656,7 @@ mod tests { // Verify epochs 0 - 20 return the final cgc requirements for epoch in 0..=20 { assert_eq!( - custody_context.custody_group_count_at_epoch(Epoch::new(epoch), &spec), + custody_context.custody_group_count_at_epoch(Epoch::new(epoch)), final_cgc, ); } diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index e559dc7689..d129072ff9 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -18,7 +18,7 @@ use tracing::{debug, error, instrument}; use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, - DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, + DataColumnSidecarList, EthSpec, Hash256, PartialDataColumnSidecarError, PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, }; @@ -75,12 +75,9 @@ const OVERFLOW_LRU_CAPACITY: usize = 32; /// proposer. Having a capacity > 1 is an optimization to prevent sync lookup from having re-fetch /// data during moments of unstable network conditions. pub struct DataAvailabilityChecker { - complete_blob_backfill: bool, availability_cache: Arc>, partial_assembler: Option>>, - slot_clock: T::SlotClock, kzg: Arc, - custody_context: Arc>, spec: Arc, } @@ -115,10 +112,8 @@ impl Debug for Availability { impl DataAvailabilityChecker { pub fn new( - complete_blob_backfill: bool, - slot_clock: T::SlotClock, kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, enable_partial_columns: bool, disable_get_blobs: bool, @@ -137,18 +132,15 @@ impl DataAvailabilityChecker { None }; Ok(Self { - complete_blob_backfill, partial_assembler, availability_cache: Arc::new(inner), - slot_clock, kzg, - custody_context, spec, }) } - pub fn custody_context(&self) -> &Arc> { - &self.custody_context + fn custody_context(&self) -> &Arc> { + self.availability_cache.custody_context() } pub fn partial_assembler(&self) -> Option<&Arc>> { @@ -310,9 +302,9 @@ impl DataAvailabilityChecker { &self, block_root: Hash256, blobs: FixedBlobSidecarList, + slot_clock: &T::SlotClock, ) -> Result, AvailabilityCheckError> { - let seen_timestamp = self - .slot_clock + let seen_timestamp = slot_clock .now_duration() .ok_or(AvailabilityCheckError::SlotClockError)?; @@ -350,9 +342,7 @@ impl DataAvailabilityChecker { // not be yet effective for data availability check, as CGC changes are only effecive from // a new epoch. let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context().sampling_columns_for_epoch(epoch); let verified_custody_columns = kzg_verified_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -390,9 +380,7 @@ impl DataAvailabilityChecker { data_columns: I, ) -> Result, AvailabilityCheckError> { let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context().sampling_columns_for_epoch(epoch); let custody_columns = data_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -507,59 +495,6 @@ impl DataAvailabilityChecker { Ok(()) } - /// Determines the blob requirements for a block. If the block is pre-deneb, no blobs are required. - /// If the epoch is from prior to the data availability boundary, no blobs are required. - pub fn blobs_required_for_epoch(&self, epoch: Epoch) -> bool { - self.da_check_required_for_epoch(epoch) && !self.spec.is_peer_das_enabled_for_epoch(epoch) - } - - /// Determines the data column requirements for an epoch. - /// - If the epoch is pre-peerdas, no data columns are required. - /// - If the epoch is from prior to the data availability boundary, no data columns are required. - pub fn data_columns_required_for_epoch(&self, epoch: Epoch) -> bool { - self.da_check_required_for_epoch(epoch) && self.spec.is_peer_das_enabled_for_epoch(epoch) - } - - /// See `Self::blobs_required_for_epoch` - fn blobs_required_for_block(&self, block: &SignedBeaconBlock) -> bool { - block.num_expected_blobs() > 0 && self.blobs_required_for_epoch(block.epoch()) - } - - /// See `Self::data_columns_required_for_epoch` - fn data_columns_required_for_block(&self, block: &SignedBeaconBlock) -> bool { - block.num_expected_blobs() > 0 && self.data_columns_required_for_epoch(block.epoch()) - } - - /// 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?; - - if self.complete_blob_backfill { - Some(fork_epoch) - } else { - 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. - pub fn da_check_required_for_epoch(&self, block_epoch: Epoch) -> bool { - self.data_availability_boundary() - .is_some_and(|da_epoch| block_epoch >= da_epoch) - } - - /// Returns `true` if the current epoch is greater than or equal to the `Deneb` epoch. - pub fn is_deneb(&self) -> bool { - self.slot_clock.now().is_some_and(|slot| { - self.spec.deneb_fork_epoch.is_some_and(|deneb_epoch| { - let now_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - now_epoch >= deneb_epoch - }) - }) - } - /// Collects metrics from the data availability checker. pub fn metrics(&self) -> DataAvailabilityCheckerMetrics { DataAvailabilityCheckerMetrics { @@ -629,7 +564,7 @@ impl DataAvailabilityChecker { let columns_to_sample = self .custody_context() - .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); // We only need to import and publish columns that we need to sample // and columns that we haven't already received @@ -886,15 +821,14 @@ impl AvailableBlock { pub fn new( block: Arc>, block_data: AvailableBlockData, - da_checker: &DataAvailabilityChecker, - spec: Arc, + custody_context: &CustodyContext, ) -> Result where T: BeaconChainTypes, { // Ensure block availability - let blobs_required = da_checker.blobs_required_for_block(&block); - let columns_required = da_checker.data_columns_required_for_block(&block); + let blobs_required = custody_context.blobs_required_for_block(&block); + let columns_required = custody_context.data_columns_required_for_block(&block); match &block_data { AvailableBlockData::NoData => { @@ -935,9 +869,8 @@ impl AvailableBlock { return Err(AvailabilityCheckError::InvalidAvailableBlockData); } - let mut column_indices = da_checker - .custody_context - .sampling_columns_for_epoch(block.epoch(), &spec) + let mut column_indices = custody_context + .sampling_columns_for_epoch(block.epoch()) .iter() .collect::>(); @@ -1081,7 +1014,8 @@ mod test { use std::time::Duration; use types::data::DataColumn; use types::{ - ChainSpec, ColumnIndex, DataColumnSidecarFulu, EthSpec, ForkName, MainnetEthSpec, Slot, + ChainSpec, ColumnIndex, DataColumnSidecarFulu, Epoch, EthSpec, ForkName, MainnetEthSpec, + Slot, }; type E = MainnetEthSpec; @@ -1096,7 +1030,7 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // GIVEN a single 32 ETH validator is attached slot 0 let epoch = Epoch::new(0); @@ -1104,10 +1038,9 @@ mod test { custody_context.register_validators( vec![(validator_0, 32_000_000_000)], epoch.start_slot(E::slots_per_epoch()), - &spec, ); assert_eq!( - custody_context.num_of_data_columns_to_sample(epoch, &spec), + custody_context.num_of_data_columns_to_sample(epoch), spec.validator_custody_requirement as usize, "sampling size should be the minimal custody requirement == 8" ); @@ -1115,11 +1048,8 @@ mod test { // WHEN additional attached validators result in a CGC increase to 10 at the end slot of the same epoch let validator_1 = 1; let cgc_change_slot = epoch.end_slot(E::slots_per_epoch()); - custody_context.register_validators( - vec![(validator_1, 32_000_000_000 * 9)], - cgc_change_slot, - &spec, - ); + custody_context + .register_validators(vec![(validator_1, 32_000_000_000 * 9)], cgc_change_slot); // AND custody columns (8) and any new extra columns (2) are received via RPC responses. // NOTE: block lookup uses the **latest** CGC (10) instead of the effective CGC (8) as the slot is unknown. let (_, data_columns) = generate_rand_block_and_data_columns::( @@ -1134,7 +1064,7 @@ mod test { // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, // which is typically epoch 2+ for MinimalEthSpec. let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective - let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch); assert_eq!( requested_columns.len(), 10, @@ -1152,7 +1082,7 @@ mod test { .expect("should put rpc custody columns"); // THEN the sampling size for the end slot of the same epoch remains unchanged - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); assert_eq!( sampling_columns.len(), spec.validator_custody_requirement as usize // 8 @@ -1183,7 +1113,7 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // GIVEN a single 32 ETH validator is attached slot 0 let epoch = Epoch::new(0); @@ -1191,10 +1121,9 @@ mod test { custody_context.register_validators( vec![(validator_0, 32_000_000_000)], epoch.start_slot(E::slots_per_epoch()), - &spec, ); assert_eq!( - custody_context.num_of_data_columns_to_sample(epoch, &spec), + custody_context.num_of_data_columns_to_sample(epoch), spec.validator_custody_requirement as usize, "sampling size should be the minimal custody requirement == 8" ); @@ -1202,11 +1131,8 @@ mod test { // WHEN additional attached validators result in a CGC increase to 10 at the end slot of the same epoch let validator_1 = 1; let cgc_change_slot = epoch.end_slot(E::slots_per_epoch()); - custody_context.register_validators( - vec![(validator_1, 32_000_000_000 * 9)], - cgc_change_slot, - &spec, - ); + custody_context + .register_validators(vec![(validator_1, 32_000_000_000 * 9)], cgc_change_slot); // AND custody columns (8) and any new extra columns (2) are received via gossip. // NOTE: CGC updates results in new topics subscriptions immediately, and extra columns may start to // arrive via gossip. @@ -1222,7 +1148,7 @@ mod test { // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, // which is typically epoch 2+ for MinimalEthSpec. let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective - let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch); assert_eq!( requested_columns.len(), 10, @@ -1238,7 +1164,7 @@ mod test { .expect("should put gossip custody columns"); // THEN the sampling size for the end slot of the same epoch remains unchanged - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); assert_eq!( sampling_columns.len(), spec.validator_custody_requirement as usize // 8 @@ -1305,8 +1231,7 @@ mod test { }; let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - let da_checker = Arc::new(new_da_checker(spec.clone())); - RangeSyncBlock::new(Arc::new(block), block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(Arc::new(block), block_data, da_checker.custody_context()) .expect("should create RPC block with custody columns") }) .collect::>(); @@ -1331,16 +1256,15 @@ mod test { let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); - let custody_context = &da_checker.custody_context; + let custody_context = da_checker.custody_context(); // Set custody requirement to 65 columns (enough to trigger reconstruction) let epoch = Epoch::new(1); custody_context.register_validators( vec![(0, 2_048_000_000_000), (1, 32_000_000_000)], // 64 + 1 Slot::new(0), - &spec, ); - let sampling_requirement = custody_context.num_of_data_columns_to_sample(epoch, &spec); + let sampling_requirement = custody_context.num_of_data_columns_to_sample(epoch); assert_eq!( sampling_requirement, 65, "sampling requirement should be 65" @@ -1362,7 +1286,7 @@ mod test { // Add 64 columns to the da checker (enough to be able to reconstruct) // Order by all_column_indices_ordered, then take first 64 - let custody_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let custody_columns = custody_context.sampling_columns_for_epoch(epoch); let custody_columns = custody_columns .iter() .filter_map(|&col_idx| data_columns.iter().find(|d| *d.index() == col_idx).cloned()) @@ -1400,7 +1324,7 @@ mod test { ); // Only the columns required for custody (65) should be imported into the cache - let sampling_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); + let sampling_columns = custody_context.sampling_columns_for_epoch(epoch); let actual_cached: HashSet = da_checker .cached_data_column_indexes(&block_root) .expect("should have cached data columns") @@ -1421,21 +1345,15 @@ mod test { ); let kzg = get_kzg(&spec); let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); + let complete_blob_backfill = false; let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, ordered_custody_column_indices, - &spec, - )); - let complete_blob_backfill = false; - DataAvailabilityChecker::new( - complete_blob_backfill, slot_clock, - kzg, - custody_context, - spec, - true, - false, - ) - .expect("should initialise data availability checker") + complete_blob_backfill, + spec.clone(), + )); + DataAvailabilityChecker::new(kzg, custody_context, spec, true, false) + .expect("should initialise data availability checker") } } 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 47740cdf5e..dc59d614df 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 @@ -349,7 +349,7 @@ impl PendingComponents { pub struct DataAvailabilityCheckerInner { /// Contains all the data we keep in memory, protected by an RwLock critical: RwLock>>, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, } @@ -365,7 +365,7 @@ pub(crate) enum ReconstructColumnsDecision { impl DataAvailabilityCheckerInner { pub fn new( capacity: usize, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, ) -> Result { Ok(Self { @@ -434,6 +434,10 @@ impl DataAvailabilityCheckerInner { f(self.critical.read().peek(block_root)) } + pub fn custody_context(&self) -> &Arc> { + &self.custody_context + } + /// Puts the KZG verified blobs into the availability cache as pending components. pub fn put_kzg_verified_blobs>>( &self, @@ -500,9 +504,7 @@ impl DataAvailabilityCheckerInner { pending_components.merge_data_columns(kzg_verified_data_columns) })?; - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let num_expected_columns = self.custody_context.num_of_data_columns_to_sample(epoch); pending_components.span.in_scope(|| { debug!( @@ -606,9 +608,7 @@ impl DataAvailabilityCheckerInner { }; let total_column_count = T::EthSpec::number_of_columns(); - let sampling_column_count = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let sampling_column_count = self.custody_context.num_of_data_columns_to_sample(epoch); let received_column_count = pending_components.verified_data_columns.len(); if pending_components.reconstruction_started { @@ -709,9 +709,7 @@ impl DataAvailabilityCheckerInner { fn get_num_expected_columns(&self, epoch: Epoch) -> Option { if self.spec.is_peer_das_enabled_for_epoch(epoch) { - let num_of_column_samples = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let num_of_column_samples = self.custody_context.num_of_data_columns_to_sample(epoch); Some(num_of_column_samples) } else { None @@ -760,6 +758,7 @@ mod test { }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; + use slot_clock::TestingSlotClock; use state_processing::ConsensusContext; use store::{HotColdDB, ItemStore, StoreConfig, database::interface::BeaconNodeBackend}; use tempfile::{TempDir, tempdir}; @@ -922,19 +921,25 @@ mod test { HotStore = BeaconNodeBackend, ColdStore = BeaconNodeBackend, EthSpec = E, + SlotClock = TestingSlotClock, >, { create_test_tracing_subscriber(); let chain_db_path = tempdir().expect("should get temp dir"); let harness = get_fulu_chain(&chain_db_path).await; let spec = harness.spec.clone(); + let complete_blob_backfill = false; + let slot_clock = harness.chain.slot_clock.clone(); + let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), - &spec, + slot_clock, + complete_blob_backfill, + spec.clone(), )); let cache = Arc::new( - DataAvailabilityCheckerInner::::new(capacity, custody_context, spec.clone()) + DataAvailabilityCheckerInner::::new(capacity, custody_context, spec) .expect("should create cache"), ); (harness, cache, chain_db_path) @@ -952,9 +957,7 @@ mod test { let epoch = pending_block.block.epoch(); let num_blobs_expected = pending_block.num_blobs_expected(); - let columns_expected = cache - .custody_context - .num_of_data_columns_to_sample(epoch, &harness.spec); + let columns_expected = cache.custody_context.num_of_data_columns_to_sample(epoch); // All columns are returned from availability_pending_block (E::number_of_columns()) // but we only need custody columns @@ -994,9 +997,7 @@ mod test { } // Get sampling column indices for this epoch - let sampling_column_indices = cache - .custody_context - .sampling_columns_for_epoch(epoch, &harness.spec); + let sampling_column_indices = cache.custody_context.sampling_columns_for_epoch(epoch); // Filter to only sampling columns let sampling_columns: Vec<_> = columns @@ -1032,9 +1033,7 @@ mod test { let root = pending_block.import_data.block_root; // Get sampling column indices for this epoch - let sampling_column_indices = cache - .custody_context - .sampling_columns_for_epoch(epoch, &harness.spec); + let sampling_column_indices = cache.custody_context.sampling_columns_for_epoch(epoch); // Filter to only sampling columns let sampling_columns: Vec<_> = columns diff --git a/beacon_node/beacon_chain/src/historical_data_columns.rs b/beacon_node/beacon_chain/src/historical_data_columns.rs index d6977d9985..1089c801ff 100644 --- a/beacon_node/beacon_chain/src/historical_data_columns.rs +++ b/beacon_node/beacon_chain/src/historical_data_columns.rs @@ -131,8 +131,7 @@ impl BeaconChain { ); } - self.data_availability_checker - .custody_context() + self.custody_context .update_and_backfill_custody_count_at_epoch(epoch, expected_cgc); self.safely_backfill_data_column_custody_info(epoch) diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs index b365f37a0a..369b968e78 100644 --- a/beacon_node/beacon_chain/src/invariants.rs +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -29,14 +29,12 @@ impl BeaconChain { .collect() }; - let custody_context = self.data_availability_checker.custody_context(); + let custody_context = self.custody_context.clone(); let ctx = InvariantContext { fork_choice_blocks, state_cache_roots: self.store.state_cache.lock().state_roots(), - custody_columns: custody_context - .custody_columns_for_epoch(None, &self.spec) - .to_vec(), + custody_columns: custody_context.custody_columns_for_epoch(None).to_vec(), pubkey_cache_pubkeys: { let cache = self.validator_pubkey_cache.read(); (0..cache.len()) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index a0d34949c6..1dc8418420 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -17,20 +17,21 @@ //! ExecutedEnvelope //! //! ``` - +use crate::data_availability_checker::AvailabilityCheckError; +use crate::{ + BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, CustodyContext, + ExecutionPayloadError, PayloadVerificationError, PayloadVerificationOutcome, +}; use state_processing::envelope_processing::EnvelopeProcessingError; +use std::collections::HashSet; use std::sync::Arc; use store::Error as DBError; use strum::AsRefStr; -use tracing::instrument; +use tracing::{instrument, warn}; use types::{ BeaconState, BeaconStateError, DataColumnSidecarList, EthSpec, ExecutionBlockHash, - ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadEnvelope, Slot, -}; - -use crate::{ - BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, ExecutionPayloadError, - PayloadVerificationError, PayloadVerificationOutcome, + ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + Slot, }; pub mod execution_pending_envelope; @@ -47,11 +48,76 @@ pub struct AvailableEnvelope { } impl AvailableEnvelope { - pub fn new( + /// Constructs an `AvailableEnvelope` from an envelope and custody column data. + /// + /// This function validates that: + /// - All required custody columns are present + /// + /// If more columns are provided than necessary, a warning is logged and the extra + /// columns are filtered out of the list. + /// + /// Returns `AvailabilityCheckError` if: + /// - `MissingCustodyColumns`: Required custody columns are missing or incomplete + pub fn new( envelope: Arc>, columns: DataColumnSidecarList, - ) -> Self { - Self { envelope, columns } + bid: &SignedExecutionPayloadBid, + custody_context: &CustodyContext, + ) -> Result + where + T: BeaconChainTypes, + { + if custody_context.data_columns_required_for_bid(bid) { + let columns_expected = custody_context.num_of_data_columns_to_sample(bid.epoch()); + + // Get required custody column indices + let required_indices = custody_context + .sampling_columns_for_epoch(bid.epoch()) + .iter() + .copied() + .collect::>(); + + // Filter to only the columns we need (deduplicates if there are duplicates) + let mut filtered_columns = Vec::new(); + let mut seen_indices = HashSet::new(); + let num_provided_columns = columns.len(); + for column in columns { + if required_indices.contains(column.index()) && seen_indices.insert(*column.index()) + { + filtered_columns.push(column); + } + } + + // Check if we have all required columns + if filtered_columns.len() != columns_expected { + return Err(AvailabilityCheckError::MissingCustodyColumns); + } + + if num_provided_columns != filtered_columns.len() { + warn!( + message = "More columns provided than expected", + envelope = %envelope.message.payload.block_hash, + num_provided_columns = %num_provided_columns, + columns_expected = %columns_expected, + ); + } + + Ok(Self { + envelope, + columns: filtered_columns, + }) + } else if columns.is_empty() { + Ok(Self { envelope, columns }) + } else { + warn!( + message = "Custody columns provided for envelope that does not require them", + envelope = %envelope.message.payload.block_hash, + ); + Ok(Self { + envelope, + columns: vec![], + }) + } } pub fn envelope(&self) -> &Arc> { diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs index c5c97418c7..33271d6d0e 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs @@ -92,14 +92,14 @@ pub struct PendingPayloadCache { /// Contains all the data we keep in memory, protected by an RwLock availability_cache: RwLock>>, kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, } impl PendingPayloadCache { pub fn new( kzg: Arc, - custody_context: Arc>, + custody_context: Arc>, spec: Arc, ) -> Result { Ok(Self { @@ -110,7 +110,7 @@ impl PendingPayloadCache { }) } - pub fn custody_context(&self) -> &Arc> { + pub fn custody_context(&self) -> &Arc> { &self.custody_context } @@ -174,7 +174,6 @@ impl PendingPayloadCache { &self, executed_envelope: AvailabilityPendingExecutedEnvelope, ) -> Result, AvailabilityCheckError> { - let epoch = executed_envelope.envelope.epoch(); let beacon_block_root = executed_envelope.envelope.beacon_block_root(); let bid = self .get_bid(&beacon_block_root) @@ -185,19 +184,15 @@ impl PendingPayloadCache { pending_components.insert_executed_payload_envelope(executed_envelope); })?; - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); - pending_components.span.in_scope(|| { debug!( component = "executed envelope", - status = pending_components.status_str(num_expected_columns), + status = pending_components.status_str(&self.custody_context), "Component added to data availability checker" ); }); - self.check_availability(beacon_block_root, pending_components, num_expected_columns) + self.check_availability(beacon_block_root, pending_components) } /// Inserts a bid into the pending payload cache. @@ -228,9 +223,7 @@ impl PendingPayloadCache { .map_err(AvailabilityCheckError::InvalidColumn)?; let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context.sampling_columns_for_epoch(epoch); let verified_custody_columns = kzg_verified_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -252,9 +245,7 @@ impl PendingPayloadCache { .get_bid(&block_root) .ok_or(AvailabilityCheckError::MissingBid(block_root))?; let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns = self - .custody_context - .sampling_columns_for_epoch(epoch, &self.spec); + let sampling_columns = self.custody_context.sampling_columns_for_epoch(epoch); let custody_columns = data_columns .into_iter() .filter(|col| sampling_columns.contains(&col.index())) @@ -280,21 +271,15 @@ impl PendingPayloadCache { pending_components.merge_data_columns(kzg_verified_data_columns) })?; - let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); - - let num_expected_columns = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); - pending_components.span.in_scope(|| { debug!( component = "data_columns", - status = pending_components.status_str(num_expected_columns), + status = pending_components.status_str(&self.custody_context), "Component added to data availability checker" ); }); - self.check_availability(block_root, pending_components, num_expected_columns) + self.check_availability(block_root, pending_components) } #[instrument(skip_all, level = "debug")] @@ -340,7 +325,7 @@ impl PendingPayloadCache { let slot = bid.message.slot; let columns_to_sample = self .custody_context() - .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); let data_columns_to_import_and_publish = all_data_columns .into_iter() @@ -388,9 +373,10 @@ impl PendingPayloadCache { &self, block_root: Hash256, pending_components: MappedRwLockReadGuard<'_, PendingComponents>, - num_expected_columns: usize, ) -> Result, AvailabilityCheckError> { - if let Some(available_envelope) = pending_components.make_available(num_expected_columns)? { + if let Some(available_envelope) = + pending_components.make_available(&self.custody_context)? + { // Explicitly drop read lock before acquiring write lock drop(pending_components); if let Some(components) = self.availability_cache.write().get_mut(&block_root) { @@ -458,9 +444,7 @@ impl PendingPayloadCache { let epoch = pending_components.bid.epoch(); let total_column_count = T::EthSpec::number_of_columns(); - let sampling_column_count = self - .custody_context - .num_of_data_columns_to_sample(epoch, &self.spec); + let sampling_column_count = self.custody_context.num_of_data_columns_to_sample(epoch); if pending_components.reconstruction_started { return ReconstructColumnsDecision::No("already started"); @@ -516,10 +500,12 @@ mod data_availability_checker_tests { }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; + use slot_clock::{SlotClock, TestingSlotClock}; + use std::time::Duration; use types::test_utils::test_unstructured; use types::{ ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, ForkName, - MinimalEthSpec, SignedExecutionPayloadEnvelope, + MinimalEthSpec, SignedExecutionPayloadEnvelope, Slot, }; type E = MinimalEthSpec; @@ -541,10 +527,18 @@ mod data_availability_checker_tests { create_test_tracing_subscriber(); let spec = Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); - let custody_context = Arc::new(CustodyContext::::new( + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + let complete_blob_backfill = false; + let custody_context = Arc::new(CustodyContext::::new( node_custody, generate_data_column_indices_rand_order::(), - &spec, + slot_clock, + complete_blob_backfill, + spec.clone(), )); let cache = Arc::new( PendingPayloadCache::::new(kzg, custody_context, spec.clone()) @@ -567,9 +561,7 @@ mod data_availability_checker_tests { cache.insert_bid(block_root, bid.clone()); let epoch = bid.message.slot.epoch(E::slots_per_epoch()); - let sampling = cache - .custody_context() - .sampling_columns_for_epoch(epoch, &cache.spec); + let sampling = cache.custody_context().sampling_columns_for_epoch(epoch); let custody = columns .into_iter() .filter(|c| sampling.contains(c.index())) diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs index e7b9009577..dac0180be8 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs @@ -1,3 +1,5 @@ +use crate::beacon_chain::BeaconChainTypes; +use crate::custody_context::CustodyContext; use crate::data_availability_checker::AvailabilityCheckError; use crate::data_column_verification::KzgVerifiedCustodyDataColumn; use crate::payload_envelope_verification::AvailabilityPendingExecutedEnvelope; @@ -27,8 +29,15 @@ pub struct PendingComponents { } impl PendingComponents { - pub fn num_blobs_expected(&self) -> usize { - self.bid.message.blob_kzg_commitments.len() + pub fn num_columns_required(&self, custody_context: &CustodyContext) -> usize + where + T: BeaconChainTypes, + { + if custody_context.data_columns_required_for_bid(&self.bid) { + custody_context.num_of_data_columns_to_sample(self.bid.epoch()) + } else { + 0 + } } /// Returns columns that have all cells present. @@ -59,7 +68,7 @@ impl PendingComponents { &mut self, kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn], ) { - let num_blobs_expected = self.num_blobs_expected(); + let num_blobs_expected = self.bid.num_blobs_expected(); for data_column in kzg_verified_data_columns { let data_column = data_column.as_data_column(); // The Vec-backed `PendingColumn` keys cells by index, so we have to allocate up to @@ -95,10 +104,13 @@ impl PendingComponents { } /// Returns `Some` if the envelope and all required data columns have been received. - pub fn make_available( + pub fn make_available( &self, - num_expected_columns: usize, - ) -> Result>, AvailabilityCheckError> { + custody_context: &CustodyContext, + ) -> Result>, AvailabilityCheckError> + where + T: BeaconChainTypes, + { // Check if the payload has been received and executed let Some(envelope) = &self.envelope else { return Ok(None); @@ -110,17 +122,24 @@ impl PendingComponents { payload_verification_outcome, } = envelope; - let columns = if self.num_blobs_expected() == 0 { - self.span.in_scope(|| { - debug!("Bid has no blobs, data is available"); - }); + let num_columns_required = self.num_columns_required(custody_context); + let columns = if num_columns_required == 0 { + if self.bid.num_blobs_expected() == 0 { + self.span.in_scope(|| { + debug!("Bid has no blobs, data is available"); + }); + } else { + self.span.in_scope(|| { + debug!("No data columns required for this epoch"); + }); + } vec![] } else { let columns = self.get_cached_data_columns(); - match columns.len().cmp(&num_expected_columns) { + match columns.len().cmp(&num_columns_required) { Ordering::Greater => { return Err(AvailabilityCheckError::Unexpected(format!( - "too many columns: got {} expected {num_expected_columns}", + "too many columns: got {} expected {num_columns_required}", columns.len() ))); } @@ -137,7 +156,8 @@ impl PendingComponents { } }; - let available_envelope = AvailableEnvelope::new(envelope.clone(), columns); + let available_envelope = + AvailableEnvelope::new(envelope.clone(), columns, &self.bid, custody_context)?; Ok(Some(AvailableExecutedEnvelope { envelope: available_envelope, @@ -160,12 +180,16 @@ impl PendingComponents { } } - pub fn status_str(&self, num_expected_columns: usize) -> String { + pub fn status_str(&self, custody_context: &CustodyContext) -> String + where + T: BeaconChainTypes, + { + let num_columns_required = self.num_columns_required(custody_context); format!( "envelope {}, data_columns {}/{}", self.envelope.is_some(), self.num_completed_columns(), - num_expected_columns + num_columns_required ) } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index deaae6cba5..ce51a82cec 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -226,29 +226,30 @@ pub fn test_da_checker( spec: Arc, node_custody_type: NodeCustodyType, ) -> DataAvailabilityChecker> { + let kzg = get_kzg(&spec); + let custody_context = test_custody_context(node_custody_type, spec.clone()); + DataAvailabilityChecker::new(kzg, custody_context, spec, true, false) + .expect("should initialise data availability checker") +} + +pub fn test_custody_context( + node_custody_type: NodeCustodyType, + spec: Arc, +) -> Arc>> { + let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); + let complete_blob_backfill = false; let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), spec.get_slot_duration(), ); - let kzg = get_kzg(&spec); - let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); - let custody_context = Arc::new(CustodyContext::new( + Arc::new(CustodyContext::new( node_custody_type, ordered_custody_column_indices, - &spec, - )); - let complete_blob_backfill = false; - DataAvailabilityChecker::new( - complete_blob_backfill, slot_clock, - kzg, - custody_context, + complete_blob_backfill, spec, - true, - false, - ) - .expect("should initialise data availability checker") + )) } pub struct Builder { @@ -3163,26 +3164,28 @@ where block: Arc>, ) -> RangeSyncBlock { let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - let is_gloas = block.fork_name_unchecked().gloas_enabled(); // For Gloas, kzg commitments live in the bid (`signed_execution_payload_bid`), so the // body's `blob_kzg_commitments()` accessor returns Err. `num_expected_blobs` already // handles both shapes. let has_blobs = block.num_expected_blobs() > 0; if !has_blobs { - return if is_gloas { + return if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let envelope = self .chain .get_payload_envelope(&block_root) .unwrap() .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, vec![])); + .map(|envelope| { + AvailableEnvelope::new(envelope, vec![], bid, &self.chain.custody_context) + }) + .transpose() + .unwrap(); RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { RangeSyncBlock::new( block, AvailableBlockData::NoData, - &self.chain.data_availability_checker, - self.chain.spec.clone(), + &self.chain.custody_context, ) .unwrap() }; @@ -3197,23 +3200,26 @@ where .unwrap() .unwrap(); let custody_columns = columns.into_iter().collect::>(); - if is_gloas { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let envelope = self .chain .get_payload_envelope(&block_root) .unwrap() .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, custody_columns)); + .map(|envelope| { + AvailableEnvelope::new( + envelope, + custody_columns, + bid, + &self.chain.custody_context, + ) + }) + .transpose() + .unwrap(); RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &self.chain.custody_context).unwrap() } } else { let blobs = self.chain.get_blobs(&block_root).unwrap().blobs(); @@ -3223,13 +3229,7 @@ where AvailableBlockData::NoData }; - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &self.chain.custody_context).unwrap() } } @@ -3239,7 +3239,7 @@ where block: Arc>>, blob_items: Option<(KzgProofs, BlobsList)>, ) -> Result, BlockError> { - if block.fork_name_unchecked().gloas_enabled() { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let columns = blob_items .map(|_| generate_data_column_sidecars_from_block(&block, &self.spec)) .unwrap_or_default(); @@ -3248,13 +3248,17 @@ where .get_payload_envelope(&block.canonical_root()) .map_err(|e| BlockError::BeaconChainError(Box::new(e)))? .map(Arc::new) - .map(|envelope| AvailableEnvelope::new(envelope, columns)); + .map(|envelope| { + AvailableEnvelope::new(envelope, columns, bid, &self.chain.custody_context) + }) + .transpose() + .unwrap(); return RangeSyncBlock::new_gloas(block, envelope).map_err(BlockError::InternalError); } Ok(if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let epoch = block.slot().epoch(E::slots_per_epoch()); - let sampling_columns = self.chain.sampling_columns_for_epoch(epoch); + let sampling_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); if blob_items.is_some_and(|(kzg_proofs, _)| !kzg_proofs.is_empty()) { // Note: this method ignores the actual custody columns and just take the first @@ -3265,18 +3269,12 @@ where .filter(|d| sampling_columns.contains(d.index())) .collect::>(); let block_data = AvailableBlockData::new_with_data_columns(columns); - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? + RangeSyncBlock::new(block, block_data, &self.chain.custody_context)? } else { RangeSyncBlock::new( block, AvailableBlockData::NoData, - &self.chain.data_availability_checker, - self.chain.spec.clone(), + &self.chain.custody_context, )? } } else { @@ -3292,12 +3290,7 @@ where AvailableBlockData::NoData }; - RangeSyncBlock::new( - block, - block_data, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? + RangeSyncBlock::new(block, block_data, &self.chain.custody_context)? }) } @@ -4007,6 +4000,7 @@ where let custody_columns = custody_columns_opt.unwrap_or_else(|| { let epoch = block.slot().epoch(E::slots_per_epoch()); self.chain + .custody_context .sampling_columns_for_epoch(epoch) .iter() .copied() diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index 94d4b3b9da..3269400c42 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -1,6 +1,7 @@ #![cfg(not(debug_assertions))] // TODO(gloas) we probably need similar test for payload envelope verification use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, LookupBlock, RangeSyncBlock}; +use beacon_chain::custody_context::CustodyContext; use beacon_chain::data_availability_checker::{AvailabilityCheckError, AvailableBlockData}; use beacon_chain::data_column_verification::CustodyDataColumn; use beacon_chain::payload_envelope_verification::AvailableEnvelope; @@ -10,7 +11,7 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, - MakeAttestationOptions, test_spec, + MakeAttestationOptions, generate_data_column_sidecars_from_block, test_spec, }, }; use beacon_chain::{ @@ -178,7 +179,7 @@ fn build_range_sync_block( where T: BeaconChainTypes, { - if block.fork_name_unchecked().gloas_enabled() { + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { let columns = match data_sidecars { Some(DataSidecars::DataColumns(columns)) => columns .iter() @@ -186,20 +187,17 @@ where .collect::>(), Some(DataSidecars::Blobs(_)) | None => vec![], }; - let envelope = execution_envelope.map(|envelope| AvailableEnvelope::new(envelope, columns)); + let envelope = execution_envelope + .map(|envelope| AvailableEnvelope::new(envelope, columns, bid, &chain.custody_context)) + .transpose() + .unwrap(); return RangeSyncBlock::new_gloas(block, envelope).unwrap(); } match data_sidecars { Some(DataSidecars::Blobs(blobs)) => { let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &chain.custody_context).unwrap() } Some(DataSidecars::DataColumns(columns)) => { let block_data = AvailableBlockData::new_with_data_columns( @@ -208,21 +206,11 @@ where .map(|c| c.as_data_column().clone()) .collect::>(), ); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() + RangeSyncBlock::new(block, block_data, &chain.custody_context).unwrap() + } + None => { + RangeSyncBlock::new(block, AvailableBlockData::NoData, &chain.custody_context).unwrap() } - None => RangeSyncBlock::new( - block, - AvailableBlockData::NoData, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap(), } } @@ -522,8 +510,7 @@ async fn chain_segment_non_linear_parent_roots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -567,8 +554,7 @@ async fn chain_segment_non_linear_slots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -602,8 +588,7 @@ async fn chain_segment_non_linear_slots() { RangeSyncBlock::new( mutated_block, blocks[3].block_data().clone(), - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -1809,8 +1794,7 @@ async fn add_base_block_to_altair_chain() { let base_range_sync_block = RangeSyncBlock::new( Arc::new(base_block.clone()), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap(); assert!(matches!( @@ -1840,8 +1824,7 @@ async fn add_base_block_to_altair_chain() { RangeSyncBlock::new( Arc::new(base_block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone() + &harness.chain.custody_context, ) .unwrap() ], @@ -1985,8 +1968,7 @@ async fn add_altair_block_to_base_chain() { RangeSyncBlock::new( Arc::new(altair_block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone() + &harness.chain.custody_context, ) .unwrap() ], @@ -2205,8 +2187,7 @@ async fn import_duplicate_block_unrealized_justification() { RangeSyncBlock::new( block.clone(), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.spec.clone(), + &harness.chain.custody_context, ) .unwrap() }; @@ -2284,8 +2265,12 @@ async fn import_execution_pending_block( } } -async fn make_gloas_range_sync_block_inputs() --> Option<(Arc>, SignedExecutionPayloadEnvelope)> { +async fn make_gloas_range_sync_block_inputs() -> Option<( + Arc>, + SignedExecutionPayloadEnvelope, + Arc>>, + DataColumnSidecarList, +)> { let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(1)).gloas_enabled() { return None; @@ -2304,16 +2289,37 @@ async fn make_gloas_range_sync_block_inputs() let state = harness.get_current_state(); let slot = harness.get_current_slot(); let ((block, _), envelope, _) = harness.make_block_with_envelope(state, slot).await; - Some((block, envelope.expect("gloas block should have envelope"))) + let custody_context = harness.chain.custody_context.clone(); + let columns = generate_data_column_sidecars_from_block(&block, &harness.chain.spec) + .into_iter() + .filter(|column| { + custody_context + .sampling_columns_for_epoch(block.epoch()) + .contains(column.index()) + }) + .collect(); + Some(( + block, + envelope.expect("gloas block should have envelope"), + custody_context, + columns, + )) } #[tokio::test] async fn range_sync_block_new_gloas_accepts_matching_envelope() { - let Some((block, envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; - - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2325,7 +2331,7 @@ async fn range_sync_block_new_gloas_accepts_matching_envelope() { #[tokio::test] async fn range_sync_block_new_gloas_allows_missing_envelope() { - let Some((block, _)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, _, _, _)) = make_gloas_range_sync_block_inputs().await else { return; }; @@ -2340,12 +2346,20 @@ async fn range_sync_block_new_gloas_allows_missing_envelope() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_slot_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.payload.slot_number += 1; - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2357,12 +2371,20 @@ async fn range_sync_block_new_gloas_rejects_slot_mismatch() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_builder_index_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.builder_index += 1; - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2374,12 +2396,20 @@ async fn range_sync_block_new_gloas_rejects_builder_index_mismatch() { #[tokio::test] async fn range_sync_block_new_gloas_rejects_block_hash_mismatch() { - let Some((block, mut envelope)) = make_gloas_range_sync_block_inputs().await else { + let Some((block, mut envelope, custody_context, columns)) = + make_gloas_range_sync_block_inputs().await + else { return; }; envelope.message.payload.block_hash = ExecutionBlockHash::repeat_byte(0x22); - let available_envelope = AvailableEnvelope::new(Arc::new(envelope), vec![]); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + .unwrap(); + let available_envelope = + AvailableEnvelope::new(Arc::new(envelope), columns, bid, &custody_context).unwrap(); let result = RangeSyncBlock::new_gloas(block, Some(available_envelope)); assert!( @@ -2442,12 +2472,8 @@ async fn range_sync_block_construction_fails_with_wrong_blob_count() { let block_data = AvailableBlockData::new_with_blobs(wrong_blobs); // Try to create RpcBlock with wrong blob count - let result = RangeSyncBlock::new( - Arc::new(block), - block_data, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), - ); + let result = + RangeSyncBlock::new(Arc::new(block), block_data, &harness.chain.custody_context); // Should fail with MissingBlobs assert!( @@ -2523,8 +2549,7 @@ async fn range_sync_block_rejects_missing_custody_columns() { let result = RangeSyncBlock::new( Arc::new(block), block_data, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ); // Should fail with MissingCustodyColumns @@ -2599,6 +2624,7 @@ async fn rpc_block_allows_construction_past_da_boundary() { // Now verify the block is past the DA boundary let da_boundary = harness .chain + .custody_context .data_availability_boundary() .expect("DA boundary should be set"); assert!( @@ -2613,8 +2639,7 @@ async fn rpc_block_allows_construction_past_da_boundary() { let result = RangeSyncBlock::new( Arc::new(block), AvailableBlockData::NoData, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), + &harness.chain.custody_context, ); assert!( diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 9f0b3675f3..537e8f40ee 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -43,7 +43,10 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let mut random_sidecar = DataColumnSidecarGloas::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.slot = slot; - random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + random_sidecar.index = harness + .chain + .custody_context + .sampling_columns_for_epoch(epoch)[0]; // For gloas, the bid must be known, e.g. in the pending payload cache let mut bid = SignedExecutionPayloadBid::::empty(); @@ -58,7 +61,10 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let mut random_sidecar = DataColumnSidecarFulu::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.signed_block_header.message.slot = slot; - random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + random_sidecar.index = harness + .chain + .custody_context + .sampling_columns_for_epoch(epoch)[0]; DataColumnSidecar::Fulu(random_sidecar) } }; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 4d392ef524..f15beb8197 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3322,13 +3322,8 @@ async fn weak_subjectivity_sync_test( let (_, block, data) = clone_block(&available_blocks[0]).deconstruct(); let mut corrupt_block = (*block).clone(); *corrupt_block.signature_mut() = Signature::empty(); - AvailableBlock::new( - Arc::new(corrupt_block), - data, - &beacon_chain.data_availability_checker, - Arc::new(spec), - ) - .expect("available block") + AvailableBlock::new(Arc::new(corrupt_block), data, &beacon_chain.custody_context) + .expect("available block") }; // Importing the invalid batch should error. @@ -4878,7 +4873,10 @@ async fn test_column_da_boundary() { // The column da boundary should be the fulu fork epoch assert_eq!( - harness.chain.column_data_availability_boundary(), + harness + .chain + .custody_context + .column_data_availability_boundary(), Some(fulu_fork_epoch) ); } @@ -5293,6 +5291,7 @@ async fn test_custody_column_filtering_regular_node() { // Get custody columns for this epoch - regular nodes only store a subset let expected_custody_columns: HashSet<_> = harness .chain + .custody_context .custody_columns_for_epoch(Some(current_slot.epoch(E::slots_per_epoch()))) .iter() .copied() @@ -5374,8 +5373,6 @@ async fn test_missing_columns_after_cgc_change() { return; } - let custody_context = harness.chain.data_availability_checker.custody_context(); - harness.advance_slot(); harness .extend_chain( @@ -5397,7 +5394,10 @@ async fn test_missing_columns_after_cgc_change() { let epoch_after_increase = Epoch::new(num_epochs_before_increase + 2); let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); - custody_context.register_validators(vec![(1, 32_000_000_000 * 9)], cgc_change_slot, &spec); + harness + .chain + .custody_context + .register_validators(vec![(1, 32_000_000_000 * 9)], cgc_change_slot); harness.advance_slot(); harness @@ -5444,8 +5444,6 @@ async fn test_safely_backfill_data_column_custody_info() { return; } - let custody_context = harness.chain.data_availability_checker.custody_context(); - harness.advance_slot(); harness .extend_chain( @@ -5461,7 +5459,10 @@ async fn test_safely_backfill_data_column_custody_info() { let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); - custody_context.register_validators(vec![(1, 32_000_000_000 * 16)], cgc_change_slot, &spec); + harness + .chain + .custody_context + .register_validators(vec![(1, 32_000_000_000 * 16)], cgc_change_slot); let epoch_after_increase = (cgc_change_slot + effective_delay_slots).epoch(E::slots_per_epoch()); diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index bdb4228765..7b7c7beb9b 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -171,7 +171,9 @@ pub fn spawn_notifier( Ok(data_column_custody_info) => { if let Some(earliest_data_column_slot) = data_column_custody_info .and_then(|info| info.earliest_data_column_slot) - && let Some(da_boundary) = beacon_chain.get_column_da_boundary() + && let Some(da_boundary) = beacon_chain + .custody_context + .column_data_availability_boundary() { sync_distance = earliest_data_column_slot.saturating_sub( da_boundary.start_slot(T::EthSpec::slots_per_epoch()), @@ -295,7 +297,9 @@ pub fn spawn_notifier( let speed = speedo.slots_per_second(); let display_speed = speed.is_some_and(|speed| speed != 0.0); let est_time_in_secs = if let (Some(da_boundary_epoch), Some(original_slot)) = ( - beacon_chain.get_column_da_boundary(), + beacon_chain + .custody_context + .column_data_availability_boundary(), original_earliest_data_column_slot, ) { let target = original_slot.saturating_sub( diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index d058f66001..456d371c71 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -200,7 +200,7 @@ pub async fn publish_execution_payload_envelope( } let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let sampling_column_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_column_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let sampling_columns = gossip_verified_columns .into_iter() .filter(|col| sampling_column_indices.contains(&col.index())) diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index 50d5c8d165..fe34863b77 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -602,7 +602,9 @@ mod tests { "precondition: test block must not be imported into fork choice yet" ); - let sampling_columns = chain.sampling_columns_for_epoch(block.epoch()); + let sampling_columns = chain + .custody_context + .sampling_columns_for_epoch(block.epoch()); let data_columns = generate_data_column_sidecars_from_block(&block, &chain.spec) .into_iter() .filter(|column| sampling_columns.contains(column.index())) @@ -615,8 +617,7 @@ mod tests { let available_block = AvailableBlock::new( block.clone(), AvailableBlockData::new_with_data_columns(data_columns), - &chain.data_availability_checker, - chain.spec.clone(), + &chain.custody_context, ) .unwrap(); diff --git a/beacon_node/http_api/src/custody.rs b/beacon_node/http_api/src/custody.rs index a43b55ceca..21004beb72 100644 --- a/beacon_node/http_api/src/custody.rs +++ b/beacon_node/http_api/src/custody.rs @@ -17,6 +17,7 @@ pub fn info( .map_err(|e| custom_server_error(format!("error reading DataColumnCustodyInfo: {e:?}")))?; let column_data_availability_boundary = chain + .custody_context .column_data_availability_boundary() .ok_or_else(|| custom_server_error("unreachable: Fulu should be enabled".to_string()))?; @@ -38,12 +39,13 @@ pub fn info( // Compute the custody columns and the CGC *at the earliest custodied slot*. The node might // have some columns prior to this, but this value is the most up-to-date view of the data the // node is custodying. - let custody_context = chain.data_availability_checker.custody_context(); - let custody_columns = custody_context - .custody_columns_for_epoch(Some(earliest_custodied_data_column_epoch), &chain.spec) + let custody_columns = chain + .custody_context + .custody_columns_for_epoch(Some(earliest_custodied_data_column_epoch)) .to_vec(); - let custody_group_count = custody_context - .custody_group_count_at_epoch(earliest_custodied_data_column_epoch, &chain.spec); + let custody_group_count = chain + .custody_context + .custody_group_count_at_epoch(earliest_custodied_data_column_epoch); Ok(CustodyInfo { earliest_custodied_data_column_slot, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 7c0959acb9..8e31e88ff8 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3179,10 +3179,11 @@ pub fn serve( .head_slot() .epoch(T::EthSpec::slots_per_epoch()) + 1; - let custody_context = chain.data_availability_checker.custody_context(); // Reset validator custody requirements to `effective_epoch` with the latest // cgc requiremnets. - custody_context.reset_validator_custody_requirements(effective_epoch); + chain + .custody_context + .reset_validator_custody_requirements(effective_epoch); // Update `DataColumnCustodyInfo` to reflect the custody change. chain.update_data_column_custody_info(Some( effective_epoch.start_slot(T::EthSpec::slots_per_epoch()), diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index e3e9839b2d..41bb515a2b 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -217,7 +217,7 @@ pub async fn publish_block>( warp_utils::reject::custom_server_error("unable to publish data column sidecars".into()) })?; let epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); - let sampling_columns_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_columns_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let sampling_columns = gossip_verified_columns .into_iter() .filter(|data_column| sampling_columns_indices.contains(&data_column.index())) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index ee699b3adc..7f3d1cd721 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -855,9 +855,8 @@ pub fn post_validator_prepare_beacon_proposer( 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) + .custody_context + .register_validators(validators_and_balances, current_slot) { chain.update_data_column_custody_info(Some( cgc_change diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 98629a1c5e..6d80344e94 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -2036,6 +2036,7 @@ fn get_custody_columns(tester: &InteractiveTester, slot: Slot) -> HashSet NetworkBeaconProcessor { async fn check_reconstruction_trigger(self: &Arc, slot: Slot, block_root: &Hash256) { if self .chain - .data_availability_checker - .custody_context() - .should_attempt_reconstruction( - slot.epoch(T::EthSpec::slots_per_epoch()), - &self.chain.spec, - ) + .custody_context + .should_attempt_reconstruction(slot.epoch(T::EthSpec::slots_per_epoch())) { // Instead of triggering reconstruction immediately, schedule it to be run. If // another column arrives, it either completes availability or pushes diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index ea5fa3e90b..8066d8c689 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -910,7 +910,7 @@ impl NetworkBeaconProcessor { return; } let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); - let custody_columns = self.chain.sampling_columns_for_epoch(epoch); + let custody_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); let self_cloned = self.clone(); let publish_fn = move |columns: Vec>| { if publish_blobs { @@ -985,7 +985,7 @@ impl NetworkBeaconProcessor { return; }; let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); - let custody_columns = self.chain.sampling_columns_for_epoch(epoch); + let custody_columns = self.chain.custody_context.sampling_columns_for_epoch(epoch); let columns = assembler.get_columns_and_mark_as_local_fetched(block_root, &header); let mut present_indices: HashSet = HashSet::with_capacity(columns.len()); diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index 37a6f3779a..fc451ebc05 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -791,7 +791,7 @@ impl NetworkBeaconProcessor { ) -> Result<(), (RpcErrorResponse, &'static str)> { let mut send_data_column_count = 0; // Only attempt lookups for columns the node has advertised and is responsible for maintaining custody of. - let available_columns = self.chain.custody_columns_for_epoch(None); + let available_columns = self.chain.custody_context.custody_columns_for_epoch(None); for data_column_ids_by_root in request.data_column_ids.as_slice() { let indices_to_retrieve = data_column_ids_by_root @@ -1595,13 +1595,14 @@ impl NetworkBeaconProcessor { req.count }; - let data_availability_boundary_slot = match self.chain.data_availability_boundary() { - Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), - None => { - debug!("Deneb fork is disabled"); - return Err((RpcErrorResponse::InvalidRequest, "Deneb fork is disabled")); - } - }; + let data_availability_boundary_slot = + match self.chain.custody_context.data_availability_boundary() { + Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), + None => { + debug!("Deneb fork is disabled"); + return Err((RpcErrorResponse::InvalidRequest, "Deneb fork is disabled")); + } + }; let oldest_blob_slot = self .chain @@ -1745,14 +1746,17 @@ impl NetworkBeaconProcessor { let request_start_slot = Slot::from(req.start_slot); - let column_data_availability_boundary_slot = - match self.chain.column_data_availability_boundary() { - Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), - None => { - debug!("Fulu fork is disabled"); - return Err((RpcErrorResponse::InvalidRequest, "Fulu fork is disabled")); - } - }; + let column_data_availability_boundary_slot = match self + .chain + .custody_context + .column_data_availability_boundary() + { + Some(boundary) => boundary.start_slot(T::EthSpec::slots_per_epoch()), + None => { + debug!("Fulu fork is disabled"); + return Err((RpcErrorResponse::InvalidRequest, "Fulu fork is disabled")); + } + }; let earliest_custodied_data_column_slot = match self.chain.earliest_custodied_data_column_epoch() { @@ -1798,6 +1802,7 @@ impl NetworkBeaconProcessor { let request_start_epoch = request_start_slot.epoch(T::EthSpec::slots_per_epoch()); let available_columns = self .chain + .custody_context .custody_columns_for_epoch(Some(request_start_epoch)); let indices_to_retrieve = req @@ -1916,9 +1921,8 @@ impl NetworkBeaconProcessor { let non_custody_indices = { let custody_columns = self .chain - .data_availability_checker - .custody_context() - .custody_columns_for_epoch(epoch_opt, &self.chain.spec); + .custody_context + .custody_columns_for_epoch(epoch_opt); requested_indices .iter() .filter(|subnet_id| !custody_columns.contains(subnet_id)) diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 89434c878e..95f7d7649f 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -340,7 +340,7 @@ impl TestRig { if chain.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let kzg = get_kzg(&chain.spec); let epoch = block.slot().epoch(E::slots_per_epoch()); - let sampling_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_indices = chain.custody_context.sampling_columns_for_epoch(epoch); let custody_columns: DataColumnSidecarList = blobs_to_data_column_sidecars( &blobs.iter().collect_vec(), kzg_proofs.clone().into_iter().collect_vec(), @@ -1836,6 +1836,7 @@ async fn test_data_columns_by_range_request_only_returns_requested_columns() { let all_custody_columns = rig .chain + .custody_context .sampling_columns_for_epoch(rig.chain.epoch().unwrap()); let available_columns: Vec = all_custody_columns.to_vec(); @@ -1897,7 +1898,10 @@ async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { let skip_slots: HashSet = [5, 6].into_iter().collect(); let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; - let all_custody_columns = rig.chain.custody_columns_for_epoch(Some(Epoch::new(0))); + let all_custody_columns = rig + .chain + .custody_context + .custody_columns_for_epoch(Some(Epoch::new(0))); let requested_column = vec![all_custody_columns[0]]; // Request a range that spans the skip slots (slots 0 through 9). diff --git a/beacon_node/network/src/service.rs b/beacon_node/network/src/service.rs index ba4aada352..cef3d5859e 100644 --- a/beacon_node/network/src/service.rs +++ b/beacon_node/network/src/service.rs @@ -294,10 +294,7 @@ impl NetworkService { let (mut libp2p, network_globals) = Network::new( executor.clone(), service_context, - beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&beacon_chain.spec), + beacon_chain.custody_context.custody_group_count_at_head(), local_keypair, ) .await?; diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index c8bb17243e..edf358976a 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -352,11 +352,14 @@ impl BackFillSync { debug!(?batch_id, msg, "Blob peer failure"); } CouplingError::EnvelopePeerFailure(msg) => { - debug!(?batch_id, msg, "Envelope peer failure"); + debug!(?batch_id, ?msg, "Envelope peer failure"); } CouplingError::InternalError(msg) => { error!(?batch_id, msg, "Block components coupling internal error"); } + CouplingError::AvailabilityCheckError(err) => { + error!(?batch_id, ?err, "Availability check error"); + } } } // A batch could be retried without the peer failing the request (disconnecting/ diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index dbf3604cf0..09ffa50f9b 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -394,12 +394,11 @@ impl SingleBlockLookup { match &mut self.data_request { DataRequest::WaitingForBlock => { if let Some(block) = self.block_request.state.peek_downloaded_data() { - let block_epoch = block - .slot() - .epoch(::EthSpec::slots_per_epoch()); - self.data_request = if block.num_expected_blobs() == 0 { - DataRequest::NoData - } else if cx.chain.should_fetch_custody_columns(block_epoch) { + self.data_request = if cx + .chain + .custody_context + .data_columns_required_for_block(block) + { DataRequest::Request { slot: block.slot(), peers: self.get_data_peers(block.payload_bid_block_hash().ok()), diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index 93c0699ded..001fabb704 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -1,8 +1,9 @@ +use beacon_chain::data_availability_checker::AvailabilityCheckError; use beacon_chain::payload_envelope_verification::AvailableEnvelope; use beacon_chain::{ BeaconChainTypes, block_verification_types::{AvailableBlockData, RangeSyncBlock}, - data_availability_checker::DataAvailabilityChecker, + custody_context::CustodyContext, data_column_verification::CustodyDataColumn, get_block_root, }; @@ -81,6 +82,13 @@ pub enum CouplingError { }, BlobPeerFailure(String), EnvelopePeerFailure(String), + AvailabilityCheckError(AvailabilityCheckError), +} + +impl From for CouplingError { + fn from(e: AvailabilityCheckError) -> Self { + CouplingError::AvailabilityCheckError(e) + } } impl RangeBlockComponentsRequest { @@ -221,7 +229,7 @@ impl RangeBlockComponentsRequest { /// Returns `Some(Err(_))` if there are issues coupling blocks with their data. pub fn responses( &mut self, - da_checker: Arc>, + custody_context: &CustodyContext, spec: Arc, ) -> Option>, CouplingError>> where @@ -241,7 +249,7 @@ impl RangeBlockComponentsRequest { RangeBlockDataRequest::NoData => Some(Self::responses_with_blobs( blocks.to_vec(), vec![], - da_checker, + custody_context, spec, )), RangeBlockDataRequest::Blobs(request) => { @@ -251,7 +259,7 @@ impl RangeBlockComponentsRequest { Some(Self::responses_with_blobs( blocks.to_vec(), blobs.to_vec(), - da_checker, + custody_context, spec, )) } @@ -294,8 +302,7 @@ impl RangeBlockComponentsRequest { column_to_peer_id, expected_custody_columns, *attempt, - da_checker, - spec, + custody_context, payload_envelopes, ); @@ -321,7 +328,7 @@ impl RangeBlockComponentsRequest { fn responses_with_blobs( blocks: Vec>>, blobs: Vec>>, - da_checker: Arc>, + custody_context: &CustodyContext, spec: Arc, ) -> Result>, CouplingError> where @@ -370,7 +377,7 @@ impl RangeBlockComponentsRequest { })?; let block_data = AvailableBlockData::new_with_blobs(blobs); responses.push( - RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, custody_context) .map_err(|e| CouplingError::BlobPeerFailure(format!("{e:?}")))?, ) } @@ -394,8 +401,7 @@ impl RangeBlockComponentsRequest { column_to_peer: HashMap, expects_custody_columns: &[ColumnIndex], attempt: usize, - da_checker: Arc>, - spec: Arc, + custody_context: &CustodyContext, payload_envelopes: Option>>>, ) -> Result>, CouplingError> where @@ -499,17 +505,24 @@ impl RangeBlockComponentsRequest { envelopes_by_block_root.as_mut() { let envelope = envelopes_by_block_root.remove(&block_root); - let available_envelope = - envelope.map(|env| AvailableEnvelope::new(env, custody_columns)); + let bid = block + .message() + .body() + .signed_execution_payload_bid() + // this really should never fail + .map_err(|_| AvailabilityCheckError::MissingBid(block_root))?; + let available_envelope = envelope + .map(|env| AvailableEnvelope::new(env, custody_columns, bid, custody_context)) + .transpose()?; RangeSyncBlock::new_gloas(block, available_envelope) .map_err(CouplingError::EnvelopePeerFailure)? } else if custody_columns.is_empty() { - RangeSyncBlock::new(block, AvailableBlockData::NoData, &da_checker, spec.clone()) + RangeSyncBlock::new(block, AvailableBlockData::NoData, custody_context) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? } else { let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, custody_context) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? }; range_sync_blocks.push(range_sync_block); @@ -562,11 +575,10 @@ mod tests { use super::RangeBlockComponentsRequest; use beacon_chain::block_verification_types::RangeSyncBlock; - use beacon_chain::custody_context::NodeCustodyType; - use beacon_chain::data_availability_checker::DataAvailabilityChecker; + use beacon_chain::custody_context::{CustodyContext, NodeCustodyType}; use beacon_chain::test_utils::{ EphemeralHarnessType, NumBlobs, generate_rand_block_and_blobs, - generate_rand_block_and_data_columns, test_da_checker, test_spec, + generate_rand_block_and_data_columns, test_custody_context, test_spec, }; use bls::Signature; use lighthouse_network::{ @@ -737,8 +749,8 @@ mod tests { fn is_finished(info: &mut RangeBlockComponentsRequest) -> bool { let spec = Arc::new(test_spec::()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - info.responses(da_checker, spec).is_some() + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + info.responses(&custody_context, spec).is_some() } fn gloas_spec() -> ChainSpec { @@ -826,7 +838,7 @@ mod tests { #[allow(clippy::type_complexity)] struct GloasSetup { info: RangeBlockComponentsRequest, - da_checker: Arc>>, + custody_context: Arc>>, spec: Arc, blocks: Vec<( Arc>, @@ -841,10 +853,9 @@ mod tests { /// ready for the per-test payload-envelope step. fn setup_gloas_coupling(count: usize) -> GloasSetup { let spec = Arc::new(gloas_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_custody_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_custody_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_gloas_blocks_and_columns(count, &spec); @@ -886,7 +897,7 @@ mod tests { GloasSetup { info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -900,7 +911,7 @@ mod tests { // Gloas each block still couples to its (empty-column) payload envelope, so the envelope // request is driven too. let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); let blocks = make_blocks_and_columns(4, NumBlobs::None, &spec); let components_id = components_id(); @@ -921,7 +932,7 @@ mod tests { .unwrap(); add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); assert_custody_columns_coupled(&responses, blocks.len(), 0); } @@ -960,19 +971,18 @@ mod tests { // FORK_NAME. spec.fulu_fork_epoch = None; let spec = Arc::new(spec); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); // Blobs are no longer required for availability, so the response succeeds without them. - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); assert!(result.is_ok()) } #[test] fn rpc_block_with_custody_columns() { let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expects_custody_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expects_custody_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_blocks_and_columns(4, NumBlobs::Number(1), &spec); @@ -1038,17 +1048,16 @@ mod tests { add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // All completed construct response - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); assert_custody_columns_coupled(&responses, blocks.len(), expects_custody_columns.len()); } #[test] fn rpc_block_with_custody_columns_batched() { let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); // Split sampling columns into two batches let mid = expected_sampling_columns.len() / 2; @@ -1126,7 +1135,7 @@ mod tests { add_envelopes_if_gloas(&mut info, payloads_req_id, &blocks); // All completed construct response - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); assert_custody_columns_coupled(&responses, blocks.len(), expected_sampling_columns.len()); } @@ -1134,20 +1143,20 @@ mod tests { fn gloas_payload_envelopes_must_complete_before_responses() { let GloasSetup { mut info, - da_checker, + custody_context, spec, .. } = setup_gloas_coupling(2); // No payload envelopes added yet, so the request must not be complete. - assert!(info.responses(da_checker, spec).is_none()); + assert!(info.responses(&custody_context, spec).is_none()); } #[test] fn gloas_payload_envelopes_are_coupled_by_block_root() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1165,7 +1174,7 @@ mod tests { ) .unwrap(); - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); assert_eq!(responses.len(), blocks.len()); for response in responses { match response { @@ -1188,7 +1197,7 @@ mod tests { fn gloas_payload_envelopes_allow_missing_envelopes() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1199,7 +1208,7 @@ mod tests { info.add_payload_envelopes(payloads_req_id, vec![blocks[0].2.clone()]) .unwrap(); - let responses = info.responses(da_checker, spec).unwrap().unwrap(); + let responses = info.responses(&custody_context, spec).unwrap().unwrap(); let count_with = |with_envelope: bool| { responses .iter() @@ -1216,7 +1225,7 @@ mod tests { fn gloas_payload_envelope_mismatch_fails_coupling() { let GloasSetup { mut info, - da_checker, + custody_context, spec, blocks, payloads_req_id, @@ -1228,7 +1237,7 @@ mod tests { info.add_payload_envelopes(payloads_req_id, vec![Arc::new(bad_envelope)]) .unwrap(); - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); assert!( matches!( result, @@ -1243,10 +1252,9 @@ mod tests { fn missing_custody_columns_from_faulty_peers() { // GIVEN: A request expecting sampling columns from multiple peers let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); @@ -1309,7 +1317,7 @@ mod tests { } // WHEN: Attempting to construct RPC blocks - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should fail with PeerFailure identifying the faulty peers assert!(result.is_err()); @@ -1337,10 +1345,9 @@ mod tests { fn retry_logic_after_peer_failures() { // GIVEN: A request expecting sampling columns where some peers initially fail let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_blocks_and_columns(2, NumBlobs::Number(1), &spec); @@ -1402,7 +1409,7 @@ mod tests { let result: Result< Vec>, crate::sync::block_sidecar_coupling::CouplingError, - > = info.responses(da_checker.clone(), spec.clone()).unwrap(); + > = info.responses(&custody_context, spec.clone()).unwrap(); assert!(result.is_err()); // AND: We retry with a new peer for the failed columns @@ -1433,7 +1440,7 @@ mod tests { .unwrap(); // WHEN: Attempting to get responses again - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should succeed with complete RangeSync blocks assert!(result.is_ok()); @@ -1445,10 +1452,9 @@ mod tests { fn max_retries_exceeded_behavior() { // GIVEN: A request where peers consistently fail to provide required columns let spec = Arc::new(custody_test_spec()); - let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); - let expected_sampling_columns = da_checker - .custody_context() - .sampling_columns_for_epoch(Epoch::new(0), &spec) + let custody_context = test_custody_context(NodeCustodyType::Fullnode, spec.clone()); + let expected_sampling_columns = custody_context + .sampling_columns_for_epoch(Epoch::new(0)) .to_vec(); let blocks = make_blocks_and_columns(1, NumBlobs::Number(1), &spec); @@ -1509,7 +1515,7 @@ mod tests { // WHEN: Multiple retry attempts are made (up to max retries) for _ in 0..MAX_COLUMN_RETRIES { - let result = info.responses(da_checker.clone(), spec.clone()).unwrap(); + let result = info.responses(&custody_context, spec.clone()).unwrap(); assert!(result.is_err()); if let Err(super::CouplingError::DataColumnPeerFailure { @@ -1522,7 +1528,7 @@ mod tests { } // AND: One final attempt after exceeding max retries - let result = info.responses(da_checker, spec).unwrap(); + let result = info.responses(&custody_context, spec).unwrap(); // THEN: Should fail with exceeded_retries = true assert!(result.is_err()); diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index c85610613c..2874bbebf1 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -162,7 +162,11 @@ impl CustodyBackFillSync { /// - The earliest data column epoch's custodied columns != previous epoch's custodied columns /// - The earliest data column epoch is a finalied epoch pub fn should_start_custody_backfill_sync(&mut self) -> bool { - let Some(da_boundary_epoch) = self.beacon_chain.get_column_da_boundary() else { + let Some(da_boundary_epoch) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; @@ -220,9 +224,8 @@ impl CustodyBackFillSync { fn restart_if_required(&mut self) -> bool { let cgc_at_head = self .beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&self.beacon_chain.spec); + .custody_context + .custody_group_count_at_head(); if cgc_at_head != self.cgc { self.restart_sync(); @@ -290,7 +293,11 @@ impl CustodyBackFillSync { } } - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return Ok(SyncStart::NotSyncing); }; @@ -309,9 +316,8 @@ impl CustodyBackFillSync { fn set_cgc(&mut self) { self.cgc = self .beacon_chain - .data_availability_checker - .custody_context() - .custody_group_count_at_head(&self.beacon_chain.spec); + .custody_context + .custody_group_count_at_head(); } fn set_start_epoch(&mut self) { @@ -379,9 +385,10 @@ impl CustodyBackFillSync { /// Creates the next required batch from the chain. If there are no more batches required, /// `None` is returned. fn include_next_batch(&mut self) -> Option { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { - return None; - }; + let column_da_boundary = self + .beacon_chain + .custody_context + .column_data_availability_boundary()?; // Skip all batches (Epochs) that don't have missing columns. for epoch in Epoch::range_inclusive_rev(self.to_be_downloaded, column_da_boundary) { @@ -715,7 +722,11 @@ impl CustodyBackFillSync { self.advance_custody_backfill_sync(batch_id); - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return Err(CustodyBackfillError::InvalidSyncState( "Can't calculate column data availability boundary".to_string(), )); @@ -889,7 +900,11 @@ impl CustodyBackFillSync { /// /// The `validating_epoch` must align with batch boundaries. fn advance_custody_backfill_sync(&mut self, validating_epoch: Epoch) { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return; }; // make sure this epoch produces an advancement, unless its at the column DA boundary @@ -986,7 +1001,11 @@ impl CustodyBackFillSync { return false; }; - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; @@ -997,7 +1016,11 @@ impl CustodyBackFillSync { /// Checks if custody backfill would complete by syncing to `start_epoch`. fn would_complete(&self, start_epoch: Epoch) -> bool { - let Some(column_da_boundary) = self.beacon_chain.get_column_da_boundary() else { + let Some(column_da_boundary) = self + .beacon_chain + .custody_context + .column_data_availability_boundary() + else { return false; }; start_epoch <= column_da_boundary diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 8b4e3c5694..100be9f4d7 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -589,6 +589,7 @@ impl SyncNetworkContext { let epoch = Slot::new(*request.start_slot()).epoch(T::EthSpec::slots_per_epoch()); let column_indexes = self .chain + .custody_context .sampling_columns_for_epoch(epoch) .iter() .cloned() @@ -694,7 +695,10 @@ impl SyncNetworkContext { data_column_requests.map(|data_column_requests| { ( data_column_requests, - self.chain.sampling_columns_for_epoch(epoch).to_vec(), + self.chain + .custody_context + .sampling_columns_for_epoch(epoch) + .to_vec(), ) }), payloads_req_id, @@ -815,10 +819,9 @@ impl SyncNetworkContext { } let range_req = entry.get_mut(); - if let Some(blocks_result) = range_req.responses( - self.chain.data_availability_checker.clone(), - self.chain.spec.clone(), - ) { + if let Some(blocks_result) = + range_req.responses(&self.chain.custody_context, self.chain.spec.clone()) + { if let Err(CouplingError::DataColumnPeerFailure { error, faulty_peers: _, @@ -1108,6 +1111,7 @@ impl SyncNetworkContext { // Include only the blob indexes not yet imported (received through gossip) let mut custody_indexes_to_fetch = self .chain + .custody_context .sampling_columns_for_epoch(block_slot.epoch(T::EthSpec::slots_per_epoch())) .iter() .copied() @@ -1424,15 +1428,11 @@ impl SyncNetworkContext { ByRangeRequestType::BlocksAndEnvelopesAndColumns } else if self .chain - .data_availability_checker + .custody_context .data_columns_required_for_epoch(epoch) { ByRangeRequestType::BlocksAndColumns - } else if self - .chain - .data_availability_checker - .blobs_required_for_epoch(epoch) - { + } else if self.chain.custody_context.blobs_required_for_epoch(epoch) { ByRangeRequestType::BlocksAndBlobs } else { ByRangeRequestType::Blocks @@ -1734,6 +1734,7 @@ impl SyncNetworkContext { let columns_by_range_peers_to_request = { let column_indexes = self .chain + .custody_context .sampling_columns_for_epoch(batch_id.epoch) .iter() .cloned() diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index 6292388339..e82bfa8a1a 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -958,6 +958,9 @@ impl SyncingChain { CouplingError::InternalError(msg) => { error!(?batch_id, msg, "Block components coupling internal error"); } + CouplingError::AvailabilityCheckError(err) => { + error!(?batch_id, ?err, "Availability check error"); + } } } // A batch could be retried without the peer failing the request (disconnecting/ diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 835b7546b3..f9afb910d9 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -1282,30 +1282,34 @@ impl TestRig { ) { let block_root = block.canonical_root(); let block_slot = block.slot(); - let range_sync_block = if block.fork_name_unchecked().gloas_enabled() { - // Gloas carries data columns in the payload envelope, not in `block_data`. - let envelope = self - .network_blocks_by_root - .get(&block_root) - .and_then(envelope_of) - .map(|envelope| AvailableEnvelope::new(envelope, columns.unwrap_or_default())); - RangeSyncBlock::new_gloas(block, envelope).unwrap() - } else { - let block_data = if let Some(columns) = columns { - AvailableBlockData::new_with_data_columns(columns) - } else if let Some(blobs) = blobs { - AvailableBlockData::new_with_blobs(blobs) + let range_sync_block = + if let Ok(bid) = block.message().body().signed_execution_payload_bid() { + // Gloas carries data columns in the payload envelope, not in `block_data`. + let envelope = self + .network_blocks_by_root + .get(&block_root) + .and_then(envelope_of) + .map(|envelope| { + AvailableEnvelope::new( + envelope, + columns.unwrap_or_default(), + bid, + &self.harness.chain.custody_context, + ) + }) + .transpose() + .unwrap(); + RangeSyncBlock::new_gloas(block, envelope).unwrap() } else { - AvailableBlockData::NoData + let block_data = if let Some(columns) = columns { + AvailableBlockData::new_with_data_columns(columns) + } else if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) + } else { + AvailableBlockData::NoData + }; + RangeSyncBlock::new(block, block_data, &self.harness.chain.custody_context).unwrap() }; - RangeSyncBlock::new( - block, - block_data, - &self.harness.chain.data_availability_checker, - self.harness.chain.spec.clone(), - ) - .unwrap() - }; self.network_blocks_by_slot .insert(block_slot, range_sync_block.clone()); self.network_blocks_by_root @@ -1622,9 +1626,8 @@ impl TestRig { fn custody_columns(&self) -> &[ColumnIndex] { self.harness .chain - .data_availability_checker - .custody_context() - .custody_columns_for_epoch(None, &self.harness.spec) + .custody_context + .custody_columns_for_epoch(None) } // Test setup diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 2ad6dcea1a..7dd6a88a1a 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -37,6 +37,10 @@ impl SignedExecutionPayloadBid { signature: Signature::empty(), } } + + pub fn num_blobs_expected(&self) -> usize { + self.message.blob_kzg_commitments.len() + } } #[cfg(test)]