From a4c1901d3b841f253d6939a2eee66e8d425b7c61 Mon Sep 17 00:00:00 2001 From: Devnet Bot Date: Mon, 18 May 2026 15:20:30 +0000 Subject: [PATCH 1/2] fix(focil): record inclusion list satisfaction on envelope import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payload_inclusion_list_satisfaction map in fork choice was never being populated. This meant should_extend_payload always returned false, causing the Empty/Full tiebreaker to always favor Empty — resulting in payload envelopes being orphaned even when they satisfied the inclusion list. After importing a valid execution payload envelope, check whether its transactions satisfy the cached inclusion list entries for that slot and record the result in fork choice. This allows the tiebreaker to correctly favor Full when the IL is satisfied. --- .../payload_envelope_verification/import.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 10e7a5f755..8d040c5310 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -255,6 +255,30 @@ impl BeaconChain { .on_valid_payload_envelope_received(block_root) .map_err(|e| EnvelopeError::InternalError(format!("{e:?}")))?; + // Check if the envelope's payload satisfies the inclusion list for this slot. + // This is used by fork choice's `should_extend_payload` tiebreaker to decide + // whether Full or Empty wins when weights are equal. + let il_slot = signed_envelope.message().slot(); + let il_satisfied = { + let il_cache = self.inclusion_list_cache.read(); + if let Some(il_transactions) = + il_cache.get_inclusion_list_transactions(il_slot, false) + { + let envelope_message = signed_envelope.message(); + let payload = envelope_message.payload(); + let payload_transactions = payload.transactions(); + let payload_tx_set: std::collections::HashSet<_> = + payload_transactions.iter().collect(); + il_transactions.iter().all(|tx| payload_tx_set.contains(tx)) + } else { + // No inclusion list for this slot — considered satisfied + true + } + }; + + fork_choice + .record_payload_inclusion_list_satisfaction(block_root, il_satisfied); + // TODO(gloas) emit SSE event if the payload became the new head payload // It is important NOT to return errors here before the database commit, because the envelope From af2fcac44421c7ec6abcb0c6c7b48e217bb4bdca Mon Sep 17 00:00:00 2001 From: Devnet Bot Date: Mon, 18 May 2026 22:14:33 +0000 Subject: [PATCH 2/2] fix(focil): align should_extend_payload with spec for gloas/heze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation unconditionally checked is_payload_inclusion_list_satisfied at the top of should_extend_payload, returning false when the root was not in the map. Since the map was only populated for heze slots (FOCIL), this caused should_extend_payload to always return false for gloas slots — making the tiebreaker favor Empty over Full for every single slot. Per spec: - Gloas: should_extend_payload checks is_payload_verified (envelope received) then timeliness/proposer conditions. No IL check. - Heze: adds is_payload_inclusion_list_satisfied as an additional gate. Fix: check payload_received first (matching is_payload_verified), then only gate on IL satisfaction if the root IS present in the map (heze slots where it gets recorded). For gloas slots where no IL exists, the check is skipped entirely. --- consensus/proto_array/src/proto_array.rs | 25 +++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 4dc1232bc8..605403e177 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1509,17 +1509,28 @@ impl ProtoArray { proto_node: &ProtoNode, proposer_boost_root: Hash256, ) -> Result { - // Per spec: is_payload_inclusion_list_satisfied returns false unless the - // root is in the map AND the value is true. - if !self - .payload_inclusion_list_satisfaction - .get(&fc_node.root) - .copied() - .unwrap_or(false) + // Per spec (Gloas): if not is_payload_verified(store, root): return False + // In our implementation, payload_received == True means the envelope was received. + if !proto_node + .payload_received() + .map_err(|_| Error::InvalidNodeVariant { block_root: fc_node.root })? { return Ok(false); } + // Per spec (Heze/FOCIL): is_payload_inclusion_list_satisfied check. + // Only applies when the IL satisfaction map has entries (i.e., heze is active). + // For gloas-only slots where no IL exists, the map won't contain the root, + // but we should NOT gate on this — only check if the root IS in the map. + if let Some(&satisfied) = self + .payload_inclusion_list_satisfaction + .get(&fc_node.root) + { + if !satisfied { + return Ok(false); + } + } + // Per spec: `proposer_root == Root()` is one of the `or` conditions that // makes `should_extend_payload` return True. if proposer_boost_root.is_zero() {