Skip to main content

commonware_consensus/marshal/coding/shards/
engine.rs

1//! Shard engine for erasure-coded block distribution and reconstruction.
2//!
3//! This module implements the core logic for distributing blocks as erasure-coded
4//! shards and reconstructing blocks from received shards.
5//!
6//! # Overview
7//!
8//! The shard engine serves two primary functions:
9//! 1. Broadcast: When a node proposes a block, the engine broadcasts
10//!    erasure-coded shards to all participants and to non-participants in
11//!    aggregate membership (peers in [`commonware_p2p::PeerSetUpdate::all`]
12//!    but not in the epoch participant list).
13//!    The leader sends each participant their indexed shard.
14//! 2. Block Reconstruction: When a node receives shards from peers, the engine
15//!    validates them and reconstructs the original block once enough valid
16//!    shards are available. Both participants and non-participants can
17//!    reconstruct blocks: participants receive their own indexed shard from
18//!    the leader, while non-participants reconstruct from shards gossiped
19//!    by participants. All participants gossip their validated shard to peers.
20//!
21//! # Message Flow
22//!
23//! ```text
24//!                           PROPOSER
25//!                              |
26//!                              | Proposed(block)
27//!                              v
28//!                    +------------------+
29//!                    |   Shard Engine   |
30//!                    +------------------+
31//!                              |
32//!            broadcast_shards (each participant's indexed shard)
33//!                              |
34//!         +--------------------+--------------------+
35//!         |                    |                    |
36//!         v                    v                    v
37//!    Participant 0        Participant 1        Participant N
38//!         |                    |                    |
39//!         | (receive shard     | (receive shard     |
40//!         |  for own index)    |  for own index)    |
41//!         v                    v                    v
42//!    +----------+         +----------+         +----------+
43//!    | Validate |         | Validate |         | Validate |
44//!    | (check)  |         | (check)  |         | (check)  |
45//!    +----------+         +----------+         +----------+
46//!         |                    |                    |
47//!         +--------------------+--------------------+
48//!                              |
49//!                    (gossip validated shards)
50//!                              |
51//!         +--------------------+--------------------+
52//!         |                    |                    |
53//!         v                    v                    v
54//!    Accumulate checked shards until minimum_shards reached
55//!         |                    |                    |
56//!         v                    v                    v
57//!            Batch verify pending shards at quorum
58//!         |                    |                    |
59//!         v                    v                    v
60//!    +-------------+      +-------------+      +-------------+
61//!    | Reconstruct |      | Reconstruct |      | Reconstruct |
62//!    |    Block    |      |    Block    |      |    Block    |
63//!    +-------------+      +-------------+      +-------------+
64//! ```
65//!
66//! # Reconstruction State Machine
67//!
68//! For each [`Commitment`] that is either leader-discovered or notarized, nodes
69//! (both participants and non-participants) maintain a [`ReconstructionState`].
70//! Before either consensus signal is observed (a leader announcement or a
71//! notarization for the commitment), shards are buffered in bounded per-peer
72//! queues:
73//!
74//! ```text
75//!    +----------------------+
76//!    | AwaitingQuorum       |
77//!    | - leader known       |
78//!    | - assigned shard     |  <--- verified immediately on receipt
79//!    |   verified eagerly   |
80//!    | - other shards       |  <--- buffered in pending_shards
81//!    |   buffered           |
82//!    +----------------------+
83//!               |
84//!               | quorum met + batch validation passes
85//!               v
86//!    +----------------------+
87//!    | Ready                |
88//!    | - checked shards     |
89//!    | - no new gossip      |
90//!    |   shards accepted    |
91//!    | - assigned shard may |
92//!    |   still arrive late  |
93//!    +----------------------+
94//!               |
95//!               | checked_shards.len() >= minimum_shards
96//!               v
97//!    +----------------------+
98//!    | Reconstruction       |
99//!    | Attempt              |
100//!    +----------------------+
101//!               |
102//!          +----+----+
103//!          |         |
104//!          v         v
105//!       Success    Failure
106//!          |         |
107//!          v         v
108//!       Cache      Remove
109//!       Block      State
110//! ```
111//!
112//! _Per-peer buffers are only kept for peers in `latest.primary`, matching [`commonware_broadcast::buffered`].
113//! When a peer is no longer in `latest.primary`, all its buffered shards are evicted._
114//!
115//! # Peer Validation and Blocking Rules
116//!
117//! The engine enforces strict validation to prevent Byzantine attacks:
118//!
119//! - All shards MUST be sent by participants in the current epoch.
120//! - Any participant may deliver the recipient's assigned shard.
121//! - Any participant may gossip its own shard.
122//! - All shards MUST pass cryptographic verification against the commitment.
123//! - Each shard index may only contribute ONE shard per commitment.
124//! - Sending a second shard for the same index with different data
125//!   (equivocation) results in blocking. Exact duplicates are silently
126//!   ignored.
127//!
128//! Peers violating these rules are blocked via the [`Blocker`] trait.
129//! Validation and blocking rules are applied while a commitment is actively
130//! tracked in reconstruction state. Once a block is already reconstructed and
131//! cached, additional shards for that commitment are ignored.
132//!
133//! _Before proposal context is known, shards are buffered in fixed-size per-peer
134//! queues until consensus signals the proposal via [`Mailbox::discovered`]
135//! or a notarization via [`Mailbox::notarized`]. A notarization activates
136//! reconstruction interest without a leader, so only sender-indexed gossip
137//! shards can be ingested. Other shards remain buffered until proposal
138//! discovery._
139
140use super::{
141    mailbox::{Mailbox, Message},
142    metrics::ShardMetrics,
143};
144use crate::{
145    Block, CertifiableBlock, Heightable,
146    marshal::{
147        coding::{
148            types::{CodedBlock, Shard},
149            validation::{ReconstructionError as InvariantError, validate_reconstruction},
150        },
151        core::Retirement,
152    },
153    types::{Epoch, Round, coding::Commitment},
154};
155use commonware_actor::mailbox;
156use commonware_codec::{Decode, Error as CodecError, Read};
157use commonware_coding::{Config as CodingConfig, Scheme as CodingScheme};
158use commonware_cryptography::{
159    Committable, Digestible, Hasher, PublicKey,
160    certificate::{Provider, Scheme as CertificateScheme},
161};
162use commonware_macros::select_loop;
163use commonware_p2p::{
164    Blocker, Provider as PeerProvider, Receiver, Recipients, Sender,
165    utils::codec::{WrappedBackgroundReceiver, WrappedSender},
166};
167use commonware_parallel::Strategy;
168use commonware_runtime::{
169    BufferPooler, Clock, ContextCell, Handle, Metrics, Spawner, spawn_cell,
170    telemetry::metrics::HistogramExt,
171};
172use commonware_utils::{
173    bitmap::BitMap,
174    channel::{fallible::OneshotExt, oneshot},
175    ordered::{Quorum, Set},
176};
177use rand_core::Rng;
178use std::{
179    collections::{BTreeMap, VecDeque, btree_map::Entry},
180    num::NonZeroUsize,
181    sync::Arc,
182};
183use thiserror::Error;
184use tracing::{debug, warn};
185
186/// An error that can occur during reconstruction of a [`CodedBlock`] from [`Shard`]s
187#[derive(Debug, Error)]
188pub enum Error<C: CodingScheme> {
189    /// An error occurred while recovering the encoded blob from the [`Shard`]s
190    #[error(transparent)]
191    Coding(C::Error),
192
193    /// An error occurred while decoding the reconstructed blob into a [`CodedBlock`]
194    #[error(transparent)]
195    Codec(#[from] CodecError),
196
197    /// The reconstructed block's digest does not match the commitment's block digest
198    #[error("block digest mismatch: reconstructed block does not match commitment digest")]
199    DigestMismatch,
200
201    /// The reconstructed block's config does not match the commitment's coding config
202    #[error("block config mismatch: reconstructed config does not match commitment config")]
203    ConfigMismatch,
204
205    /// The reconstructed block's embedded context does not match the commitment context digest
206    #[error("block context mismatch: reconstructed context does not match commitment context")]
207    ContextMismatch,
208}
209
210#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
211enum BlockSubscriptionKey<K, D> {
212    Commitment(K),
213    Digest(D),
214}
215
216/// Configuration for the [`Engine`].
217pub struct Config<P, S, X, D, C, H, B, T>
218where
219    P: PublicKey,
220    S: Provider<Scope = Epoch>,
221    X: Blocker<PublicKey = P>,
222    D: PeerProvider<PublicKey = P>,
223    C: CodingScheme,
224    H: Hasher,
225    B: CertifiableBlock,
226    T: Strategy,
227{
228    /// The scheme provider.
229    pub scheme_provider: S,
230
231    /// The peer blocker.
232    pub blocker: X,
233
234    /// [`Read`] configuration for decoding [`Shard`]s.
235    pub shard_codec_cfg: <Shard<B, C, H> as Read>::Cfg,
236
237    /// [`commonware_codec::Read`] configuration for decoding blocks.
238    pub block_codec_cfg: B::Cfg,
239
240    /// The strategy used for parallel computation.
241    pub strategy: T,
242
243    /// The size of the mailbox buffer.
244    pub mailbox_size: NonZeroUsize,
245
246    /// Number of shards to buffer per peer.
247    ///
248    /// Shards for commitments without a reconstruction state are buffered per
249    /// peer in a fixed-size ring to bound memory under Byzantine spam. These
250    /// shards are only ingested when consensus provides a leader via
251    /// [`Mailbox::discovered`] or reports a notarization via
252    /// [`Mailbox::notarized`].
253    ///
254    /// The worst-case total memory usage for the set of shard buffers is
255    /// `num_participants * peer_buffer_size * max_shard_size`.
256    pub peer_buffer_size: NonZeroUsize,
257
258    /// Capacity of the channel between the background receiver and the engine.
259    ///
260    /// The background receiver decodes incoming network messages in a separate
261    /// task and forwards them to the engine over a mailbox with this
262    /// capacity.
263    pub background_channel_capacity: NonZeroUsize,
264
265    /// Provider for peer set information. Pre-leader shards are buffered per
266    /// peer only while that peer appears in the
267    /// [`commonware_p2p::PeerSetUpdate::latest`] primary set, matching
268    /// [`commonware_broadcast::buffered::Engine`]. Broadcast delivery uses the
269    /// aggregate [`commonware_p2p::PeerSetUpdate::all`] union.
270    pub peer_provider: D,
271}
272
273/// The data currently owned for a consensus commitment.
274enum CommitmentPhase<B, C, H, P>
275where
276    B: Block,
277    C: CodingScheme,
278    H: Hasher,
279    P: PublicKey,
280{
281    /// Shards are still being accumulated or validated.
282    Reconstructing(ReconstructionState<P, B, C, H>),
283    /// The block is cached. Reconstruction state remains only while shard-specific
284    /// evidence may still arrive.
285    Cached {
286        block: Arc<CodedBlock<B, C, H>>,
287        reconstruction: Option<ReconstructionState<P, B, C, H>>,
288    },
289}
290
291/// The single lifecycle owner for a consensus commitment.
292///
293/// The observation round determines both retention and the epoch whose participant
294/// scheme classifies shards. Keeping it outside the phase prevents cached and
295/// reconstructing views of the same commitment from diverging.
296struct CommitmentRecord<B, C, H, P>
297where
298    B: Block,
299    C: CodingScheme,
300    H: Hasher,
301    P: PublicKey,
302{
303    round: Round,
304    phase: CommitmentPhase<B, C, H, P>,
305    proposed: bool,
306}
307
308/// Lifecycle records keyed by the complete commitment, including its coding
309/// root and configuration.
310type CommitmentRecords<B, C, H, P> = BTreeMap<Commitment<B, C, H>, CommitmentRecord<B, C, H, P>>;
311
312/// The current lifecycle status of a commitment.
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314enum CommitmentStatus {
315    /// No record exists for the commitment.
316    Absent,
317    /// The commitment is accumulating or validating shards.
318    Reconstructing,
319    /// The commitment has an available block.
320    Cached,
321}
322
323/// Why a commitment is eligible for retirement.
324#[derive(Clone, Copy)]
325enum RetirementReason {
326    /// Durable progress explicitly retired the commitment.
327    Exact,
328    /// The commitment's last observation is covered by the durable round floor.
329    Floor,
330}
331
332impl<B, C, H, P> CommitmentRecord<B, C, H, P>
333where
334    B: Block,
335    C: CodingScheme,
336    H: Hasher,
337    P: PublicKey,
338{
339    /// Creates a record that is reconstructing a commitment observed at `round`.
340    const fn reconstructing(round: Round, reconstruction: ReconstructionState<P, B, C, H>) -> Self {
341        Self {
342            round,
343            phase: CommitmentPhase::Reconstructing(reconstruction),
344            proposed: false,
345        }
346    }
347
348    /// Creates a record for a block already available at `round`.
349    const fn cached(round: Round, block: Arc<CodedBlock<B, C, H>>) -> Self {
350        Self {
351            round,
352            phase: CommitmentPhase::Cached {
353                block,
354                reconstruction: None,
355            },
356            proposed: false,
357        }
358    }
359
360    /// Returns the latest valid observation round.
361    const fn round(&self) -> Round {
362        self.round
363    }
364
365    /// Ensures that `round` uses the epoch that owns this commitment record.
366    fn validate_epoch(&self, round: Round) -> Result<(), Epoch> {
367        let existing_epoch = self.round.epoch();
368        if existing_epoch != round.epoch() {
369            return Err(existing_epoch);
370        }
371        Ok(())
372    }
373
374    /// Records a same-epoch observation without moving the retention round backward.
375    fn observe(&mut self, round: Round) -> Result<(), Epoch> {
376        self.validate_epoch(round)?;
377        self.round = self.round.max(round);
378        Ok(())
379    }
380
381    /// Returns the cached block, if available.
382    const fn block(&self) -> Option<&Arc<CodedBlock<B, C, H>>> {
383        match &self.phase {
384            CommitmentPhase::Reconstructing(_) => None,
385            CommitmentPhase::Cached { block, .. } => Some(block),
386        }
387    }
388
389    /// Returns shard reconstruction state retained for the commitment, if any.
390    const fn reconstruction(&self) -> Option<&ReconstructionState<P, B, C, H>> {
391        match &self.phase {
392            CommitmentPhase::Reconstructing(reconstruction) => Some(reconstruction),
393            CommitmentPhase::Cached { reconstruction, .. } => reconstruction.as_ref(),
394        }
395    }
396
397    /// Returns mutable shard reconstruction state retained for the commitment, if any.
398    const fn reconstruction_mut(&mut self) -> Option<&mut ReconstructionState<P, B, C, H>> {
399        match &mut self.phase {
400            CommitmentPhase::Reconstructing(reconstruction) => Some(reconstruction),
401            CommitmentPhase::Cached { reconstruction, .. } => reconstruction.as_mut(),
402        }
403    }
404
405    /// Returns whether the local validator can satisfy its assigned-shard obligation.
406    fn is_assigned_shard_ready(&self) -> bool {
407        self.proposed
408            || self
409                .reconstruction()
410                .is_some_and(ReconstructionState::is_assigned_shard_verified)
411    }
412
413    /// Records that the local validator built and cached this commitment.
414    const fn mark_proposed(&mut self) {
415        self.proposed = true;
416    }
417
418    /// Caches `block` while preserving reconstruction state needed for shard readiness.
419    ///
420    /// If a block is already cached, retains and returns the existing instance.
421    fn install_block(&mut self, block: Arc<CodedBlock<B, C, H>>) -> Arc<CodedBlock<B, C, H>> {
422        let previous = std::mem::replace(
423            &mut self.phase,
424            CommitmentPhase::Cached {
425                block: Arc::clone(&block),
426                reconstruction: None,
427            },
428        );
429        self.phase = match previous {
430            CommitmentPhase::Reconstructing(reconstruction) => CommitmentPhase::Cached {
431                block: Arc::clone(&block),
432                reconstruction: Some(reconstruction),
433            },
434            cached @ CommitmentPhase::Cached { .. } => cached,
435        };
436        self.block()
437            .cloned()
438            .expect("installing a block must leave a cached phase")
439    }
440}
441
442/// A network layer for broadcasting and receiving [`CodedBlock`]s as [`Shard`]s.
443///
444/// When enough [`Shard`]s are present in the mailbox, the [`Engine`] may facilitate
445/// reconstruction of the original [`CodedBlock`] and notify any subscribers waiting for it.
446pub struct Engine<E, S, X, D, C, H, B, P, T>
447where
448    E: BufferPooler + Rng + Spawner + Metrics + Clock,
449    S: Provider<Scope = Epoch>,
450    S::Scheme: CertificateScheme<PublicKey = P>,
451    X: Blocker,
452    D: PeerProvider<PublicKey = P>,
453    C: CodingScheme,
454    H: Hasher,
455    B: CertifiableBlock,
456    P: PublicKey,
457    T: Strategy,
458{
459    /// Context held by the actor.
460    context: ContextCell<E>,
461
462    /// Receiver for incoming messages to the actor.
463    mailbox: mailbox::Receiver<Message<B, C, H, P>>,
464
465    /// The scheme provider.
466    scheme_provider: S,
467
468    /// The peer blocker.
469    blocker: X,
470
471    /// [`Read`] configuration for decoding [`Shard`]s.
472    shard_codec_cfg: <Shard<B, C, H> as Read>::Cfg,
473
474    /// [`Read`] configuration for decoding [`CodedBlock`]s.
475    block_codec_cfg: B::Cfg,
476
477    /// The strategy used for parallel shard verification.
478    strategy: T,
479
480    /// The cache and reconstruction lifecycle for each observed [`Commitment`].
481    records: CommitmentRecords<B, C, H, P>,
482
483    /// Per-peer ring buffers for shards received before leader announcement.
484    ///
485    /// Empty buffers are retained for active peers and only evicted when the
486    /// peer leaves `latest.primary`.
487    peer_buffers: BTreeMap<P, VecDeque<Shard<B, C, H>>>,
488
489    /// Maximum buffered pre-leader shards per peer.
490    peer_buffer_size: NonZeroUsize,
491
492    /// Provider for peer set information.
493    peer_provider: D,
494
495    /// Latest union of peer membership from the peer set subscription
496    /// ([`commonware_p2p::PeerSetUpdate::all`]).
497    aggregate_peers: Set<P>,
498
499    /// Latest primary peers allowed to retain pre-leader shard buffers.
500    latest_primary_peers: Set<P>,
501
502    /// Capacity of the background receiver channel.
503    background_channel_capacity: NonZeroUsize,
504
505    /// Open subscriptions for assigned shard verification for the keyed
506    /// [`Commitment`].
507    ///
508    /// For participants, readiness is satisfied once the shard for the local
509    /// participant index has been verified. Reconstruction from peer gossip is
510    /// tracked separately and does not satisfy this readiness condition.
511    ///
512    /// Proposers are a special case: they satisfy readiness once their local
513    /// proposal is cached because they already hold all shards.
514    assigned_shard_verified_subscriptions: BTreeMap<Commitment<B, C, H>, Vec<oneshot::Sender<()>>>,
515
516    /// Open subscriptions for the reconstruction of a [`CodedBlock`] with
517    /// the keyed [`Commitment`].
518    #[allow(clippy::type_complexity)]
519    block_subscriptions: BTreeMap<
520        BlockSubscriptionKey<Commitment<B, C, H>, B::Digest>,
521        Vec<oneshot::Sender<Arc<CodedBlock<B, C, H>>>>,
522    >,
523
524    /// Metrics for the shard engine.
525    metrics: ShardMetrics<P>,
526}
527
528impl<E, S, X, D, C, H, B, P, T> Engine<E, S, X, D, C, H, B, P, T>
529where
530    E: BufferPooler + Rng + Spawner + Metrics + Clock,
531    S: Provider<Scope = Epoch>,
532    S::Scheme: CertificateScheme<PublicKey = P>,
533    X: Blocker<PublicKey = P>,
534    D: PeerProvider<PublicKey = P>,
535    C: CodingScheme,
536    H: Hasher,
537    B: CertifiableBlock,
538    P: PublicKey,
539    T: Strategy,
540{
541    /// Create a new [`Engine`] with the given configuration.
542    pub fn new(context: E, config: Config<P, S, X, D, C, H, B, T>) -> (Self, Mailbox<B, C, H, P>) {
543        let metrics = ShardMetrics::new(&context);
544        let (sender, mailbox) = mailbox::new(context.child("mailbox"), config.mailbox_size);
545        (
546            Self {
547                context: ContextCell::new(context),
548                mailbox,
549                scheme_provider: config.scheme_provider,
550                blocker: config.blocker,
551                shard_codec_cfg: config.shard_codec_cfg,
552                block_codec_cfg: config.block_codec_cfg,
553                strategy: config.strategy,
554                records: BTreeMap::new(),
555                peer_buffers: BTreeMap::new(),
556                peer_buffer_size: config.peer_buffer_size,
557                peer_provider: config.peer_provider,
558                aggregate_peers: Set::default(),
559                latest_primary_peers: Set::default(),
560                background_channel_capacity: config.background_channel_capacity,
561                assigned_shard_verified_subscriptions: BTreeMap::new(),
562                block_subscriptions: BTreeMap::new(),
563                metrics,
564            },
565            Mailbox::new(sender),
566        )
567    }
568
569    /// Start the engine.
570    pub fn start(
571        mut self,
572        network: (impl Sender<PublicKey = P>, impl Receiver<PublicKey = P>),
573    ) -> Handle<()> {
574        spawn_cell!(self.context, self.run(network))
575    }
576
577    /// Run the shard engine's event loop.
578    async fn run(
579        mut self,
580        (sender, receiver): (impl Sender<PublicKey = P>, impl Receiver<PublicKey = P>),
581    ) {
582        let mut sender = WrappedSender::<_, Shard<B, C, H>>::new(
583            self.context.network_buffer_pool().clone(),
584            sender,
585        );
586        let (receiver_service, mut receiver) =
587            WrappedBackgroundReceiver::<_, P, X, _, Shard<B, C, H>, T>::new(
588                self.context.child("shard_ingress"),
589                receiver,
590                self.shard_codec_cfg.clone(),
591                self.blocker.clone(),
592                self.background_channel_capacity,
593                self.strategy.clone(),
594            );
595        // Keep the handle alive to prevent the background receiver from being aborted.
596        let _receiver_handle = receiver_service.start();
597        let mut peer_set_subscription = self.peer_provider.subscribe().await;
598
599        select_loop! {
600            self.context,
601            on_start => {
602                // Clean up closed subscriptions.
603                self.block_subscriptions.retain(|_, subscribers| {
604                    subscribers.retain(|tx| !tx.is_closed());
605                    !subscribers.is_empty()
606                });
607                self.assigned_shard_verified_subscriptions
608                    .retain(|_, subscribers| {
609                        subscribers.retain(|tx| !tx.is_closed());
610                        !subscribers.is_empty()
611                    });
612            },
613            on_stopped => {
614                debug!("received shutdown signal, stopping shard engine");
615            },
616            Some(update) = peer_set_subscription.recv() else {
617                debug!("peer set subscription closed");
618                return;
619            } => {
620                let all_peers = update.all.union();
621                self.update_latest_primary_peers(update.latest.primary);
622                self.aggregate_peers = all_peers;
623            },
624            Some(message) = self.mailbox.recv() else {
625                debug!("shard mailbox closed, stopping shard engine");
626                return;
627            } => {
628                if message.response_closed() {
629                    continue;
630                }
631
632                match message {
633                    Message::Proposed { block, round } => {
634                        self.broadcast_shards(&mut sender, round, block);
635                    }
636                    Message::Discovered {
637                        commitment,
638                        leader,
639                        round,
640                    } => {
641                        self.handle_external_proposal(&mut sender, commitment, leader, round);
642                    }
643                    Message::Notarized { commitment, round } => {
644                        self.handle_notarized_commitment(&mut sender, commitment, round);
645                    }
646                    Message::GetByCommitment {
647                        commitment,
648                        response,
649                    } => {
650                        let block = self
651                            .records
652                            .get(&commitment)
653                            .and_then(CommitmentRecord::block)
654                            .cloned();
655                        response.send_lossy(block);
656                    }
657                    Message::GetByDigest { digest, response } => {
658                        let block = self.records.values().find_map(|record| {
659                            let block = record.block()?;
660                            (block.digest() == digest).then(|| Arc::clone(block))
661                        });
662                        response.send_lossy(block);
663                    }
664                    Message::SubscribeAssignedShardVerified {
665                        commitment,
666                        response,
667                    } => {
668                        self.handle_assigned_shard_verified_subscription(commitment, response);
669                    }
670                    Message::SubscribeByCommitment {
671                        commitment,
672                        response,
673                    } => {
674                        self.handle_block_subscription(
675                            BlockSubscriptionKey::Commitment(commitment),
676                            response,
677                        );
678                    }
679                    Message::SubscribeByDigest { digest, response } => {
680                        self.handle_block_subscription(
681                            BlockSubscriptionKey::Digest(digest),
682                            response,
683                        );
684                    }
685                    Message::Retire { update } => {
686                        self.retire(update);
687                    }
688                }
689            },
690            Some((peer, shard)) = receiver.recv() else {
691                debug!("receiver closed, stopping shard engine");
692                return;
693            } => {
694                self.handle_network_shard(&mut sender, peer, shard);
695            },
696        }
697    }
698
699    /// Handles a decoded shard received from the network.
700    fn handle_network_shard<Sr: Sender<PublicKey = P>>(
701        &mut self,
702        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
703        peer: P,
704        shard: Shard<B, C, H>,
705    ) {
706        self.metrics.shards_received.get_or_create_by(&peer).inc();
707
708        let commitment = shard.commitment();
709        if !self.should_handle_network_shard(commitment) {
710            return;
711        }
712
713        if let Some(record) = self.records.get(&commitment)
714            && let Some(existing) = record.reconstruction()
715        {
716            let round = record.round();
717            let Some(scheme) = self.scheme_provider.scheme(round.epoch()) else {
718                warn!(%commitment, "no scheme for epoch, ignoring shard");
719                return;
720            };
721
722            // Notarized recovery can create state before leader discovery. Until
723            // the leader is known, only sender-indexed gossip shards are safe to
724            // ingest: a participant may only gossip its own shard.
725            if existing.leader().is_none()
726                && let Some(sender_index) = scheme.participants().index(&peer)
727            {
728                let expected_index: u16 = sender_index
729                    .get()
730                    .try_into()
731                    .expect("participant index impossibly out of bounds");
732                if shard.index() != expected_index {
733                    // A mismatched shard may be assigned to us, but it cannot be
734                    // classified until consensus supplies the proposal context.
735                    self.buffer_peer_shard(peer, shard);
736                    return;
737                }
738            }
739
740            let state = self
741                .records
742                .get_mut(&commitment)
743                .and_then(CommitmentRecord::reconstruction_mut)
744                .expect("reconstruction checked as present");
745            let progressed = state.on_network_shard(
746                peer,
747                shard,
748                InsertCtx::new(scheme.as_ref(), &self.strategy),
749                &mut self.blocker,
750            );
751            if progressed {
752                self.try_advance(sender, commitment);
753            }
754        } else {
755            self.buffer_peer_shard(peer, shard);
756        }
757    }
758
759    /// Returns whether an incoming network shard should still be processed.
760    ///
761    /// Shards for reconstructed commitments are normally ignored. The only
762    /// exception is a late shard for the assigned index, which we still accept
763    /// so we can notify readiness and gossip it to slower peers.
764    fn should_handle_network_shard(&self, commitment: Commitment<B, C, H>) -> bool {
765        if let Some(record) = self.records.get(&commitment)
766            && record.block().is_some()
767        {
768            // State can be populated before our assigned shard is verified. Keep
769            // handling shards until that state is complete.
770            return record
771                .reconstruction()
772                .is_some_and(|s| !s.is_assigned_shard_verified());
773        }
774        true
775    }
776
777    /// Attempts to reconstruct a [`CodedBlock`] from the checked [`Shard`]s present in the
778    /// [`ReconstructionState`].
779    ///
780    /// # Returns
781    /// - `Ok(Some(block))` if reconstruction was successful or the block was already reconstructed.
782    /// - `Ok(None)` if reconstruction could not be attempted due to insufficient checked shards.
783    /// - `Err(_)` if reconstruction was attempted but failed.
784    #[allow(clippy::type_complexity)]
785    fn try_reconstruct(
786        &mut self,
787        commitment: Commitment<B, C, H>,
788    ) -> Result<Option<Arc<CodedBlock<B, C, H>>>, Error<C>> {
789        let Some(record) = self.records.get_mut(&commitment) else {
790            return Ok(None);
791        };
792        if let Some(block) = record.block() {
793            return Ok(Some(Arc::clone(block)));
794        }
795        let round = record.round();
796        let state = record
797            .reconstruction_mut()
798            .expect("an uncached commitment record must be reconstructing");
799        if state.checked_shards().len() < usize::from(commitment.config().minimum_shards.get()) {
800            debug!(%commitment, "not enough checked shards to reconstruct block");
801            return Ok(None);
802        }
803        // Attempt to reconstruct the encoded blob
804        let start = self.context.current();
805        let blob = C::decode(
806            &commitment.config(),
807            &commitment.root(),
808            state.checked_shards().iter(),
809            &self.strategy,
810        )
811        .map_err(Error::Coding)?;
812        self.metrics
813            .erasure_decode_duration
814            .observe_between(start, self.context.current());
815
816        // Attempt to decode the block from the encoded blob
817        let (inner, config): (B, CodingConfig) =
818            Decode::decode_cfg(&mut blob.as_slice(), &(self.block_codec_cfg.clone(), ()))?;
819
820        match validate_reconstruction(&inner, config, commitment) {
821            Ok(()) => {}
822            Err(InvariantError::BlockDigest) => {
823                return Err(Error::DigestMismatch);
824            }
825            Err(InvariantError::CodingConfig) => {
826                warn!(
827                    %commitment,
828                    expected_config = ?commitment.config(),
829                    actual_config = ?config,
830                    "reconstructed block config does not match commitment config, but digest matches"
831                );
832                return Err(Error::ConfigMismatch);
833            }
834            Err(InvariantError::ContextDigest(expected, actual)) => {
835                warn!(
836                    %commitment,
837                    expected_context_digest = ?expected,
838                    actual_context_digest = ?actual,
839                    "reconstructed block context digest does not match commitment context digest"
840                );
841                return Err(Error::ContextMismatch);
842            }
843        }
844
845        // Construct a coding block with a _trusted_ commitment. `S::decode` verified the blob's
846        // integrity against the commitment, so shards can be lazily re-constructed if need be.
847        let block = self
848            .cache_block(round, Arc::new(CodedBlock::new_trusted(inner, commitment)))
849            .expect("reconstruction uses its commitment record's epoch");
850        self.metrics.blocks_reconstructed_total.inc();
851        Ok(Some(block))
852    }
853
854    /// Handles leader announcements for a commitment and advances reconstruction.
855    fn handle_external_proposal<Sr: Sender<PublicKey = P>>(
856        &mut self,
857        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
858        commitment: Commitment<B, C, H>,
859        leader: P,
860        round: Round,
861    ) {
862        let Some(scheme) = self.scheme_provider.scheme(round.epoch()) else {
863            warn!(%commitment, "no scheme for epoch, ignoring external proposal");
864            return;
865        };
866        let participants = scheme.participants();
867        if participants.index(&leader).is_none() {
868            warn!(?leader, %commitment, "leader update for non-participant, ignoring");
869            return;
870        }
871        // A reconstructed block normally makes duplicate leader announcements
872        // redundant, unless notarized recovery created leaderless state first.
873        // In that case, the leader announcement must still populate the
874        // leader-dependent path.
875        let Some(status) = self.observe_existing_commitment(commitment, round) else {
876            return;
877        };
878        if status == CommitmentStatus::Cached
879            && self
880                .records
881                .get(&commitment)
882                .and_then(CommitmentRecord::reconstruction)
883                .is_none_or(|state| state.leader().is_some())
884        {
885            return;
886        }
887        if let Some(state) = self
888            .records
889            .get_mut(&commitment)
890            .and_then(CommitmentRecord::reconstruction_mut)
891        {
892            if let Some(existing) = state.leader() {
893                if existing != &leader {
894                    // A later leader is expected when this commitment is
895                    // re-proposed. Retaining the first does not impede participant
896                    // readiness because assigned shards are source-independent.
897                    debug!(
898                        existing = ?existing,
899                        ?leader,
900                        %commitment,
901                        "commitment already has a leader, ignoring update"
902                    );
903                }
904                return;
905            }
906            state
907                .set_leader(leader)
908                .expect("leader was checked as absent");
909        } else {
910            let participants_len = u64::try_from(participants.len())
911                .expect("participant count impossibly out of bounds");
912            self.insert_reconstruction_record(
913                commitment,
914                round,
915                ReconstructionState::new(Some(leader), participants_len),
916            );
917        }
918        let buffered_progress = self.ingest_buffered_shards(commitment);
919        if buffered_progress {
920            self.try_advance(sender, commitment);
921        }
922    }
923
924    /// Handles notarized reconstruction interest before the leader is known.
925    ///
926    /// This is intentionally narrower than leader discovery: it may reconstruct
927    /// the block from sender-indexed gossip shards, but it cannot mark the
928    /// local assigned shard as verified.
929    fn handle_notarized_commitment<Sr: Sender<PublicKey = P>>(
930        &mut self,
931        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
932        commitment: Commitment<B, C, H>,
933        round: Round,
934    ) {
935        let Some(status) = self.observe_existing_commitment(commitment, round) else {
936            return;
937        };
938        if status == CommitmentStatus::Cached {
939            return;
940        }
941        if status == CommitmentStatus::Reconstructing {
942            let buffered_progress = self.ingest_buffered_shards(commitment);
943            if buffered_progress {
944                self.try_advance(sender, commitment);
945            }
946            return;
947        }
948        let Some(scheme) = self.scheme_provider.scheme(round.epoch()) else {
949            warn!(%commitment, "no scheme for epoch, ignoring notarized commitment");
950            return;
951        };
952        let participants_len = u64::try_from(scheme.participants().len())
953            .expect("participant count impossibly out of bounds");
954        self.insert_reconstruction_record(
955            commitment,
956            round,
957            ReconstructionState::new(None, participants_len),
958        );
959        let buffered_progress = self.ingest_buffered_shards(commitment);
960        if buffered_progress {
961            self.try_advance(sender, commitment);
962        }
963    }
964
965    /// Buffer a shard from a peer until a leader is known.
966    fn buffer_peer_shard(&mut self, peer: P, shard: Shard<B, C, H>) {
967        if self.latest_primary_peers.position(&peer).is_none() {
968            debug!(
969                ?peer,
970                "pre-leader shard from peer outside latest.primary not buffered"
971            );
972            return;
973        }
974        let queue = self.peer_buffers.entry(peer).or_default();
975        if queue.len() >= self.peer_buffer_size.get() {
976            let _ = queue.pop_front();
977        }
978        queue.push_back(shard);
979    }
980
981    fn update_latest_primary_peers(&mut self, peers: Set<P>) {
982        self.peer_buffers
983            .retain(|peer, _| peers.position(peer).is_some());
984        self.latest_primary_peers = peers;
985    }
986
987    /// Ingest buffered pre-leader shards for a commitment into active state.
988    ///
989    /// Before proposal context is known, only sender-indexed gossip is
990    /// actionable. Once context exists, the local assigned index is valid from
991    /// any participant because its proof is bound to the commitment.
992    fn ingest_buffered_shards(&mut self, commitment: Commitment<B, C, H>) -> bool {
993        let record = self
994            .records
995            .get(&commitment)
996            .expect("buffered shards can only be ingested with a commitment record");
997        let round = record.round();
998        let state = record
999            .reconstruction()
1000            .expect("buffered shards can only be ingested with reconstruction state");
1001        let leader_known = state.leader().is_some();
1002        let Some(scheme) = self.scheme_provider.scheme(round.epoch()) else {
1003            warn!(%commitment, "no scheme for epoch, dropping buffered shards");
1004            return false;
1005        };
1006
1007        let mut buffered = Vec::new();
1008        for (peer, queue) in self.peer_buffers.iter_mut() {
1009            let mut i = 0;
1010            while i < queue.len() {
1011                if queue[i].commitment() != commitment {
1012                    i += 1;
1013                    continue;
1014                }
1015                if !leader_known {
1016                    let Some(sender_index) = scheme.participants().index(peer) else {
1017                        i += 1;
1018                        continue;
1019                    };
1020                    let expected_index: u16 = sender_index
1021                        .get()
1022                        .try_into()
1023                        .expect("participant index impossibly out of bounds");
1024                    if queue[i].index() != expected_index {
1025                        i += 1;
1026                        continue;
1027                    }
1028                }
1029                let shard = queue.swap_remove_back(i).expect("index is valid");
1030                buffered.push((peer.clone(), shard));
1031            }
1032        }
1033
1034        let state = self
1035            .records
1036            .get_mut(&commitment)
1037            .and_then(CommitmentRecord::reconstruction_mut)
1038            .expect("reconstruction state checked before buffered shard drain");
1039
1040        // Ingest buffered shards into the active reconstruction state. Batch verification
1041        // will be triggered if there are enough shards to meet the quorum threshold.
1042        let mut progressed = false;
1043        let ctx = InsertCtx::new(scheme.as_ref(), &self.strategy);
1044        for (peer, shard) in buffered {
1045            progressed |= state.on_network_shard(peer, shard, ctx, &mut self.blocker);
1046        }
1047        progressed
1048    }
1049
1050    /// Records a consensus observation on an existing commitment owner.
1051    ///
1052    /// Returns the record's phase, [`CommitmentStatus::Absent`] if it has no
1053    /// owner yet, or `None` when its owner is bound to a different epoch.
1054    fn observe_existing_commitment(
1055        &mut self,
1056        commitment: Commitment<B, C, H>,
1057        round: Round,
1058    ) -> Option<CommitmentStatus> {
1059        let observed_epoch = round.epoch();
1060        let Some(record) = self.records.get_mut(&commitment) else {
1061            return Some(CommitmentStatus::Absent);
1062        };
1063        if let Err(existing_epoch) = record.observe(round) {
1064            warn!(
1065                %commitment,
1066                %existing_epoch,
1067                %observed_epoch,
1068                "commitment observation has conflicting epoch, ignoring"
1069            );
1070            return None;
1071        }
1072        Some(if record.block().is_some() {
1073            CommitmentStatus::Cached
1074        } else {
1075            CommitmentStatus::Reconstructing
1076        })
1077    }
1078
1079    /// Creates the first lifecycle record for a reconstructing commitment.
1080    fn insert_reconstruction_record(
1081        &mut self,
1082        commitment: Commitment<B, C, H>,
1083        round: Round,
1084        reconstruction: ReconstructionState<P, B, C, H>,
1085    ) {
1086        let Entry::Vacant(entry) = self.records.entry(commitment) else {
1087            unreachable!("commitment status was checked as absent");
1088        };
1089        entry.insert(CommitmentRecord::reconstructing(round, reconstruction));
1090        self.metrics.reconstruction_states_count.inc();
1091    }
1092
1093    /// Cache a block and notify all subscribers waiting on it.
1094    fn cache_block(
1095        &mut self,
1096        round: Round,
1097        block: Arc<CodedBlock<B, C, H>>,
1098    ) -> Result<Arc<CodedBlock<B, C, H>>, Epoch> {
1099        let commitment = block.commitment();
1100        let cached = match self.records.entry(commitment) {
1101            Entry::Occupied(mut entry) => {
1102                entry.get_mut().observe(round)?;
1103                let newly_cached = entry.get().block().is_none();
1104                let cached = entry.get_mut().install_block(block);
1105                if newly_cached {
1106                    self.metrics.reconstructed_blocks_cache_count.inc();
1107                }
1108                cached
1109            }
1110            Entry::Vacant(entry) => {
1111                entry.insert(CommitmentRecord::cached(round, Arc::clone(&block)));
1112                self.metrics.reconstructed_blocks_cache_count.inc();
1113                block
1114            }
1115        };
1116        self.notify_block_subscribers(Arc::clone(&cached));
1117        Ok(cached)
1118    }
1119
1120    /// Broadcasts the shards of a [`CodedBlock`] and caches the block.
1121    ///
1122    /// - Participants receive the shard matching their participant index.
1123    /// - Non-participants in aggregate membership receive the leader's shard.
1124    fn broadcast_shards<Sr: Sender<PublicKey = P>>(
1125        &mut self,
1126        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
1127        round: Round,
1128        block: Arc<CodedBlock<B, C, H>>,
1129    ) {
1130        let commitment = block.commitment();
1131
1132        if let Some(record) = self.records.get(&commitment)
1133            && let Err(existing_epoch) = record.validate_epoch(round)
1134        {
1135            warn!(
1136                %commitment,
1137                %existing_epoch,
1138                observed_epoch = %round.epoch(),
1139                "local proposal has conflicting epoch, ignoring"
1140            );
1141            return;
1142        }
1143
1144        let Some(scheme) = self.scheme_provider.scheme(round.epoch()) else {
1145            warn!(%commitment, "no scheme available, cannot broadcast shards");
1146            return;
1147        };
1148        let participants = scheme.participants();
1149        let Some(me) = scheme.me() else {
1150            warn!(
1151                %commitment,
1152                "cannot broadcast shards: local proposer is not a participant"
1153            );
1154            return;
1155        };
1156
1157        let shard_count = block.shards(&self.strategy).len();
1158        if shard_count != participants.len() {
1159            warn!(
1160                %commitment,
1161                shard_count,
1162                participants = participants.len(),
1163                "cannot broadcast shards: participant/shard count mismatch"
1164            );
1165            return;
1166        }
1167
1168        let my_index = me.get() as usize;
1169        let leader_shard = block
1170            .shard(my_index as u16)
1171            .expect("proposer's shard must exist");
1172
1173        // Broadcast each participant their corresponding shard.
1174        for (index, peer) in participants.iter().enumerate() {
1175            if index == my_index {
1176                continue;
1177            }
1178
1179            let Some(shard) = block.shard(index as u16) else {
1180                warn!(
1181                    %commitment,
1182                    index,
1183                    "cannot broadcast shards: missing shard for participant index"
1184                );
1185                return;
1186            };
1187            let _ = sender.send(Recipients::One(peer.clone()), shard, true);
1188        }
1189
1190        // Send the leader's shard to peers in aggregate membership who are not participants.
1191        let non_participants: Vec<P> = self
1192            .aggregate_peers
1193            .iter()
1194            .filter(|peer| participants.index(peer).is_none())
1195            .cloned()
1196            .collect();
1197        if !non_participants.is_empty() {
1198            let _ = sender.send(Recipients::Some(non_participants), leader_shard, true);
1199        }
1200
1201        // Cache the block so we don't have to reconstruct it again.
1202        self.cache_block(round, block)
1203            .expect("local proposal epoch was validated before broadcast");
1204        self.records
1205            .get_mut(&commitment)
1206            .expect("caching a local proposal must create a commitment record")
1207            .mark_proposed();
1208
1209        // Local proposals bypass reconstruction, so shard subscribers waiting
1210        // for "our valid shard arrived" still need a notification.
1211        self.notify_assigned_shard_verified_subscribers(commitment);
1212
1213        debug!(?commitment, "broadcasted shards");
1214    }
1215
1216    /// Gossips a validated [`Shard`] using [`commonware_p2p::Recipients::All`].
1217    fn broadcast_shard<Sr: Sender<PublicKey = P>>(
1218        &mut self,
1219        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
1220        shard: Shard<B, C, H>,
1221    ) {
1222        let commitment = shard.commitment();
1223        let peers = sender.send(Recipients::All, shard, true);
1224        debug!(
1225            ?commitment,
1226            peers = peers.len(),
1227            "broadcasted shard to all peers"
1228        );
1229    }
1230
1231    /// Broadcasts any pending validated shard and attempts reconstruction.
1232    ///
1233    /// Successful reconstruction caches the block while retaining any shard state
1234    /// still needed for assigned-shard readiness. Failed reconstruction retires the
1235    /// commitment and its commitment-specific subscriptions.
1236    fn try_advance<Sr: Sender<PublicKey = P>>(
1237        &mut self,
1238        sender: &mut WrappedSender<Sr, Shard<B, C, H>>,
1239        commitment: Commitment<B, C, H>,
1240    ) {
1241        if let Some(state) = self
1242            .records
1243            .get_mut(&commitment)
1244            .and_then(CommitmentRecord::reconstruction_mut)
1245        {
1246            match state.take_pending_action() {
1247                Some(AssignedShardVerifiedAction::Broadcast(shard)) => {
1248                    self.broadcast_shard(sender, shard);
1249                    self.notify_assigned_shard_verified_subscribers(commitment);
1250                }
1251                Some(AssignedShardVerifiedAction::NotifyOnly) => {
1252                    self.notify_assigned_shard_verified_subscribers(commitment);
1253                }
1254                None => {}
1255            }
1256        }
1257
1258        match self.try_reconstruct(commitment) {
1259            Ok(Some(block)) => {
1260                // Do not prune other reconstruction state here. A Byzantine
1261                // leader can equivocate by proposing multiple commitments in
1262                // the same round, so more than one block may be reconstructed
1263                // for a given round. Retirement is deferred until durable
1264                // application progress supplies both eligibility signals.
1265                debug!(
1266                    %commitment,
1267                    parent = %block.parent(),
1268                    height = %block.height(),
1269                    "successfully reconstructed block from shards"
1270                );
1271            }
1272            Ok(None) => {
1273                debug!(%commitment, "not enough checked shards to reconstruct block");
1274            }
1275            Err(err) => {
1276                warn!(%commitment, ?err, "failed to reconstruct block from checked shards");
1277                self.retire_commitment(commitment, RetirementReason::Exact);
1278                self.metrics.reconstruction_failures_total.inc();
1279            }
1280        }
1281    }
1282
1283    /// Handles the registry of an assigned shard verification subscription.
1284    ///
1285    /// For participants this is tied to verification of the shard for the local
1286    /// index, not to generic block reconstruction.
1287    fn handle_assigned_shard_verified_subscription(
1288        &mut self,
1289        commitment: Commitment<B, C, H>,
1290        response: oneshot::Sender<()>,
1291    ) {
1292        // Answer immediately if our own shard has been verified or we built the block.
1293        if self
1294            .records
1295            .get(&commitment)
1296            .is_some_and(CommitmentRecord::is_assigned_shard_ready)
1297        {
1298            response.send_lossy(());
1299            return;
1300        }
1301
1302        self.assigned_shard_verified_subscriptions
1303            .entry(commitment)
1304            .or_default()
1305            .push(response);
1306    }
1307
1308    /// Handles the registry of a block subscription.
1309    fn handle_block_subscription(
1310        &mut self,
1311        key: BlockSubscriptionKey<Commitment<B, C, H>, B::Digest>,
1312        response: oneshot::Sender<Arc<CodedBlock<B, C, H>>>,
1313    ) {
1314        let block = match key {
1315            BlockSubscriptionKey::Commitment(commitment) => self
1316                .records
1317                .get(&commitment)
1318                .and_then(CommitmentRecord::block),
1319            BlockSubscriptionKey::Digest(digest) => self
1320                .records
1321                .values()
1322                .filter_map(CommitmentRecord::block)
1323                .find(|block| block.digest() == digest),
1324        };
1325
1326        // Answer immediately if we have the block cached.
1327        if let Some(block) = block {
1328            response.send_lossy(Arc::clone(block));
1329            return;
1330        }
1331
1332        self.block_subscriptions
1333            .entry(key)
1334            .or_default()
1335            .push(response);
1336    }
1337
1338    /// Notifies and cleans up any subscriptions waiting for assigned shard
1339    /// verification.
1340    fn notify_assigned_shard_verified_subscribers(&mut self, commitment: Commitment<B, C, H>) {
1341        if let Some(mut subscribers) = self
1342            .assigned_shard_verified_subscriptions
1343            .remove(&commitment)
1344        {
1345            for subscriber in subscribers.drain(..) {
1346                subscriber.send_lossy(());
1347            }
1348        }
1349    }
1350
1351    /// Notifies and cleans up any subscriptions for a reconstructed block.
1352    fn notify_block_subscribers(&mut self, block: Arc<CodedBlock<B, C, H>>) {
1353        let commitment = block.commitment();
1354        let digest = block.digest();
1355
1356        // Notify by-commitment subscribers.
1357        if let Some(mut subscribers) = self
1358            .block_subscriptions
1359            .remove(&BlockSubscriptionKey::Commitment(commitment))
1360        {
1361            for subscriber in subscribers.drain(..) {
1362                subscriber.send_lossy(Arc::clone(&block));
1363            }
1364        }
1365
1366        // Notify by-digest subscribers.
1367        if let Some(mut subscribers) = self
1368            .block_subscriptions
1369            .remove(&BlockSubscriptionKey::Digest(digest))
1370        {
1371            for subscriber in subscribers.drain(..) {
1372                subscriber.send_lossy(Arc::clone(&block));
1373            }
1374        }
1375    }
1376
1377    /// Retires one commitment and applies the subscription policy for the cause.
1378    fn retire_commitment(&mut self, commitment: Commitment<B, C, H>, reason: RetirementReason) {
1379        let had_reconstruction = self.records.remove(&commitment).is_some_and(|record| {
1380            let had_reconstruction = record.reconstruction().is_some();
1381            if had_reconstruction {
1382                self.metrics.reconstruction_states_count.dec();
1383            }
1384            if record.block().is_some() {
1385                self.metrics.reconstructed_blocks_cache_count.dec();
1386            }
1387            had_reconstruction
1388        });
1389
1390        if matches!(reason, RetirementReason::Exact) || had_reconstruction {
1391            self.assigned_shard_verified_subscriptions
1392                .remove(&commitment);
1393        }
1394        if matches!(reason, RetirementReason::Exact) {
1395            // Before marshal accepts a block, a candidate can claim a digest it cannot
1396            // reconstruct. Retiring it therefore does not prove the digest unavailable.
1397            self.block_subscriptions
1398                .remove(&BlockSubscriptionKey::Commitment(commitment));
1399        }
1400    }
1401
1402    /// Retires cached blocks and reconstruction state after durable application progress.
1403    ///
1404    /// Retirement waits for durable progress because a Byzantine leader may produce multiple
1405    /// valid commitments in one round.
1406    fn retire(&mut self, update: Retirement<Commitment<B, C, H>>) {
1407        let Retirement {
1408            round_floor,
1409            exact_retirements,
1410        } = update;
1411        // Durable processing makes existing exact-commitment and assigned-shard waits for these
1412        // states obsolete. Digest waits are not commitment-specific.
1413        for commitment in exact_retirements {
1414            self.retire_commitment(commitment, RetirementReason::Exact);
1415        }
1416
1417        // Entries observed in later rounds may still be needed for certification. Block
1418        // subscriptions remain open because local ingress can still satisfy them after a floor.
1419        let retired = self
1420            .records
1421            .iter()
1422            .filter_map(|(commitment, record)| {
1423                (record.round() <= round_floor).then_some(*commitment)
1424            })
1425            .collect::<Vec<_>>();
1426        for commitment in retired {
1427            self.retire_commitment(commitment, RetirementReason::Floor);
1428        }
1429    }
1430}
1431
1432/// Erasure coded block reconstruction state machine.
1433enum ReconstructionState<P, B, C, H>
1434where
1435    P: PublicKey,
1436    B: Digestible,
1437    C: CodingScheme,
1438    H: Hasher,
1439{
1440    /// Stage 1: accumulate shards. The shard for our assigned index is verified
1441    /// immediately. All other shards are buffered until enough are available
1442    /// for batch verification.
1443    AwaitingQuorum(AwaitingQuorumState<P, B, C, H>),
1444    /// Stage 2: batch validation passed. Checked shards are available for
1445    /// reconstruction.
1446    Ready(ReadyState<P, B, C, H>),
1447}
1448
1449/// Action to take once assigned shard verification has been established.
1450///
1451/// Participants broadcast the shard to all peers, while non-participants
1452/// only notify local subscribers.
1453enum AssignedShardVerifiedAction<B: Digestible, C: CodingScheme, H: Hasher> {
1454    /// Broadcast the shard to all peers and notify local subscribers.
1455    Broadcast(Shard<B, C, H>),
1456    /// Only notify local subscribers (non-participant validated the leader's shard).
1457    NotifyOnly,
1458}
1459
1460/// A coding shard paired with its participant index.
1461struct IndexedShard<C: CodingScheme> {
1462    index: u16,
1463    data: C::Shard,
1464}
1465
1466/// State shared across all reconstruction phases.
1467struct CommonState<P, B, C, H>
1468where
1469    P: PublicKey,
1470    B: Digestible,
1471    C: CodingScheme,
1472    H: Hasher,
1473{
1474    /// The leader associated with this reconstruction state, if consensus has
1475    /// provided it.
1476    leader: Option<P>,
1477    /// Our validated shard and the action to take with it.
1478    pending_action: Option<AssignedShardVerifiedAction<B, C, H>>,
1479    /// Shards that have been verified and are ready to contribute to reconstruction.
1480    checked_shards: Vec<C::CheckedShard>,
1481    /// Bitmap tracking which participant indices have contributed a shard.
1482    contributed: BitMap,
1483    /// Raw shard data received per index, retained for equivocation detection.
1484    /// Keyed by shard index.
1485    received_shards: BTreeMap<u16, C::Shard>,
1486    /// Whether the shard for our assigned index has been verified.
1487    assigned_shard_verified: bool,
1488}
1489
1490/// Phase data for `ReconstructionState::AwaitingQuorum`.
1491///
1492/// In this phase, the leader may be unknown. Sender-indexed shards can still be
1493/// buffered until enough are available to attempt batch validation. Once proposal
1494/// context is known, the shard for our assigned index is verified eagerly via
1495/// `C::check`, regardless of which participant delivered it.
1496struct AwaitingQuorumState<P, B, C, H>
1497where
1498    P: PublicKey,
1499    B: Digestible,
1500    C: CodingScheme,
1501    H: Hasher,
1502{
1503    common: CommonState<P, B, C, H>,
1504    /// Shards pending batch validation, keyed by sender.
1505    pending_shards: BTreeMap<P, IndexedShard<C>>,
1506}
1507
1508/// Phase data for `ReconstructionState::Ready`.
1509///
1510/// Batch validation has passed. Checked shards are available for
1511/// reconstruction.
1512struct ReadyState<P, B, C, H>
1513where
1514    P: PublicKey,
1515    B: Digestible,
1516    C: CodingScheme,
1517    H: Hasher,
1518{
1519    common: CommonState<P, B, C, H>,
1520}
1521
1522impl<P, B, C, H> CommonState<P, B, C, H>
1523where
1524    P: PublicKey,
1525    B: Digestible,
1526    C: CodingScheme,
1527    H: Hasher,
1528{
1529    /// Create a new empty common state for the provided leader.
1530    fn new(leader: Option<P>, participants_len: u64) -> Self {
1531        Self {
1532            leader,
1533            pending_action: None,
1534            checked_shards: Vec::new(),
1535            contributed: BitMap::zeroes(participants_len),
1536            received_shards: BTreeMap::new(),
1537            assigned_shard_verified: false,
1538        }
1539    }
1540}
1541
1542impl<P, B, C, H> CommonState<P, B, C, H>
1543where
1544    P: PublicKey,
1545    B: Digestible,
1546    C: CodingScheme,
1547    H: Hasher,
1548{
1549    /// Verify the assigned shard and store it.
1550    ///
1551    /// When `is_participant` is true, the validated shard is stored for
1552    /// broadcasting to peers. When false (non-participant), only subscriber
1553    /// notification is scheduled.
1554    ///
1555    /// Returns `false` if verification fails (sender is blocked), `true` on
1556    /// success.
1557    fn verify_assigned_shard(
1558        &mut self,
1559        sender: P,
1560        commitment: Commitment<B, C, H>,
1561        shard: IndexedShard<C>,
1562        is_participant: bool,
1563        blocker: &mut impl Blocker<PublicKey = P>,
1564    ) -> bool {
1565        // Store data for equivocation detection first (move), then clone
1566        // once for check. This avoids a second clone compared to cloning
1567        // for both check and storage.
1568        self.received_shards.insert(shard.index, shard.data);
1569        let data = self.received_shards.get(&shard.index).unwrap();
1570        let Ok(checked) = C::check(&commitment.config(), &commitment.root(), shard.index, data)
1571        else {
1572            self.received_shards.remove(&shard.index);
1573            commonware_p2p::block!(blocker, sender, "invalid assigned shard received");
1574            return false;
1575        };
1576
1577        self.contributed.set(u64::from(shard.index), true);
1578        self.checked_shards.push(checked);
1579        self.assigned_shard_verified = true;
1580        self.pending_action = Some(if is_participant {
1581            AssignedShardVerifiedAction::Broadcast(Shard::new(
1582                commitment,
1583                shard.index,
1584                data.clone(),
1585            ))
1586        } else {
1587            AssignedShardVerifiedAction::NotifyOnly
1588        });
1589        true
1590    }
1591}
1592
1593impl<P, B, C, H> AwaitingQuorumState<P, B, C, H>
1594where
1595    P: PublicKey,
1596    B: Digestible,
1597    C: CodingScheme,
1598    H: Hasher,
1599{
1600    /// Check whether quorum is met and, if so, batch-validate all pending
1601    /// shards in parallel. Returns `Some(ReadyState)` on successful transition.
1602    fn try_transition(
1603        &mut self,
1604        commitment: Commitment<B, C, H>,
1605        participants_len: u64,
1606        strategy: &impl Strategy,
1607        blocker: &mut impl Blocker<PublicKey = P>,
1608    ) -> Option<ReadyState<P, B, C, H>> {
1609        let minimum = usize::from(commitment.config().minimum_shards.get());
1610        if self.common.checked_shards.len() + self.pending_shards.len() < minimum {
1611            return None;
1612        }
1613
1614        // Batch-validate all pending weak shards in parallel.
1615        let pending = std::mem::take(&mut self.pending_shards);
1616        let (new_checked, to_block) =
1617            strategy.map_partition_collect_vec(pending, |(peer, shard)| {
1618                let checked = C::check(
1619                    &commitment.config(),
1620                    &commitment.root(),
1621                    shard.index,
1622                    &shard.data,
1623                );
1624                (peer, checked.ok())
1625            });
1626
1627        for peer in to_block {
1628            commonware_p2p::block!(blocker, peer, "invalid shard received");
1629        }
1630        for checked in new_checked {
1631            self.common.checked_shards.push(checked);
1632        }
1633
1634        // After validation, some may have failed; recheck threshold.
1635        if self.common.checked_shards.len() < minimum {
1636            return None;
1637        }
1638
1639        // Transition to Ready.
1640        let leader = self.common.leader.clone();
1641        let common =
1642            std::mem::replace(&mut self.common, CommonState::new(leader, participants_len));
1643        Some(ReadyState { common })
1644    }
1645}
1646
1647/// Context required for processing incoming network shards.
1648struct InsertCtx<'a, Sch, S>
1649where
1650    Sch: CertificateScheme,
1651    S: Strategy,
1652{
1653    scheme: &'a Sch,
1654    strategy: &'a S,
1655    participants_len: u64,
1656}
1657
1658impl<Sch: CertificateScheme, S: Strategy> Clone for InsertCtx<'_, Sch, S> {
1659    fn clone(&self) -> Self {
1660        *self
1661    }
1662}
1663
1664impl<Sch: CertificateScheme, S: Strategy> Copy for InsertCtx<'_, Sch, S> {}
1665
1666impl<'a, Sch: CertificateScheme, S: Strategy> InsertCtx<'a, Sch, S> {
1667    fn new(scheme: &'a Sch, strategy: &'a S) -> Self {
1668        let participants_len = u64::try_from(scheme.participants().len())
1669            .expect("participant count impossibly out of bounds");
1670        Self {
1671            scheme,
1672            strategy,
1673            participants_len,
1674        }
1675    }
1676}
1677
1678impl<P, B, C, H> ReconstructionState<P, B, C, H>
1679where
1680    P: PublicKey,
1681    B: Digestible,
1682    C: CodingScheme,
1683    H: Hasher,
1684{
1685    /// Create an initial reconstruction state for a commitment.
1686    fn new(leader: Option<P>, participants_len: u64) -> Self {
1687        Self::AwaitingQuorum(AwaitingQuorumState {
1688            common: CommonState::new(leader, participants_len),
1689            pending_shards: BTreeMap::new(),
1690        })
1691    }
1692
1693    /// Access common state shared across all phases.
1694    const fn common(&self) -> &CommonState<P, B, C, H> {
1695        match self {
1696            Self::AwaitingQuorum(state) => &state.common,
1697            Self::Ready(state) => &state.common,
1698        }
1699    }
1700
1701    /// Mutably access common state shared across all phases.
1702    const fn common_mut(&mut self) -> &mut CommonState<P, B, C, H> {
1703        match self {
1704            Self::AwaitingQuorum(state) => &mut state.common,
1705            Self::Ready(state) => &mut state.common,
1706        }
1707    }
1708
1709    /// Return the leader associated with this state.
1710    const fn leader(&self) -> Option<&P> {
1711        self.common().leader.as_ref()
1712    }
1713
1714    /// Set the leader for this state if it has not already been set.
1715    fn set_leader(&mut self, leader: P) -> Result<(), P> {
1716        if self.common().leader.is_some() {
1717            return Err(leader);
1718        }
1719        self.common_mut().leader = Some(leader);
1720        Ok(())
1721    }
1722
1723    /// Returns whether the shard for our assigned index has been verified.
1724    const fn is_assigned_shard_verified(&self) -> bool {
1725        self.common().assigned_shard_verified
1726    }
1727
1728    /// Returns all verified shards accumulated for reconstruction.
1729    const fn checked_shards(&self) -> &[C::CheckedShard] {
1730        self.common().checked_shards.as_slice()
1731    }
1732
1733    /// Takes the pending action for this commitment's validated shard.
1734    ///
1735    /// Returns [`None`] if the assigned shard hasn't been validated yet.
1736    const fn take_pending_action(&mut self) -> Option<AssignedShardVerifiedAction<B, C, H>> {
1737        self.common_mut().pending_action.take()
1738    }
1739
1740    /// Handle an incoming network shard.
1741    ///
1742    /// Returns `true` only when the shard caused state progress (buffered,
1743    /// validated, or transitioned), and `false` when rejected/blocked.
1744    ///
1745    /// ## Peer Blocking Rules
1746    ///
1747    /// The `sender` may be blocked via the provided [`Blocker`] if any of
1748    /// the following rules are violated:
1749    ///
1750    /// - MUST be sent by a participant in the current epoch. Non-participant
1751    ///   senders are blocked.
1752    /// - A participant's assigned index may be delivered by any participant.
1753    /// - Other shards MUST match the sender's participant index.
1754    /// - Once proposal context is known, any other shard index results in
1755    ///   blocking.
1756    /// - Each shard index may only contribute ONE shard per commitment.
1757    ///   Sending a second shard for the same index with different data
1758    ///   (equivocation) results in blocking the sender.
1759    /// - The assigned shard is verified eagerly via [`CodingScheme::check`].
1760    ///   If verification fails, the sender is blocked.
1761    /// - Own-index shards are buffered in `pending_shards` and
1762    ///   batch-validated when quorum is reached. Invalid shards discovered
1763    ///   during batch validation result in blocking their respective
1764    ///   senders.
1765    ///
1766    /// ## Silent Discard Rules
1767    ///
1768    /// The following conditions cause a shard to be silently ignored
1769    /// without blocking the sender:
1770    ///
1771    /// - Exact duplicate of a previously received shard for the same index.
1772    /// - The index has already been marked as contributed (via the bitmap,
1773    ///   e.g. after batch validation).
1774    /// - Own-index shards that arrive after the state has transitioned to
1775    ///   [`ReconstructionState::Ready`] (i.e., batch validation has already
1776    ///   passed). An assigned shard for our index is still accepted in
1777    ///   `Ready` state to ensure we verify and re-broadcast it.
1778    /// - Before a reconstruction state exists, shards are buffered at the
1779    ///   engine level in bounded per-peer queues until [`Mailbox::discovered`]
1780    ///   or [`Mailbox::notarized`] creates state for this commitment.
1781    fn on_network_shard<Sch, S, X>(
1782        &mut self,
1783        sender: P,
1784        shard: Shard<B, C, H>,
1785        ctx: InsertCtx<'_, Sch, S>,
1786        blocker: &mut X,
1787    ) -> bool
1788    where
1789        Sch: CertificateScheme<PublicKey = P>,
1790        S: Strategy,
1791        X: Blocker<PublicKey = P>,
1792    {
1793        let Some(sender_index) = ctx.scheme.participants().index(&sender) else {
1794            commonware_p2p::block!(blocker, sender, "shard sent by non-participant");
1795            return false;
1796        };
1797        let commitment = shard.commitment();
1798        let indexed = IndexedShard {
1799            index: shard.index(),
1800            data: shard.into_inner(),
1801        };
1802
1803        // A participant's assigned shard is source-independent because it is
1804        // verified eagerly. Every other shard must be sender-owned so each sender
1805        // contributes at most one shard before batch verification.
1806        let sender_index: u16 = sender_index
1807            .get()
1808            .try_into()
1809            .expect("participant index impossibly out of bounds");
1810        let assigned_index: Option<u16> = ctx.scheme.me().map(|assigned_index| {
1811            assigned_index
1812                .get()
1813                .try_into()
1814                .expect("participant index impossibly out of bounds")
1815        });
1816        let is_from_leader = self.leader().is_some_and(|leader| leader == &sender);
1817        let is_assigned_shard = assigned_index
1818            .is_some_and(|assigned_index| indexed.index == assigned_index)
1819            || assigned_index.is_none() && is_from_leader && indexed.index == sender_index;
1820        let is_gossip_shard = indexed.index == sender_index;
1821        if !is_assigned_shard && !is_gossip_shard {
1822            if self.leader().is_some() {
1823                commonware_p2p::block!(
1824                    blocker,
1825                    sender,
1826                    shard_index = indexed.index,
1827                    "shard index is neither assigned nor sender-owned"
1828                );
1829            }
1830            return false;
1831        }
1832
1833        // Equivocation/duplicate check.
1834        if let Some(existing) = self.common().received_shards.get(&indexed.index) {
1835            if existing != &indexed.data {
1836                commonware_p2p::block!(blocker, sender, "shard equivocation");
1837            }
1838            return false;
1839        }
1840
1841        // Check if this index already contributed (via batch validation).
1842        if self.common().contributed.get(u64::from(indexed.index)) {
1843            return false;
1844        }
1845
1846        // The assigned shard is always verified eagerly, even after transitioning
1847        // to Ready. This ensures we broadcast it to help slower peers reach quorum.
1848        if is_assigned_shard && !self.common().assigned_shard_verified {
1849            let progressed = self.common_mut().verify_assigned_shard(
1850                sender,
1851                commitment,
1852                indexed,
1853                ctx.scheme.me().is_some(),
1854                blocker,
1855            );
1856
1857            if progressed
1858                && let Self::AwaitingQuorum(state) = self
1859                && let Some(ready) =
1860                    state.try_transition(commitment, ctx.participants_len, ctx.strategy, blocker)
1861            {
1862                *self = Self::Ready(ready);
1863            }
1864            return progressed;
1865        }
1866
1867        // Gossip shards are only accepted while awaiting quorum.
1868        let Self::AwaitingQuorum(state) = self else {
1869            return false;
1870        };
1871
1872        // Buffer for batch validation.
1873        state
1874            .common
1875            .received_shards
1876            .insert(indexed.index, indexed.data.clone());
1877        state.common.contributed.set(u64::from(indexed.index), true);
1878        state.pending_shards.insert(sender, indexed);
1879        if let Some(ready) =
1880            state.try_transition(commitment, ctx.participants_len, ctx.strategy, blocker)
1881        {
1882            *self = Self::Ready(ready);
1883        }
1884
1885        true
1886    }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892    use crate::{
1893        marshal::{coding::types::coding_config_for_participants, mocks::block::EmptyBlock},
1894        types::{Epoch, Height, View},
1895    };
1896    use bytes::Bytes;
1897    use commonware_codec::Encode;
1898    use commonware_coding::{
1899        CodecConfig, Config as CodingConfig, PhasedAsScheme, ReedSolomon, Zoda,
1900    };
1901    use commonware_cryptography::{
1902        Committable, Digest, Sha256, Signer,
1903        certificate::{Scoped, Subject},
1904        ed25519::{PrivateKey, PublicKey},
1905        impl_certificate_ed25519,
1906        sha256::Digest as Sha256Digest,
1907    };
1908    use commonware_macros::{select, test_traced};
1909    use commonware_p2p::{
1910        Manager as _, TrackedPeers,
1911        simulated::{self, Control, Link, Oracle},
1912    };
1913    use commonware_parallel::Sequential;
1914    use commonware_runtime::{Quota, Runner, Supervisor as _, deterministic};
1915    use commonware_utils::{
1916        N3f1, NZUsize, Participant, channel::oneshot::error::TryRecvError, ordered::Set,
1917        probability,
1918    };
1919    use std::{
1920        future::Future,
1921        marker::PhantomData,
1922        num::{NonZeroU32, NonZeroUsize},
1923        sync::{
1924            Arc,
1925            atomic::{AtomicIsize, Ordering},
1926        },
1927        time::Duration,
1928    };
1929
1930    #[derive(Clone, Debug)]
1931    pub struct TestSubject {
1932        pub message: Bytes,
1933    }
1934
1935    impl Subject for TestSubject {
1936        type Namespace = Vec<u8>;
1937
1938        fn namespace<'a>(&self, derived: &'a Self::Namespace) -> &'a [u8] {
1939            derived
1940        }
1941
1942        fn message(&self) -> Bytes {
1943            self.message.clone()
1944        }
1945    }
1946
1947    impl_certificate_ed25519!(TestSubject, Vec<u8>, N3f1);
1948
1949    const SCHEME_NAMESPACE: &[u8] = b"_COMMONWARE_SHARD_ENGINE_TEST";
1950
1951    /// The max size of a shard sent over the wire.
1952    const MAX_SHARD_SIZE: usize = 1024 * 1024; // 1 MiB
1953
1954    /// The default link configuration for tests.
1955    const DEFAULT_LINK: Link = Link {
1956        latency: Duration::from_millis(50),
1957        jitter: Duration::ZERO,
1958        success_rate: probability!(1.0),
1959    };
1960
1961    /// Rate limit quota for tests (effectively unlimited).
1962    const TEST_QUOTA: Quota = Quota::per_second(NonZeroU32::MAX);
1963
1964    /// The parallelization strategy used for tests.
1965    const STRATEGY: Sequential = Sequential;
1966
1967    /// A scheme provider that maps each epoch to a potentially different scheme.
1968    ///
1969    /// For most tests only epoch 0 is registered, matching the previous
1970    /// `ConstantProvider` behaviour. Cross-epoch tests register additional
1971    /// epochs with different participant sets.
1972    #[derive(Clone)]
1973    struct MultiEpochProvider {
1974        schemes: BTreeMap<Epoch, Arc<Scheme>>,
1975    }
1976
1977    impl MultiEpochProvider {
1978        fn single(scheme: Scheme) -> Self {
1979            let mut schemes = BTreeMap::new();
1980            schemes.insert(Epoch::zero(), Arc::new(scheme));
1981            Self { schemes }
1982        }
1983
1984        fn with_epoch(mut self, epoch: Epoch, scheme: Scheme) -> Self {
1985            self.schemes.insert(epoch, Arc::new(scheme));
1986            self
1987        }
1988    }
1989
1990    impl Provider for MultiEpochProvider {
1991        type Scope = Epoch;
1992        type Scheme = Scheme;
1993
1994        fn scoped(&self, scope: Epoch) -> Option<Scoped<Scheme>> {
1995            self.schemes.get(&scope).cloned().map(Scoped::scheme)
1996        }
1997    }
1998
1999    /// A one-epoch scheme provider that churns to `None` after a fixed number
2000    /// of successful scope lookups.
2001    #[derive(Clone)]
2002    struct ChurningProvider {
2003        scheme: Arc<Scheme>,
2004        remaining_successes: Arc<AtomicIsize>,
2005    }
2006
2007    impl ChurningProvider {
2008        fn new(scheme: Scheme, successes: isize) -> Self {
2009            Self {
2010                scheme: Arc::new(scheme),
2011                remaining_successes: Arc::new(AtomicIsize::new(successes)),
2012            }
2013        }
2014    }
2015
2016    impl Provider for ChurningProvider {
2017        type Scope = Epoch;
2018        type Scheme = Scheme;
2019
2020        fn scoped(&self, scope: Epoch) -> Option<Scoped<Scheme>> {
2021            if scope != Epoch::zero() {
2022                return None;
2023            }
2024            if self.remaining_successes.fetch_sub(1, Ordering::AcqRel) <= 0 {
2025                return None;
2026            }
2027            Some(Scoped::scheme(Arc::clone(&self.scheme)))
2028        }
2029    }
2030
2031    // Type aliases for test convenience.
2032    type B = EmptyBlock<H>;
2033    type H = Sha256;
2034    type P = PublicKey;
2035    type C = ReedSolomon<H>;
2036    type X = Control<P, deterministic::Context>;
2037    type O = Oracle<P, deterministic::Context>;
2038    type Prov = MultiEpochProvider;
2039    type NetworkSender = simulated::Sender<P, deterministic::Context>;
2040    type D = simulated::Manager<P, deterministic::Context>;
2041    type ShardEngine<S> = Engine<deterministic::Context, Prov, X, D, S, H, B, P, Sequential>;
2042    type ChurningShardEngine<S> =
2043        Engine<deterministic::Context, ChurningProvider, X, D, S, H, B, P, Sequential>;
2044
2045    async fn assert_blocked(oracle: &O, blocker: &P, blocked: &P) {
2046        let blocked_peers = oracle.blocked().await.unwrap();
2047        let is_blocked = blocked_peers
2048            .iter()
2049            .any(|(a, b)| a == blocker && b == blocked);
2050        assert!(is_blocked, "expected {blocker} to have blocked {blocked}");
2051    }
2052
2053    /// A participant in the test network with its engine mailbox and blocker.
2054    struct Peer<S: CodingScheme = C> {
2055        /// The peer's public key.
2056        public_key: PublicKey,
2057        /// The peer's index in the participant set.
2058        index: Participant,
2059        /// The mailbox for sending messages to the peer's shard engine.
2060        mailbox: Mailbox<B, S, H, P>,
2061        /// Raw network sender for injecting messages (e.g., byzantine behavior).
2062        sender: NetworkSender,
2063    }
2064
2065    /// A non-participant in the test network with its engine mailbox.
2066    #[allow(dead_code)]
2067    struct NonParticipant<S: CodingScheme = C> {
2068        /// The peer's public key.
2069        public_key: PublicKey,
2070        /// The mailbox for sending messages to the peer's shard engine.
2071        mailbox: Mailbox<B, S, H, P>,
2072        /// Raw network sender for injecting messages.
2073        sender: NetworkSender,
2074    }
2075
2076    /// Test fixture for setting up multiple participants with shard engines.
2077    struct Fixture<S: CodingScheme = C> {
2078        /// Number of primary peers created during setup.
2079        num_primary_peers: usize,
2080        /// Number of secondary peers created during setup.
2081        num_secondary_peers: usize,
2082        /// Number of peers introduced after setup.
2083        num_future_peers: usize,
2084        /// Additional epochs that use the fixture's participant set.
2085        additional_scheme_epochs: Vec<Epoch>,
2086        /// Network link configuration.
2087        link: Link,
2088        /// Per-peer capacity for shards received before leader discovery.
2089        peer_buffer_size: NonZeroUsize,
2090        /// Marker for the coding scheme type parameter.
2091        _marker: PhantomData<S>,
2092    }
2093
2094    impl<S: CodingScheme> Default for Fixture<S> {
2095        fn default() -> Self {
2096            Self {
2097                num_primary_peers: 4,
2098                num_secondary_peers: 0,
2099                num_future_peers: 0,
2100                additional_scheme_epochs: Vec::new(),
2101                link: DEFAULT_LINK,
2102                peer_buffer_size: NZUsize!(64),
2103                _marker: PhantomData,
2104            }
2105        }
2106    }
2107
2108    impl<S: CodingScheme> Fixture<S> {
2109        pub fn start<F: Future<Output = ()>>(
2110            self,
2111            f: impl FnOnce(
2112                Self,
2113                deterministic::Context,
2114                O,
2115                Vec<Peer<S>>,
2116                Vec<NonParticipant<S>>,
2117                CodingConfig,
2118            ) -> F,
2119        ) {
2120            let executor = deterministic::Runner::default();
2121            executor.start(|context| async move {
2122                let mut private_keys = (0..self.num_primary_peers)
2123                    .map(|i| PrivateKey::from_seed(i as u64))
2124                    .collect::<Vec<_>>();
2125                private_keys.sort_by_key(|s| s.public_key());
2126                let peer_keys: Vec<P> = private_keys.iter().map(|c| c.public_key()).collect();
2127
2128                let participants: Set<P> = Set::from_iter_dedup(peer_keys.clone());
2129
2130                let mut np_private_keys = (0..self.num_secondary_peers)
2131                    .map(|i| PrivateKey::from_seed((self.num_primary_peers + i) as u64))
2132                    .collect::<Vec<_>>();
2133                np_private_keys.sort_by_key(|s| s.public_key());
2134                let np_keys: Vec<P> = np_private_keys.iter().map(|k| k.public_key()).collect();
2135
2136                let (network, oracle) =
2137                    simulated::Network::<deterministic::Context, P>::new_with_split_peers(
2138                        context.child("network"),
2139                        simulated::Config {
2140                            max_size: MAX_SHARD_SIZE as u32,
2141                            max_peers_per_set: NZUsize!(
2142                                self.num_primary_peers
2143                                    + self.num_secondary_peers.max(self.num_future_peers)
2144                            ),
2145                            disconnect_on_block: true,
2146                            tracked_peer_sets: NZUsize!(1),
2147                        },
2148                        peer_keys.clone(),
2149                        np_keys.clone(),
2150                    )
2151                    .await;
2152                network.start();
2153
2154                let all_keys: Vec<P> = peer_keys.iter().chain(np_keys.iter()).cloned().collect();
2155
2156                let mut registrations = BTreeMap::new();
2157                for key in all_keys.iter() {
2158                    let control = oracle.control(key.clone());
2159                    let (sender, receiver) = control
2160                        .register(0, TEST_QUOTA)
2161                        .await
2162                        .expect("registration should succeed");
2163                    registrations.insert(key.clone(), (control, sender, receiver));
2164                }
2165                for p1 in all_keys.iter() {
2166                    for p2 in all_keys.iter() {
2167                        if p2 == p1 {
2168                            continue;
2169                        }
2170                        oracle
2171                            .add_link(p1.clone(), p2.clone(), self.link.clone())
2172                            .await
2173                            .expect("link should be added");
2174                    }
2175                }
2176
2177                let coding_config =
2178                    coding_config_for_participants(u16::try_from(self.num_primary_peers).unwrap());
2179
2180                let mut peers = Vec::with_capacity(self.num_primary_peers);
2181                for (idx, peer_key) in peer_keys.iter().enumerate() {
2182                    let (control, sender, receiver) = registrations
2183                        .remove(peer_key)
2184                        .expect("peer should be registered");
2185
2186                    let participant = Participant::new(idx as u32);
2187                    let engine_context = context.child("peer").with_attribute("index", idx);
2188
2189                    let scheme = Scheme::signer(
2190                        SCHEME_NAMESPACE,
2191                        participants.clone(),
2192                        private_keys[idx].clone(),
2193                    )
2194                    .expect("signer scheme should be created");
2195                    let mut scheme_provider = MultiEpochProvider::single(scheme);
2196                    for epoch in self.additional_scheme_epochs.iter().copied() {
2197                        let scheme = Scheme::signer(
2198                            SCHEME_NAMESPACE,
2199                            participants.clone(),
2200                            private_keys[idx].clone(),
2201                        )
2202                        .expect("signer scheme should be created");
2203                        scheme_provider = scheme_provider.with_epoch(epoch, scheme);
2204                    }
2205
2206                    let config = Config {
2207                        scheme_provider,
2208                        blocker: control.clone(),
2209                        shard_codec_cfg: CodecConfig {
2210                            maximum_shard_size: MAX_SHARD_SIZE,
2211                        },
2212                        block_codec_cfg: (),
2213                        strategy: STRATEGY,
2214                        mailbox_size: NZUsize!(1024),
2215                        peer_buffer_size: self.peer_buffer_size,
2216                        background_channel_capacity: NZUsize!(1024),
2217                        peer_provider: oracle.manager(),
2218                    };
2219
2220                    let (engine, mailbox) = ShardEngine::new(engine_context, config);
2221                    let sender_clone = sender.clone();
2222                    engine.start((sender, receiver));
2223
2224                    peers.push(Peer {
2225                        public_key: peer_key.clone(),
2226                        index: participant,
2227                        mailbox,
2228                        sender: sender_clone,
2229                    });
2230                }
2231
2232                let mut non_participants = Vec::with_capacity(self.num_secondary_peers);
2233                for (idx, np_key) in np_keys.iter().enumerate() {
2234                    let (control, sender, receiver) = registrations
2235                        .remove(np_key)
2236                        .expect("non-participant should be registered");
2237
2238                    let engine_context = context
2239                        .child("non_participant")
2240                        .with_attribute("index", idx);
2241
2242                    let scheme = Scheme::verifier(SCHEME_NAMESPACE, participants.clone());
2243                    let mut scheme_provider = MultiEpochProvider::single(scheme);
2244                    for epoch in self.additional_scheme_epochs.iter().copied() {
2245                        scheme_provider = scheme_provider.with_epoch(
2246                            epoch,
2247                            Scheme::verifier(SCHEME_NAMESPACE, participants.clone()),
2248                        );
2249                    }
2250
2251                    let config = Config {
2252                        scheme_provider,
2253                        blocker: control.clone(),
2254                        shard_codec_cfg: CodecConfig {
2255                            maximum_shard_size: MAX_SHARD_SIZE,
2256                        },
2257                        block_codec_cfg: (),
2258                        strategy: STRATEGY,
2259                        mailbox_size: NZUsize!(1024),
2260                        peer_buffer_size: self.peer_buffer_size,
2261                        background_channel_capacity: NZUsize!(1024),
2262                        peer_provider: oracle.manager(),
2263                    };
2264
2265                    let (engine, mailbox) = ShardEngine::new(engine_context, config);
2266                    let sender_clone = sender.clone();
2267                    engine.start((sender, receiver));
2268
2269                    non_participants.push(NonParticipant {
2270                        public_key: np_key.clone(),
2271                        mailbox,
2272                        sender: sender_clone,
2273                    });
2274                }
2275
2276                f(
2277                    self,
2278                    context,
2279                    oracle,
2280                    peers,
2281                    non_participants,
2282                    coding_config,
2283                )
2284                .await;
2285            });
2286        }
2287    }
2288
2289    #[test_traced]
2290    fn test_e2e_broadcast_and_reconstruction() {
2291        let fixture = Fixture {
2292            num_primary_peers: 10,
2293            ..Default::default()
2294        };
2295
2296        fixture.start(
2297            |config, context, _, mut peers, _, coding_config| async move {
2298                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2299                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2300                let commitment = coded_block.commitment();
2301
2302                let leader = peers[0].public_key.clone();
2303                let round = Round::new(Epoch::zero(), View::new(1));
2304                peers[0].mailbox.proposed(round, coded_block.clone());
2305
2306                // Inform all peers of the leader so shards are processed.
2307                for peer in peers[1..].iter_mut() {
2308                    peer.mailbox.discovered(commitment, leader.clone(), round);
2309                }
2310                context.sleep(config.link.latency).await;
2311
2312                for peer in peers.iter_mut() {
2313                    peer.mailbox
2314                        .subscribe_assigned_shard_verified(commitment)
2315                        .await
2316                        .expect("shard subscription should complete");
2317                }
2318                context.sleep(config.link.latency).await;
2319
2320                for peer in peers.iter_mut() {
2321                    let reconstructed = peer
2322                        .mailbox
2323                        .get(commitment)
2324                        .await
2325                        .expect("block should be reconstructed");
2326                    assert_eq!(reconstructed.commitment(), commitment);
2327                    assert_eq!(reconstructed.height(), coded_block.height());
2328                }
2329            },
2330        );
2331    }
2332
2333    #[test_traced]
2334    fn test_e2e_broadcast_and_reconstruction_zoda() {
2335        let fixture = Fixture {
2336            num_primary_peers: 10,
2337            ..Default::default()
2338        };
2339
2340        fixture.start(
2341            |config, context, _, mut peers, _, coding_config| async move {
2342                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2343                let coded_block = CodedBlock::<B, PhasedAsScheme<Zoda<H>>, H>::new(
2344                    inner,
2345                    coding_config,
2346                    &STRATEGY,
2347                );
2348                let commitment = coded_block.commitment();
2349
2350                let leader = peers[0].public_key.clone();
2351                let round = Round::new(Epoch::zero(), View::new(1));
2352                peers[0].mailbox.proposed(round, coded_block.clone());
2353
2354                // Inform all peers of the leader so shards are processed.
2355                for peer in peers[1..].iter_mut() {
2356                    peer.mailbox.discovered(commitment, leader.clone(), round);
2357                }
2358                context.sleep(config.link.latency).await;
2359
2360                for peer in peers.iter_mut() {
2361                    peer.mailbox
2362                        .subscribe_assigned_shard_verified(commitment)
2363                        .await
2364                        .expect("shard subscription should complete");
2365                }
2366                context.sleep(config.link.latency).await;
2367
2368                for peer in peers.iter_mut() {
2369                    let reconstructed = peer
2370                        .mailbox
2371                        .get(commitment)
2372                        .await
2373                        .expect("block should be reconstructed");
2374                    assert_eq!(reconstructed.commitment(), commitment);
2375                    assert_eq!(reconstructed.height(), coded_block.height());
2376                }
2377            },
2378        );
2379    }
2380
2381    #[test_traced]
2382    fn test_block_subscriptions() {
2383        let fixture = Fixture {
2384            num_primary_peers: 10,
2385            ..Default::default()
2386        };
2387
2388        fixture.start(
2389            |config, context, _, mut peers, _, coding_config| async move {
2390                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2391                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2392                let commitment = coded_block.commitment();
2393                let digest = coded_block.digest();
2394
2395                let leader = peers[0].public_key.clone();
2396                let round = Round::new(Epoch::zero(), View::new(1));
2397
2398                // Subscribe before broadcasting.
2399                let commitment_sub = peers[1].mailbox.subscribe(commitment);
2400                let digest_sub = peers[2].mailbox.subscribe_by_digest(digest);
2401
2402                peers[0].mailbox.proposed(round, coded_block.clone());
2403
2404                // Inform all peers of the leader so shards are processed.
2405                for peer in peers[1..].iter_mut() {
2406                    peer.mailbox.discovered(commitment, leader.clone(), round);
2407                }
2408                context.sleep(config.link.latency * 2).await;
2409
2410                for peer in peers.iter_mut() {
2411                    peer.mailbox
2412                        .subscribe_assigned_shard_verified(commitment)
2413                        .await
2414                        .expect("shard subscription should complete");
2415                }
2416                context.sleep(config.link.latency).await;
2417
2418                let block_by_commitment =
2419                    commitment_sub.await.expect("subscription should resolve");
2420                assert_eq!(block_by_commitment.commitment(), commitment);
2421                assert_eq!(block_by_commitment.height(), coded_block.height());
2422
2423                let block_by_digest = digest_sub.await.expect("subscription should resolve");
2424                assert_eq!(block_by_digest.commitment(), commitment);
2425                assert_eq!(block_by_digest.height(), coded_block.height());
2426            },
2427        );
2428    }
2429
2430    #[test_traced]
2431    fn test_proposer_preproposal_subscriptions_resolve_after_local_cache() {
2432        let fixture = Fixture {
2433            num_primary_peers: 10,
2434            ..Default::default()
2435        };
2436
2437        fixture.start(|config, context, _, peers, _, coding_config| async move {
2438            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2439            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2440            let commitment = coded_block.commitment();
2441            let digest = coded_block.digest();
2442            let round = Round::new(Epoch::zero(), View::new(1));
2443
2444            // Subscribe on the proposer before it caches the locally proposed block.
2445            let shard_sub = peers[0].mailbox.subscribe_assigned_shard_verified(commitment);
2446            let commitment_sub = peers[0].mailbox.subscribe(commitment);
2447            let digest_sub = peers[0].mailbox.subscribe_by_digest(digest);
2448
2449            peers[0].mailbox.proposed(round, coded_block.clone());
2450            context.sleep(config.link.latency).await;
2451
2452            select! {
2453                result = shard_sub => {
2454                    result.expect("shard subscription should resolve");
2455                },
2456                _ = context.sleep(Duration::from_secs(5)) => {
2457                    panic!("shard subscription did not resolve after local proposal cache");
2458                }
2459            }
2460
2461            let block_by_commitment = select! {
2462                result = commitment_sub => {
2463                    result.expect("block subscription by commitment should resolve")
2464                },
2465                _ = context.sleep(Duration::from_secs(5)) => {
2466                    panic!("block subscription by commitment did not resolve after local proposal cache");
2467                }
2468            };
2469            assert_eq!(block_by_commitment.commitment(), commitment);
2470            assert_eq!(block_by_commitment.height(), coded_block.height());
2471
2472            let block_by_digest = select! {
2473                result = digest_sub => {
2474                    result.expect("block subscription by digest should resolve")
2475                },
2476                _ = context.sleep(Duration::from_secs(5)) => {
2477                    panic!("block subscription by digest did not resolve after local proposal cache");
2478                }
2479            };
2480            assert_eq!(block_by_digest.commitment(), commitment);
2481            assert_eq!(block_by_digest.height(), coded_block.height());
2482        });
2483    }
2484
2485    #[test_traced]
2486    fn test_shard_subscription_rejects_invalid_shard() {
2487        let fixture = Fixture::<C>::default();
2488        fixture.start(
2489            |config, context, oracle, mut peers, _, coding_config| async move {
2490                // peers[0] = byzantine
2491                // peers[1] = honest proposer
2492                // peers[2] = receiver
2493
2494                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2495                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2496                let commitment = coded_block.commitment();
2497                let receiver_index = peers[2].index.get() as u16;
2498
2499                let valid_shard = coded_block.shard(receiver_index).expect("missing shard");
2500
2501                // Corrupt the shard's index to one that doesn't match
2502                // peers[0]'s participant index, triggering a block.
2503                let mut invalid_shard = valid_shard.clone();
2504                invalid_shard.index = peers[3].index.get() as u16;
2505
2506                // Receiver subscribes to their shard and learns the leader.
2507                let receiver_pk = peers[2].public_key.clone();
2508                let leader = peers[1].public_key.clone();
2509                peers[2].mailbox.discovered(
2510                    commitment,
2511                    leader,
2512                    Round::new(Epoch::zero(), View::new(1)),
2513                );
2514                let mut shard_sub = peers[2]
2515                    .mailbox
2516                    .subscribe_assigned_shard_verified(commitment);
2517
2518                // Byzantine peer sends the invalid shard.
2519                let invalid_bytes = invalid_shard.encode();
2520                peers[0]
2521                    .sender
2522                    .send(Recipients::One(receiver_pk.clone()), invalid_bytes, true);
2523
2524                context.sleep(config.link.latency * 2).await;
2525
2526                assert!(
2527                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
2528                    "subscription should not resolve from invalid shard"
2529                );
2530                assert_blocked(&oracle, &peers[2].public_key, &peers[0].public_key).await;
2531
2532                // Honest proposer sends the valid shard.
2533                let valid_bytes = valid_shard.encode();
2534                peers[1]
2535                    .sender
2536                    .send(Recipients::One(receiver_pk), valid_bytes, true);
2537                context.sleep(config.link.latency * 2).await;
2538
2539                // Subscription should now resolve.
2540                select! {
2541                    _ = shard_sub => {},
2542                    _ = context.sleep(Duration::from_secs(5)) => {
2543                        panic!("subscription did not complete after valid shard arrival");
2544                    },
2545                };
2546            },
2547        );
2548    }
2549
2550    #[test_traced]
2551    fn test_retire_uses_inclusive_retirement_floor() {
2552        let fixture = Fixture::<C>::default();
2553        fixture.start(|_, context, _, mut peers, _, coding_config| async move {
2554            // The processed commitment was re-proposed above the retirement floor. A malicious
2555            // candidate was observed below it.
2556            let processed = CodedBlock::<B, C, H>::new(
2557                B::new(Sha256Digest::EMPTY, Height::new(1), 100),
2558                coding_config,
2559                &STRATEGY,
2560            );
2561            let malicious = CodedBlock::<B, C, H>::new(
2562                B::new(Sha256Digest::EMPTY, Height::new(u64::MAX), 100),
2563                coding_config,
2564                &STRATEGY,
2565            );
2566            let equal = CodedBlock::<B, C, H>::new(
2567                B::new(Sha256Digest::EMPTY, Height::new(3), 100),
2568                coding_config,
2569                &STRATEGY,
2570            );
2571            let later = CodedBlock::<B, C, H>::new(
2572                B::new(Sha256Digest::EMPTY, Height::new(2), 100),
2573                coding_config,
2574                &STRATEGY,
2575            );
2576            let processed_commitment = processed.commitment();
2577            let malicious_commitment = malicious.commitment();
2578            let equal_commitment = equal.commitment();
2579            let later_commitment = later.commitment();
2580
2581            // Cache all blocks via `proposed`.
2582            let peer = &mut peers[0];
2583            peer.mailbox
2584                .proposed(Round::new(Epoch::zero(), View::new(1)), processed.clone());
2585            peer.mailbox
2586                .proposed(Round::new(Epoch::zero(), View::new(2)), malicious);
2587            peer.mailbox
2588                .proposed(Round::new(Epoch::zero(), View::new(3)), equal);
2589            peer.mailbox
2590                .proposed(Round::new(Epoch::zero(), View::new(1)), later.clone());
2591            // Re-proposals refresh ownership independently of the commitment's
2592            // original context round.
2593            peer.mailbox
2594                .proposed(Round::new(Epoch::zero(), View::new(5)), processed);
2595            peer.mailbox
2596                .proposed(Round::new(Epoch::zero(), View::new(4)), later);
2597            context.sleep(Duration::from_millis(10)).await;
2598
2599            // Verify all blocks are in the cache.
2600            assert!(
2601                peer.mailbox.get(processed_commitment).await.is_some(),
2602                "processed block should be cached"
2603            );
2604            assert!(
2605                peer.mailbox.get(malicious_commitment).await.is_some(),
2606                "malicious block should be cached"
2607            );
2608            assert!(
2609                peer.mailbox.get(equal_commitment).await.is_some(),
2610                "equal-round block should be cached"
2611            );
2612            assert!(
2613                peer.mailbox.get(later_commitment).await.is_some(),
2614                "later block should be cached"
2615            );
2616
2617            // The authoritative retirement floor removes every earlier candidate and the exact
2618            // processed commitment, even when that commitment was observed above the floor.
2619            peer.mailbox.retire(Retirement {
2620                round_floor: Round::new(Epoch::zero(), View::new(3)),
2621                exact_retirements: vec![processed_commitment],
2622            });
2623            context.sleep(Duration::from_millis(10)).await;
2624
2625            assert!(
2626                peer.mailbox.get(processed_commitment).await.is_none(),
2627                "exact processed block should be pruned above the floor"
2628            );
2629            assert!(
2630                peer.mailbox.get(malicious_commitment).await.is_none(),
2631                "earlier malicious block should be pruned"
2632            );
2633            assert!(
2634                peer.mailbox.get(equal_commitment).await.is_none(),
2635                "block observed at the retirement floor should be pruned"
2636            );
2637            assert!(
2638                peer.mailbox.get(later_commitment).await.is_some(),
2639                "non-target block observed above the floor should remain cached"
2640            );
2641        });
2642    }
2643
2644    #[test_traced]
2645    fn test_retire_unseen_commitment_applies_floor() {
2646        let fixture = Fixture::<C>::default();
2647        fixture.start(|_, context, _, mut peers, _, coding_config| async move {
2648            let make_block = |id| {
2649                CodedBlock::<B, C, H>::new(
2650                    B::new(Sha256Digest::EMPTY, Height::new(id), id),
2651                    coding_config,
2652                    &STRATEGY,
2653                )
2654            };
2655            let cached_old = make_block(1);
2656            let cached_equal = make_block(2);
2657            let cached_later = make_block(3);
2658            let state_old = make_block(4).commitment();
2659            let state_equal = make_block(5).commitment();
2660            let state_later = make_block(6).commitment();
2661            let unseen = make_block(7).commitment();
2662            let cached_old_commitment = cached_old.commitment();
2663            let cached_equal_commitment = cached_equal.commitment();
2664            let cached_later_commitment = cached_later.commitment();
2665            let old_round = Round::new(Epoch::zero(), View::new(2));
2666            let floor = Round::new(Epoch::zero(), View::new(3));
2667            let later_round = Round::new(Epoch::zero(), View::new(4));
2668            let leader = peers[1].public_key.clone();
2669            let peer = &mut peers[0];
2670
2671            peer.mailbox.proposed(old_round, cached_old);
2672            peer.mailbox.proposed(floor, cached_equal);
2673            peer.mailbox.proposed(later_round, cached_later);
2674            peer.mailbox
2675                .discovered(state_old, leader.clone(), old_round);
2676            peer.mailbox.discovered(state_equal, leader.clone(), floor);
2677            peer.mailbox
2678                .discovered(state_later, leader.clone(), old_round);
2679            peer.mailbox.discovered(state_later, leader, later_round);
2680            let mut state_old_sub = peer.mailbox.subscribe(state_old);
2681            let mut state_equal_sub = peer.mailbox.subscribe(state_equal);
2682            let mut state_later_sub = peer.mailbox.subscribe(state_later);
2683            context.sleep(Duration::from_millis(10)).await;
2684
2685            peer.mailbox.retire(Retirement {
2686                round_floor: floor,
2687                exact_retirements: vec![unseen],
2688            });
2689            context.sleep(Duration::from_millis(10)).await;
2690
2691            assert!(peer.mailbox.get(cached_old_commitment).await.is_none());
2692            assert!(peer.mailbox.get(cached_equal_commitment).await.is_none());
2693            assert!(peer.mailbox.get(cached_later_commitment).await.is_some());
2694            assert!(matches!(state_old_sub.try_recv(), Err(TryRecvError::Empty)));
2695            assert!(matches!(
2696                state_equal_sub.try_recv(),
2697                Err(TryRecvError::Empty)
2698            ));
2699            assert!(matches!(
2700                state_later_sub.try_recv(),
2701                Err(TryRecvError::Empty)
2702            ));
2703        });
2704    }
2705
2706    #[test_traced]
2707    fn test_local_reproposal_refreshes_existing_reconstruction_state() {
2708        let fixture = Fixture::<C>::default();
2709        fixture.start(|_, context, _, mut peers, _, coding_config| async move {
2710            let live = CodedBlock::<B, C, H>::new(
2711                B::new(Sha256Digest::EMPTY, Height::new(1), 100),
2712                coding_config,
2713                &STRATEGY,
2714            );
2715            let unseen = CodedBlock::<B, C, H>::new(
2716                B::new(Sha256Digest::EMPTY, Height::new(2), 200),
2717                coding_config,
2718                &STRATEGY,
2719            )
2720            .commitment();
2721            let state_first = CodedBlock::<B, C, H>::new(
2722                B::new(Sha256Digest::EMPTY, Height::new(3), 300),
2723                coding_config,
2724                &STRATEGY,
2725            );
2726            let live_commitment = live.commitment();
2727            let state_first_commitment = state_first.commitment();
2728            let leader = peers[0].public_key.clone();
2729            let original_round = Round::new(Epoch::zero(), View::new(1));
2730            let floor = Round::new(Epoch::zero(), View::new(3));
2731            let reproposal_round = Round::new(Epoch::zero(), View::new(4));
2732            let peer = &mut peers[0];
2733
2734            peer.mailbox
2735                .discovered(live_commitment, leader, original_round);
2736            peer.mailbox.proposed(reproposal_round, live);
2737            peer.mailbox
2738                .notarized(state_first_commitment, reproposal_round);
2739            peer.mailbox.proposed(floor, state_first);
2740            assert!(peer.mailbox.get(live_commitment).await.is_some());
2741
2742            let mut shard_sub = peer
2743                .mailbox
2744                .subscribe_assigned_shard_verified(live_commitment);
2745            context.sleep(Duration::from_millis(10)).await;
2746            assert!(
2747                matches!(shard_sub.try_recv(), Ok(())),
2748                "late subscription should resolve after a local reproposal"
2749            );
2750
2751            peer.mailbox.retire(Retirement {
2752                round_floor: floor,
2753                exact_retirements: vec![unseen],
2754            });
2755            context.sleep(Duration::from_millis(10)).await;
2756
2757            assert!(peer.mailbox.get(live_commitment).await.is_some());
2758            assert!(peer.mailbox.get(state_first_commitment).await.is_some());
2759            let mut retained_shard_sub = peer
2760                .mailbox
2761                .subscribe_assigned_shard_verified(live_commitment);
2762            context.sleep(Duration::from_millis(10)).await;
2763            assert!(
2764                matches!(retained_shard_sub.try_recv(), Ok(())),
2765                "retained local proposal should resolve a late subscription"
2766            );
2767        });
2768    }
2769
2770    #[test_traced]
2771    fn test_duplicate_leader_shard_ignored() {
2772        let fixture = Fixture::<C>::default();
2773        fixture.start(
2774            |config, context, oracle, mut peers, _, coding_config| async move {
2775                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2776                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2777                let commitment = coded_block.commitment();
2778
2779                // Get peer 2's own-index shard (the one the leader sends them).
2780                let peer2_index = peers[2].index.get() as u16;
2781                let peer2_shard = coded_block.shard(peer2_index).expect("missing shard");
2782                let shard_bytes = peer2_shard.encode();
2783
2784                let peer2_pk = peers[2].public_key.clone();
2785                let leader = peers[0].public_key.clone();
2786
2787                // Inform peer 2 that peer 0 is the leader.
2788                peers[2].mailbox.discovered(
2789                    commitment,
2790                    leader,
2791                    Round::new(Epoch::zero(), View::new(1)),
2792                );
2793
2794                // Send peer 2 their shard from peer 0 (leader, first time - should succeed).
2795                peers[0]
2796                    .sender
2797                    .send(Recipients::One(peer2_pk.clone()), shard_bytes.clone(), true);
2798                context.sleep(config.link.latency * 2).await;
2799
2800                // Send the same shard again from peer 0 (leader duplicate - ignored).
2801                peers[0]
2802                    .sender
2803                    .send(Recipients::One(peer2_pk), shard_bytes, true);
2804                context.sleep(config.link.latency * 2).await;
2805
2806                // The leader should NOT be blocked for sending an identical duplicate.
2807                let blocked_peers = oracle.blocked().await.unwrap();
2808                let is_blocked = blocked_peers
2809                    .iter()
2810                    .any(|(a, b)| a == &peers[2].public_key && b == &peers[0].public_key);
2811                assert!(
2812                    !is_blocked,
2813                    "leader should not be blocked for duplicate shard"
2814                );
2815            },
2816        );
2817    }
2818
2819    #[test_traced]
2820    fn test_equivocating_leader_shard_blocks_peer() {
2821        let fixture = Fixture::<C>::default();
2822        fixture.start(
2823            |config, context, oracle, mut peers, _, coding_config| async move {
2824                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2825                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
2826                let commitment = coded_block1.commitment();
2827
2828                // Create a second block with different payload to get different shard data.
2829                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(1), 200);
2830                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
2831
2832                // Get peer 2's shard from both blocks.
2833                let peer2_index = peers[2].index.get() as u16;
2834                let shard_bytes1 = coded_block1
2835                    .shard(peer2_index)
2836                    .expect("missing shard")
2837                    .encode();
2838                let mut equivocating_shard =
2839                    coded_block2.shard(peer2_index).expect("missing shard");
2840                // Override the commitment so it targets the same reconstruction state.
2841                equivocating_shard.commitment = commitment;
2842                let shard_bytes2 = equivocating_shard.encode();
2843
2844                let peer2_pk = peers[2].public_key.clone();
2845                let leader = peers[0].public_key.clone();
2846
2847                // Inform peer 2 that peer 0 is the leader.
2848                peers[2].mailbox.discovered(
2849                    commitment,
2850                    leader,
2851                    Round::new(Epoch::zero(), View::new(1)),
2852                );
2853
2854                // Send peer 2 their shard from the leader (first time - succeeds).
2855                peers[0]
2856                    .sender
2857                    .send(Recipients::One(peer2_pk.clone()), shard_bytes1, true);
2858                context.sleep(config.link.latency * 2).await;
2859
2860                // Send a different shard from the leader (equivocation - should block).
2861                peers[0]
2862                    .sender
2863                    .send(Recipients::One(peer2_pk), shard_bytes2, true);
2864                context.sleep(config.link.latency * 2).await;
2865
2866                // Peer 2 should have blocked the leader for equivocation.
2867                assert_blocked(&oracle, &peers[2].public_key, &peers[0].public_key).await;
2868            },
2869        );
2870    }
2871
2872    #[test_traced]
2873    fn test_non_leader_wrong_index_shard_blocked() {
2874        // Test that a non-leader sending a shard with the wrong index is blocked.
2875        // Non-leaders must send shards at their own participant index.
2876        let fixture = Fixture::<C>::default();
2877        fixture.start(
2878            |config, context, oracle, mut peers, _, coding_config| async move {
2879                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2880                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2881                let commitment = coded_block.commitment();
2882
2883                // Get a shard that belongs to neither the sender nor receiver.
2884                let unrelated_index = peers[3].index.get() as u16;
2885                let unrelated_shard = coded_block.shard(unrelated_index).expect("missing shard");
2886                let shard_bytes = unrelated_shard.encode();
2887
2888                let peer2_pk = peers[2].public_key.clone();
2889                let leader = peers[0].public_key.clone();
2890
2891                // Inform peer 2 that peer 0 is the leader.
2892                peers[2].mailbox.discovered(
2893                    commitment,
2894                    leader,
2895                    Round::new(Epoch::zero(), View::new(1)),
2896                );
2897
2898                // Peer 1 (not the leader) sends peer 2 a shard for peer 3. It
2899                // cannot be either sender-indexed gossip or an assigned shard.
2900                peers[1]
2901                    .sender
2902                    .send(Recipients::One(peer2_pk), shard_bytes, true);
2903                context.sleep(config.link.latency * 2).await;
2904
2905                // Peer 1 should be blocked by peer 2 for wrong shard index.
2906                assert_blocked(&oracle, &peers[2].public_key, &peers[1].public_key).await;
2907            },
2908        );
2909    }
2910
2911    #[test_traced]
2912    fn test_buffered_wrong_index_shard_blocked_on_leader_arrival() {
2913        // Test that when a non-leader's shard with the wrong index is buffered
2914        // (leader unknown) and then the leader arrives, the sender is blocked.
2915        let fixture = Fixture::<C>::default();
2916        fixture.start(
2917            |config, context, oracle, mut peers, _, coding_config| async move {
2918                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2919                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2920                let commitment = coded_block.commitment();
2921
2922                // Get a shard that belongs to neither the sender nor receiver.
2923                let unrelated_index = peers[3].index.get() as u16;
2924                let unrelated_shard = coded_block.shard(unrelated_index).expect("missing shard");
2925                let shard_bytes = unrelated_shard.encode();
2926
2927                let peer2_pk = peers[2].public_key.clone();
2928
2929                // Peer 1 sends peer 3's shard before the leader is known.
2930                peers[1]
2931                    .sender
2932                    .send(Recipients::One(peer2_pk), shard_bytes, true);
2933                context.sleep(config.link.latency * 2).await;
2934
2935                // Nobody should be blocked yet (shard is buffered, leader unknown).
2936                let blocked = oracle.blocked().await.unwrap();
2937                assert!(
2938                    blocked.is_empty(),
2939                    "no peers should be blocked while leader is unknown"
2940                );
2941
2942                // Now inform peer 2 that peer 0 is the leader.
2943                // This drains the impossible candidate: it belongs to neither
2944                // peer 1 nor peer 2.
2945                let leader = peers[0].public_key.clone();
2946                peers[2].mailbox.discovered(
2947                    commitment,
2948                    leader,
2949                    Round::new(Epoch::zero(), View::new(1)),
2950                );
2951                context.sleep(Duration::from_millis(10)).await;
2952
2953                assert_blocked(&oracle, &peers[2].public_key, &peers[1].public_key).await;
2954            },
2955        );
2956    }
2957
2958    #[test_traced]
2959    fn test_assigned_shard_from_non_leader_accepted() {
2960        let fixture = Fixture::<C>::default();
2961        fixture.start(
2962            |_config, context, oracle, mut peers, _, coding_config| async move {
2963                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
2964                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
2965                let commitment = coded_block.commitment();
2966
2967                // Get peer 2's assigned shard.
2968                let peer2_index = peers[2].index.get() as u16;
2969                let peer2_shard = coded_block.shard(peer2_index).expect("missing shard");
2970                let shard_bytes = peer2_shard.encode();
2971
2972                let peer2_pk = peers[2].public_key.clone();
2973                let leader = peers[0].public_key.clone();
2974
2975                // Subscribe before shards arrive so we can verify acceptance.
2976                let shard_sub = peers[2]
2977                    .mailbox
2978                    .subscribe_assigned_shard_verified(commitment);
2979
2980                // Discover the proposal under peer 0.
2981                peers[2].mailbox.discovered(
2982                    commitment,
2983                    leader,
2984                    Round::new(Epoch::zero(), View::new(1)),
2985                );
2986
2987                // The assigned shard is commitment-bound, so another participant
2988                // may deliver it without being treated as a conflicting proposer.
2989                peers[1]
2990                    .sender
2991                    .send(Recipients::One(peer2_pk), shard_bytes, true);
2992
2993                select! {
2994                    _ = shard_sub => {},
2995                    _ = context.sleep(Duration::from_secs(5)) => {
2996                        panic!("subscription did not complete after assigned shard");
2997                    },
2998                };
2999
3000                assert!(oracle.blocked().await.unwrap().is_empty());
3001            },
3002        );
3003    }
3004
3005    #[test_traced]
3006    fn test_non_participant_external_proposed_ignored() {
3007        let fixture = Fixture::<C>::default();
3008        fixture.start(
3009            |config, context, oracle, mut peers, _, coding_config| async move {
3010                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3011                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3012                let commitment = coded_block.commitment();
3013
3014                // Get the shard the leader would send to peer 2 (at peer 2's index).
3015                let peer2_index = peers[2].index.get() as u16;
3016                let peer2_shard = coded_block.shard(peer2_index).expect("missing shard");
3017                let shard_bytes = peer2_shard.encode();
3018
3019                let peer2_pk = peers[2].public_key.clone();
3020                let leader = peers[0].public_key.clone();
3021                let non_participant_leader = PrivateKey::from_seed(10_000).public_key();
3022
3023                // Subscribe before shards arrive.
3024                let shard_sub = peers[2]
3025                    .mailbox
3026                    .subscribe_assigned_shard_verified(commitment);
3027
3028                // A non-participant leader update should be ignored.
3029                peers[2].mailbox.discovered(
3030                    commitment,
3031                    non_participant_leader,
3032                    Round::new(Epoch::zero(), View::new(1)),
3033                );
3034
3035                // Leader unknown path: this shard should be buffered, not blocked.
3036                peers[0]
3037                    .sender
3038                    .send(Recipients::One(peer2_pk.clone()), shard_bytes.clone(), true);
3039                context.sleep(config.link.latency * 2).await;
3040
3041                let blocked = oracle.blocked().await.unwrap();
3042                let leader_blocked = blocked
3043                    .iter()
3044                    .any(|(a, b)| a == &peers[2].public_key && b == &leader);
3045                assert!(
3046                    !leader_blocked,
3047                    "leader should not be blocked when non-participant update is ignored"
3048                );
3049
3050                // A valid leader update should then process buffered shards and resolve subscription.
3051                peers[2].mailbox.discovered(
3052                    commitment,
3053                    leader,
3054                    Round::new(Epoch::zero(), View::new(1)),
3055                );
3056                context.sleep(config.link.latency * 2).await;
3057
3058                select! {
3059                    _ = shard_sub => {},
3060                    _ = context.sleep(Duration::from_secs(5)) => {
3061                        panic!("subscription did not complete after valid leader update");
3062                    },
3063                };
3064            },
3065        );
3066    }
3067
3068    #[test_traced]
3069    fn test_rejected_leader_does_not_refresh_retirement_round() {
3070        let fixture = Fixture::<C>::default();
3071        fixture.start(|_, context, _, mut peers, _, coding_config| async move {
3072            let block = CodedBlock::<B, C, H>::new(
3073                B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3074                coding_config,
3075                &STRATEGY,
3076            );
3077            let commitment = block.commitment();
3078            let unrelated = CodedBlock::<B, C, H>::new(
3079                B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3080                coding_config,
3081                &STRATEGY,
3082            )
3083            .commitment();
3084            let leader = peers[0].public_key.clone();
3085            let non_participant = PrivateKey::from_seed(10_000).public_key();
3086            let receiver = &mut peers[2];
3087
3088            let mut subscription = receiver
3089                .mailbox
3090                .subscribe_assigned_shard_verified(commitment);
3091            receiver.mailbox.discovered(
3092                commitment,
3093                leader,
3094                Round::new(Epoch::zero(), View::new(1)),
3095            );
3096            receiver.mailbox.discovered(
3097                commitment,
3098                non_participant,
3099                Round::new(Epoch::zero(), View::new(10)),
3100            );
3101            receiver.mailbox.retire(Retirement {
3102                round_floor: Round::new(Epoch::zero(), View::new(5)),
3103                exact_retirements: vec![unrelated],
3104            });
3105            context.sleep(Duration::from_millis(10)).await;
3106
3107            assert!(matches!(subscription.try_recv(), Err(TryRecvError::Closed)));
3108        });
3109    }
3110
3111    #[test_traced]
3112    fn test_cross_epoch_observations_do_not_refresh_retirement_round() {
3113        let fixture = Fixture::<C> {
3114            additional_scheme_epochs: vec![Epoch::new(1)],
3115            ..Default::default()
3116        };
3117        fixture.start(|_, context, _, mut peers, _, coding_config| async move {
3118            let cached = CodedBlock::<B, C, H>::new(
3119                B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3120                coding_config,
3121                &STRATEGY,
3122            );
3123            let incomplete = CodedBlock::<B, C, H>::new(
3124                B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3125                coding_config,
3126                &STRATEGY,
3127            );
3128            let incomplete_commitment = incomplete.commitment();
3129            let unrelated = CodedBlock::<B, C, H>::new(
3130                B::new(Sha256Digest::EMPTY, Height::new(3), 300),
3131                coding_config,
3132                &STRATEGY,
3133            )
3134            .commitment();
3135            let cached_commitment = cached.commitment();
3136            let leader = peers[0].public_key.clone();
3137            let receiver = &mut peers[2];
3138            let original_round = Round::new(Epoch::zero(), View::new(1));
3139
3140            receiver.mailbox.proposed(original_round, cached);
3141            receiver
3142                .mailbox
3143                .discovered(incomplete_commitment, leader, original_round);
3144            let mut subscription = receiver
3145                .mailbox
3146                .subscribe_assigned_shard_verified(incomplete_commitment);
3147
3148            let conflicting_round = Round::new(Epoch::new(1), View::new(10));
3149            receiver.mailbox.proposed(conflicting_round, incomplete);
3150            receiver
3151                .mailbox
3152                .notarized(cached_commitment, conflicting_round);
3153            receiver
3154                .mailbox
3155                .notarized(incomplete_commitment, conflicting_round);
3156            receiver.mailbox.retire(Retirement {
3157                round_floor: Round::new(Epoch::zero(), View::new(5)),
3158                exact_retirements: vec![unrelated],
3159            });
3160            context.sleep(Duration::from_millis(10)).await;
3161
3162            assert!(receiver.mailbox.get(cached_commitment).await.is_none());
3163            assert!(matches!(subscription.try_recv(), Err(TryRecvError::Closed)));
3164        });
3165    }
3166
3167    #[test_traced]
3168    fn test_shard_from_non_participant_blocks_peer() {
3169        let fixture = Fixture {
3170            num_future_peers: 1,
3171            ..Fixture::<C>::default()
3172        };
3173        fixture.start(
3174            |config, context, oracle, peers, _, coding_config| async move {
3175                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3176                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3177                let commitment = coded_block.commitment();
3178
3179                let leader = peers[0].public_key.clone();
3180                let receiver_pk = peers[2].public_key.clone();
3181
3182                let non_participant_key = PrivateKey::from_seed(10_000);
3183                let non_participant_pk = non_participant_key.public_key();
3184
3185                let non_participant_control = oracle.control(non_participant_pk.clone());
3186                let (mut non_participant_sender, _non_participant_receiver) =
3187                    non_participant_control
3188                        .register(0, TEST_QUOTA)
3189                        .await
3190                        .expect("registration should succeed");
3191                oracle
3192                    .add_link(
3193                        non_participant_pk.clone(),
3194                        receiver_pk.clone(),
3195                        DEFAULT_LINK,
3196                    )
3197                    .await
3198                    .expect("link should be added");
3199                oracle.manager().track(
3200                    2,
3201                    TrackedPeers::new(
3202                        Set::from_iter_dedup(peers.iter().map(|peer| peer.public_key.clone())),
3203                        Set::from_iter_dedup([non_participant_pk.clone()]),
3204                    ),
3205                );
3206                context.sleep(Duration::from_millis(10)).await;
3207
3208                peers[2].mailbox.discovered(
3209                    commitment,
3210                    leader,
3211                    Round::new(Epoch::zero(), View::new(1)),
3212                );
3213
3214                let peer2_index = peers[2].index.get() as u16;
3215                let shard = coded_block.shard(peer2_index).expect("missing shard");
3216                let shard_bytes = shard.encode();
3217
3218                non_participant_sender.send(Recipients::One(receiver_pk), shard_bytes, true);
3219                context.sleep(config.link.latency * 2).await;
3220
3221                assert_blocked(&oracle, &peers[2].public_key, &non_participant_pk).await;
3222            },
3223        );
3224    }
3225
3226    #[test_traced]
3227    fn test_preleader_shard_from_non_participant_is_not_buffered() {
3228        let fixture = Fixture {
3229            num_future_peers: 1,
3230            ..Fixture::<C>::default()
3231        };
3232        fixture.start(
3233            |config, context, oracle, peers, _, coding_config| async move {
3234                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3235                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3236                let commitment = coded_block.commitment();
3237
3238                let leader = peers[0].public_key.clone();
3239                let receiver_pk = peers[2].public_key.clone();
3240
3241                let non_participant_key = PrivateKey::from_seed(10_000);
3242                let non_participant_pk = non_participant_key.public_key();
3243
3244                let non_participant_control = oracle.control(non_participant_pk.clone());
3245                let (mut non_participant_sender, _non_participant_receiver) =
3246                    non_participant_control
3247                        .register(0, TEST_QUOTA)
3248                        .await
3249                        .expect("registration should succeed");
3250                oracle
3251                    .add_link(
3252                        non_participant_pk.clone(),
3253                        receiver_pk.clone(),
3254                        DEFAULT_LINK,
3255                    )
3256                    .await
3257                    .expect("link should be added");
3258                oracle.manager().track(
3259                    2,
3260                    TrackedPeers::new(
3261                        Set::from_iter_dedup(peers.iter().map(|peer| peer.public_key.clone())),
3262                        Set::from_iter_dedup([non_participant_pk.clone()]),
3263                    ),
3264                );
3265                context.sleep(Duration::from_millis(10)).await;
3266
3267                let peer2_index = peers[2].index.get() as u16;
3268                let shard = coded_block.shard(peer2_index).expect("missing shard");
3269                let shard_bytes = shard.encode();
3270                let mut shard_sub = peers[2]
3271                    .mailbox
3272                    .subscribe_assigned_shard_verified(commitment);
3273
3274                non_participant_sender.send(Recipients::One(receiver_pk), shard_bytes, true);
3275                context.sleep(config.link.latency * 2).await;
3276
3277                peers[2].mailbox.discovered(
3278                    commitment,
3279                    leader,
3280                    Round::new(Epoch::zero(), View::new(1)),
3281                );
3282                context.sleep(config.link.latency * 2).await;
3283
3284                let blocked = oracle.blocked().await.unwrap();
3285                let non_participant_blocked = blocked
3286                    .iter()
3287                    .any(|(a, b)| a == &peers[2].public_key && b == &non_participant_pk);
3288                assert!(
3289                    !non_participant_blocked,
3290                    "non-participant should not be blocked when its pre-leader shard is ignored"
3291                );
3292                assert!(
3293                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
3294                    "pre-leader shard from non-participant should not be buffered"
3295                );
3296            },
3297        );
3298    }
3299
3300    #[test_traced]
3301    fn test_duplicate_shard_ignored() {
3302        // Use 10 peers so minimum_shards=4, giving us time to send duplicate before reconstruction.
3303        let fixture: Fixture<C> = Fixture {
3304            num_primary_peers: 10,
3305            ..Default::default()
3306        };
3307
3308        fixture.start(
3309            |config, context, oracle, mut peers, _, coding_config| async move {
3310                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3311                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3312
3313                // Get peer 2's shard (from the leader).
3314                let peer2_index = peers[2].index.get() as u16;
3315                let peer2_shard = coded_block.shard(peer2_index).expect("missing shard");
3316
3317                // Get peer 1's shard.
3318                let peer1_index = peers[1].index.get() as u16;
3319                let peer1_shard = coded_block.shard(peer1_index).expect("missing shard");
3320
3321                let peer2_pk = peers[2].public_key.clone();
3322                let leader = peers[0].public_key.clone();
3323
3324                // Inform peer 2 of the leader.
3325                peers[2].mailbox.discovered(
3326                    coded_block.commitment(),
3327                    leader,
3328                    Round::new(Epoch::zero(), View::new(1)),
3329                );
3330
3331                // Send peer 2 their shard from the leader (1 checked shard).
3332                let leader_shard_bytes = peer2_shard.encode();
3333                peers[0]
3334                    .sender
3335                    .send(Recipients::One(peer2_pk.clone()), leader_shard_bytes, true);
3336                context.sleep(config.link.latency * 2).await;
3337
3338                // Send peer 1's shard to peer 2 (first time - should succeed, 2 checked shards).
3339                let peer1_shard_bytes = peer1_shard.encode();
3340                peers[1].sender.send(
3341                    Recipients::One(peer2_pk.clone()),
3342                    peer1_shard_bytes.clone(),
3343                    true,
3344                );
3345                context.sleep(config.link.latency * 2).await;
3346
3347                // Send the same shard again (exact duplicate - should be ignored, not blocked).
3348                // With 10 peers, minimum_shards=4, so we haven't reconstructed yet.
3349                peers[1]
3350                    .sender
3351                    .send(Recipients::One(peer2_pk), peer1_shard_bytes, true);
3352                context.sleep(config.link.latency * 2).await;
3353
3354                // Peer 1 should NOT be blocked for sending an identical duplicate.
3355                let blocked_peers = oracle.blocked().await.unwrap();
3356                let is_blocked = blocked_peers
3357                    .iter()
3358                    .any(|(a, b)| a == &peers[2].public_key && b == &peers[1].public_key);
3359                assert!(
3360                    !is_blocked,
3361                    "peer should not be blocked for exact duplicate shard"
3362                );
3363            },
3364        );
3365    }
3366
3367    #[test_traced]
3368    fn test_equivocating_shard_blocks_peer() {
3369        // Use 10 peers so minimum_shards=4, giving us time to send equivocating shard.
3370        let fixture: Fixture<C> = Fixture {
3371            num_primary_peers: 10,
3372            ..Default::default()
3373        };
3374
3375        fixture.start(
3376            |config, context, oracle, mut peers, _, coding_config| async move {
3377                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3378                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
3379
3380                // Create a second block with different payload to get different shard data.
3381                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(1), 200);
3382                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
3383
3384                // Get peer 1's shard from block 1.
3385                let peer1_index = peers[1].index.get() as u16;
3386                let peer1_shard = coded_block1.shard(peer1_index).expect("missing shard");
3387
3388                // Get peer 1's shard from block 2 (different data, same index).
3389                let mut peer1_equivocating_shard =
3390                    coded_block2.shard(peer1_index).expect("missing shard");
3391                // Override the commitment to match block 1 so the shard targets
3392                // the same reconstruction state.
3393                peer1_equivocating_shard.commitment = coded_block1.commitment();
3394
3395                let peer2_pk = peers[2].public_key.clone();
3396                let leader = peers[0].public_key.clone();
3397
3398                // Inform peer 2 of the leader.
3399                peers[2].mailbox.discovered(
3400                    coded_block1.commitment(),
3401                    leader,
3402                    Round::new(Epoch::zero(), View::new(1)),
3403                );
3404
3405                // Send peer 2 the leader's shard (verified immediately).
3406                let peer2_index = peers[2].index.get() as u16;
3407                let leader_shard = coded_block1.shard(peer2_index).expect("missing shard");
3408                let leader_shard_bytes = leader_shard.encode();
3409                peers[0]
3410                    .sender
3411                    .send(Recipients::One(peer2_pk.clone()), leader_shard_bytes, true);
3412                context.sleep(config.link.latency * 2).await;
3413
3414                // Send peer 1's valid shard to peer 2 (first time - succeeds).
3415                let shard_bytes = peer1_shard.encode();
3416                peers[1]
3417                    .sender
3418                    .send(Recipients::One(peer2_pk.clone()), shard_bytes, true);
3419                context.sleep(config.link.latency * 2).await;
3420
3421                // Send a different shard from peer 1 (equivocation - should block).
3422                let equivocating_bytes = peer1_equivocating_shard.encode();
3423                peers[1]
3424                    .sender
3425                    .send(Recipients::One(peer2_pk), equivocating_bytes, true);
3426                context.sleep(config.link.latency * 2).await;
3427
3428                // Peer 2 should have blocked peer 1 for equivocation.
3429                assert_blocked(&oracle, &peers[2].public_key, &peers[1].public_key).await;
3430            },
3431        );
3432    }
3433
3434    #[test_traced]
3435    fn test_reconstruction_states_pruned_at_or_below_reconstructed_view() {
3436        // Use 10 peers so minimum_shards=4.
3437        let fixture: Fixture<C> = Fixture {
3438            num_primary_peers: 10,
3439            ..Default::default()
3440        };
3441
3442        fixture.start(
3443            |config, context, oracle, mut peers, _, coding_config| async move {
3444                // Commitment A at lower view (1).
3445                let block_a = CodedBlock::<B, C, H>::new(
3446                    B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3447                    coding_config,
3448                    &STRATEGY,
3449                );
3450                let commitment_a = block_a.commitment();
3451
3452                // Commitment B at higher view (2), which we will reconstruct.
3453                let block_b = CodedBlock::<B, C, H>::new(
3454                    B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3455                    coding_config,
3456                    &STRATEGY,
3457                );
3458                let commitment_b = block_b.commitment();
3459
3460                let peer2_pk = peers[2].public_key.clone();
3461                let leader = peers[0].public_key.clone();
3462
3463                // Create state for A and ingest one shard from peer1.
3464                peers[2].mailbox.discovered(
3465                    commitment_a,
3466                    leader.clone(),
3467                    Round::new(Epoch::zero(), View::new(1)),
3468                );
3469                let shard_a = block_a
3470                    .shard(peers[1].index.get() as u16)
3471                    .expect("missing shard")
3472                    .encode();
3473                peers[1]
3474                    .sender
3475                    .send(Recipients::One(peer2_pk.clone()), shard_a.clone(), true);
3476                context.sleep(config.link.latency * 2).await;
3477
3478                // Create/reconstruct B at higher view.
3479                peers[2].mailbox.discovered(
3480                    commitment_b,
3481                    leader,
3482                    Round::new(Epoch::zero(), View::new(2)),
3483                );
3484                // Leader's shard for peer2.
3485                let leader_shard_b = block_b
3486                    .shard(peers[2].index.get() as u16)
3487                    .expect("missing shard")
3488                    .encode();
3489                peers[0]
3490                    .sender
3491                    .send(Recipients::One(peer2_pk.clone()), leader_shard_b, true);
3492
3493                // Three shards for minimum threshold (4 total with leader's).
3494                for i in [1usize, 3usize, 4usize] {
3495                    let shard = block_b
3496                        .shard(peers[i].index.get() as u16)
3497                        .expect("missing shard")
3498                        .encode();
3499                    peers[i]
3500                        .sender
3501                        .send(Recipients::One(peer2_pk.clone()), shard, true);
3502                }
3503                context.sleep(config.link.latency * 4).await;
3504
3505                // B should reconstruct.
3506                let reconstructed = peers[2]
3507                    .mailbox
3508                    .get(commitment_b)
3509                    .await
3510                    .expect("block B should reconstruct");
3511                assert_eq!(reconstructed.commitment(), commitment_b);
3512
3513                // A state should be pruned (at/below reconstructed view). Sending the same
3514                // shard for A again should NOT be treated as duplicate.
3515                peers[1]
3516                    .sender
3517                    .send(Recipients::One(peer2_pk), shard_a, true);
3518                context.sleep(config.link.latency * 2).await;
3519
3520                let blocked = oracle.blocked().await.unwrap();
3521                let blocked_peer1 = blocked
3522                    .iter()
3523                    .any(|(a, b)| a == &peers[2].public_key && b == &peers[1].public_key);
3524                assert!(
3525                    !blocked_peer1,
3526                    "peer1 should not be blocked after lower-view state was pruned"
3527                );
3528            },
3529        );
3530    }
3531
3532    #[test_traced]
3533    fn test_later_notarization_refreshes_reconstruction_state_round() {
3534        let fixture: Fixture<C> = Fixture {
3535            num_primary_peers: 10,
3536            ..Default::default()
3537        };
3538
3539        fixture.start(
3540            |config, context, _, mut peers, _, coding_config| async move {
3541                let live = CodedBlock::<B, C, H>::new(
3542                    B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3543                    coding_config,
3544                    &STRATEGY,
3545                );
3546                let finalized = CodedBlock::<B, C, H>::new(
3547                    B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3548                    coding_config,
3549                    &STRATEGY,
3550                );
3551                let live_commitment = live.commitment();
3552                let finalized_commitment = finalized.commitment();
3553                let receiver_idx = 3usize;
3554                let receiver_pk = peers[receiver_idx].public_key.clone();
3555                let leader = peers[0].public_key.clone();
3556
3557                peers[receiver_idx].mailbox.discovered(
3558                    live_commitment,
3559                    leader,
3560                    Round::new(Epoch::zero(), View::new(1)),
3561                );
3562                let mut live_sub = peers[receiver_idx].mailbox.subscribe(live_commitment);
3563
3564                // The same commitment becomes live in a later round while the application
3565                // still has an older finalization to acknowledge.
3566                peers[receiver_idx]
3567                    .mailbox
3568                    .notarized(live_commitment, Round::new(Epoch::zero(), View::new(4)));
3569                context.sleep(Duration::from_millis(10)).await;
3570                peers[receiver_idx].mailbox.retire(Retirement {
3571                    round_floor: Round::new(Epoch::zero(), View::new(3)),
3572                    exact_retirements: vec![finalized_commitment],
3573                });
3574                context.sleep(Duration::from_millis(10)).await;
3575
3576                assert!(
3577                    matches!(live_sub.try_recv(), Err(TryRecvError::Empty)),
3578                    "later-round reconstruction subscription should remain open"
3579                );
3580
3581                let leader_shard = live
3582                    .shard(peers[receiver_idx].index.get() as u16)
3583                    .expect("missing leader shard");
3584                peers[0].sender.send(
3585                    Recipients::One(receiver_pk.clone()),
3586                    leader_shard.encode(),
3587                    true,
3588                );
3589                for i in [1usize, 2usize, 4usize] {
3590                    let shard = live
3591                        .shard(peers[i].index.get() as u16)
3592                        .expect("missing gossip shard");
3593                    peers[i].sender.send(
3594                        Recipients::One(receiver_pk.clone()),
3595                        shard.encode(),
3596                        true,
3597                    );
3598                }
3599
3600                select! {
3601                    result = live_sub => {
3602                        let reconstructed =
3603                            result.expect("later-round reconstruction should remain live");
3604                        assert_eq!(reconstructed.commitment(), live_commitment);
3605                    },
3606                    _ = context.sleep(config.link.latency * 10) => {
3607                        panic!("later-round reconstruction did not complete");
3608                    },
3609                }
3610            },
3611        );
3612    }
3613
3614    #[test_traced]
3615    fn test_cached_observations_refresh_reconstruction_state_round() {
3616        let fixture: Fixture<C> = Fixture {
3617            num_primary_peers: 10,
3618            ..Default::default()
3619        };
3620
3621        fixture.start(
3622            |config, context, _, mut peers, _, coding_config| async move {
3623                let live = CodedBlock::<B, C, H>::new(
3624                    B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3625                    coding_config,
3626                    &STRATEGY,
3627                );
3628                let finalized = CodedBlock::<B, C, H>::new(
3629                    B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3630                    coding_config,
3631                    &STRATEGY,
3632                );
3633                let live_commitment = live.commitment();
3634                let finalized_commitment = finalized.commitment();
3635                let leader = peers[0].public_key.clone();
3636                let receivers = [3usize, 6usize];
3637                let receiver_keys = [
3638                    peers[receivers[0]].public_key.clone(),
3639                    peers[receivers[1]].public_key.clone(),
3640                ];
3641                let original_round = Round::new(Epoch::zero(), View::new(1));
3642
3643                for &receiver_idx in &receivers {
3644                    peers[receiver_idx].mailbox.discovered(
3645                        live_commitment,
3646                        leader.clone(),
3647                        original_round,
3648                    );
3649                }
3650
3651                // Reconstruct from gossip while leaving assigned-shard verification pending.
3652                for sender_idx in [1usize, 2usize, 4usize, 5usize] {
3653                    let shard = live
3654                        .shard(peers[sender_idx].index.get() as u16)
3655                        .expect("missing gossip shard")
3656                        .encode();
3657                    for receiver in &receiver_keys {
3658                        peers[sender_idx].sender.send(
3659                            Recipients::One(receiver.clone()),
3660                            shard.clone(),
3661                            true,
3662                        );
3663                    }
3664                }
3665                context.sleep(config.link.latency * 4).await;
3666
3667                for &receiver_idx in &receivers {
3668                    assert!(
3669                        peers[receiver_idx]
3670                            .mailbox
3671                            .get(live_commitment)
3672                            .await
3673                            .is_some(),
3674                        "block should be cached before its round is refreshed"
3675                    );
3676                }
3677
3678                let mut notarized_sub = peers[receivers[0]]
3679                    .mailbox
3680                    .subscribe_assigned_shard_verified(live_commitment);
3681                let mut discovered_sub = peers[receivers[1]]
3682                    .mailbox
3683                    .subscribe_assigned_shard_verified(live_commitment);
3684                let later_round = Round::new(Epoch::zero(), View::new(4));
3685                peers[receivers[0]]
3686                    .mailbox
3687                    .notarized(live_commitment, later_round);
3688                peers[receivers[1]].mailbox.discovered(
3689                    live_commitment,
3690                    leader,
3691                    later_round,
3692                );
3693                context.sleep(Duration::from_millis(10)).await;
3694
3695                let prune_round = Round::new(Epoch::zero(), View::new(3));
3696                for &receiver_idx in &receivers {
3697                    peers[receiver_idx].mailbox.retire(Retirement {
3698                        round_floor: prune_round,
3699                        exact_retirements: vec![finalized_commitment],
3700                    });
3701                }
3702                context.sleep(Duration::from_millis(10)).await;
3703
3704                assert!(
3705                    matches!(notarized_sub.try_recv(), Err(TryRecvError::Empty)),
3706                    "cached notarization should keep reconstruction state live"
3707                );
3708                assert!(
3709                    matches!(discovered_sub.try_recv(), Err(TryRecvError::Empty)),
3710                    "cached discovery should keep reconstruction state live"
3711                );
3712                for &receiver_idx in &receivers {
3713                    assert!(
3714                        peers[receiver_idx]
3715                            .mailbox
3716                            .get(live_commitment)
3717                            .await
3718                            .is_some(),
3719                        "cached observation should keep the reconstructed block live"
3720                    );
3721                }
3722
3723                for (&receiver_idx, receiver) in receivers.iter().zip(&receiver_keys) {
3724                    let leader_shard = live
3725                        .shard(peers[receiver_idx].index.get() as u16)
3726                        .expect("missing leader shard");
3727                    peers[0].sender.send(
3728                        Recipients::One(receiver.clone()),
3729                        leader_shard.encode(),
3730                        true,
3731                    );
3732                }
3733
3734                select! {
3735                    result = notarized_sub => {
3736                        result.expect("notarized reconstruction state should accept the leader shard");
3737                    },
3738                    _ = context.sleep(config.link.latency * 10) => {
3739                        panic!("notarized reconstruction state did not accept the leader shard");
3740                    },
3741                }
3742                select! {
3743                    result = discovered_sub => {
3744                        result.expect("discovered reconstruction state should accept the leader shard");
3745                    },
3746                    _ = context.sleep(config.link.latency * 10) => {
3747                        panic!("discovered reconstruction state did not accept the leader shard");
3748                    },
3749                }
3750            },
3751        );
3752    }
3753
3754    #[test_traced]
3755    fn test_local_proposal_prune_clears_older_reconstruction_state() {
3756        let fixture: Fixture<C> = Fixture {
3757            num_primary_peers: 10,
3758            ..Default::default()
3759        };
3760
3761        fixture.start(
3762            |config, context, oracle, mut peers, _, coding_config| async move {
3763                let block_a = CodedBlock::<B, C, H>::new(
3764                    B::new(Sha256Digest::EMPTY, Height::new(1), 100),
3765                    coding_config,
3766                    &STRATEGY,
3767                );
3768                let commitment_a = block_a.commitment();
3769
3770                let block_b = CodedBlock::<B, C, H>::new(
3771                    B::new(Sha256Digest::EMPTY, Height::new(2), 200),
3772                    coding_config,
3773                    &STRATEGY,
3774                );
3775                let commitment_b = block_b.commitment();
3776
3777                let peer2_pk = peers[2].public_key.clone();
3778                let leader = peers[0].public_key.clone();
3779                let round_a = Round::new(Epoch::zero(), View::new(1));
3780                let round_b = Round::new(Epoch::zero(), View::new(2));
3781
3782                peers[2].mailbox.discovered(commitment_a, leader, round_a);
3783
3784                let peer1_index = peers[1].index.get() as u16;
3785                let shard_a = block_a.shard(peer1_index).expect("missing shard");
3786
3787                let block_a_equivocating = CodedBlock::<B, C, H>::new(
3788                    B::new(Sha256Digest::EMPTY, Height::new(1), 300),
3789                    coding_config,
3790                    &STRATEGY,
3791                );
3792                let mut equivocating_shard = block_a_equivocating
3793                    .shard(peer1_index)
3794                    .expect("missing shard");
3795                equivocating_shard.commitment = commitment_a;
3796
3797                peers[1]
3798                    .sender
3799                    .send(Recipients::One(peer2_pk.clone()), shard_a.encode(), true);
3800                context.sleep(config.link.latency * 2).await;
3801
3802                peers[2].mailbox.proposed(round_b, block_b);
3803                assert!(
3804                    peers[2].mailbox.get(commitment_b).await.is_some(),
3805                    "local proposal should be cached before pruning"
3806                );
3807                peers[2].mailbox.retire(Retirement {
3808                    round_floor: round_b,
3809                    exact_retirements: vec![commitment_b],
3810                });
3811
3812                peers[1]
3813                    .sender
3814                    .send(Recipients::One(peer2_pk), equivocating_shard.encode(), true);
3815                context.sleep(config.link.latency * 2).await;
3816
3817                let blocked = oracle.blocked().await.unwrap();
3818                let blocked_peer1 = blocked
3819                    .iter()
3820                    .any(|(a, b)| a == &peers[2].public_key && b == &peers[1].public_key);
3821                assert!(
3822                    !blocked_peer1,
3823                    "peer1 should not be blocked after older state was pruned"
3824                );
3825            },
3826        );
3827    }
3828
3829    #[test_traced]
3830    fn test_pending_shards_batch_validated_at_quorum() {
3831        // Test that shards buffered in pending_shards are batch-validated once
3832        // the minimum shard threshold is met, enabling reconstruction.
3833        //
3834        // With 10 peers: minimum_shards = (10-1)/3 + 1 = 4
3835        // The leader (peer 0) sends peer 3 their own-index shard (verified
3836        // immediately). Peers 1, 2, 4 send their own shards (buffered in
3837        // pending_shards). Once the leader's shard + 3 pending shards >= 4,
3838        // batch validation fires and reconstruction succeeds.
3839        let fixture: Fixture<C> = Fixture {
3840            num_primary_peers: 10,
3841            ..Default::default()
3842        };
3843
3844        fixture.start(
3845            |config, context, oracle, mut peers, _, coding_config| async move {
3846                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3847                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3848                let commitment = coded_block.commitment();
3849
3850                let peer3_pk = peers[3].public_key.clone();
3851                let leader = peers[0].public_key.clone();
3852
3853                // Inform peer 3 that peer 0 is the leader.
3854                peers[3].mailbox.discovered(
3855                    commitment,
3856                    leader,
3857                    Round::new(Epoch::zero(), View::new(1)),
3858                );
3859
3860                // Send shards from peers 1, 2, 4 (their own indices).
3861                // These are buffered in pending_shards for batch validation.
3862                for &sender_idx in &[1, 2, 4] {
3863                    let shard = coded_block
3864                        .shard(peers[sender_idx].index.get() as u16)
3865                        .expect("missing shard");
3866                    let shard_bytes = shard.encode();
3867                    peers[sender_idx].sender.send(
3868                        Recipients::One(peer3_pk.clone()),
3869                        shard_bytes,
3870                        true,
3871                    );
3872                }
3873
3874                context.sleep(config.link.latency * 2).await;
3875
3876                // Block should not be reconstructed yet (no leader shard verified).
3877                let block = peers[3].mailbox.get(commitment).await;
3878                assert!(block.is_none(), "block should not be reconstructed yet");
3879
3880                // Now the leader (peer 0) sends peer 3's own-index shard.
3881                // This is verified immediately, and with the 3 pending shards
3882                // we reach minimum_shards=4 -> batch validation + reconstruction.
3883                let peer3_index = peers[3].index.get() as u16;
3884                let leader_shard = coded_block.shard(peer3_index).expect("missing shard");
3885                let leader_shard_bytes = leader_shard.encode();
3886                peers[0]
3887                    .sender
3888                    .send(Recipients::One(peer3_pk), leader_shard_bytes, true);
3889
3890                context.sleep(config.link.latency * 2).await;
3891
3892                // No peers should be blocked (all shards were valid).
3893                let blocked = oracle.blocked().await.unwrap();
3894                assert!(
3895                    blocked.is_empty(),
3896                    "no peers should be blocked for valid pending shards"
3897                );
3898
3899                // Block should now be reconstructed (4 checked shards >= minimum_shards).
3900                let block = peers[3].mailbox.get(commitment).await;
3901                assert!(
3902                    block.is_some(),
3903                    "block should be reconstructed after batch validation"
3904                );
3905
3906                // Verify the reconstructed block has the correct commitment.
3907                let reconstructed = block.unwrap();
3908                assert_eq!(
3909                    reconstructed.commitment(),
3910                    commitment,
3911                    "reconstructed block should have correct commitment"
3912                );
3913            },
3914        );
3915    }
3916
3917    #[test_traced]
3918    fn test_peer_shards_buffered_until_external_proposed() {
3919        // Test that shards received before leader announcement do not progress
3920        // reconstruction until Discovered is delivered.
3921        let fixture: Fixture<C> = Fixture {
3922            num_primary_peers: 10,
3923            ..Default::default()
3924        };
3925
3926        fixture.start(
3927            |config, context, oracle, mut peers, _, coding_config| async move {
3928                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
3929                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
3930                let commitment = coded_block.commitment();
3931
3932                let receiver_idx = 3usize;
3933                let receiver_pk = peers[receiver_idx].public_key.clone();
3934                let leader = peers[0].public_key.clone();
3935
3936                // Subscribe before any shards arrive.
3937                let mut shard_sub = peers[receiver_idx]
3938                    .mailbox
3939                    .subscribe_assigned_shard_verified(commitment);
3940
3941                // Send the leader's shard (for receiver's index) and three shards,
3942                // all before leader announcement.
3943                let leader_shard = coded_block
3944                    .shard(peers[receiver_idx].index.get() as u16)
3945                    .expect("missing shard")
3946                    .encode();
3947                peers[0]
3948                    .sender
3949                    .send(Recipients::One(receiver_pk.clone()), leader_shard, true);
3950
3951                for i in [1usize, 2usize, 4usize] {
3952                    let shard = coded_block
3953                        .shard(peers[i].index.get() as u16)
3954                        .expect("missing shard")
3955                        .encode();
3956                    peers[i]
3957                        .sender
3958                        .send(Recipients::One(receiver_pk.clone()), shard, true);
3959                }
3960
3961                context.sleep(config.link.latency * 2).await;
3962
3963                // No leader yet: shard subscription should still be pending and block unavailable.
3964                assert!(
3965                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
3966                    "shard subscription should not resolve before leader announcement"
3967                );
3968                assert!(
3969                    peers[receiver_idx].mailbox.get(commitment).await.is_none(),
3970                    "block should not reconstruct before leader announcement"
3971                );
3972
3973                // Announce leader, which drains buffered shards and should progress immediately.
3974                peers[receiver_idx].mailbox.discovered(
3975                    commitment,
3976                    leader,
3977                    Round::new(Epoch::zero(), View::new(1)),
3978                );
3979
3980                select! {
3981                    _ = shard_sub => {},
3982                    _ = context.sleep(Duration::from_secs(5)) => {
3983                        panic!("shard subscription did not resolve after leader announcement");
3984                    },
3985                }
3986
3987                context.sleep(config.link.latency * 2).await;
3988                assert!(
3989                    peers[receiver_idx].mailbox.get(commitment).await.is_some(),
3990                    "block should reconstruct after buffered shards are ingested"
3991                );
3992
3993                // All shards were valid and from participants.
3994                assert!(
3995                    oracle.blocked().await.unwrap().is_empty(),
3996                    "no peers should be blocked for valid buffered shards"
3997                );
3998            },
3999        );
4000    }
4001
4002    #[test_traced]
4003    fn test_notarized_commitment_reconstructs_from_buffered_peer_shards_without_leader() {
4004        let fixture: Fixture<C> = Fixture {
4005            num_primary_peers: 10,
4006            ..Default::default()
4007        };
4008
4009        fixture.start(
4010            |config, context, oracle, mut peers, _, coding_config| async move {
4011                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4012                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4013                let commitment = coded_block.commitment();
4014                let round = Round::new(Epoch::zero(), View::new(1));
4015
4016                let receiver_idx = 3usize;
4017                let receiver = peers[receiver_idx].public_key.clone();
4018
4019                let block_sub = peers[receiver_idx].mailbox.subscribe(commitment);
4020
4021                // Four sender-indexed shards are enough to reconstruct without
4022                // classifying any sender as the leader.
4023                for sender_idx in [1usize, 2, 4, 5] {
4024                    let shard = coded_block
4025                        .shard(peers[sender_idx].index.get() as u16)
4026                        .expect("missing shard")
4027                        .encode();
4028                    peers[sender_idx].sender.send(
4029                        Recipients::One(receiver.clone()),
4030                        shard,
4031                        true,
4032                    );
4033                }
4034                context.sleep(config.link.latency * 2).await;
4035
4036                assert!(
4037                    peers[receiver_idx].mailbox.get(commitment).await.is_none(),
4038                    "block should not reconstruct before the commitment is notarized"
4039                );
4040
4041                peers[receiver_idx].mailbox.notarized(commitment, round);
4042
4043                select! {
4044                    _ = block_sub => {},
4045                    _ = context.sleep(Duration::from_secs(5)) => {
4046                        panic!("block subscription did not resolve after notarized reconstruction interest");
4047                    },
4048                }
4049
4050                let reconstructed = peers[receiver_idx]
4051                    .mailbox
4052                    .get(commitment)
4053                    .await
4054                    .expect("block should reconstruct from buffered peer shards");
4055                assert_eq!(reconstructed.commitment(), commitment);
4056
4057                let mut assigned = peers[receiver_idx]
4058                    .mailbox
4059                    .subscribe_assigned_shard_verified(commitment);
4060                assert!(
4061                    matches!(assigned.try_recv(), Err(TryRecvError::Empty)),
4062                    "leaderless reconstruction must not satisfy assigned shard readiness"
4063                );
4064
4065                let leader = peers[0].public_key.clone();
4066                peers[receiver_idx]
4067                    .mailbox
4068                    .discovered(commitment, leader, round);
4069                let leader_shard = coded_block
4070                    .shard(peers[receiver_idx].index.get() as u16)
4071                    .expect("missing leader shard")
4072                    .encode();
4073                peers[0].sender.send(
4074                    Recipients::One(receiver),
4075                    leader_shard,
4076                    true,
4077                );
4078
4079                select! {
4080                    _ = assigned => {},
4081                    _ = context.sleep(Duration::from_secs(5)) => {
4082                        panic!("assigned shard subscription did not resolve after leader discovery");
4083                    },
4084                }
4085
4086                assert!(
4087                    oracle.blocked().await.unwrap().is_empty(),
4088                    "valid sender-indexed shards should not block peers"
4089                );
4090            },
4091        );
4092    }
4093
4094    #[test_traced]
4095    fn test_late_subscription_uses_notarized_cache_after_peer_buffer_pressure() {
4096        let fixture: Fixture<C> = Fixture {
4097            num_primary_peers: 10,
4098            peer_buffer_size: NZUsize!(1),
4099            ..Default::default()
4100        };
4101
4102        fixture.start(
4103            |config, context, oracle, mut peers, _, coding_config| async move {
4104                let target = CodedBlock::<B, C, H>::new(
4105                    B::new(Sha256Digest::EMPTY, Height::new(1), 1),
4106                    coding_config,
4107                    &STRATEGY,
4108                );
4109                let target_commitment = target.commitment();
4110                let receiver_idx = 3usize;
4111                let receiver = peers[receiver_idx].public_key.clone();
4112                let initial_round = Round::new(Epoch::zero(), View::new(1));
4113                let refreshed_round = Round::new(Epoch::zero(), View::new(4));
4114
4115                for sender_idx in [1usize, 2, 4, 5] {
4116                    let shard = target
4117                        .shard(peers[sender_idx].index.get() as u16)
4118                        .expect("missing target shard")
4119                        .encode();
4120                    peers[sender_idx].sender.send(
4121                        Recipients::One(receiver.clone()),
4122                        shard,
4123                        true,
4124                    );
4125                }
4126                context.sleep(config.link.latency * 2).await;
4127
4128                peers[receiver_idx]
4129                    .mailbox
4130                    .notarized(target_commitment, initial_round);
4131                let target_sub = peers[receiver_idx].mailbox.subscribe(target_commitment);
4132                select! {
4133                    result = target_sub => {
4134                        let block = result.expect("notarized target should reconstruct");
4135                        assert_eq!(block.commitment(), target_commitment);
4136                    },
4137                    _ = context.sleep(Duration::from_secs(5)) => {
4138                        panic!("notarized target did not reconstruct");
4139                    },
4140                }
4141
4142                // A later certification observation refreshes the cached record's retention
4143                // round. The record owns the block independently of bounded pre-leader shard
4144                // buffers.
4145                peers[receiver_idx]
4146                    .mailbox
4147                    .notarized(target_commitment, refreshed_round);
4148                assert!(
4149                    peers[receiver_idx]
4150                        .mailbox
4151                        .get(target_commitment)
4152                        .await
4153                        .is_some(),
4154                    "target should be cached after its notarization is refreshed"
4155                );
4156
4157                // The fixture retains one pre-leader shard per peer. One authenticated peer
4158                // sends two distinct codec-valid shards to exercise the same eviction boundary.
4159                let pressure_blocks = [2u64, 3].map(|id| {
4160                    CodedBlock::<B, C, H>::new(
4161                        B::new(Sha256Digest::EMPTY, Height::new(id), id),
4162                        coding_config,
4163                        &STRATEGY,
4164                    )
4165                });
4166                for block in &pressure_blocks {
4167                    let shard = block
4168                        .shard(peers[1].index.get() as u16)
4169                        .expect("missing pressure shard")
4170                        .encode();
4171                    peers[1].sender.send(
4172                        Recipients::One(receiver.clone()),
4173                        shard,
4174                        true,
4175                    );
4176                }
4177                context.sleep(config.link.latency * 2).await;
4178
4179                let [evicted_block, retained_block] = &pressure_blocks;
4180                for (block, should_reconstruct) in
4181                    [(evicted_block, false), (retained_block, true)]
4182                {
4183                    for sender_idx in [2usize, 4, 5] {
4184                        let shard = block
4185                            .shard(peers[sender_idx].index.get() as u16)
4186                            .expect("missing complementary pressure shard")
4187                            .encode();
4188                        peers[sender_idx].sender.send(
4189                            Recipients::One(receiver.clone()),
4190                            shard,
4191                            true,
4192                        );
4193                    }
4194                    context.sleep(config.link.latency * 2).await;
4195
4196                    let commitment = block.commitment();
4197                    peers[receiver_idx]
4198                        .mailbox
4199                        .notarized(commitment, initial_round);
4200                    if should_reconstruct {
4201                        let block_sub = peers[receiver_idx].mailbox.subscribe(commitment);
4202                        select! {
4203                            result = block_sub => {
4204                                let block = result.expect("retained pressure block should reconstruct");
4205                                assert_eq!(block.commitment(), commitment);
4206                            },
4207                            _ = context.sleep(Duration::from_secs(5)) => {
4208                                panic!("retained same-peer shard did not reconstruct");
4209                            },
4210                        }
4211                    } else {
4212                        assert!(
4213                            peers[receiver_idx]
4214                                .mailbox
4215                                .get(commitment)
4216                                .await
4217                                .is_none(),
4218                            "evicted same-peer shard should not reconstruct"
4219                        );
4220                    }
4221                }
4222
4223                peers[receiver_idx].mailbox.retire(Retirement {
4224                    round_floor: Round::new(Epoch::zero(), View::new(3)),
4225                    exact_retirements: Vec::new(),
4226                });
4227
4228                // This is the subscription used by Coding's Marshal buffer. It is installed
4229                // only after peer pressure and retirement, with no resolver in this fixture.
4230                let late_sub = peers[receiver_idx].mailbox.subscribe(target_commitment);
4231                select! {
4232                    result = late_sub => {
4233                        let block = result.expect("refreshed target should remain cached");
4234                        assert_eq!(block.commitment(), target_commitment);
4235                    },
4236                    _ = context.sleep(Duration::from_secs(5)) => {
4237                        panic!("late target subscription lost cached ownership");
4238                    },
4239                }
4240
4241                assert!(
4242                    oracle.blocked().await.unwrap().is_empty(),
4243                    "valid pressure shards should not block peers"
4244                );
4245            },
4246        );
4247    }
4248
4249    #[test_traced]
4250    fn test_leader_shard_after_notarized_is_buffered_until_discovered() {
4251        let fixture: Fixture<C> = Fixture {
4252            num_primary_peers: 10,
4253            ..Default::default()
4254        };
4255
4256        fixture.start(
4257            |config, context, oracle, mut peers, _, coding_config| async move {
4258                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4259                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4260                let commitment = coded_block.commitment();
4261                let round = Round::new(Epoch::zero(), View::new(1));
4262
4263                let leader_idx = 0usize;
4264                let receiver_idx = 3usize;
4265                let leader = peers[leader_idx].public_key.clone();
4266                let receiver = peers[receiver_idx].public_key.clone();
4267
4268                peers[receiver_idx].mailbox.notarized(commitment, round);
4269                let assigned = peers[receiver_idx]
4270                    .mailbox
4271                    .subscribe_assigned_shard_verified(commitment);
4272
4273                let leader_shard = coded_block
4274                    .shard(peers[receiver_idx].index.get() as u16)
4275                    .expect("missing receiver shard")
4276                    .encode();
4277                peers[leader_idx]
4278                    .sender
4279                    .send(Recipients::One(receiver), leader_shard, true);
4280
4281                context.sleep(config.link.latency * 2).await;
4282                peers[receiver_idx]
4283                    .mailbox
4284                    .discovered(commitment, leader, round);
4285
4286                assigned
4287                    .await
4288                    .expect("assigned shard should resolve after leader discovery");
4289                assert!(
4290                    oracle.blocked().await.unwrap().is_empty(),
4291                    "valid leader shard should not block peers"
4292                );
4293            },
4294        );
4295    }
4296
4297    #[test_traced]
4298    fn test_post_leader_shards_processed_immediately() {
4299        // Test that shards arriving after leader announcement are processed
4300        // without waiting for any extra trigger.
4301        let fixture: Fixture<C> = Fixture {
4302            num_primary_peers: 10,
4303            ..Default::default()
4304        };
4305
4306        fixture.start(
4307            |config, context, oracle, mut peers, _, coding_config| async move {
4308                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4309                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4310                let commitment = coded_block.commitment();
4311
4312                let receiver_idx = 3usize;
4313                let receiver_pk = peers[receiver_idx].public_key.clone();
4314                let leader = peers[0].public_key.clone();
4315
4316                let shard_sub = peers[receiver_idx]
4317                    .mailbox
4318                    .subscribe_assigned_shard_verified(commitment);
4319                peers[receiver_idx].mailbox.discovered(
4320                    commitment,
4321                    leader.clone(),
4322                    Round::new(Epoch::zero(), View::new(1)),
4323                );
4324
4325                // Send leader's shard (for receiver's index) after leader is known.
4326                let leader_shard = coded_block
4327                    .shard(peers[receiver_idx].index.get() as u16)
4328                    .expect("missing shard")
4329                    .encode();
4330                peers[0]
4331                    .sender
4332                    .send(Recipients::One(receiver_pk.clone()), leader_shard, true);
4333
4334                // Subscription should resolve from the leader's shard.
4335                select! {
4336                    _ = shard_sub => {},
4337                    _ = context.sleep(Duration::from_secs(5)) => {
4338                        panic!("shard subscription did not resolve after post-leader shard");
4339                    },
4340                }
4341
4342                // Send enough shards after leader known to reconstruct.
4343                for i in [1usize, 2usize, 4usize] {
4344                    let shard = coded_block
4345                        .shard(peers[i].index.get() as u16)
4346                        .expect("missing shard")
4347                        .encode();
4348                    peers[i]
4349                        .sender
4350                        .send(Recipients::One(receiver_pk.clone()), shard, true);
4351                }
4352
4353                context.sleep(config.link.latency * 2).await;
4354                let reconstructed = peers[receiver_idx]
4355                    .mailbox
4356                    .get(commitment)
4357                    .await
4358                    .expect("block should reconstruct from post-leader shards");
4359                assert_eq!(reconstructed.commitment(), commitment);
4360
4361                assert!(
4362                    oracle.blocked().await.unwrap().is_empty(),
4363                    "no peers should be blocked for valid post-leader shards"
4364                );
4365            },
4366        );
4367    }
4368
4369    #[test_traced]
4370    fn test_invalid_shard_codec_blocks_peer() {
4371        // Test that receiving an invalid shard (codec failure) blocks the sender.
4372        let fixture: Fixture<C> = Fixture {
4373            num_primary_peers: 4,
4374            ..Default::default()
4375        };
4376
4377        fixture.start(
4378            |config, context, oracle, mut peers, _, _coding_config| async move {
4379                let peer0_pk = peers[0].public_key.clone();
4380                let peer1_pk = peers[1].public_key.clone();
4381
4382                // Send garbage bytes that will fail codec decoding.
4383                let garbage = Bytes::from(vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
4384                peers[1]
4385                    .sender
4386                    .send(Recipients::One(peer0_pk.clone()), garbage, true);
4387
4388                context.sleep(config.link.latency * 2).await;
4389
4390                // Peer 1 should be blocked by peer 0 for sending invalid shard.
4391                assert_blocked(&oracle, &peer0_pk, &peer1_pk).await;
4392            },
4393        );
4394    }
4395
4396    #[test_traced]
4397    fn test_duplicate_buffered_shard_does_not_block_before_leader() {
4398        // Test that duplicate shards before leader announcement are
4399        // buffered and do not immediately block the sender.
4400        let fixture: Fixture<C> = Fixture {
4401            ..Default::default()
4402        };
4403
4404        fixture.start(
4405            |config, context, oracle, mut peers, _, coding_config| async move {
4406                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4407                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4408
4409                // Get peer 2's shard.
4410                let peer2_index = peers[2].index.get() as u16;
4411                let peer2_shard = coded_block.shard(peer2_index).expect("missing shard");
4412                let shard_bytes = peer2_shard.encode();
4413
4414                let peer2_pk = peers[2].public_key.clone();
4415
4416                // Do NOT set a leader — shards should be buffered.
4417
4418                // Peer 1 sends the shard to peer 2 (buffered, leader unknown).
4419                peers[1]
4420                    .sender
4421                    .send(Recipients::One(peer2_pk.clone()), shard_bytes.clone(), true);
4422                context.sleep(config.link.latency * 2).await;
4423
4424                // No one should be blocked yet.
4425                let blocked = oracle.blocked().await.unwrap();
4426                assert!(blocked.is_empty(), "no peers should be blocked yet");
4427
4428                // Peer 1 sends the same shard AGAIN (duplicate while leader unknown).
4429                peers[1]
4430                    .sender
4431                    .send(Recipients::One(peer2_pk), shard_bytes, true);
4432                context.sleep(config.link.latency * 2).await;
4433
4434                // Still no blocking before a leader is known.
4435                let blocked = oracle.blocked().await.unwrap();
4436                assert!(
4437                    blocked.is_empty(),
4438                    "no peers should be blocked before leader"
4439                );
4440            },
4441        );
4442    }
4443
4444    #[test_traced]
4445    fn test_invalid_leader_shard_crypto_blocks_leader() {
4446        // Test that a leader shard failing cryptographic verification
4447        // results in the leader being blocked.
4448        let fixture: Fixture<C> = Fixture {
4449            ..Default::default()
4450        };
4451
4452        fixture.start(
4453            |config, context, oracle, mut peers, _, coding_config| async move {
4454                // Create two different blocks — shard from block2 won't verify
4455                // against commitment from block1.
4456                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4457                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
4458                let commitment1 = coded_block1.commitment();
4459
4460                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
4461                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
4462
4463                // Get peer 2's shard from block2, but re-wrap it with
4464                // block1's commitment so it fails verification.
4465                let peer2_index = peers[2].index.get() as u16;
4466                let mut wrong_shard = coded_block2.shard(peer2_index).expect("missing shard");
4467                wrong_shard.commitment = commitment1;
4468                let wrong_bytes = wrong_shard.encode();
4469
4470                let peer2_pk = peers[2].public_key.clone();
4471                let leader = peers[0].public_key.clone();
4472
4473                // Inform peer 2 that peer 0 is the leader.
4474                peers[2].mailbox.discovered(
4475                    commitment1,
4476                    leader,
4477                    Round::new(Epoch::zero(), View::new(1)),
4478                );
4479
4480                // Leader (peer 0) sends the invalid shard.
4481                peers[0]
4482                    .sender
4483                    .send(Recipients::One(peer2_pk), wrong_bytes, true);
4484                context.sleep(config.link.latency * 2).await;
4485
4486                // Peer 0 (leader) should be blocked for invalid crypto.
4487                assert_blocked(&oracle, &peers[2].public_key, &peers[0].public_key).await;
4488            },
4489        );
4490    }
4491
4492    #[test_traced]
4493    fn test_invalid_assigned_shard_from_non_leader_blocks_only_sender() {
4494        // A Byzantine participant can race the leader with garbage at the
4495        // victim's assigned index, since the assigned index is accepted from
4496        // any participant. The sender must be blocked without poisoning the
4497        // slot: the leader's genuine shard must still verify afterward.
4498        let fixture: Fixture<C> = Fixture {
4499            ..Default::default()
4500        };
4501
4502        fixture.start(
4503            |config, context, oracle, mut peers, _, coding_config| async move {
4504                // Create two different blocks — shard from block2 won't verify
4505                // against commitment from block1.
4506                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4507                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
4508                let commitment1 = coded_block1.commitment();
4509
4510                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
4511                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
4512
4513                // Get peer 2's shard from block2, but re-wrap it with
4514                // block1's commitment so it fails verification.
4515                let peer2_index = peers[2].index.get() as u16;
4516                let mut wrong_shard = coded_block2.shard(peer2_index).expect("missing shard");
4517                wrong_shard.commitment = commitment1;
4518                let wrong_bytes = wrong_shard.encode();
4519
4520                let peer2_pk = peers[2].public_key.clone();
4521                let leader = peers[0].public_key.clone();
4522
4523                let mut shard_sub = peers[2]
4524                    .mailbox
4525                    .subscribe_assigned_shard_verified(commitment1);
4526
4527                // Inform peer 2 that peer 0 is the leader.
4528                peers[2].mailbox.discovered(
4529                    commitment1,
4530                    leader,
4531                    Round::new(Epoch::zero(), View::new(1)),
4532                );
4533
4534                // Non-leader peer 1 sends the invalid shard at peer 2's
4535                // assigned index.
4536                peers[1]
4537                    .sender
4538                    .send(Recipients::One(peer2_pk.clone()), wrong_bytes, true);
4539                context.sleep(config.link.latency * 2).await;
4540
4541                // Peer 1 should be blocked for invalid crypto, and the assigned
4542                // slot must not be treated as satisfied.
4543                assert_blocked(&oracle, &peers[2].public_key, &peers[1].public_key).await;
4544                assert!(
4545                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
4546                    "subscription should not resolve from invalid shard"
4547                );
4548
4549                // The leader's genuine shard for the same index must still verify.
4550                let real_bytes = coded_block1
4551                    .shard(peer2_index)
4552                    .expect("missing shard")
4553                    .encode();
4554                peers[0]
4555                    .sender
4556                    .send(Recipients::One(peer2_pk), real_bytes, true);
4557                select! {
4558                    _ = shard_sub => {},
4559                    _ = context.sleep(Duration::from_secs(5)) => {
4560                        panic!("genuine assigned shard did not verify after invalid one");
4561                    },
4562                };
4563            },
4564        );
4565    }
4566
4567    #[test_traced]
4568    fn test_shard_index_mismatch_blocks_peer() {
4569        // Test that a shard whose shard index doesn't match the sender's
4570        // participant index results in blocking the sender.
4571        let fixture: Fixture<C> = Fixture {
4572            num_primary_peers: 10,
4573            ..Default::default()
4574        };
4575
4576        fixture.start(
4577            |config, context, oracle, mut peers, _, coding_config| async move {
4578                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4579                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4580                let commitment = coded_block.commitment();
4581
4582                // Get peer 3's leader shard so peer 3 can validate shards.
4583                let peer3_index = peers[3].index.get() as u16;
4584                let leader_shard = coded_block.shard(peer3_index).expect("missing shard");
4585
4586                // Get peer 1's valid shard, then change the index to peer 4's index.
4587                let peer1_index = peers[1].index.get() as u16;
4588                let mut wrong_index_shard = coded_block.shard(peer1_index).expect("missing shard");
4589                // Mutate the index so it doesn't match sender (peer 1).
4590                wrong_index_shard.index = peers[4].index.get() as u16;
4591                let wrong_bytes = wrong_index_shard.encode();
4592
4593                let peer3_pk = peers[3].public_key.clone();
4594                let leader = peers[0].public_key.clone();
4595
4596                // Inform peer 3 of the leader and send them the leader shard.
4597                peers[3].mailbox.discovered(
4598                    commitment,
4599                    leader,
4600                    Round::new(Epoch::zero(), View::new(1)),
4601                );
4602                let shard_bytes = leader_shard.encode();
4603                peers[0]
4604                    .sender
4605                    .send(Recipients::One(peer3_pk.clone()), shard_bytes, true);
4606                context.sleep(config.link.latency * 2).await;
4607
4608                // Peer 1 sends a shard with a mismatched index to peer 3.
4609                peers[1]
4610                    .sender
4611                    .send(Recipients::One(peer3_pk), wrong_bytes, true);
4612                context.sleep(config.link.latency * 2).await;
4613
4614                // Peer 1 should be blocked for shard index mismatch.
4615                assert_blocked(&oracle, &peers[3].public_key, &peers[1].public_key).await;
4616            },
4617        );
4618    }
4619
4620    #[test_traced]
4621    fn test_invalid_shard_crypto_blocks_peer() {
4622        // Test that a shard failing cryptographic verification
4623        // results in blocking the sender once batch validation fires at quorum.
4624        let fixture: Fixture<C> = Fixture {
4625            num_primary_peers: 10,
4626            ..Default::default()
4627        };
4628
4629        fixture.start(
4630            |config, context, oracle, mut peers, _, coding_config| async move {
4631                // Create two different blocks.
4632                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4633                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
4634                let commitment1 = coded_block1.commitment();
4635
4636                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
4637                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
4638
4639                // Get peer 3's leader shard from block1 (valid).
4640                let peer3_index = peers[3].index.get() as u16;
4641                let leader_shard = coded_block1.shard(peer3_index).expect("missing shard");
4642
4643                // Get peer 1's shard from block2, but re-wrap with block1's
4644                // commitment so verification fails.
4645                let peer1_index = peers[1].index.get() as u16;
4646                let mut wrong_shard = coded_block2.shard(peer1_index).expect("missing shard");
4647                wrong_shard.commitment = commitment1;
4648                let wrong_bytes = wrong_shard.encode();
4649
4650                let peer3_pk = peers[3].public_key.clone();
4651                let leader = peers[0].public_key.clone();
4652
4653                // Inform peer 3 of the leader and send the valid leader shard.
4654                peers[3].mailbox.discovered(
4655                    commitment1,
4656                    leader,
4657                    Round::new(Epoch::zero(), View::new(1)),
4658                );
4659                let shard_bytes = leader_shard.encode();
4660                peers[0]
4661                    .sender
4662                    .send(Recipients::One(peer3_pk.clone()), shard_bytes, true);
4663                context.sleep(config.link.latency * 2).await;
4664
4665                // Peer 1 sends the invalid shard.
4666                peers[1]
4667                    .sender
4668                    .send(Recipients::One(peer3_pk.clone()), wrong_bytes, true);
4669                context.sleep(config.link.latency * 2).await;
4670
4671                // No block yet: batch validation deferred until quorum.
4672                // Send valid shards from peers 2 and 4 to reach quorum
4673                // (minimum_shards = 4: 1 leader + 3 pending).
4674                for &idx in &[2, 4] {
4675                    let peer_index = peers[idx].index.get() as u16;
4676                    let shard = coded_block1.shard(peer_index).expect("missing shard");
4677                    let bytes = shard.encode();
4678                    peers[idx]
4679                        .sender
4680                        .send(Recipients::One(peer3_pk.clone()), bytes, true);
4681                }
4682                context.sleep(config.link.latency * 2).await;
4683
4684                // Peer 1 should be blocked for invalid shard crypto.
4685                assert_blocked(&oracle, &peers[3].public_key, &peers[1].public_key).await;
4686            },
4687        );
4688    }
4689
4690    #[test_traced]
4691    fn test_reconstruction_recovers_after_quorum_with_one_invalid_shard() {
4692        // With 10 peers, minimum_shards=4.
4693        // Contribute exactly 4 shards first (1 leader + 3 pending), with one invalid:
4694        // quorum is reached, but checked_shards stays at 3 after batch validation.
4695        // Then send one more valid shard to meet reconstruction threshold.
4696        let fixture: Fixture<C> = Fixture {
4697            num_primary_peers: 10,
4698            ..Default::default()
4699        };
4700
4701        fixture.start(
4702            |config, context, oracle, mut peers, _, coding_config| async move {
4703                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4704                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
4705                let commitment1 = coded_block1.commitment();
4706
4707                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
4708                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
4709
4710                let receiver_idx = 3usize;
4711                let receiver_pk = peers[receiver_idx].public_key.clone();
4712
4713                // Prepare one invalid shard: shard data from block2, commitment from block1.
4714                let peer1_index = peers[1].index.get() as u16;
4715                let mut invalid_shard = coded_block2.shard(peer1_index).expect("missing shard");
4716                invalid_shard.commitment = commitment1;
4717
4718                // Announce leader and deliver receiver's leader shard.
4719                let leader = peers[0].public_key.clone();
4720                peers[receiver_idx].mailbox.discovered(
4721                    commitment1,
4722                    leader,
4723                    Round::new(Epoch::zero(), View::new(1)),
4724                );
4725                let leader_shard = coded_block1
4726                    .shard(peers[receiver_idx].index.get() as u16)
4727                    .expect("missing shard")
4728                    .encode();
4729                peers[0]
4730                    .sender
4731                    .send(Recipients::One(receiver_pk.clone()), leader_shard, true);
4732
4733                // Contribute exactly minimum_shards total:
4734                // - invalid shard from peer1
4735                // - valid shard from peer2
4736                // - valid shard from peer4
4737                peers[1].sender.send(
4738                    Recipients::One(receiver_pk.clone()),
4739                    invalid_shard.encode(),
4740                    true,
4741                );
4742                for idx in [2usize, 4usize] {
4743                    let shard = coded_block1
4744                        .shard(peers[idx].index.get() as u16)
4745                        .expect("missing shard")
4746                        .encode();
4747                    peers[idx]
4748                        .sender
4749                        .send(Recipients::One(receiver_pk.clone()), shard, true);
4750                }
4751
4752                context.sleep(config.link.latency * 2).await;
4753
4754                // Invalid shard should be blocked, and reconstruction should not happen yet.
4755                assert_blocked(
4756                    &oracle,
4757                    &peers[receiver_idx].public_key,
4758                    &peers[1].public_key,
4759                )
4760                .await;
4761                assert!(
4762                    peers[receiver_idx].mailbox.get(commitment1).await.is_none(),
4763                    "block should not reconstruct with only 3 checked shards"
4764                );
4765
4766                // Send one additional valid shard; this should now satisfy checked threshold.
4767                let extra_shard = coded_block1
4768                    .shard(peers[5].index.get() as u16)
4769                    .expect("missing shard")
4770                    .encode();
4771                peers[5]
4772                    .sender
4773                    .send(Recipients::One(receiver_pk), extra_shard, true);
4774
4775                context.sleep(config.link.latency * 2).await;
4776
4777                let reconstructed = peers[receiver_idx]
4778                    .mailbox
4779                    .get(commitment1)
4780                    .await
4781                    .expect("block should reconstruct after additional valid shard");
4782                assert_eq!(reconstructed.commitment(), commitment1);
4783            },
4784        );
4785    }
4786
4787    #[test_traced]
4788    fn test_invalid_pending_shard_blocked_on_drain() {
4789        // Test that a shard buffered in pending shards (before checking data) is
4790        // blocked when batch validation runs at quorum and verification fails.
4791        let fixture: Fixture<C> = Fixture {
4792            num_primary_peers: 10,
4793            ..Default::default()
4794        };
4795
4796        fixture.start(
4797            |config, context, oracle, mut peers, _, coding_config| async move {
4798                // Create two different blocks.
4799                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4800                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
4801                let commitment1 = coded_block1.commitment();
4802
4803                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
4804                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
4805
4806                // Get peer 1's shard from block2, but wrap with block1's commitment.
4807                let peer1_index = peers[1].index.get() as u16;
4808                let mut wrong_shard = coded_block2.shard(peer1_index).expect("missing shard");
4809                wrong_shard.commitment = commitment1;
4810                let wrong_bytes = wrong_shard.encode();
4811
4812                let peer3_pk = peers[3].public_key.clone();
4813
4814                // Send the invalid shard BEFORE the leader shard (no checking data yet,
4815                // so it gets buffered in pending shards).
4816                peers[1]
4817                    .sender
4818                    .send(Recipients::One(peer3_pk.clone()), wrong_bytes, true);
4819                context.sleep(config.link.latency * 2).await;
4820
4821                // No one should be blocked yet (shard is buffered).
4822                let blocked = oracle.blocked().await.unwrap();
4823                assert!(blocked.is_empty(), "no peers should be blocked yet");
4824
4825                // Send valid shards from peers 2 and 4 so the pending count
4826                // reaches quorum once the leader shard arrives
4827                // (minimum_shards = 4: 1 leader + 3 pending).
4828                for &idx in &[2, 4] {
4829                    let peer_index = peers[idx].index.get() as u16;
4830                    let shard = coded_block1.shard(peer_index).expect("missing shard");
4831                    let bytes = shard.encode();
4832                    peers[idx]
4833                        .sender
4834                        .send(Recipients::One(peer3_pk.clone()), bytes, true);
4835                }
4836                context.sleep(config.link.latency * 2).await;
4837
4838                // No one should be blocked yet (all shards are buffered pending leader).
4839                let blocked = oracle.blocked().await.unwrap();
4840                assert!(blocked.is_empty(), "no peers should be blocked yet");
4841
4842                // Now inform peer 3 of the leader and send the valid leader shard.
4843                let leader = peers[0].public_key.clone();
4844                peers[3].mailbox.discovered(
4845                    commitment1,
4846                    leader,
4847                    Round::new(Epoch::zero(), View::new(1)),
4848                );
4849                let peer3_index = peers[3].index.get() as u16;
4850                let leader_shard = coded_block1.shard(peer3_index).expect("missing shard");
4851                let shard_bytes = leader_shard.encode();
4852                peers[0]
4853                    .sender
4854                    .send(Recipients::One(peer3_pk), shard_bytes, true);
4855                context.sleep(config.link.latency * 2).await;
4856
4857                // Peer 1 should be blocked after batch validation validates and
4858                // rejects their invalid shard.
4859                assert_blocked(&oracle, &peers[3].public_key, &peers[1].public_key).await;
4860            },
4861        );
4862    }
4863
4864    #[test_traced]
4865    fn test_cross_epoch_buffered_shard_not_blocked() {
4866        let executor = deterministic::Runner::default();
4867        executor.start(|context| async move {
4868            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
4869                context.child("network"),
4870                simulated::Config {
4871                    max_size: MAX_SHARD_SIZE as u32,
4872                    max_peers_per_set: NZUsize!(2),
4873                    disconnect_on_block: true,
4874                    tracked_peer_sets: NZUsize!(1),
4875                },
4876            );
4877            network.start();
4878
4879            // Epoch 0 participants: peers 0..4 (seeds 0..4).
4880            // Epoch 1 participants: peers 0..3 + peer 4 (seed 4 replaces seed 3).
4881            let mut epoch0_keys: Vec<PrivateKey> = (0..4).map(PrivateKey::from_seed).collect();
4882            epoch0_keys.sort_by_key(|s| s.public_key());
4883            let epoch0_pks: Vec<P> = epoch0_keys.iter().map(|c| c.public_key()).collect();
4884            let epoch0_set: Set<P> = Set::from_iter_dedup(epoch0_pks.clone());
4885
4886            let future_peer_key = PrivateKey::from_seed(4);
4887            let future_peer_pk = future_peer_key.public_key();
4888            let mut epoch1_pks: Vec<P> = epoch0_pks[..3]
4889                .iter()
4890                .cloned()
4891                .chain(std::iter::once(future_peer_pk.clone()))
4892                .collect();
4893            epoch1_pks.sort();
4894            let epoch1_set: Set<P> = Set::from_iter_dedup(epoch1_pks);
4895
4896            let receiver_idx_in_epoch0 = epoch0_set
4897                .index(&epoch0_pks[0])
4898                .expect("receiver must be in epoch 0")
4899                .get() as usize;
4900            let receiver_key = epoch0_keys[receiver_idx_in_epoch0].clone();
4901            let receiver_pk = receiver_key.public_key();
4902
4903            let receiver_control = oracle.control(receiver_pk.clone());
4904            let (sender_handle, receiver_handle) = receiver_control
4905                .register(0, TEST_QUOTA)
4906                .await
4907                .expect("registration should succeed");
4908
4909            let future_peer_control = oracle.control(future_peer_pk.clone());
4910            let (mut future_peer_sender, _future_peer_receiver) = future_peer_control
4911                .register(0, TEST_QUOTA)
4912                .await
4913                .expect("registration should succeed");
4914            oracle
4915                .add_link(future_peer_pk.clone(), receiver_pk.clone(), DEFAULT_LINK)
4916                .await
4917                .expect("link should be added");
4918            oracle.manager().track(
4919                0,
4920                Set::from_iter_dedup([receiver_pk.clone(), future_peer_pk.clone()]),
4921            );
4922            context.sleep(Duration::from_millis(10)).await;
4923
4924            // Set up the receiver's engine with a multi-epoch provider.
4925            let scheme_epoch0 =
4926                Scheme::signer(SCHEME_NAMESPACE, epoch0_set.clone(), receiver_key.clone())
4927                    .expect("signer scheme should be created");
4928            let scheme_epoch1 =
4929                Scheme::signer(SCHEME_NAMESPACE, epoch1_set.clone(), receiver_key.clone())
4930                    .expect("signer scheme should be created");
4931            let scheme_provider =
4932                MultiEpochProvider::single(scheme_epoch0).with_epoch(Epoch::new(1), scheme_epoch1);
4933
4934            let config: Config<_, _, _, _, C, _, _, _> = Config {
4935                scheme_provider,
4936                blocker: receiver_control.clone(),
4937                shard_codec_cfg: CodecConfig {
4938                    maximum_shard_size: MAX_SHARD_SIZE,
4939                },
4940                block_codec_cfg: (),
4941                strategy: STRATEGY,
4942                mailbox_size: NZUsize!(1024),
4943                peer_buffer_size: NZUsize!(64),
4944                background_channel_capacity: NZUsize!(1024),
4945                peer_provider: oracle.manager(),
4946            };
4947
4948            let (engine, mailbox) = ShardEngine::new(context.child("receiver"), config);
4949            engine.start((sender_handle, receiver_handle));
4950
4951            // Build a coded block using epoch 1's participant set.
4952            let coding_config = coding_config_for_participants(epoch1_set.len() as u16);
4953            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
4954            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
4955            let commitment = coded_block.commitment();
4956
4957            // The future peer creates a shard at their epoch 1 index.
4958            let future_peer_index = epoch1_set
4959                .index(&future_peer_pk)
4960                .expect("future peer must be in epoch 1");
4961            let future_shard = coded_block
4962                .shard(future_peer_index.get() as u16)
4963                .expect("missing shard");
4964            let shard_bytes = future_shard.encode();
4965
4966            // Send the shard BEFORE external_proposed (goes to pre-leader buffer).
4967            future_peer_sender.send(Recipients::One(receiver_pk.clone()), shard_bytes, true);
4968            context.sleep(DEFAULT_LINK.latency * 2).await;
4969
4970            // No one should be blocked yet (shard is buffered, leader unknown).
4971            let blocked = oracle.blocked().await.unwrap();
4972            assert!(
4973                blocked.is_empty(),
4974                "no peers should be blocked while shard is buffered"
4975            );
4976
4977            // Announce the leader with an epoch 1 round.
4978            let leader = epoch0_pks[1].clone();
4979            mailbox.discovered(commitment, leader, Round::new(Epoch::new(1), View::new(1)));
4980            context.sleep(DEFAULT_LINK.latency * 2).await;
4981
4982            // The future peer is a valid participant in epoch 1, so they must NOT
4983            // be blocked after their buffered shard is ingested.
4984            let blocked = oracle.blocked().await.unwrap();
4985            assert!(
4986                blocked.is_empty(),
4987                "future-epoch participant should not be blocked: {blocked:?}"
4988            );
4989        });
4990    }
4991
4992    #[test_traced]
4993    fn test_shard_broadcast_survives_provider_churn() {
4994        let executor = deterministic::Runner::default();
4995        executor.start(|context| async move {
4996            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
4997                context.child("network"),
4998                simulated::Config {
4999                    max_size: MAX_SHARD_SIZE as u32,
5000                    max_peers_per_set: NZUsize!(4),
5001                    disconnect_on_block: true,
5002                    tracked_peer_sets: NZUsize!(1),
5003                },
5004            );
5005            network.start();
5006
5007            let mut private_keys: Vec<PrivateKey> = (0..4).map(PrivateKey::from_seed).collect();
5008            private_keys.sort_by_key(|s| s.public_key());
5009            let peer_keys: Vec<P> = private_keys.iter().map(|k| k.public_key()).collect();
5010            let participants: Set<P> = Set::from_iter_dedup(peer_keys.clone());
5011
5012            let leader_idx = 0usize;
5013            let broadcaster_idx = 1usize;
5014            let receiver_idx = 2usize;
5015
5016            let leader_pk = peer_keys[leader_idx].clone();
5017            let broadcaster_pk = peer_keys[broadcaster_idx].clone();
5018            let receiver_pk = peer_keys[receiver_idx].clone();
5019
5020            let mut registrations = BTreeMap::new();
5021            for key in &peer_keys {
5022                let control = oracle.control(key.clone());
5023                let (sender, receiver) = control
5024                    .register(0, TEST_QUOTA)
5025                    .await
5026                    .expect("registration should succeed");
5027                registrations.insert(key.clone(), (control, sender, receiver));
5028            }
5029
5030            for src in &peer_keys {
5031                for dst in &peer_keys {
5032                    if src == dst {
5033                        continue;
5034                    }
5035                    oracle
5036                        .add_link(src.clone(), dst.clone(), DEFAULT_LINK)
5037                        .await
5038                        .expect("link should be added");
5039                }
5040            }
5041            oracle.manager().track(0, participants.clone());
5042            context.sleep(Duration::from_millis(10)).await;
5043
5044            let (_leader_control, mut leader_sender, _leader_receiver) = registrations
5045                .remove(&leader_pk)
5046                .expect("leader should be registered");
5047            let (broadcaster_control, broadcaster_sender, broadcaster_receiver) = registrations
5048                .remove(&broadcaster_pk)
5049                .expect("broadcaster should be registered");
5050            let (receiver_control, receiver_sender, receiver_receiver) = registrations
5051                .remove(&receiver_pk)
5052                .expect("receiver should be registered");
5053
5054            let broadcaster_scheme = Scheme::signer(
5055                SCHEME_NAMESPACE,
5056                participants.clone(),
5057                private_keys[broadcaster_idx].clone(),
5058            )
5059            .expect("signer scheme should be created");
5060            // `discovered` performs two scoped lookups (`handle_external_proposal`
5061            // and `ingest_buffered_shards`). Leader-shard validation is the third.
5062            // Any additional lookup for epoch 0 churns to `None`.
5063            let broadcaster_provider = ChurningProvider::new(broadcaster_scheme, 3);
5064            let broadcaster_config: Config<_, _, _, _, C, _, _, _> = Config {
5065                scheme_provider: broadcaster_provider,
5066                blocker: broadcaster_control.clone(),
5067                shard_codec_cfg: CodecConfig {
5068                    maximum_shard_size: MAX_SHARD_SIZE,
5069                },
5070                block_codec_cfg: (),
5071                strategy: STRATEGY,
5072                mailbox_size: NZUsize!(1024),
5073                peer_buffer_size: NZUsize!(64),
5074                background_channel_capacity: NZUsize!(1024),
5075                peer_provider: oracle.manager(),
5076            };
5077            let (broadcaster_engine, broadcaster_mailbox) =
5078                ChurningShardEngine::new(context.child("broadcaster"), broadcaster_config);
5079            broadcaster_engine.start((broadcaster_sender, broadcaster_receiver));
5080
5081            let receiver_scheme = Scheme::signer(
5082                SCHEME_NAMESPACE,
5083                participants.clone(),
5084                private_keys[receiver_idx].clone(),
5085            )
5086            .expect("signer scheme should be created");
5087            let receiver_config: Config<_, _, _, _, C, _, _, _> = Config {
5088                scheme_provider: MultiEpochProvider::single(receiver_scheme),
5089                blocker: receiver_control.clone(),
5090                shard_codec_cfg: CodecConfig {
5091                    maximum_shard_size: MAX_SHARD_SIZE,
5092                },
5093                block_codec_cfg: (),
5094                strategy: STRATEGY,
5095                mailbox_size: NZUsize!(1024),
5096                peer_buffer_size: NZUsize!(64),
5097                background_channel_capacity: NZUsize!(1024),
5098                peer_provider: oracle.manager(),
5099            };
5100            let (receiver_engine, receiver_mailbox) =
5101                ShardEngine::new(context.child("receiver"), receiver_config);
5102            receiver_engine.start((receiver_sender, receiver_receiver));
5103
5104            let coding_config = coding_config_for_participants(peer_keys.len() as u16);
5105            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5106            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5107            let commitment = coded_block.commitment();
5108            let round = Round::new(Epoch::zero(), View::new(1));
5109
5110            broadcaster_mailbox.discovered(commitment, leader_pk.clone(), round);
5111            receiver_mailbox.discovered(commitment, leader_pk.clone(), round);
5112            context.sleep(DEFAULT_LINK.latency).await;
5113
5114            let broadcaster_index = participants
5115                .index(&broadcaster_pk)
5116                .expect("broadcaster must be a participant")
5117                .get() as u16;
5118            let broadcaster_shard = coded_block
5119                .shard(broadcaster_index)
5120                .expect("missing shard")
5121                .encode();
5122            leader_sender.send(Recipients::One(broadcaster_pk), broadcaster_shard, true);
5123
5124            let receiver_index = participants
5125                .index(&receiver_pk)
5126                .expect("receiver must be a participant")
5127                .get() as u16;
5128            let receiver_shard = coded_block
5129                .shard(receiver_index)
5130                .expect("missing shard")
5131                .encode();
5132            leader_sender.send(Recipients::One(receiver_pk.clone()), receiver_shard, true);
5133
5134            context.sleep(DEFAULT_LINK.latency * 3).await;
5135
5136            let reconstructed = receiver_mailbox.get(commitment).await;
5137            assert!(
5138                reconstructed.is_some(),
5139                "receiver should reconstruct after broadcaster validates and broadcasts shard"
5140            );
5141        });
5142    }
5143
5144    #[test_traced]
5145    fn test_failed_reconstruction_digest_mismatch_then_recovery() {
5146        // Byzantine scenario: all shards pass coding verification (correct root) but the
5147        // decoded blob has a different digest than what the commitment claims. This triggers
5148        // Error::DigestMismatch in try_reconstruct. Verify that:
5149        //   1. The failed commitment's state is cleaned up
5150        //   2. The exact commitment subscription closes
5151        //   3. The digest subscription survives the invalid candidate
5152        //   4. The valid commitment later reconstructs the claimed digest
5153        let fixture: Fixture<C> = Fixture {
5154            num_primary_peers: 10,
5155            ..Default::default()
5156        };
5157
5158        fixture.start(
5159            |config, context, _oracle, mut peers, _, coding_config| async move {
5160                // Block 1: the "claimed" block (its digest goes in the fake commitment).
5161                let inner1 = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5162                let coded_block1 = CodedBlock::<B, C, H>::new(inner1, coding_config, &STRATEGY);
5163
5164                // Block 2: the actual data behind the shards.
5165                let inner2 = B::new(Sha256Digest::EMPTY, Height::new(2), 200);
5166                let coded_block2 = CodedBlock::<B, C, H>::new(inner2, coding_config, &STRATEGY);
5167                let real_commitment2 = coded_block2.commitment();
5168
5169                // This is an invalid claim, not a second accepted commitment for block1.
5170                // Build it from block1's digest and block2's coding root/context/config.
5171                // Shards from block2 will verify against block2's root (present in the fake
5172                // commitment), but try_reconstruct will decode block2 and find its digest != D1.
5173                let fake_commitment = Commitment::from((
5174                    coded_block1.digest(),
5175                    real_commitment2.root(),
5176                    real_commitment2.context(),
5177                    coding_config,
5178                ));
5179
5180                let receiver_idx = 3usize;
5181                let receiver_pk = peers[receiver_idx].public_key.clone();
5182                let leader = peers[0].public_key.clone();
5183                let round = Round::new(Epoch::zero(), View::new(1));
5184
5185                // Discover the fake commitment.
5186                peers[receiver_idx]
5187                    .mailbox
5188                    .discovered(fake_commitment, leader.clone(), round);
5189
5190                // Open a block subscription before sending shards.
5191                let mut block_sub = peers[receiver_idx].mailbox.subscribe(fake_commitment);
5192                let mut digest_sub = peers[receiver_idx]
5193                    .mailbox
5194                    .subscribe_by_digest(coded_block1.digest());
5195
5196                // Send the receiver's shard (from block2, with fake commitment).
5197                let receiver_shard_idx = peers[receiver_idx].index.get() as u16;
5198                let mut leader_shard = coded_block2
5199                    .shard(receiver_shard_idx)
5200                    .expect("missing shard");
5201                leader_shard.commitment = fake_commitment;
5202                peers[0].sender.send(
5203                    Recipients::One(receiver_pk.clone()),
5204                    leader_shard.encode(),
5205                    true,
5206                );
5207
5208                // Send enough shards to reach minimum_shards (4 for 10 peers).
5209                // Need 3 more shards after the leader's shard.
5210                for &idx in &[1usize, 2, 4] {
5211                    let peer_shard_idx = peers[idx].index.get() as u16;
5212                    let mut shard = coded_block2.shard(peer_shard_idx).expect("missing shard");
5213                    shard.commitment = fake_commitment;
5214                    peers[idx].sender.send(
5215                        Recipients::One(receiver_pk.clone()),
5216                        shard.encode(),
5217                        true,
5218                    );
5219                }
5220
5221                context.sleep(config.link.latency * 2).await;
5222
5223                // Reconstruction should have failed with DigestMismatch.
5224                // State for fake_commitment should be removed (engine.rs:792).
5225                assert!(
5226                    peers[receiver_idx]
5227                        .mailbox
5228                        .get(fake_commitment)
5229                        .await
5230                        .is_none(),
5231                    "block should not be available after DigestMismatch"
5232                );
5233
5234                // Commitment validity governs the exact-commitment subscription.
5235                // The digest subscription accepts another valid commitment.
5236                assert!(
5237                    matches!(block_sub.try_recv(), Err(TryRecvError::Closed)),
5238                    "subscription should close for failed reconstruction"
5239                );
5240                assert!(
5241                    matches!(digest_sub.try_recv(), Err(TryRecvError::Empty)),
5242                    "digest subscription should survive failed reconstruction"
5243                );
5244                // Now verify the engine is not stuck: send valid shards for block1's real
5245                // commitment and confirm reconstruction succeeds.
5246                let real_commitment1 = coded_block1.commitment();
5247                let round2 = Round::new(Epoch::zero(), View::new(2));
5248                peers[receiver_idx]
5249                    .mailbox
5250                    .discovered(real_commitment1, leader.clone(), round2);
5251
5252                let leader_shard1 = coded_block1
5253                    .shard(receiver_shard_idx)
5254                    .expect("missing shard");
5255                peers[0].sender.send(
5256                    Recipients::One(receiver_pk.clone()),
5257                    leader_shard1.encode(),
5258                    true,
5259                );
5260
5261                for &idx in &[1usize, 2, 4] {
5262                    let peer_shard_idx = peers[idx].index.get() as u16;
5263                    let shard = coded_block1.shard(peer_shard_idx).expect("missing shard");
5264                    peers[idx].sender.send(
5265                        Recipients::One(receiver_pk.clone()),
5266                        shard.encode(),
5267                        true,
5268                    );
5269                }
5270
5271                context.sleep(config.link.latency * 2).await;
5272
5273                let reconstructed = peers[receiver_idx]
5274                    .mailbox
5275                    .get(real_commitment1)
5276                    .await
5277                    .expect("valid block should reconstruct after prior failure");
5278                assert_eq!(reconstructed.commitment(), real_commitment1);
5279                let by_digest = digest_sub
5280                    .await
5281                    .expect("valid commitment should satisfy digest subscription");
5282                assert_eq!(by_digest.commitment(), real_commitment1);
5283            },
5284        );
5285    }
5286
5287    #[test_traced]
5288    fn test_failed_reconstruction_context_mismatch_then_recovery() {
5289        // Byzantine scenario: shards decode to a block whose digest and coding root/config
5290        // match the commitment, but the commitment carries a mismatched context digest.
5291        // The engine must reject reconstruction and keep the commitment unresolved.
5292        let fixture: Fixture<C> = Fixture {
5293            num_primary_peers: 10,
5294            ..Default::default()
5295        };
5296
5297        fixture.start(
5298            |config, context, _oracle, mut peers, _, coding_config| async move {
5299                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5300                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5301                let real_commitment = coded_block.commitment();
5302
5303                let wrong_context_digest = Sha256::hash(&[b"wrong_context"]);
5304                assert_ne!(
5305                    real_commitment.context(),
5306                    wrong_context_digest,
5307                    "test requires a distinct context digest"
5308                );
5309                let fake_commitment = Commitment::from((
5310                    coded_block.digest(),
5311                    real_commitment.root(),
5312                    wrong_context_digest,
5313                    coding_config,
5314                ));
5315
5316                let receiver_idx = 3usize;
5317                let receiver_pk = peers[receiver_idx].public_key.clone();
5318                let leader = peers[0].public_key.clone();
5319                let round = Round::new(Epoch::zero(), View::new(1));
5320
5321                peers[receiver_idx]
5322                    .mailbox
5323                    .discovered(fake_commitment, leader.clone(), round);
5324                let mut block_sub = peers[receiver_idx].mailbox.subscribe(fake_commitment);
5325
5326                let receiver_shard_idx = peers[receiver_idx].index.get() as u16;
5327                let mut leader_shard = coded_block
5328                    .shard(receiver_shard_idx)
5329                    .expect("missing shard");
5330                leader_shard.commitment = fake_commitment;
5331                peers[0].sender.send(
5332                    Recipients::One(receiver_pk.clone()),
5333                    leader_shard.encode(),
5334                    true,
5335                );
5336
5337                for &idx in &[1usize, 2, 4] {
5338                    let peer_shard_idx = peers[idx].index.get() as u16;
5339                    let mut shard = coded_block.shard(peer_shard_idx).expect("missing shard");
5340                    shard.commitment = fake_commitment;
5341                    peers[idx].sender.send(
5342                        Recipients::One(receiver_pk.clone()),
5343                        shard.encode(),
5344                        true,
5345                    );
5346                }
5347
5348                context.sleep(config.link.latency * 2).await;
5349
5350                assert!(
5351                    peers[receiver_idx]
5352                        .mailbox
5353                        .get(fake_commitment)
5354                        .await
5355                        .is_none(),
5356                    "block should not be available after ContextMismatch"
5357                );
5358                assert!(
5359                    matches!(block_sub.try_recv(), Err(TryRecvError::Closed)),
5360                    "subscription should close for context-mismatched commitment"
5361                );
5362
5363                // Verify the receiver still reconstructs valid commitments afterward.
5364                let round2 = Round::new(Epoch::zero(), View::new(2));
5365                peers[receiver_idx]
5366                    .mailbox
5367                    .discovered(real_commitment, leader.clone(), round2);
5368
5369                let real_leader_shard = coded_block
5370                    .shard(receiver_shard_idx)
5371                    .expect("missing shard");
5372                peers[0].sender.send(
5373                    Recipients::One(receiver_pk.clone()),
5374                    real_leader_shard.encode(),
5375                    true,
5376                );
5377
5378                for &idx in &[1usize, 2, 4] {
5379                    let peer_shard_idx = peers[idx].index.get() as u16;
5380                    let shard = coded_block.shard(peer_shard_idx).expect("missing shard");
5381                    peers[idx].sender.send(
5382                        Recipients::One(receiver_pk.clone()),
5383                        shard.encode(),
5384                        true,
5385                    );
5386                }
5387
5388                context.sleep(config.link.latency * 2).await;
5389
5390                let reconstructed = peers[receiver_idx]
5391                    .mailbox
5392                    .get(real_commitment)
5393                    .await
5394                    .expect("valid block should reconstruct after prior context mismatch");
5395                assert_eq!(reconstructed.commitment(), real_commitment);
5396            },
5397        );
5398    }
5399
5400    #[test_traced]
5401    fn test_same_round_equivocation_preserves_certifiable_recovery() {
5402        // Regression coverage for same-round leader equivocation:
5403        // - leader equivocates across two commitments in the same round
5404        // - we receive a shard for commitment B (the certifiable one)
5405        // - commitment A reconstructs first
5406        // - commitment B must still remain recoverable
5407        // - the leader must not be blocked (a leader that crashes after its
5408        //   broadcast but before its local persist legitimately re-proposes
5409        //   a different block for the same round after restart)
5410        let fixture: Fixture<C> = Fixture {
5411            num_primary_peers: 10,
5412            ..Default::default()
5413        };
5414
5415        fixture.start(
5416            |config, context, oracle, mut peers, _, coding_config| async move {
5417                let receiver_idx = 3usize;
5418                let receiver_pk = peers[receiver_idx].public_key.clone();
5419                let receiver_shard_idx = peers[receiver_idx].index.get() as u16;
5420
5421                let leader = peers[0].public_key.clone();
5422                let round = Round::new(Epoch::zero(), View::new(7));
5423
5424                // Two different commitments in the same round (equivocation scenario).
5425                let block_a = CodedBlock::<B, C, H>::new(
5426                    B::new(Sha256Digest::EMPTY, Height::new(1), 111),
5427                    coding_config,
5428                    &STRATEGY,
5429                );
5430                let commitment_a = block_a.commitment();
5431                let block_b = CodedBlock::<B, C, H>::new(
5432                    B::new(Sha256Digest::EMPTY, Height::new(1), 222),
5433                    coding_config,
5434                    &STRATEGY,
5435                );
5436                let commitment_b = block_b.commitment();
5437
5438                // Receiver learns both commitments in the same round.
5439                peers[receiver_idx]
5440                    .mailbox
5441                    .discovered(commitment_a, leader.clone(), round);
5442                peers[receiver_idx]
5443                    .mailbox
5444                    .discovered(commitment_b, leader.clone(), round);
5445
5446                // Subscribe to the certifiable commitment before any reconstruction.
5447                let certifiable_sub = peers[receiver_idx].mailbox.subscribe(commitment_b);
5448
5449                // We receive our shard for commitment B from the equivocating leader.
5450                let shard_b = block_b
5451                    .shard(receiver_shard_idx)
5452                    .expect("missing shard")
5453                    .encode();
5454                peers[0]
5455                    .sender
5456                    .send(Recipients::One(receiver_pk.clone()), shard_b, true);
5457
5458                // Reconstruct conflicting commitment A first.
5459                let shard_a = block_a
5460                    .shard(receiver_shard_idx)
5461                    .expect("missing shard")
5462                    .encode();
5463                peers[0]
5464                    .sender
5465                    .send(Recipients::One(receiver_pk.clone()), shard_a, true);
5466                for i in [1usize, 2usize, 4usize] {
5467                    let shard_a = block_a
5468                        .shard(peers[i].index.get() as u16)
5469                        .expect("missing shard")
5470                        .encode();
5471                    peers[i]
5472                        .sender
5473                        .send(Recipients::One(receiver_pk.clone()), shard_a, true);
5474                }
5475                context.sleep(config.link.latency * 4).await;
5476                let reconstructed_a = peers[receiver_idx]
5477                    .mailbox
5478                    .get(commitment_a)
5479                    .await
5480                    .expect("conflicting commitment should reconstruct first");
5481                assert_eq!(reconstructed_a.commitment(), commitment_a);
5482
5483                // Commitment B should still be recoverable after A reconstructed.
5484                for i in [1usize, 2usize, 4usize] {
5485                    let shard_b = block_b
5486                        .shard(peers[i].index.get() as u16)
5487                        .expect("missing shard")
5488                        .encode();
5489                    peers[i]
5490                        .sender
5491                        .send(Recipients::One(receiver_pk.clone()), shard_b, true);
5492                }
5493
5494                select! {
5495                    result = certifiable_sub => {
5496                        let reconstructed_b =
5497                            result.expect("certifiable commitment should remain recoverable");
5498                        assert_eq!(reconstructed_b.commitment(), commitment_b);
5499                    },
5500                    _ = context.sleep(Duration::from_secs(5)) => {
5501                        panic!("certifiable commitment was not recoverable after same-round equivocation");
5502                    },
5503                }
5504
5505                // Cross-commitment equivocation within a round is tolerated,
5506                // so the leader must not be blocked.
5507                let blocked_peers = oracle.blocked().await.unwrap();
5508                let is_blocked = blocked_peers
5509                    .iter()
5510                    .any(|(a, b)| a == &receiver_pk && b == &leader);
5511                assert!(
5512                    !is_blocked,
5513                    "leader must not be blocked for same-round cross-commitment shards"
5514                );
5515            },
5516        );
5517    }
5518
5519    #[test_traced]
5520    fn test_leader_unrelated_shard_blocks_peer() {
5521        // Regression test: if the leader sends an unrelated/invalid shard
5522        // (i.e. a shard for a different participant index), the receiver must
5523        // block the leader.
5524        let fixture: Fixture<C> = Fixture {
5525            num_primary_peers: 10,
5526            ..Default::default()
5527        };
5528
5529        fixture.start(
5530            |config, context, oracle, mut peers, _, coding_config| async move {
5531                // Commitment being tracked by the receiver.
5532                let tracked_block = CodedBlock::<B, C, H>::new(
5533                    B::new(Sha256Digest::EMPTY, Height::new(1), 100),
5534                    coding_config,
5535                    &STRATEGY,
5536                );
5537                let tracked_commitment = tracked_block.commitment();
5538
5539                // Separate block used to source "unrelated" shard data.
5540                let unrelated_block = CodedBlock::<B, C, H>::new(
5541                    B::new(Sha256Digest::EMPTY, Height::new(2), 200),
5542                    coding_config,
5543                    &STRATEGY,
5544                );
5545
5546                let receiver_idx = 3usize;
5547                let receiver_pk = peers[receiver_idx].public_key.clone();
5548                let leader_idx = 0usize;
5549                let leader_pk = peers[leader_idx].public_key.clone();
5550
5551                // Receiver tracks the commitment with peer0 as leader.
5552                peers[receiver_idx].mailbox.discovered(
5553                    tracked_commitment,
5554                    leader_pk.clone(),
5555                    Round::new(Epoch::zero(), View::new(1)),
5556                );
5557
5558                // Construct an unrelated shard from peer1's slot and retarget
5559                // its commitment to the tracked commitment so it hits active state.
5560                let mut unrelated_shard = unrelated_block
5561                    .shard(peers[1].index.get() as u16)
5562                    .expect("missing shard");
5563                unrelated_shard.commitment = tracked_commitment;
5564
5565                // Leader sends this unrelated/invalid shard to receiver.
5566                // The shard index no longer matches sender's participant index,
5567                // so leader must be blocked.
5568                peers[leader_idx].sender.send(
5569                    Recipients::One(receiver_pk),
5570                    unrelated_shard.encode(),
5571                    true,
5572                );
5573                context.sleep(config.link.latency * 2).await;
5574
5575                assert_blocked(&oracle, &peers[receiver_idx].public_key, &leader_pk).await;
5576            },
5577        );
5578    }
5579
5580    #[test_traced]
5581    fn test_withholding_leader_victim_reconstructs_via_gossip() {
5582        // A Byzantine leader withholds the shard destined for one participant.
5583        // That participant should still reconstruct the block from shards
5584        // gossiped by other participants (sent via Recipients::All) without
5585        // any backfill mechanism.
5586        let fixture = Fixture {
5587            num_primary_peers: 10,
5588            ..Default::default()
5589        };
5590
5591        fixture.start(
5592            |config, context, oracle, mut peers, _, coding_config| async move {
5593                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5594                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5595                let commitment = coded_block.commitment();
5596                let round = Round::new(Epoch::zero(), View::new(1));
5597
5598                let leader = peers[0].public_key.clone();
5599                let victim = peers[1].public_key.clone();
5600
5601                // Sever the link from leader to victim so the leader's
5602                // direct shard never arrives.
5603                oracle
5604                    .remove_link(leader.clone(), victim.clone())
5605                    .await
5606                    .expect("remove_link should succeed");
5607
5608                // Leader proposes. The victim will not receive a direct shard
5609                // because the link is severed.
5610                peers[0].mailbox.proposed(round, coded_block.clone());
5611
5612                // Inform all non-leader peers of the leader so they validate
5613                // and re-broadcast their shards via Recipients::All.
5614                for peer in peers[1..].iter_mut() {
5615                    peer.mailbox.discovered(commitment, leader.clone(), round);
5616                }
5617                context.sleep(config.link.latency * 2).await;
5618
5619                // The victim should reconstruct via gossiped shards from other
5620                // participants even though the leader withheld.
5621                let block_sub = peers[1].mailbox.subscribe(commitment);
5622                select! {
5623                    result = block_sub => {
5624                        let reconstructed = result.expect("block subscription should resolve");
5625                        assert_eq!(reconstructed.commitment(), commitment);
5626                        assert_eq!(reconstructed.height(), coded_block.height());
5627                    },
5628                    _ = context.sleep(Duration::from_secs(5)) => {
5629                        panic!("victim did not reconstruct block despite withholding leader");
5630                    },
5631                }
5632
5633                // All other participants should also have reconstructed.
5634                for peer in peers[2..].iter_mut() {
5635                    let reconstructed = peer
5636                        .mailbox
5637                        .get(commitment)
5638                        .await
5639                        .expect("block should be reconstructed");
5640                    assert_eq!(reconstructed.commitment(), commitment);
5641                }
5642
5643                // No peer should be blocked — withholding is not detectable.
5644                let blocked = oracle.blocked().await.unwrap();
5645                assert!(
5646                    blocked.is_empty(),
5647                    "no peer should be blocked in withholding leader test"
5648                );
5649            },
5650        );
5651    }
5652
5653    /// When the leader withholds its shard from a participant, the block
5654    /// can still be reconstructed from gossipped shards. However, the shard
5655    /// subscription must NOT resolve because the participant's own shard was
5656    /// never verified. Voting requires own-shard verification to ensure the
5657    /// participant re-broadcasts its shard and helps slower peers reach quorum.
5658    #[test_traced]
5659    fn test_shard_subscription_pending_after_reconstruction_without_leader_shard() {
5660        let fixture = Fixture {
5661            num_primary_peers: 10,
5662            ..Default::default()
5663        };
5664
5665        fixture.start(
5666            |config, context, oracle, mut peers, _, coding_config| async move {
5667                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5668                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5669                let commitment = coded_block.commitment();
5670                let round = Round::new(Epoch::zero(), View::new(1));
5671
5672                let leader = peers[0].public_key.clone();
5673                let victim = peers[1].public_key.clone();
5674
5675                // Remove the link from leader to victim so the leader's shard
5676                // never reaches the victim directly.
5677                oracle
5678                    .remove_link(leader.clone(), victim.clone())
5679                    .await
5680                    .expect("remove_link should succeed");
5681
5682                // Subscribe to the shard and block BEFORE any broadcasting.
5683                let mut shard_sub = peers[1]
5684                    .mailbox
5685                    .subscribe_assigned_shard_verified(commitment);
5686                let block_sub = peers[1].mailbox.subscribe(commitment);
5687
5688                // Leader broadcasts.
5689                peers[0].mailbox.proposed(round, coded_block.clone());
5690
5691                // All non-leader peers discover the leader.
5692                for peer in peers[1..].iter_mut() {
5693                    peer.mailbox.discovered(commitment, leader.clone(), round);
5694                }
5695
5696                // Wait for gossip to propagate.
5697                context.sleep(config.link.latency * 4).await;
5698
5699                // Block subscription should resolve (victim reconstructs from
5700                // gossipped shards).
5701                let reconstructed = block_sub.await.expect("block subscription should resolve");
5702                assert_eq!(reconstructed.commitment(), commitment);
5703
5704                let mut late_shard_sub = peers[1]
5705                    .mailbox
5706                    .subscribe_assigned_shard_verified(commitment);
5707                context.sleep(Duration::from_millis(10)).await;
5708
5709                // Neither an existing nor a late shard subscription may resolve because
5710                // the leader never sent the victim its own shard.
5711                assert!(
5712                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
5713                    "shard subscription must not resolve without own shard verification"
5714                );
5715                assert!(
5716                    matches!(late_shard_sub.try_recv(), Err(TryRecvError::Empty)),
5717                    "late shard subscription must not resolve from reconstruction alone"
5718                );
5719            },
5720        );
5721    }
5722
5723    #[test_traced]
5724    fn test_broadcast_routes_participant_and_non_participant_shards() {
5725        let fixture = Fixture {
5726            num_secondary_peers: 1,
5727            ..Default::default()
5728        };
5729
5730        fixture.start(
5731            |config, context, oracle, mut peers, non_participants, coding_config| async move {
5732                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5733                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5734                let commitment = coded_block.commitment();
5735
5736                let leader = peers[0].public_key.clone();
5737                let round = Round::new(Epoch::zero(), View::new(1));
5738                peers[0].mailbox.proposed(round, coded_block.clone());
5739
5740                for peer in peers[1..].iter_mut() {
5741                    peer.mailbox.discovered(commitment, leader.clone(), round);
5742                }
5743                for np in non_participants.iter() {
5744                    np.mailbox.discovered(commitment, leader.clone(), round);
5745                }
5746                context.sleep(config.link.latency * 2).await;
5747
5748                // Participants should receive and validate their own shards.
5749                for peer in peers.iter_mut() {
5750                    peer.mailbox
5751                        .subscribe_assigned_shard_verified(commitment)
5752                        .await
5753                        .expect("participant shard subscription should complete");
5754                }
5755
5756                // Non-participant should receive and validate the leader's shard.
5757                for np in non_participants.iter() {
5758                    np.mailbox
5759                        .subscribe_assigned_shard_verified(commitment)
5760                        .await
5761                        .expect("non-participant shard subscription should complete");
5762                }
5763                context.sleep(config.link.latency).await;
5764
5765                // Non-participant should reconstruct the block from received shards.
5766                for np in non_participants.iter() {
5767                    let reconstructed = np
5768                        .mailbox
5769                        .get(commitment)
5770                        .await
5771                        .expect("non-participant should reconstruct block");
5772                    assert_eq!(reconstructed.commitment(), commitment);
5773                }
5774
5775                let blocked = oracle.blocked().await.unwrap();
5776                assert!(
5777                    blocked.is_empty(),
5778                    "no peer should be blocked in participant/non-participant shard routing test"
5779                );
5780            },
5781        );
5782    }
5783
5784    #[test_traced]
5785    fn test_non_participant_reconstructs_after_discovered() {
5786        let fixture = Fixture {
5787            num_secondary_peers: 1,
5788            ..Default::default()
5789        };
5790
5791        fixture.start(
5792            |config, context, oracle, mut peers, non_participants, coding_config| async move {
5793                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5794                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5795                let commitment = coded_block.commitment();
5796                let round = Round::new(Epoch::zero(), View::new(1));
5797
5798                let leader = peers[0].public_key.clone();
5799                peers[0].mailbox.proposed(round, coded_block.clone());
5800
5801                // Inform participants of the leader so they validate and re-broadcast
5802                // shards.
5803                for peer in peers[1..].iter_mut() {
5804                    peer.mailbox.discovered(commitment, leader.clone(), round);
5805                }
5806                context.sleep(config.link.latency).await;
5807
5808                // Non-participant discovers the leader after shards are already
5809                // propagating through the network.
5810                let np = &non_participants[0];
5811                let block_sub = np.mailbox.subscribe(commitment);
5812                np.mailbox.discovered(commitment, leader.clone(), round);
5813
5814                // Wait for enough shards (leader's shard + shards from
5815                // participants) to arrive and reconstruct.
5816                select! {
5817                    result = block_sub => {
5818                        let reconstructed = result.expect("block subscription should resolve");
5819                        assert_eq!(reconstructed.commitment(), commitment);
5820                        assert_eq!(reconstructed.height(), coded_block.height());
5821                    },
5822                    _ = context.sleep(Duration::from_secs(5)) => {
5823                        panic!("non-participant block subscription did not resolve");
5824                    },
5825                }
5826
5827                let blocked = oracle.blocked().await.unwrap();
5828                assert!(
5829                    blocked.is_empty(),
5830                    "no peer should be blocked in non-participant reconstruction test"
5831                );
5832            },
5833        );
5834    }
5835
5836    #[test_traced]
5837    fn test_peer_set_update_evicts_peer_buffers() {
5838        // Shards buffered before leader announcement should be evicted when
5839        // the sender leaves latest.primary. Even if the overlap window keeps
5840        // the sender connected, fresh pre-leader shards from that peer must
5841        // not recreate the buffer.
5842        let executor = deterministic::Runner::default();
5843        executor.start(|context| async move {
5844            let num_peers = 10usize;
5845            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
5846                context.child("network"),
5847                simulated::Config {
5848                    max_size: MAX_SHARD_SIZE as u32,
5849                    max_peers_per_set: NZUsize!(num_peers),
5850                    disconnect_on_block: true,
5851                    tracked_peer_sets: NZUsize!(2),
5852                },
5853            );
5854            network.start();
5855
5856            let mut private_keys = (0..num_peers)
5857                .map(|i| PrivateKey::from_seed(i as u64))
5858                .collect::<Vec<_>>();
5859            private_keys.sort_by_key(|s| s.public_key());
5860            let peer_keys: Vec<P> = private_keys.iter().map(|c| c.public_key()).collect();
5861            let participants: Set<P> = Set::from_iter_dedup(peer_keys.clone());
5862
5863            // Test from the perspective of a single receiver (peer 3).
5864            let receiver_idx = 3usize;
5865            let receiver_pk = peer_keys[receiver_idx].clone();
5866            let leader_pk = peer_keys[0].clone();
5867
5868            let receiver_control = oracle.control(receiver_pk.clone());
5869            let (sender_handle, receiver_handle) = receiver_control
5870                .register(0, TEST_QUOTA)
5871                .await
5872                .expect("registration should succeed");
5873
5874            // Register the leader so it can send shards.
5875            let leader_control = oracle.control(leader_pk.clone());
5876            let (mut leader_sender, _leader_receiver) = leader_control
5877                .register(0, TEST_QUOTA)
5878                .await
5879                .expect("registration should succeed");
5880            oracle
5881                .add_link(leader_pk.clone(), receiver_pk.clone(), DEFAULT_LINK)
5882                .await
5883                .expect("link should be added");
5884
5885            // Track the full participant set so the engine sees all peers.
5886            oracle.manager().track(0, participants.clone());
5887            context.sleep(Duration::from_millis(10)).await;
5888
5889            let scheme = Scheme::signer(
5890                SCHEME_NAMESPACE,
5891                participants.clone(),
5892                private_keys[receiver_idx].clone(),
5893            )
5894            .expect("signer scheme should be created");
5895
5896            let config: Config<_, _, _, _, C, _, _, _> = Config {
5897                scheme_provider: MultiEpochProvider::single(scheme),
5898                blocker: receiver_control.clone(),
5899                shard_codec_cfg: CodecConfig {
5900                    maximum_shard_size: MAX_SHARD_SIZE,
5901                },
5902                block_codec_cfg: (),
5903                strategy: STRATEGY,
5904                mailbox_size: NZUsize!(1024),
5905                peer_buffer_size: NZUsize!(64),
5906                background_channel_capacity: NZUsize!(1024),
5907                peer_provider: oracle.manager(),
5908            };
5909
5910            let (engine, mailbox) = ShardEngine::new(context.child("receiver"), config);
5911            engine.start((sender_handle, receiver_handle));
5912
5913            // Build a coded block and extract the shard destined for the receiver.
5914            let coding_config = coding_config_for_participants(num_peers as u16);
5915            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
5916            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
5917            let commitment = coded_block.commitment();
5918
5919            let receiver_participant = participants
5920                .index(&receiver_pk)
5921                .expect("receiver must be a participant");
5922            let leader_shard = coded_block
5923                .shard(receiver_participant.get() as u16)
5924                .expect("missing shard");
5925            let shard_bytes = leader_shard.encode();
5926
5927            // Send the shard BEFORE leader announcement (it gets buffered).
5928            leader_sender.send(
5929                Recipients::One(receiver_pk.clone()),
5930                shard_bytes.clone(),
5931                true,
5932            );
5933            context.sleep(DEFAULT_LINK.latency * 2).await;
5934
5935            // Now send a peer set update that excludes the leader.
5936            let remaining: Set<P> =
5937                Set::from_iter_dedup(peer_keys.iter().filter(|pk| **pk != leader_pk).cloned());
5938            oracle.manager().track(1, remaining);
5939            context.sleep(Duration::from_millis(10)).await;
5940
5941            // The retained overlap window still lets the leader reach the receiver,
5942            // but this fresh pre-leader shard must not be buffered again.
5943            leader_sender.send(Recipients::One(receiver_pk.clone()), shard_bytes, true);
5944            context.sleep(DEFAULT_LINK.latency * 2).await;
5945
5946            // Announce the leader. Buffered shards from the leader should have been
5947            // evicted, so the shard will NOT be ingested.
5948            let mut shard_sub = mailbox.subscribe_assigned_shard_verified(commitment);
5949            mailbox.discovered(
5950                commitment,
5951                leader_pk.clone(),
5952                Round::new(Epoch::zero(), View::new(1)),
5953            );
5954            context.sleep(DEFAULT_LINK.latency * 2).await;
5955
5956            // The shard subscription should still be pending (no shard was ingested).
5957            assert!(
5958                matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
5959                "shard subscription should not resolve after evicted leader's buffer"
5960            );
5961            assert!(
5962                mailbox.get(commitment).await.is_none(),
5963                "block should not reconstruct from evicted buffers"
5964            );
5965        });
5966    }
5967
5968    #[test_traced]
5969    fn test_peer_buffer_lifetime_tracks_latest_primary() {
5970        let executor = deterministic::Runner::default();
5971        executor.start(|context| async move {
5972            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
5973                context.child("network"),
5974                simulated::Config {
5975                    max_size: MAX_SHARD_SIZE as u32,
5976                    max_peers_per_set: NZUsize!(1),
5977                    disconnect_on_block: true,
5978                    tracked_peer_sets: NZUsize!(1),
5979                },
5980            );
5981            network.start();
5982
5983            let mut private_keys = (0..4)
5984                .map(|i| PrivateKey::from_seed(i as u64))
5985                .collect::<Vec<_>>();
5986            private_keys.sort_by_key(|s| s.public_key());
5987            let peer_keys: Vec<P> = private_keys.iter().map(|c| c.public_key()).collect();
5988            let receiver_pk = peer_keys[0].clone();
5989            let sender_pk = peer_keys[1].clone();
5990            let participants: Set<P> = Set::from_iter_dedup(peer_keys);
5991
5992            let receiver_control = oracle.control(receiver_pk);
5993            let scheme = Scheme::signer(
5994                SCHEME_NAMESPACE,
5995                participants.clone(),
5996                private_keys[0].clone(),
5997            )
5998            .expect("signer scheme should be created");
5999
6000            let config: Config<_, _, _, _, C, _, _, _> = Config {
6001                scheme_provider: MultiEpochProvider::single(scheme),
6002                blocker: receiver_control,
6003                shard_codec_cfg: CodecConfig {
6004                    maximum_shard_size: MAX_SHARD_SIZE,
6005                },
6006                block_codec_cfg: (),
6007                strategy: STRATEGY,
6008                mailbox_size: NZUsize!(16),
6009                peer_buffer_size: NZUsize!(4),
6010                background_channel_capacity: NZUsize!(16),
6011                peer_provider: oracle.manager(),
6012            };
6013
6014            let (mut engine, _mailbox) = ShardEngine::new(context.child("engine"), config);
6015
6016            // Only `sender_pk` is in `latest.primary`, so only that peer may retain a pre-leader
6017            // buffer row (`buffer_peer_shard` / `peer_buffers`).
6018            engine.update_latest_primary_peers(Set::from_iter_dedup([sender_pk.clone()]));
6019
6020            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
6021            let coded_block = CodedBlock::<B, C, H>::new(
6022                inner,
6023                coding_config_for_participants(participants.len() as u16),
6024                &STRATEGY,
6025            );
6026            let shard = coded_block.shard(0).expect("missing shard");
6027
6028            // Pre-leader path: buffer one shard before any leader or notarized interest arrives.
6029            engine.buffer_peer_shard(sender_pk.clone(), shard);
6030            assert_eq!(
6031                engine.peer_buffers.get(&sender_pk).map(VecDeque::len),
6032                Some(1),
6033                "peer buffer should contain the buffered shard"
6034            );
6035
6036            // Empty primary: no peer may retain buffers; `update_latest_primary_peers` drops the
6037            // staged shard and the deque entry for `sender_pk`.
6038            engine.update_latest_primary_peers(Set::default());
6039            assert!(
6040                !engine.peer_buffers.contains_key(&sender_pk),
6041                "peer buffer should be evicted once sender leaves latest.primary"
6042            );
6043        });
6044    }
6045
6046    #[test_traced]
6047    fn test_old_epoch_buffered_shards_are_dropped_after_cutover() {
6048        let executor = deterministic::Runner::default();
6049        executor.start(|context| async move {
6050            let num_peers = 6usize;
6051            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
6052                context.child("network"),
6053                simulated::Config {
6054                    max_size: MAX_SHARD_SIZE as u32,
6055                    max_peers_per_set: NZUsize!(num_peers - 1),
6056                    disconnect_on_block: true,
6057                    tracked_peer_sets: NZUsize!(2),
6058                },
6059            );
6060            network.start();
6061
6062            let mut private_keys = (0..num_peers)
6063                .map(|i| PrivateKey::from_seed(i as u64))
6064                .collect::<Vec<_>>();
6065            private_keys.sort_by_key(|s| s.public_key());
6066            let peer_keys: Vec<P> = private_keys.iter().map(|c| c.public_key()).collect();
6067
6068            // Epoch 0: first five peers. Epoch 1: swap out `peer_keys[0]` for `peer_keys[5]` so the
6069            // cutover changes who is in `latest.primary` while `tracked_peer_sets` retains overlap.
6070            let epoch0_set: Set<P> = Set::from_iter_dedup(peer_keys[..5].iter().cloned());
6071            let epoch1_set: Set<P> = Set::from_iter_dedup([
6072                peer_keys[1].clone(),
6073                peer_keys[2].clone(),
6074                peer_keys[3].clone(),
6075                peer_keys[4].clone(),
6076                peer_keys[5].clone(),
6077            ]);
6078
6079            let receiver_idx = 3usize;
6080            let receiver_pk = peer_keys[receiver_idx].clone();
6081            let receiver_key = private_keys[receiver_idx].clone();
6082            let leader_pk = peer_keys[0].clone();
6083
6084            let receiver_control = oracle.control(receiver_pk.clone());
6085            let (sender_handle, receiver_handle) = receiver_control
6086                .register(0, TEST_QUOTA)
6087                .await
6088                .expect("registration should succeed");
6089
6090            let leader_control = oracle.control(leader_pk.clone());
6091            let (mut leader_sender, _leader_receiver) = leader_control
6092                .register(0, TEST_QUOTA)
6093                .await
6094                .expect("registration should succeed");
6095            oracle
6096                .add_link(leader_pk.clone(), receiver_pk.clone(), DEFAULT_LINK)
6097                .await
6098                .expect("link should be added");
6099
6100            // Peer-set id 0: epoch 0 primaries before any cutover.
6101            oracle.manager().track(0, epoch0_set.clone());
6102            context.sleep(Duration::from_millis(10)).await;
6103
6104            let scheme_epoch0 =
6105                Scheme::signer(SCHEME_NAMESPACE, epoch0_set.clone(), receiver_key.clone())
6106                    .expect("epoch 0 signer scheme should be created");
6107            let scheme_epoch1 =
6108                Scheme::signer(SCHEME_NAMESPACE, epoch1_set.clone(), receiver_key.clone())
6109                    .expect("epoch 1 signer scheme should be created");
6110
6111            let config: Config<_, _, _, _, C, _, _, _> = Config {
6112                scheme_provider: MultiEpochProvider::single(scheme_epoch0)
6113                    .with_epoch(Epoch::new(1), scheme_epoch1),
6114                blocker: receiver_control.clone(),
6115                shard_codec_cfg: CodecConfig {
6116                    maximum_shard_size: MAX_SHARD_SIZE,
6117                },
6118                block_codec_cfg: (),
6119                strategy: STRATEGY,
6120                mailbox_size: NZUsize!(1024),
6121                peer_buffer_size: NZUsize!(64),
6122                background_channel_capacity: NZUsize!(1024),
6123                peer_provider: oracle.manager(),
6124            };
6125
6126            // Receiver engine: schemes for both epochs so post-cutover validation can run if needed.
6127            let (engine, mailbox) = ShardEngine::new(context.child("receiver"), config);
6128            engine.start((sender_handle, receiver_handle));
6129
6130            let coding_config = coding_config_for_participants(epoch0_set.len() as u16);
6131            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
6132            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
6133            let commitment = coded_block.commitment();
6134
6135            let receiver_participant = epoch0_set
6136                .index(&receiver_pk)
6137                .expect("receiver must be an epoch 0 participant");
6138            let leader_shard = coded_block
6139                .shard(receiver_participant.get() as u16)
6140                .expect("missing shard");
6141
6142            // Inbound: epoch-0 leader shard arrives before `Discovered` (pre-leader buffer path).
6143            leader_sender.send(
6144                Recipients::One(receiver_pk.clone()),
6145                leader_shard.encode(),
6146                true,
6147            );
6148            context.sleep(DEFAULT_LINK.latency * 2).await;
6149
6150            // Cutover to epoch 1 primaries before `Discovered`: `leader_pk` (epoch-0-only) is no
6151            // longer in `latest.primary`, so overlap-buffered shards for that sender must not feed
6152            // reconstruction.
6153            oracle.manager().track(1, epoch1_set);
6154            context.sleep(Duration::from_millis(10)).await;
6155
6156            // Leader announcement for the old commitment: should not complete reconstruction from
6157            // dropped pre-cutover buffers.
6158            let mut shard_sub = mailbox.subscribe_assigned_shard_verified(commitment);
6159            mailbox.discovered(
6160                commitment,
6161                leader_pk,
6162                Round::new(Epoch::zero(), View::new(1)),
6163            );
6164            context.sleep(DEFAULT_LINK.latency * 2).await;
6165
6166            assert!(
6167                matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
6168                "old-epoch shard subscription should stay pending after cutover"
6169            );
6170            assert!(
6171                mailbox.get(commitment).await.is_none(),
6172                "old-epoch commitment should not reconstruct from overlap-only buffered shards"
6173            );
6174        });
6175    }
6176
6177    /// If the evicted node leaves the
6178    /// [`commonware_p2p::PeerSetUpdate::latest`] primary set, it must still
6179    /// reconstruct once the leader is discovered, as long as enough buffered
6180    /// shards came from peers that remain in `latest.primary`.
6181    ///
6182    /// This does not rely on a self-buffered shard or a leader-delivered shard:
6183    /// reconstruction should succeed from the remaining buffered peer shards
6184    /// alone.
6185    #[test_traced]
6186    fn test_evicted_node_still_reconstructs_from_buffered_peer_shards() {
6187        let executor = deterministic::Runner::default();
6188        executor.start(|context| async move {
6189            let num_peers = 10usize;
6190            let (network, oracle) = simulated::Network::<deterministic::Context, P>::new(
6191                context.child("network"),
6192                simulated::Config {
6193                    max_size: MAX_SHARD_SIZE as u32,
6194                    max_peers_per_set: NZUsize!(num_peers),
6195                    disconnect_on_block: true,
6196                    tracked_peer_sets: NZUsize!(2),
6197                },
6198            );
6199            network.start();
6200
6201            let mut private_keys = (0..num_peers)
6202                .map(|i| PrivateKey::from_seed(i as u64))
6203                .collect::<Vec<_>>();
6204            private_keys.sort_by_key(|s| s.public_key());
6205            let peer_keys: Vec<P> = private_keys.iter().map(|c| c.public_key()).collect();
6206            let participants: Set<P> = Set::from_iter_dedup(peer_keys.clone());
6207
6208            // Receiver (`peer_keys[1]`) is evicted from `latest.primary` after shards are buffered.
6209            // The leader (`peer_keys[0]`) has no link to the receiver, so reconstruction cannot use a
6210            // leader-delivered shard or a self-buffered shard; it must use gossip from peers 2/4/5/6 only.
6211            let receiver_idx = 1usize;
6212            let receiver_pk = peer_keys[receiver_idx].clone();
6213            let leader_pk = peer_keys[0].clone();
6214            let peer2_pk = peer_keys[2].clone();
6215            let peer4_pk = peer_keys[4].clone();
6216            let peer5_pk = peer_keys[5].clone();
6217            let peer6_pk = peer_keys[6].clone();
6218
6219            let receiver_control = oracle.control(receiver_pk.clone());
6220            let (evicted_sender, evicted_receiver) = receiver_control
6221                .register(0, TEST_QUOTA)
6222                .await
6223                .expect("registration should succeed");
6224
6225            let peer2_control = oracle.control(peer2_pk.clone());
6226            let (mut peer2_sender, _peer2_receiver) = peer2_control
6227                .register(0, TEST_QUOTA)
6228                .await
6229                .expect("registration should succeed");
6230
6231            let peer4_control = oracle.control(peer4_pk.clone());
6232            let (mut peer4_sender, _peer4_receiver) = peer4_control
6233                .register(0, TEST_QUOTA)
6234                .await
6235                .expect("registration should succeed");
6236
6237            let peer5_control = oracle.control(peer5_pk.clone());
6238            let (mut peer5_sender, _peer5_receiver) = peer5_control
6239                .register(0, TEST_QUOTA)
6240                .await
6241                .expect("registration should succeed");
6242
6243            let peer6_control = oracle.control(peer6_pk.clone());
6244            let (mut peer6_sender, _peer6_receiver) = peer6_control
6245                .register(0, TEST_QUOTA)
6246                .await
6247                .expect("registration should succeed");
6248
6249            // Only secondary peers that will forward shards are connected to the receiver (not the leader).
6250            for sender in [&peer2_pk, &peer4_pk, &peer5_pk, &peer6_pk] {
6251                oracle
6252                    .add_link(sender.clone(), receiver_pk.clone(), DEFAULT_LINK)
6253                    .await
6254                    .expect("link should be added");
6255            }
6256
6257            // Start with the full committee so the receiver's signer scheme matches the coded block.
6258            oracle.manager().track(0, participants.clone());
6259            context.sleep(Duration::from_millis(10)).await;
6260
6261            let scheme = Scheme::signer(
6262                SCHEME_NAMESPACE,
6263                participants.clone(),
6264                private_keys[receiver_idx].clone(),
6265            )
6266            .expect("signer scheme should be created");
6267
6268            let config: Config<_, _, _, _, C, _, _, _> = Config {
6269                scheme_provider: MultiEpochProvider::single(scheme),
6270                blocker: receiver_control.clone(),
6271                shard_codec_cfg: CodecConfig {
6272                    maximum_shard_size: MAX_SHARD_SIZE,
6273                },
6274                block_codec_cfg: (),
6275                strategy: STRATEGY,
6276                mailbox_size: NZUsize!(1024),
6277                peer_buffer_size: NZUsize!(64),
6278                background_channel_capacity: NZUsize!(1024),
6279                peer_provider: oracle.manager(),
6280            };
6281
6282            let (engine, mailbox) = ShardEngine::new(context.child("evicted"), config);
6283            engine.start((evicted_sender, evicted_receiver));
6284
6285            let coding_config = coding_config_for_participants(num_peers as u16);
6286            let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
6287            let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
6288            let commitment = coded_block.commitment();
6289
6290            let peer2_shard = coded_block.shard(2).expect("missing shard 2").encode();
6291            let peer4_shard = coded_block.shard(4).expect("missing shard 4").encode();
6292            let peer5_shard = coded_block.shard(5).expect("missing shard 5").encode();
6293            let peer6_shard = coded_block.shard(6).expect("missing shard 6").encode();
6294
6295            let block_sub = mailbox.subscribe(commitment);
6296
6297            // Pre-`Discovered` path: four shards from peers that will still be in `latest.primary` after
6298            // the receiver is evicted (indices 2, 4, 5, 6). Together they are enough to reconstruct.
6299            peer2_sender
6300                .send(
6301                    Recipients::One(receiver_pk.clone()),
6302                    peer2_shard,
6303                    true,
6304                );
6305            peer4_sender
6306                .send(
6307                    Recipients::One(receiver_pk.clone()),
6308                    peer4_shard,
6309                    true,
6310                );
6311            peer5_sender
6312                .send(
6313                    Recipients::One(receiver_pk.clone()),
6314                    peer5_shard,
6315                    true,
6316                );
6317            peer6_sender
6318                .send(
6319                    Recipients::One(receiver_pk.clone()),
6320                    peer6_shard,
6321                    true,
6322                );
6323            context.sleep(DEFAULT_LINK.latency * 2).await;
6324
6325            // Evict the receiver from `latest.primary`: buffered shards from remaining primaries must
6326            // still count toward reconstruction once the leader is known.
6327            let latest_primary: Set<P> = Set::from_iter_dedup(
6328                peer_keys
6329                    .iter()
6330                    .filter(|pk| **pk != receiver_pk)
6331                    .cloned(),
6332            );
6333            oracle.manager().track(1, latest_primary);
6334            context.sleep(Duration::from_millis(10)).await;
6335
6336            // Leader announcement drains overlap-buffered peer shards; the evicted receiver should
6337            // still reach quorum without ever receiving the leader's direct shard.
6338            mailbox
6339                .discovered(
6340                    commitment,
6341                    leader_pk.clone(),
6342                    Round::new(Epoch::zero(), View::new(1)),
6343                );
6344
6345            select! {
6346                _ = block_sub => {},
6347                _ = context.sleep(Duration::from_secs(5)) => {
6348                    panic!("block subscription did not resolve after leader discovery");
6349                },
6350            }
6351
6352            context.sleep(DEFAULT_LINK.latency * 2).await;
6353            let block = mailbox.get(commitment).await;
6354            assert!(
6355                block.is_some(),
6356                "evicted node should reconstruct from buffered shards sent by remaining latest.primary peers"
6357            );
6358            assert_eq!(block.unwrap().commitment(), commitment);
6359
6360            assert!(
6361                oracle.blocked().await.unwrap().is_empty(),
6362                "no peer should be blocked when overlapping shards are valid"
6363            );
6364        });
6365    }
6366
6367    /// When peer gossip shards arrive before the leader's direct shard,
6368    /// the state may transition to Ready before the leader shard is
6369    /// processed. The late leader shard must still be accepted, verified,
6370    /// and broadcast so that slower peers can reach quorum.
6371    #[test_traced]
6372    fn test_late_leader_shard_accepted_after_quorum_transition() {
6373        let fixture = Fixture {
6374            num_primary_peers: 10,
6375            ..Default::default()
6376        };
6377
6378        fixture.start(
6379            |config, context, oracle, mut peers, _, coding_config| async move {
6380                let inner = B::new(Sha256Digest::EMPTY, Height::new(1), 100);
6381                let coded_block = CodedBlock::<B, C, H>::new(inner, coding_config, &STRATEGY);
6382                let commitment = coded_block.commitment();
6383                let round = Round::new(Epoch::zero(), View::new(1));
6384
6385                let leader_idx = 0usize;
6386                let victim_idx = 1usize;
6387                let leader = peers[leader_idx].public_key.clone();
6388                let victim = peers[victim_idx].public_key.clone();
6389
6390                // Sever the link from leader to victim so the leader's
6391                // direct shard does not arrive initially.
6392                oracle
6393                    .remove_link(leader.clone(), victim.clone())
6394                    .await
6395                    .expect("remove_link should succeed");
6396
6397                // Leader proposes. All peers except the victim get their
6398                // shard from the leader, verify it, and gossip it.
6399                peers[leader_idx]
6400                    .mailbox
6401                    .proposed(round, coded_block.clone());
6402
6403                // Inform all non-leader peers of the leader.
6404                for peer in peers[1..].iter_mut() {
6405                    peer.mailbox.discovered(commitment, leader.clone(), round);
6406                }
6407
6408                // Wait for gossip to propagate. The victim should
6409                // reconstruct the block from gossiped peer shards,
6410                // transitioning to Ready without its own shard.
6411                context.sleep(config.link.latency * 4).await;
6412
6413                let block_sub = peers[victim_idx].mailbox.subscribe(commitment);
6414                select! {
6415                    result = block_sub => {
6416                        let reconstructed = result.expect("block subscription should resolve");
6417                        assert_eq!(reconstructed.commitment(), commitment);
6418                    },
6419                    _ = context.sleep(Duration::from_secs(5)) => {
6420                        panic!("victim did not reconstruct block from gossip");
6421                    },
6422                }
6423
6424                // The shard subscription should NOT have resolved yet
6425                // because the victim has not verified its own shard.
6426                let mut shard_sub = peers[victim_idx]
6427                    .mailbox
6428                    .subscribe_assigned_shard_verified(commitment);
6429                assert!(
6430                    matches!(shard_sub.try_recv(), Err(TryRecvError::Empty)),
6431                    "shard subscription must not resolve before own shard is verified"
6432                );
6433
6434                // Now restore the link so the leader's shard arrives late.
6435                oracle
6436                    .add_link(leader.clone(), victim.clone(), DEFAULT_LINK)
6437                    .await
6438                    .expect("add_link should succeed");
6439
6440                // Re-send the leader's shard manually via the leader's
6441                // network sender (the engine already broadcast it earlier,
6442                // but the link was down).
6443                let leader_shard = coded_block
6444                    .shard(peers[victim_idx].index.get() as u16)
6445                    .expect("missing victim shard");
6446                peers[leader_idx].sender.send(
6447                    Recipients::One(victim.clone()),
6448                    leader_shard.encode(),
6449                    true,
6450                );
6451                context.sleep(config.link.latency * 2).await;
6452
6453                // The shard subscription should now resolve because the
6454                // late leader shard was accepted and verified.
6455                select! {
6456                    _ = shard_sub => {},
6457                    _ = context.sleep(Duration::from_secs(5)) => {
6458                        panic!("shard subscription did not resolve after late leader shard");
6459                    },
6460                }
6461
6462                // No peer should be blocked.
6463                let blocked = oracle.blocked().await.unwrap();
6464                assert!(
6465                    blocked.is_empty(),
6466                    "no peer should be blocked in late leader shard test"
6467                );
6468
6469                // After both reconstruction and assigned shard readiness,
6470                // additional gossip shards should be silently ignored.
6471                let extra_sender_idx = 2usize;
6472                let extra_shard = coded_block
6473                    .shard(peers[extra_sender_idx].index.get() as u16)
6474                    .expect("missing shard");
6475                peers[extra_sender_idx].sender.send(
6476                    Recipients::One(victim.clone()),
6477                    extra_shard.encode(),
6478                    true,
6479                );
6480                context.sleep(config.link.latency * 2).await;
6481
6482                // The gossip shard should be silently dropped (not blocked).
6483                let blocked = oracle.blocked().await.unwrap();
6484                assert!(
6485                    blocked.is_empty(),
6486                    "gossip shard after full reconstruction should be silently ignored"
6487                );
6488            },
6489        );
6490    }
6491}