diff --git a/crates/hotshot/new-protocol/src/consensus.rs b/crates/hotshot/new-protocol/src/consensus.rs index e8655735ca0..2a314ff229d 100644 --- a/crates/hotshot/new-protocol/src/consensus.rs +++ b/crates/hotshot/new-protocol/src/consensus.rs @@ -1665,6 +1665,26 @@ impl Consensus { parent_cert.data.leaf_commit }; let Some(header) = self.headers.get(&(view, parent_commitment)) else { + // The header request issued on the TC targeted the lock held at + // that moment; if the lock moved since (bridged legacy QC at + // cutover), re-request. The block builder dedups by (view, parent). + if view_change_evidence.is_some() { + let request_epoch = + if is_last_block(proposal.block_header.block_number(), *self.epoch_height) { + proposal.epoch + 1 + } else { + proposal.epoch + }; + if self.is_leader(view, request_epoch) { + outbox.push_back(ConsensusOutput::RequestBlockAndHeader( + BlockAndHeaderRequest { + view, + epoch: request_epoch, + parent_proposal: proposal.clone(), + }, + )); + } + } debug!("no block header"); return; }; diff --git a/crates/hotshot/new-protocol/src/tests/consensus.rs b/crates/hotshot/new-protocol/src/tests/consensus.rs index 16483ad7347..79d04c2f54c 100644 --- a/crates/hotshot/new-protocol/src/tests/consensus.rs +++ b/crates/hotshot/new-protocol/src/tests/consensus.rs @@ -23,9 +23,9 @@ use crate::{ tests::common::{ assertions::{ any, count_matching, decides_view, is_leaf_decided, is_persist_proposal, is_proposal, - is_record_action, is_request_block_and_header, is_request_state, is_send_cert2, - is_send_timeout_cert, is_send_timeout_vote, is_view_changed, is_vote1, is_vote2, - node_index_for_key, + is_proposal_for_view, is_record_action, is_request_block_and_header, is_request_state, + is_send_cert2, is_send_timeout_cert, is_send_timeout_vote, is_view_changed, is_vote1, + is_vote2, node_index_for_key, }, utils::{ConsensusHarness, MockBlock, state_verified_input}, }, @@ -838,6 +838,179 @@ async fn test_leader_proposes_after_timeout() { ); } +/// A timeout-backed proposal chains only from the locked cert, never from a +/// Cert1 of the timed-out view. +#[tokio::test] +async fn test_timeout_proposal_chains_from_lock_not_timed_out_cert1() { + let test_data = TestData::new(5).await; + let leader_for_view_3 = test_data.views[2].leader_public_key; + let leader_index = node_index_for_key(&leader_for_view_3); + let mut harness = ConsensusHarness::new(leader_index).await; + + // Lock on cert1(1). + harness + .apply(test_data.views[0].proposal_input_consensus(&leader_for_view_3)) + .await; + harness + .apply(test_data.views[0].block_reconstructed_input()) + .await; + harness.apply(test_data.views[0].cert1_input()).await; + + // View 2 arrives via fetch (no optimistic header request); its cert1 + // forms but the block is never reconstructed, so the lock stays at 1. + harness + .apply(ConsensusInput::FetchedProposal( + test_data.views[1].proposal_message(), + )) + .await; + harness.apply(test_data.views[1].cert1_input()).await; + assert!( + harness + .consensus + .cert1_at(ViewNumber::new(2)) + .is_some_and(|_| harness.consensus.locked_view() == Some(ViewNumber::new(1))), + "setup: cert1(2) must be held while the lock stays at view 1" + ); + assert!( + !any(harness.outputs(), |o| is_proposal_for_view(o, 3)), + "no header for a view-2 parent yet, so view 3 must not be proposed" + ); + + harness.apply(test_data.views[1].timeout_cert_input()).await; + + let justify_views: Vec = harness + .outputs() + .iter() + .filter_map(|output| match output { + ConsensusOutput::SendProposal(p) if *p.data.view_number == 3 => { + Some(*p.data.justify_qc.view_number) + }, + _ => None, + }) + .collect(); + assert_eq!( + justify_views, + vec![1], + "timeout-backed proposal must chain from the locked cert1(1), not the timed-out view's \ + cert1(2)" + ); +} + +/// Cutover exception: a bridged legacy QC higher than the lock is adopted +/// as the lock and proposing is retried on it, re-requesting the header for +/// the new lock. +#[tokio::test] +async fn test_bridged_legacy_qc_adopts_lock_and_reproposes() { + let test_data = TestData::new(5).await; + let leader_for_view_3 = test_data.views[2].leader_public_key; + let leader_index = node_index_for_key(&leader_for_view_3); + let mut harness = ConsensusHarness::new(leader_index).await; + + // Lock on cert1(1); hold view 2's proposal but no cert for it. + harness + .apply(test_data.views[0].proposal_input_consensus(&leader_for_view_3)) + .await; + harness + .apply(test_data.views[0].block_reconstructed_input()) + .await; + harness.apply(test_data.views[0].cert1_input()).await; + harness + .apply(ConsensusInput::FetchedProposal( + test_data.views[1].proposal_message(), + )) + .await; + + // Manual outbox from here: the TC's header request stays unfulfilled, so + // the leader has not proposed view 3 when the legacy QC arrives. + let mut outbox = Outbox::new(); + harness + .consensus + .apply(test_data.views[1].timeout_cert_input(), &mut outbox); + assert!( + !outbox + .iter() + .any(|o| matches!(o, ConsensusOutput::PersistProposal(_))), + "view 3 must not be proposed while the header is outstanding" + ); + + harness + .consensus + .register_legacy_qc(&test_data.views[1].cert1); + assert_eq!(harness.consensus.locked_view(), Some(ViewNumber::new(2))); + + // The TC-time header request (parent view 1) completes; its HeaderCreated + // retriggers maybe_propose, which must re-request for the adopted lock. + let old_parent = test_data.views[0].proposal_message().proposal.data; + let old_commitment = proposal_commitment(&old_parent); + let old_leaf: Leaf2 = old_parent.into(); + let stale_block = MockBlock::new(); + let stale_header = TestBlockHeader::new( + &old_leaf, + stale_block.payload_commitment, + stale_block.builder_commitment, + stale_block.metadata, + TEST_VERSIONS.test.base, + ); + harness.consensus.apply( + ConsensusInput::HeaderCreated(ViewNumber::new(3), old_commitment, stale_header), + &mut outbox, + ); + let request_epoch = outbox + .iter() + .find_map(|o| match o { + ConsensusOutput::RequestBlockAndHeader(r) + if r.parent_proposal.view_number == ViewNumber::new(2) => + { + Some(r.epoch) + }, + _ => None, + }) + .expect("header must be re-requested for the adopted lock's parent"); + + // Fulfill the re-request. + let parent_proposal = test_data.views[1].proposal_message().proposal.data; + let parent_commitment = proposal_commitment(&parent_proposal); + let mock_block = MockBlock::new(); + let parent_leaf: Leaf2 = parent_proposal.into(); + let header = TestBlockHeader::new( + &parent_leaf, + mock_block.payload_commitment, + mock_block.builder_commitment, + mock_block.metadata, + TEST_VERSIONS.test.base, + ); + harness.consensus.apply( + ConsensusInput::BlockBuilt { + view: ViewNumber::new(3), + epoch: request_epoch, + payload: mock_block.block, + metadata: mock_block.metadata, + payload_commitment: mock_block.payload_commitment, + }, + &mut outbox, + ); + harness.consensus.apply( + ConsensusInput::HeaderCreated(ViewNumber::new(3), parent_commitment, header), + &mut outbox, + ); + + let proposals: Vec<(u64, bool)> = outbox + .iter() + .filter_map(|output| match output { + ConsensusOutput::PersistProposal(p) if *p.data.view_number == 3 => Some(( + *p.data.justify_qc.view_number, + p.data.view_change_evidence.is_some(), + )), + _ => None, + }) + .collect(); + assert_eq!( + proposals, + vec![(2, true)], + "re-proposal must justify the adopted legacy QC and carry the TC" + ); +} + /// Non-leader node does NOT send a proposal. #[tokio::test] async fn test_non_leader_does_not_propose() { diff --git a/crates/hotshot/new-protocol/src/tests/legacy_cutover.rs b/crates/hotshot/new-protocol/src/tests/legacy_cutover.rs index 09543d092a3..50244340416 100644 --- a/crates/hotshot/new-protocol/src/tests/legacy_cutover.rs +++ b/crates/hotshot/new-protocol/src/tests/legacy_cutover.rs @@ -7,7 +7,10 @@ use std::{ collections::{BTreeMap, BTreeSet}, net::Ipv4Addr, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; @@ -332,6 +335,7 @@ async fn run_cutover_node( decision_tx: UnboundedSender, external_events_tx: async_broadcast::Sender>, legacy: Arc>>, + new_proto_view: Arc, ) { // Mirror production (`ConsensusHandle::activate`): the coordinator // stays parked until legacy crosses the upgrade boundary; only then @@ -350,6 +354,7 @@ async fn run_cutover_node( tracing::warn!("seed extraction returned None; coordinator will not be seeded"); } coord.start(seed); + new_proto_view.store(*coord.current_view(), Ordering::Relaxed); loop { match coord.next_consensus_input().await { @@ -360,6 +365,9 @@ async fn run_cutover_node( }, Err(err) => tracing::warn!(%err, "cutover coord: non-critical error"), } + // Publish progress for the silencers, which key node shutdowns off + // the fastest live node's view (legacy or new-protocol). + new_proto_view.store(*coord.current_view(), Ordering::Relaxed); while let Some(output) = coord.outbox_mut().pop_front() { if let ConsensusOutput::LeafDecided { leaves, .. } = &output { @@ -440,42 +448,42 @@ async fn spawn_node( ); let (decision_tx, decision_rx) = mpsc::unbounded_channel::(); + let new_proto_view = Arc::new(AtomicU64::new(0)); let runner_abort = tokio::spawn(run_cutover_node( coord, decision_tx, external_events_tx, legacy, + new_proto_view.clone(), )) .abort_handle(); NodeState { decision_rx, runner_abort, + new_proto_view, } } struct NodeState { decision_rx: mpsc::UnboundedReceiver, runner_abort: AbortHandle, + /// Latest view this node's new-protocol coordinator has entered + /// (0 while parked pre-cutover). + new_proto_view: Arc, } struct SilentNode { idx: usize, - /// Shut down once any other node's `cur_view` reaches this view. + /// Shut down once any other node's legacy or new-protocol view + /// reaches this view. at_view: ViewNumber, } -/// Verify every live node decides `target_decisions` views with no -/// gap outside `expected_failed_views`. -/// -/// When `loose` is `false`, the assertion is exact: every view in -/// `expected_failed_views` that falls inside the decided range must -/// actually be a gap. When `loose` is `true`, `expected_failed_views` -/// is interpreted as a permitted-failures superset — gaps must lie -/// inside it, but predicted views that ended up being decided do not -/// trip the assertion. The loose variant is used by the permutation -/// sweep below, where the exact post-cutover failure pattern is hard -/// to predict precisely. +/// Verify every live node decides `target_decisions` views and that the +/// gaps inside each node's decided range are exactly +/// `expected_failed_views`: every gap must be listed, and every listed +/// view that falls inside the range must actually be a gap. async fn run_cutover_test( num_nodes: usize, target_decisions: usize, @@ -483,7 +491,6 @@ async fn run_cutover_test( deadline: Duration, view_timeout: Duration, silent_nodes: Vec, - loose: bool, ) { crate::logging::init_test_logging(); @@ -513,9 +520,14 @@ async fn run_cutover_test( ); } + let new_proto_views: Vec> = node_state + .iter() + .map(|ns| ns.new_proto_view.clone()) + .collect(); for silent in &silent_nodes { bg_handles.push(spawn_silence_at_view( &legacy_arcs, + &new_proto_views, silent, node_state[silent.idx].runner_abort.clone(), )); @@ -580,7 +592,7 @@ async fn run_cutover_test( .collect::>(), ); } - if !loose && in_chain && expected_fail { + if in_chain && expected_fail { panic!( "live node {i} committed view {v} but it was listed in \ expected_failed_views={:?}", @@ -631,17 +643,29 @@ async fn run_cutover_test( } } -async fn await_legacy_view( - watch: &[Arc>>], +/// Wait until any watched node's view — legacy `cur_view` or new-protocol +/// coordinator view — reaches `target_view`. Watching both sides is what +/// makes post-cutover silencing punctual: legacy parks at the cutover view +/// and only creeps past it via timeouts, long after the new protocol has +/// raced ahead. +async fn await_node_at_view( + legacy: &[Arc>>], + new_proto: &[Arc], target_view: ViewNumber, timeout: Duration, ) { let deadline = Instant::now() + timeout; loop { if Instant::now() > deadline { - panic!("watcher did not observe cur_view >= {target_view} in time"); + panic!("watcher did not observe any view >= {target_view} in time"); } - for legacy in watch { + if new_proto + .iter() + .any(|v| v.load(Ordering::Relaxed) >= *target_view) + { + return; + } + for legacy in legacy { if legacy.read().await.cur_view().await >= target_view { return; } @@ -652,6 +676,7 @@ async fn await_legacy_view( fn spawn_silence_at_view( legacy_arcs: &[Arc>>], + new_proto_views: &[Arc], silent: &SilentNode, runner_abort: AbortHandle, ) -> AbortHandle { @@ -661,11 +686,17 @@ fn spawn_silence_at_view( .filter(|(i, _)| *i != silent.idx) .map(|(_, l)| l.clone()) .collect(); + let np_watch: Vec<_> = new_proto_views + .iter() + .enumerate() + .filter(|(i, _)| *i != silent.idx) + .map(|(_, v)| v.clone()) + .collect(); let target = silent.idx; let target_view = silent.at_view; let target_legacy = legacy_arcs[silent.idx].clone(); tokio::spawn(async move { - await_legacy_view(&watch, target_view, Duration::from_secs(120)).await; + await_node_at_view(&watch, &np_watch, target_view, Duration::from_secs(120)).await; runner_abort.abort(); target_legacy.write().await.shut_down().await; tracing::info!(node = target, at_view = *target_view, "took node offline"); @@ -676,27 +707,22 @@ fn spawn_silence_at_view( /// `upgrade_view + TEST_UPGRADE_CONSTANTS.finish_offset`. const PREDICTED_CUTOVER_VIEW: u64 = UPGRADE_VIEW + 20; -/// Last legacy view — naturally TC2-skipped at cutover. Used as the -/// anchor for the permutation sweep below: each test silences some -/// subset of `{V-2, V-1, V, V+1, V+2}`. +/// Last legacy view. Anchor for the permutation sweep below: each test +/// silences some subset of `{V-2, V-1, V, V+1, V+2}`. const V: u64 = PREDICTED_CUTOVER_VIEW - 1; -/// Happy path. The QC for `cutover_view - 1` forms in the legacy protocol and -/// rides into the new protocol via the cutover seed's `high_qc`, so the first -/// leader proposes directly on it (no TC2 skip, no timer wait) and every view -/// is decided. Under start-time skew, the cutover seed can be snapshotted -/// before that QC lands, so the boundary views (`V`, `PREDICTED_CUTOVER_VIEW`) -/// may be TC2-skipped instead; these are permitted (loose check). +/// Happy path. The QC for `cutover_view - 1` reaches the new protocol via +/// the cutover seed's `high_qc` or the `LegacyHighQcFormed` bridge, and the +/// first leader proposes on it. No view may fail: every leader is online. #[tokio::test(flavor = "multi_thread")] async fn legacy_runs_upgrade_then_new_protocol_takes_over() { run_cutover_test( 4, 6, - views([V, PREDICTED_CUTOVER_VIEW]), + BTreeSet::new(), Duration::from_secs(180), DEFAULT_NEW_PROTO_VIEW_TIMEOUT, Vec::new(), - true, ) .await; } @@ -717,7 +743,6 @@ async fn legacy_last_view_times_out_then_new_protocol_takes_over() { idx: silent_idx, at_view: ViewNumber::new(PREDICTED_CUTOVER_VIEW - 2), }], - false, ) .await; } @@ -751,7 +776,6 @@ async fn legacy_two_views_view_sync_then_new_protocol_takes_over() { at_view: trigger, }, ], - false, ) .await; } @@ -771,7 +795,6 @@ async fn new_protocol_first_leader_offline_then_recovers() { idx: silent_idx, at_view: ViewNumber::new(PREDICTED_CUTOVER_VIEW), }], - false, ) .await; } @@ -779,11 +802,8 @@ async fn new_protocol_first_leader_offline_then_recovers() { /// Non-terminal legacy timeout: silence a pre-cutover leader several /// views before cutover. Views 22 and 23 can never commit (votes for 22 /// die with the silenced leader of 23), and the dead node's post-cutover -/// slots (27, 31) time out. Whether the boundary views 21 and 24 appear -/// in the new protocol's decide stream depends on when the seed was -/// snapshotted relative to QC formation, so the check must be loose: -/// deciding them via the seeded `justify_qc` chain walk is the behavior -/// this test guards, not a failure. +/// slots (27, 31) time out. Every other view, including the boundary, +/// must be decided. #[tokio::test(flavor = "multi_thread")] async fn legacy_view_before_last_times_out_then_new_protocol_takes_over() { const NUM_NODES: usize = 4; @@ -791,14 +811,13 @@ async fn legacy_view_before_last_times_out_then_new_protocol_takes_over() { run_cutover_test( NUM_NODES, 6, - views([22, 23, 24, 27, 31]), + views([22, 23, 27, 31]), Duration::from_secs(240), DEFAULT_NEW_PROTO_VIEW_TIMEOUT, vec![SilentNode { idx: silent_idx, at_view: ViewNumber::new(PREDICTED_CUTOVER_VIEW - 3), }], - true, ) .await; } @@ -832,44 +851,42 @@ fn perm_deadline(n_silent: usize) -> Duration { } } -/// Build a `SilentNode` that takes out the leader of `view`. For -/// pre-cutover views we trip the silencer at `view - 1` so the node is -/// gone before its leader slot; post-cutover, legacy doesn't reliably -/// advance far past `PREDICTED_CUTOVER_VIEW`, so use `view` itself -/// (matching `new_protocol_first_leader_offline_then_recovers`). +/// Build a `SilentNode` that takes out the leader of `view`: the silencer +/// trips at `view - 1`, so the ~50ms kill lands well before the leader's +/// proposal for `view` (which needs at least a full round of cert +/// aggregation plus a block build). The silencer watches legacy and +/// new-protocol progress, so this holds on both sides of the cutover. fn silent_for_view(view: u64, num_nodes: usize) -> SilentNode { - let silent_idx = view as usize % num_nodes; - let at_view = if view < PREDICTED_CUTOVER_VIEW { - view - 1 - } else { - view - }; SilentNode { - idx: silent_idx, - at_view: ViewNumber::new(at_view), + idx: view as usize % num_nodes, + at_view: ViewNumber::new(view - 1), } } -/// Build a permissive failed-views superset for the loose check. -/// Includes the natural TC2 skip, the first post-cutover view (nodes -/// detect the cutover and start their coordinators at slightly -/// different times, so it can time out before enough of them are up), -/// each silencer's `at_view` (boundary effect when the silent node -/// disconnects mid-view), and every downstream view where the silent -/// node would be leader. `max_view` is a conservative ceiling on the -/// highest view a live node will decide before the loop exits. -fn permitted_failures( +/// The exact set of views that fail when the leaders of `views_to_silence` +/// are taken down. Every failure is attributable to a silenced leader: +/// - the silenced view itself (its leader never proposes), +/// - for silenced views at or before the cutover, the preceding view too +/// (legacy vote1s are unicast to the next leader, so they die with it; +/// post-cutover, vote1s are aggregated by the same view's leader, so +/// there is no such spillover), and +/// - every later leader slot of the silent node (dead from `at_view` on). +/// +/// `max_view` bounds the slot enumeration; it only needs to exceed the +/// highest view a live node decides before the collection loop exits. +fn expected_failures( num_nodes: usize, - silent_nodes: &[SilentNode], + views_to_silence: &[u64], max_view: u64, ) -> BTreeSet { let mut failed = BTreeSet::new(); - failed.insert(ViewNumber::new(PREDICTED_CUTOVER_VIEW - 1)); - failed.insert(ViewNumber::new(PREDICTED_CUTOVER_VIEW)); - for s in silent_nodes { - let at_view = *s.at_view; - failed.insert(ViewNumber::new(at_view)); - for v in at_view..=max_view { + for &view in views_to_silence { + let s = silent_for_view(view, num_nodes); + failed.insert(ViewNumber::new(view)); + if view <= PREDICTED_CUTOVER_VIEW { + failed.insert(ViewNumber::new(view - 1)); + } + for v in *s.at_view..=max_view { if (v as usize) % num_nodes == s.idx { failed.insert(ViewNumber::new(v)); } @@ -879,25 +896,23 @@ fn permitted_failures( } /// Run a single permutation: silence the leader of every view in -/// `views_to_silence`, then assert liveness with a permissive -/// failed-views set (gaps must lie inside the predicted set, but -/// predicted views that actually decided don't trip the assertion). +/// `views_to_silence` and assert that exactly the attributable views +/// fail. async fn run_perm_test(views_to_silence: Vec) { let n_silent = views_to_silence.len(); let num_nodes = perm_num_nodes(n_silent); + let expected = expected_failures(num_nodes, &views_to_silence, 50); let silent_nodes: Vec = views_to_silence .iter() .map(|&v| silent_for_view(v, num_nodes)) .collect(); - let permitted = permitted_failures(num_nodes, &silent_nodes, 50); run_cutover_test( num_nodes, 6, - permitted, + expected, perm_deadline(n_silent), DEFAULT_NEW_PROTO_VIEW_TIMEOUT, silent_nodes, - true, ) .await; } diff --git a/crates/hotshot/task-impls/src/consensus/handlers.rs b/crates/hotshot/task-impls/src/consensus/handlers.rs index ef42eacb8ee..32039f22a61 100644 --- a/crates/hotshot/task-impls/src/consensus/handlers.rs +++ b/crates/hotshot/task-impls/src/consensus/handlers.rs @@ -489,13 +489,14 @@ pub(crate) async fn handle_timeout // Forward the same vote to any external listener so the espresso bridge // can submit it to the new-protocol coordinator at the legacy → 0.8 // upgrade boundary. Only emit when a target upgrade is decided (i.e. - // we know a cutover is coming) and the view is not past the cutover, - // to avoid spurious events in normal operation. The check is cheap; - // the event payload is small. + // we know a cutover is coming) and the view is strictly before the + // cutover: legacy re-times-out the parked boundary view forever, and + // bridging those votes would prematurely TC2-skip the first new-protocol + // view. The check is cheap; the event payload is small. if task_state .upgrade_lock .decided_upgrade_cert() - .is_some_and(|cert| view_number <= cert.data.new_version_first_view) + .is_some_and(|cert| view_number < cert.data.new_version_first_view) { broadcast_event( Event { diff --git a/crates/hotshot/task-impls/src/consensus/mod.rs b/crates/hotshot/task-impls/src/consensus/mod.rs index 569dbef19af..8f1bf383f9a 100644 --- a/crates/hotshot/task-impls/src/consensus/mod.rs +++ b/crates/hotshot/task-impls/src/consensus/mod.rs @@ -262,13 +262,15 @@ impl> ConsensusTaskState } }, HotShotEvent::Qc2Formed(either::Left(qc)) - if self.upgrade_lock.new_protocol_active(self.cur_view) + if self.upgrade_lock.new_protocol_active(qc.view_number() + 1) && !self.upgrade_lock.new_protocol_active(qc.view_number()) => { // Cutover boundary only: the gated proposal path won't land this // last-legacy QC in `high_qc`, so capture it here for - // `extract_pre_cutover_seed` to carry across. `update_high_qc` is - // monotone, so this is a no-op outside the cutover window. + // `extract_pre_cutover_seed` to carry across. Keyed on the QC's + // view, not `cur_view`: `Qc2Formed` fires once, and the + // aggregator can form this QC before processing its own + // ViewChange into the cutover view. let mut consensus_writer = self.consensus.write().await; let _ = consensus_writer.update_high_qc(qc.clone()); drop(consensus_writer); diff --git a/crates/hotshot/task-impls/src/helpers.rs b/crates/hotshot/task-impls/src/helpers.rs index 21a08a73724..8128138ab7c 100644 --- a/crates/hotshot/task-impls/src/helpers.rs +++ b/crates/hotshot/task-impls/src/helpers.rs @@ -695,12 +695,18 @@ pub(crate) async fn parent_leaf_and_state( epoch_height: u64, ) -> Result<(Leaf2, Arc<::ValidatedState>)> { let consensus_reader = consensus.read().await; - let vsm_contains_parent_view = consensus_reader + // A `Da` or `Failed` entry in the state map doesn't carry the parent leaf + // and state, so it can't be proposed on; treat it like a missing view and + // fetch the proposal. This matters when we form the QC from unicast votes + // before validating the parent proposal ourselves: the DA proposal's + // entry would otherwise mask the fetch and abort our proposal. + let vsm_contains_parent_leaf = consensus_reader .validated_state_map() - .contains_key(&parent_qc.view_number()); + .get(&parent_qc.view_number()) + .is_some_and(|view| view.leaf_and_state().is_some()); drop(consensus_reader); - if !vsm_contains_parent_view { + if !vsm_contains_parent_leaf { let _ = fetch_proposal( parent_qc, event_sender.clone(),