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