From 2fc96415a818e50e6ce6ab81efabb2a9cf7f5890 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:47:27 +0200 Subject: [PATCH] Add gloas parent-envelope-unknown lookup tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the lookup test rig for Gloas: - Capture per-block execution payload envelopes from the external harness and serve them to peers via a new `network_envelopes_by_root` map. - Handle `RequestType::PayloadEnvelopesByRoot` in `simulate_on_request` and `Work::RpcPayloadEnvelope` in the simulator processor branch. - Allow `TestRig` callers to override the genesis validator count and bump initial balances to `max_effective_balance_electra` post-Electra, which Gloas committee-selection requires for genesis init to converge. Adds four tests for the parent-envelope-unknown flow (each verified red/green by stubbing the corresponding source path): - `creates_envelope_and_child_lookups` — `UnknownParentEnvelope` produces exactly one envelope-only lookup for the parent root and one child lookup awaiting that envelope. - `idempotent_triggers` — repeated triggers for the same parent merge into the existing envelope lookup; no duplicate lookups are created. - `issues_payload_envelopes_by_root_rpc` — the envelope-only lookup dispatches a `PayloadEnvelopesByRoot` RPC for the parent block_root. - `drops_cascade_on_rpc_error` — when the envelope RPC errors, the envelope lookup is dropped and the awaiting child cascades with it. The end-to-end happy path (envelope arrives → child unblocks → block imports → head advances) is gated on `process_execution_payload_envelope` supporting `AvailabilityPending`, which today returns `InternalError("Pending payload envelope not yet implemented")`. That gap is independent of this PR's lookup machinery. --- beacon_node/network/src/sync/tests/lookups.rs | 225 +++++++++++++++++- beacon_node/network/src/sync/tests/mod.rs | 4 +- 2 files changed, 215 insertions(+), 14 deletions(-) diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index a26996ec5e..9ff0bec15d 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -37,7 +37,7 @@ use tokio::sync::mpsc; use tracing::info; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, EthSpec, ForkContext, ForkName, - Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, + Hash256, MinimalEthSpec as E, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, test_utils::{SeedableRng, XorShiftRng}, }; @@ -209,6 +209,9 @@ pub(crate) struct TestRigConfig { fulu_test_type: FuluTestType, /// Override the node custody type derived from `fulu_test_type` node_custody_type_override: Option, + /// Override the number of validators in the harness genesis state. Defaults to 1. + /// Some forks (e.g. Gloas) cannot initialise a state with a single validator. + validator_count_override: Option, } impl TestRig { @@ -222,9 +225,9 @@ impl TestRig { ); // Initialise a new beacon chain - let harness = BeaconChainHarness::>::builder(E) + let mut builder = BeaconChainHarness::>::builder(E) .spec(spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(test_rig_config.validator_count_override.unwrap_or(1)) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(clock.clone()) @@ -232,8 +235,17 @@ impl TestRig { test_rig_config .node_custody_type_override .unwrap_or_else(|| test_rig_config.fulu_test_type.we_node_custody_type()), - ) - .build(); + ); + // Post-Electra forks need validators with effective balance close to + // `max_effective_balance_electra` for balance-weighted committee + // selection (sync committee, PTC) to converge during genesis. + if spec.electra_fork_epoch == Some(types::Epoch::new(0)) { + let max_eb = spec.max_effective_balance_electra; + builder = builder.with_genesis_state_builder(move |b| { + b.set_initial_balance_fn(Box::new(move |_| max_eb)) + }); + } + let harness = builder.build(); let chain = harness.chain.clone(); let fork_context = Arc::new(ForkContext::new::( @@ -305,6 +317,7 @@ 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(), @@ -319,6 +332,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type: FuluTestType::WeFullnodeThemSupernode, node_custody_type_override: None, + validator_count_override: None, }) } @@ -327,6 +341,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type: FuluTestType::WeFullnodeThemSupernode, node_custody_type_override: Some(node_custody_type), + validator_count_override: None, }) } @@ -429,9 +444,9 @@ impl TestRig { process_fn.await } } - Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) => { - process_fn.await - } + Work::RpcBlobs { process_fn } + | Work::RpcCustodyColumn(process_fn) + | Work::RpcPayloadEnvelope { process_fn } => process_fn.await, Work::ChainSegment { process_fn, process_id: (chain_id, batch_epoch), @@ -671,6 +686,27 @@ impl TestRig { self.send_rpc_columns_response(req_id, peer_id, &columns); } + (RequestType::PayloadEnvelopesByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_envelopes_response(req_id, peer_id, &[]); + } + + let envelopes = req + .beacon_block_roots + .iter() + .map(|block_root| { + self.network_envelopes_by_root + .get(block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown envelope: {block_root:?}") + }) + .clone() + }) + .collect::>(); + self.send_rpc_envelopes_response(req_id, peer_id, &envelopes); + } + (RequestType::BlocksByRange(req), AppRequestId::Sync(req_id)) => { if self.complete_strategy.skip_by_range_routes { return; @@ -894,6 +930,36 @@ impl TestRig { }); } + fn send_rpc_envelopes_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + envelopes: &[Arc>], + ) { + let block_roots = envelopes + .iter() + .map(|e| e.beacon_block_root()) + .collect::>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with envelopes for {block_roots:?}" + )); + + for envelope in envelopes { + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: Some(envelope.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: None, + seen_timestamp: D, + }); + } + fn send_rpc_columns_response( &mut self, sync_request_id: SyncRequestId, @@ -936,16 +1002,25 @@ impl TestRig { pub(super) async fn build_chain(&mut self, block_count: usize) -> Hash256 { let mut blocks = vec![]; - // Initialise a new beacon chain - let external_harness = BeaconChainHarness::>::builder(E) + // Initialise a new beacon chain. Match the local harness's validator count and + // balance hooks so post-Electra forks (where genesis-time committee selection is + // balance-weighted) can initialise. + let validator_count = self.harness.validator_keypairs.len(); + let mut builder = BeaconChainHarness::>::builder(E) .spec(self.harness.spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(validator_count) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(self.harness.chain.slot_clock.clone()) // Make the external harness a supernode so all columns are available - .node_custody_type(NodeCustodyType::Supernode) - .build(); + .node_custody_type(NodeCustodyType::Supernode); + if self.harness.spec.electra_fork_epoch == Some(types::Epoch::new(0)) { + let max_eb = self.harness.spec.max_effective_balance_electra; + builder = builder.with_genesis_state_builder(move |b| { + b.set_initial_balance_fn(Box::new(move |_| max_eb)) + }); + } + let external_harness = builder.build(); // Ensure all blocks have data. Otherwise, the triggers for unknown blob parent and unknown // data column parent fail. external_harness @@ -974,6 +1049,14 @@ impl TestRig { self.network_blocks_by_root .insert(block_root, block.clone()); self.network_blocks_by_slot.insert(block_slot, block); + // Post-Gloas, also capture the execution payload envelope so peers can serve it. + if self.is_after_gloas() + && let Ok(Some(envelope)) = + external_harness.chain.store.get_payload_envelope(&block_root) + { + self.network_envelopes_by_root + .insert(block_root, Arc::new(envelope)); + } self.log(&format!( "Produced block {} index {i} in external harness", block_slot, @@ -1444,6 +1527,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type, node_custody_type_override: None, + validator_count_override: None, }) }) } @@ -1460,6 +1544,22 @@ impl TestRig { self.fork_name.fulu_enabled() } + pub fn is_after_gloas(&self) -> bool { + self.fork_name.gloas_enabled() + } + + fn new_after_gloas() -> Option { + // Gloas requires more than 1 validator to initialise the genesis state + // (committee/sampling computations fail with `InvalidIndicesCount`). + genesis_fork().gloas_enabled().then(|| { + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: None, + validator_count_override: Some(1024), + }) + }) + } + fn trigger_unknown_parent_block(&mut self, peer_id: PeerId, block: Arc>) { let block_root = block.canonical_root(); self.send_sync_message(SyncMessage::UnknownParentBlock(peer_id, block, block_root)) @@ -1483,6 +1583,18 @@ impl TestRig { )); } + /// Trigger an envelope-unknown lookup for the last block in the chain. Caller is + /// expected to have already imported the parent block (via `import_blocks_up_to_slot`) + /// without registering its envelope. + fn trigger_with_last_unknown_parent_envelope(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let last_block = self.get_last_block().block_cloned(); + let block_root = last_block.canonical_root(); + self.send_sync_message(SyncMessage::UnknownParentEnvelope( + peer_id, last_block, block_root, + )); + } + fn rand_block(&mut self) -> SignedBeaconBlock { self.rand_block_and_blobs(NumBlobs::None).0 } @@ -2639,3 +2751,90 @@ async fn crypto_on_fail_with_bad_column_kzg_proof() { r.assert_penalties_of_type("lookup_custody_column_processing_failure"); } } + +// --------------------------------------------------------------------------- +// Gloas: parent envelope unknown lookup +// --------------------------------------------------------------------------- +// +// These tests exercise the lookup-sync state machine introduced in PR #9039: +// when a gossip block's parent execution payload envelope is missing, +// `SyncManager` is expected to create two single-block lookups — an envelope-only +// lookup for the parent block_root and a "child" lookup that holds the gossip +// block and waits on `AwaitingParent::Envelope(parent_root)`. The envelope-only +// lookup issues a `PayloadEnvelopesByRoot` RPC; on completion it unblocks the +// child via `continue_envelope_child_lookups`. +// +// The tests below cover lookup creation, RPC routing, and drop-cascade +// behaviour. The end-to-end happy path is gated on +// `process_execution_payload_envelope` supporting `AvailabilityPending` (today +// it returns `InternalError("Pending payload envelope not yet implemented")`), +// which is tracked separately. See `process_rpc_envelope` in `sync_methods.rs`. + +/// Builds a 2-block gloas chain in the external harness and locally imports block 1 +/// (parent) WITHOUT registering its envelope, leaving `is_payload_received(parent_root)` +/// false — the precondition for `BlockError::ParentEnvelopeUnknown`. +async fn setup_unknown_parent_envelope_scenario() -> Option { + let mut r = TestRig::new_after_gloas()?; + r.build_chain(2).await; + r.import_blocks_up_to_slot(1).await; + Some(r) +} + +fn payload_envelope_request_count(rig: &TestRig) -> usize { + rig.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::PayloadEnvelopesByRoot(_))) + .count() +} + +/// Triggering `UnknownParentEnvelope` creates exactly two lookups: an envelope-only +/// lookup for the parent and a child lookup for the gossip block awaiting that envelope. +#[tokio::test] +async fn unknown_parent_envelope_creates_envelope_and_child_lookups() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.assert_single_lookups_count(2); +} + +/// Repeated `UnknownParentEnvelope` triggers for the same parent must not spawn extra +/// lookups (peers are merged into the existing envelope lookup). +#[tokio::test] +async fn unknown_parent_envelope_idempotent_triggers() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.trigger_with_last_unknown_parent_envelope(); + r.assert_single_lookups_count(2); +} + +/// The envelope-only lookup must dispatch a `PayloadEnvelopesByRoot` RPC for the +/// parent block_root. +#[tokio::test] +async fn unknown_parent_envelope_issues_payload_envelopes_by_root_rpc() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::new()).await; + assert_eq!( + payload_envelope_request_count(&r), + 1, + "expected exactly one PayloadEnvelopesByRoot request" + ); +} + +/// If the envelope RPC errors out, the envelope-only lookup is dropped and the +/// drop cascades to the awaiting child lookup. +#[tokio::test] +async fn unknown_parent_envelope_drops_cascade_on_rpc_error() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::IoError("test".into()))) + .await; + r.assert_failed_lookup_sync(); +} diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 6e948e4726..29dd7b898e 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -22,7 +22,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, Slot}; +use types::{ForkName, Hash256, MinimalEthSpec as E, SignedExecutionPayloadEnvelope, Slot}; mod lookups; mod range; @@ -79,6 +79,8 @@ 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>, + /// Execution payload envelopes (Gloas) keyed by beacon block root, available to peers. + network_envelopes_by_root: HashMap>>, penalties: Vec, /// All seen lookups through the test run seen_lookups: HashMap,