Skip to main content

commonware_glue/dkg/probe/
mod.rs

1//! Discover the public epoch material a joining node needs before consensus starts.
2//!
3//! A node that is starting fresh cannot construct epoch-scoped state until it learns the current
4//! epoch's participant set. That set lives in the [`EpochInfo`] of a finalized boundary block.
5//! The [`Actor`] discovers that block, publishes the resulting [`Artifact`] (which also carries
6//! the state-sync floor), and then serves the same boundary material to other joining peers.
7//!
8//! This protocol is an extension of [`stateful::probe`](crate::stateful::probe): it begins with
9//! the same solicit-and-sample floor discovery (built on the same shared sample core) and adds
10//! requests for the floor epoch's boundary finalization and block, which carry the epoch's
11//! public [`EpochInfo`].
12//!
13//! At startup the node knows a canonical participant snapshot (the complete dealer, player, and
14//! next-player sets of a configured bootstrap epoch), a constant certificate verifier valid
15//! across all epochs, and the epoch length. When discovery begins, the actor tracks the
16//! snapshot's canonical peer set at the bootstrap epoch's own peer-set ID; the orchestrator
17//! tracks identical contents if it later enters that epoch, so the registrations never
18//! conflict. Solicitation, membership, and the fault budgets below all apply to the snapshot's
19//! dealers: the epoch's active committee of share holders and certificate signers.
20//!
21//! Addressable deployments seed the snapshot's transport [`Directory`] alongside this
22//! weak-subjectivity checkpoint through [`Bootstrap::directory`]. The actor activates the
23//! snapshot only when its first subscriber appears. If activation fails, the actor shuts down
24//! before sending a request and drops all pending subscribers. The discovered [`Artifact`]
25//! carries the target epoch's own directory in its [`EpochInfo`], so the joining node needs no
26//! out-of-band address source for the epoch it syncs into.
27//!
28//! # Trust Model
29//!
30//! The configured peers and constant verifier are the weakly subjective checkpoint for startup.
31//! For `n` configured members, `f` is the maximum fault count under the `3f + 1` model and the
32//! discovery sample threshold is `f + 1`.
33//!
34//! Rotation out of the active committee is not what the budgets bound: a rotated-out member that
35//! keeps running an honest, chain-following node at its configured identity costs nothing. What
36//! matters is what members do after rotating. At bootstrap time:
37//!
38//! - At most `f` members may be Byzantine or stale, where "stale" means honest but no longer
39//!   following the chain. A frozen node replies honestly with an old finalization, which is
40//!   indistinguishable from an adversarial replay, so it spends the same budget.
41//! - At most `f` members may be unreachable (shut down, address changed, identity retired).
42//!   These cost liveness only; they cannot inject anything.
43//! - The remaining `f + 1` honest, current, reachable members guarantee both liveness (the
44//!   sample completes) and recency (every `f + 1` sample contains at least one of them).
45//!
46//! Both budgets may be fully spent simultaneously. Operators should refresh the configured set
47//! once they can no longer vouch that `f + 1` members remain live and current, exactly as one
48//! refreshes a weak-subjectivity checkpoint. Passing a subset of the committee mis-derives `f`
49//! and cannot be detected at startup; it is the same trust class as a wrong genesis.
50//!
51//! Certificate forgery is impossible regardless of these budgets: the threshold group key is
52//! reshare-invariant and finalizations are self-certifying, so an old committee can never sign
53//! for a round it did not finalize. Recency is the only weak-subjectivity dimension, and the
54//! sample supplies exactly that. See [`stateful::probe`](crate::stateful::probe) for the
55//! extended `f + 1` recency argument this actor inherits.
56//!
57//! # Protocol
58//!
59//! The actor is a two-state machine: it discovers an [`Artifact`], then serves boundary material.
60//!
61//! ## Discovery: solicit and sample
62//!
63//! Once a subscriber appears, [`Actor`] solicits every configured peer's latest finalization:
64//!
65//! ```text
66//!                +-- LatestRequest --> peer 1
67//!                |
68//!   Actor -------+-- LatestRequest --> peer 2
69//!                |
70//!                +-- LatestRequest --> peer 3
71//!
72//!   peer 2 --LatestResponse(finalization)--> Actor
73//! ```
74//!
75//! Replies are verified with the all-epoch verifier. At most one reply is counted per peer, only
76//! configured members may reply, and replies below the bootstrap epoch are ignored (the chain
77//! reached that epoch by definition, so any current member holds a finalization at or above its
78//! boundary). Once `f + 1` distinct peers have replied, the highest finalization becomes the
79//! state-sync floor and names the target epoch:
80//!
81//! ```text
82//!   peer 1 --LatestResponse(round 10)-->\               replies
83//!   peer 2 --LatestResponse(round 12)--> +-> Actor {10, 12, 13}
84//!   peer 3 --LatestResponse(round 13)-->/                     |
85//!                                                             v
86//!                          sample reached, highest reply becomes the floor: 13
87//! ```
88//!
89//! If too few peers reply before `retry_timeout`, collected replies are cleared and the
90//! solicitation is re-issued. Retry is a liveness mechanism only.
91//!
92//! ## Discovery: boundary fetch
93//!
94//! The floor's epoch identifies the target epoch, but not its boundary block. The actor asks
95//! every peer for the boundary finalization. These responses are small, so peers can answer in
96//! parallel without duplicating the boundary block:
97//!
98//! ```text
99//!                +-- BoundaryRequest(epoch) --> peer 1
100//!                |
101//!   Actor -------+-- BoundaryRequest(epoch) --> peer 2
102//!                |
103//!                +-- BoundaryRequest(epoch) --> peer 3
104//!
105//!   peer 2 --BoundaryResponse(finalization)--> Actor
106//! ```
107//!
108//! After verifying a boundary finalization, the actor requests its committed block only from that
109//! responder. Other verified responders are retained as failover candidates:
110//!
111//! ```text
112//!   Actor --BlockRequest(epoch)-------> peer 2
113//!   peer 2 --BlockResponse(epoch, block)--> Actor
114//! ```
115//!
116//! The block's [`EpochInfo`] is packaged into an [`Artifact`] together with the sampled floor and
117//! published to subscribers. The floor and the epoch info are fixed atomically, so the artifact's
118//! epoch always equals the floor's epoch:
119//!
120//! ```text
121//!   floor + boundary finalization + boundary block
122//!       --> Artifact { epoch, finalization, info, floor }
123//! ```
124//!
125//! A floor in epoch zero resolves from the locally known genesis info without a boundary fetch.
126//!
127//! ## Serving
128//!
129//! After a source of finalized blocks is attached, the actor enters service and answers peers'
130//! latest-finalization, boundary finalization, and boundary block requests for the rest of the
131//! process lifetime:
132//!
133//! ```text
134//!   peer --LatestRequest---------------> Actor --lookup--> LatestResponse -------> peer
135//!   peer --BoundaryRequest(epoch)-----> Actor --lookup--> BoundaryResponse -----> peer
136//!   peer --BlockRequest(epoch)---------> Actor --lookup--> BlockResponse --------> peer
137//! ```
138//!
139//! An epoch with no known boundary block is answered with nothing, as is a latest-finalization
140//! request when marshal has no finalization yet.
141
142use crate::dkg::{
143    ReshareBlock,
144    network::Directory,
145    types::{EpochInfo, Participants},
146};
147use commonware_consensus::{
148    marshal::core::Variant as MarshalVariant,
149    simplex::{scheme::Scheme, types::Finalization},
150    types::Epoch,
151};
152use commonware_cryptography::{
153    Digest, PublicKey, bls12381::primitives::variant::Variant as BlsVariant,
154};
155use commonware_utils::sequence::Unit;
156
157mod actor;
158pub use actor::{Actor, Config};
159
160mod mailbox;
161pub use mailbox::Mailbox;
162
163mod wire;
164
165/// The weakly subjective checkpoint a joining node bootstraps from.
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub struct Bootstrap<P: PublicKey, D: Directory<P> = Unit> {
168    /// Epoch whose participant snapshot is [`Bootstrap::participants`].
169    ///
170    /// Latest-finalization replies below this epoch are ignored, so the
171    /// discovered floor is never older than the configured trust point.
172    pub epoch: Epoch,
173    /// The complete participant snapshot of [`Bootstrap::epoch`].
174    ///
175    /// Discovery solicits and samples `f + 1` of the snapshot's dealers,
176    /// which are the epoch's active committee (its share holders and
177    /// certificate signers), so the dealers must be that complete committee:
178    /// a subset mis-derives `f`. See the module docs for the trust model and
179    /// the budgets on faulty, stale, and unreachable members.
180    ///
181    /// The snapshot must match the epoch's canonical [`Participants`]: when
182    /// discovery begins, the actor tracks the snapshot's
183    /// [`tracked_peers`](Participants::tracked_peers) at the epoch's own
184    /// peer-set ID, and all peers must track the same set contents at the
185    /// same ID. The orchestrator tracks the identical contents if it later
186    /// enters the bootstrap epoch, so the duplicate registration is benign.
187    pub participants: Participants<P>,
188    /// Transport directory for [`Bootstrap::participants`], seeded alongside
189    /// the checkpoint.
190    ///
191    /// Discovery runs before any application state exists, so the directory
192    /// is part of the weak-subjectivity configuration rather than resolved
193    /// from a registry.
194    pub directory: D,
195}
196
197/// Concrete probe artifact for a marshal variant.
198pub(crate) type ActorArtifact<S, V> = Artifact<
199    S,
200    <V as MarshalVariant>::Commitment,
201    <<V as MarshalVariant>::ApplicationBlock as ReshareBlock>::Variant,
202    <<V as MarshalVariant>::ApplicationBlock as ReshareBlock>::Directory,
203>;
204
205/// Public epoch material discovered during bootstrap.
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct Artifact<S, D, V, Dir = Unit>
208where
209    S: Scheme<D>,
210    D: Digest,
211    V: BlsVariant,
212    Dir: Directory<S::PublicKey>,
213{
214    /// Finalization of the boundary block that carried the epoch info.
215    ///
216    /// Epoch zero is anchored by genesis and has no boundary finalization.
217    pub finalization: Option<Finalization<S, D>>,
218    /// Public epoch information from the finalized boundary block.
219    ///
220    /// Carries the epoch's transport directory, so a joining node can activate
221    /// the discovered epoch's peers without any application state.
222    pub info: EpochInfo<V, S::PublicKey, Dir>,
223    /// Highest finalization from the `f + 1` peer sample.
224    ///
225    /// This is the state-sync floor: it is at least as recent as the freshest
226    /// honest reply in the sample.
227    pub floor: Finalization<S, D>,
228}
229
230#[cfg(test)]
231mod tests {
232    use super::{Actor, Bootstrap, Config, wire};
233    use crate::dkg::{
234        probe::Artifact,
235        tests::mocks,
236        types::{EpochInfo, EpochOutcome, Payload},
237    };
238    use commonware_actor::Feedback;
239    use commonware_codec::Encode as _;
240    use commonware_consensus::{
241        Epochable as _, Heightable as _, Reporter as _,
242        marshal::{self, Start, resolver::p2p as marshal_resolver},
243        simplex::types::{Activity, Finalization, Finalize, Proposal},
244        types::{Epoch, Epocher as _, FixedEpocher, Height, Round, View, ViewDelta},
245    };
246    use commonware_cryptography::{
247        Digest as _, Digestible as _, Hasher as _,
248        bls12381::{
249            dkg::feldman_desmedt::deal,
250            primitives::sharing::{Mode, Sharing},
251        },
252        certificate::Verifier as _,
253        sha256::Sha256,
254    };
255    use commonware_macros::select;
256    use commonware_p2p::{
257        Receiver as _, Recipients, Sender as _,
258        simulated::{
259            Config as NetworkConfig, Link, Network, Oracle, Receiver as SimReceiver,
260            Sender as SimSender,
261        },
262    };
263    use commonware_parallel::Sequential;
264    use commonware_runtime::{
265        Clock as _, Handle, Quota, Runner as _, Supervisor as _, buffer::paged::CacheRef,
266        deterministic,
267    };
268    use commonware_storage::archive::immutable;
269    use commonware_utils::{
270        N3f1, NZDuration, NZU16, NZU32, NZU64, NZUsize, TestRng, channel::oneshot, non_empty,
271        ordered::Set, probability, sequence::Unit,
272    };
273    use std::{num::NonZeroU64, time::Duration};
274
275    const BACKFILL_CHANNEL: u64 = 0;
276    const BOUNDARY_CHANNEL: u64 = 1;
277    const TEST_QUOTA: Quota = Quota::per_second(NZU32!(1_000_000));
278    const BLOCKS_PER_EPOCH: NonZeroU64 = NZU64!(2);
279    const LINK: Link = Link {
280        latency: Duration::from_millis(1),
281        jitter: Duration::ZERO,
282        success_rate: probability!(1.0),
283    };
284
285    struct Harness {
286        participants: Vec<mocks::TestPublicKey>,
287        schemes: Vec<mocks::TestScheme>,
288        source_boundary_sender: SimSender<mocks::TestPublicKey, deterministic::Context>,
289        client_boundary_sender: SimSender<mocks::TestPublicKey, deterministic::Context>,
290        client_boundary_receiver: SimReceiver<mocks::TestPublicKey>,
291        backup_boundary_sender: SimSender<mocks::TestPublicKey, deterministic::Context>,
292        backup_boundary_receiver: SimReceiver<mocks::TestPublicKey>,
293        oracle: Oracle<mocks::TestPublicKey, deterministic::Context>,
294        joiner: super::Mailbox<mocks::TestScheme, mocks::TestMarshalVariant>,
295        boundary: mocks::TestBlock,
296        boundary_finalization: Finalization<mocks::TestScheme, mocks::TestDigest>,
297        boundary_sharing: Sharing<mocks::TestBlsVariant>,
298        _handles: Vec<Handle<()>>,
299        _network: Handle<()>,
300    }
301
302    impl Harness {
303        async fn start(context: &mut deterministic::Context) -> Self {
304            Self::start_with(context, true).await
305        }
306
307        async fn start_with(context: &mut deterministic::Context, source_serves: bool) -> Self {
308            let boundaries = if source_serves {
309                vec![Epoch::new(1)]
310            } else {
311                Vec::new()
312            };
313            Self::start_with_boundaries(context, boundaries).await
314        }
315
316        async fn start_with_boundaries(
317            context: &mut deterministic::Context,
318            source_boundaries: Vec<Epoch>,
319        ) -> Self {
320            Self::start_full(context, source_boundaries, Epoch::zero()).await
321        }
322
323        async fn start_full(
324            context: &mut deterministic::Context,
325            source_boundaries: Vec<Epoch>,
326            bootstrap_epoch: Epoch,
327        ) -> Self {
328            let fixture = mocks::scheme_fixture_n(context, 4);
329            let participants = fixture.participants.clone();
330
331            let (network, oracle) = Network::new_with_peers(
332                context.child("network"),
333                NetworkConfig {
334                    max_size: 1024 * 1024,
335                    max_peers_per_set: NZUsize!(participants.len()),
336                    disconnect_on_block: true,
337                    tracked_peer_sets: NZUsize!(1),
338                },
339                participants.clone(),
340            )
341            .await;
342            let network = network.start();
343            for from in &participants {
344                for to in &participants {
345                    if from != to {
346                        oracle
347                            .add_link(from.clone(), to.clone(), LINK)
348                            .await
349                            .expect("failed to add link");
350                    }
351                }
352            }
353
354            let (boundary, boundary_sharing) =
355                boundary_block(Epoch::new(1), participants[0].clone(), &participants);
356            let genesis = genesis_info(&participants);
357            let first_boundary_finalization =
358                boundary_finalization(Epoch::new(1), boundary.digest(), &fixture.schemes);
359            let source_boundaries = source_boundaries
360                .into_iter()
361                .map(|epoch| {
362                    let (block, _) = boundary_block(epoch, participants[0].clone(), &participants);
363                    let finalization =
364                        boundary_finalization(epoch, block.digest(), &fixture.schemes);
365                    (block, finalization)
366                })
367                .collect::<Vec<_>>();
368
369            let (source_marshal, marshal_handle) = start_marshal(
370                context.child("source_marshal"),
371                &oracle,
372                &fixture.participants,
373                &fixture.schemes,
374                0,
375                source_boundaries,
376            )
377            .await;
378
379            let source_control = oracle.control(participants[0].clone());
380            let source_boundaries = source_control
381                .register(BOUNDARY_CHANNEL, TEST_QUOTA)
382                .await
383                .expect("failed to register source boundaries");
384            let source_boundary_sender = source_boundaries.0.clone();
385            let (source_actor, source_mailbox) = Actor::new(Config {
386                context: context.child("source_probe"),
387                manager: oracle.manager(),
388                bootstrap: Bootstrap {
389                    epoch: bootstrap_epoch,
390                    participants: genesis.participants(),
391                    directory: Unit,
392                },
393                verifier: fixture.schemes[0].clone(),
394                genesis: genesis.clone(),
395                strategy: Sequential,
396                blocker: oracle.control(participants[0].clone()),
397                blocks_per_epoch: BLOCKS_PER_EPOCH,
398                retry_timeout: NZDuration!(Duration::from_millis(500)),
399                mailbox_size: NZUsize!(16),
400                block_codec_config: (),
401            });
402            source_mailbox.attach(source_marshal.clone());
403            let source_handle = source_actor.start(source_boundaries);
404
405            let joiner_control = oracle.control(participants[1].clone());
406            let joiner_boundaries = joiner_control
407                .register(BOUNDARY_CHANNEL, TEST_QUOTA)
408                .await
409                .expect("failed to register joiner boundaries");
410            let (joiner_actor, joiner) = Actor::new(Config {
411                context: context.child("joiner_probe"),
412                manager: oracle.manager(),
413                bootstrap: Bootstrap {
414                    epoch: bootstrap_epoch,
415                    participants: genesis.participants(),
416                    directory: Unit,
417                },
418                verifier: fixture.schemes[1].clone(),
419                genesis,
420                strategy: Sequential,
421                blocker: oracle.control(participants[1].clone()),
422                blocks_per_epoch: BLOCKS_PER_EPOCH,
423                retry_timeout: NZDuration!(Duration::from_millis(500)),
424                mailbox_size: NZUsize!(16),
425                block_codec_config: (),
426            });
427            let joiner_handle = joiner_actor.start(joiner_boundaries);
428            let client_boundaries = oracle
429                .control(participants[2].clone())
430                .register(BOUNDARY_CHANNEL, TEST_QUOTA)
431                .await
432                .expect("failed to register client boundaries");
433            let backup_boundaries = oracle
434                .control(participants[3].clone())
435                .register(BOUNDARY_CHANNEL, TEST_QUOTA)
436                .await
437                .expect("failed to register backup boundaries");
438
439            Self {
440                participants,
441                schemes: fixture.schemes,
442                source_boundary_sender,
443                client_boundary_sender: client_boundaries.0,
444                client_boundary_receiver: client_boundaries.1,
445                backup_boundary_sender: backup_boundaries.0,
446                backup_boundary_receiver: backup_boundaries.1,
447                oracle,
448                joiner,
449                boundary,
450                boundary_finalization: first_boundary_finalization,
451                boundary_sharing,
452                _handles: vec![marshal_handle, source_handle, joiner_handle],
453                _network: network,
454            }
455        }
456
457        /// Builds a latest finalization for `epoch` committing to `digest`.
458        fn latest_finalization(
459            &self,
460            epoch: Epoch,
461            digest: mocks::TestDigest,
462        ) -> Finalization<mocks::TestScheme, mocks::TestDigest> {
463            finalization(
464                Proposal::new(Round::new(epoch, View::new(2)), View::new(1), digest),
465                &self.schemes,
466            )
467        }
468
469        /// The default latest reply: a finalization within epoch 1 committing
470        /// to the epoch-1 boundary digest.
471        fn target_finalization(&self) -> Finalization<mocks::TestScheme, mocks::TestDigest> {
472            self.latest_finalization(Epoch::new(1), self.boundary.digest())
473        }
474
475        fn latest_response(
476            finalization: Finalization<mocks::TestScheme, mocks::TestDigest>,
477        ) -> Vec<u8> {
478            wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::LatestResponse(
479                finalization,
480            )
481            .encode()
482            .to_vec()
483        }
484
485        fn reply_latest_from_client(
486            &mut self,
487            finalization: Finalization<mocks::TestScheme, mocks::TestDigest>,
488        ) {
489            self.client_boundary_sender.send(
490                Recipients::One(self.participants[1].clone()),
491                Self::latest_response(finalization),
492                false,
493            );
494        }
495
496        fn reply_latest_from_backup(
497            &mut self,
498            finalization: Finalization<mocks::TestScheme, mocks::TestDigest>,
499        ) {
500            self.backup_boundary_sender.send(
501                Recipients::One(self.participants[1].clone()),
502                Self::latest_response(finalization),
503                false,
504            );
505        }
506
507        /// Completes a sample for the target finalization from the client and
508        /// backup peers.
509        fn complete_target_sample(&mut self) -> Finalization<mocks::TestScheme, mocks::TestDigest> {
510            let target = self.target_finalization();
511            self.reply_latest_from_client(target.clone());
512            self.reply_latest_from_backup(target.clone());
513            target
514        }
515
516        async fn next_request(receiver: &mut SimReceiver<mocks::TestPublicKey>) -> wire::Request {
517            let (_, message) = receiver.recv().await.expect("boundary request");
518            wire::read_request(message)
519                .expect("decode boundary request")
520                .expect("boundary request tag")
521        }
522
523        async fn expect_latest_request(receiver: &mut SimReceiver<mocks::TestPublicKey>) {
524            match Self::next_request(receiver).await {
525                wire::Request::Latest => {}
526                wire::Request::Boundary(_) | wire::Request::Block(_) => {
527                    panic!("expected latest request")
528                }
529            }
530        }
531
532        async fn next_boundary_request(receiver: &mut SimReceiver<mocks::TestPublicKey>) -> Epoch {
533            match Self::next_request(receiver).await {
534                wire::Request::Boundary(epoch) => epoch,
535                wire::Request::Block(_) | wire::Request::Latest => {
536                    panic!("expected finalization request")
537                }
538            }
539        }
540
541        async fn next_block_request(receiver: &mut SimReceiver<mocks::TestPublicKey>) -> Epoch {
542            match Self::next_request(receiver).await {
543                wire::Request::Block(epoch) => epoch,
544                wire::Request::Boundary(_) | wire::Request::Latest => {
545                    panic!("expected block request")
546                }
547            }
548        }
549
550        async fn next_client_boundary_request(&mut self) -> Epoch {
551            Self::next_boundary_request(&mut self.client_boundary_receiver).await
552        }
553    }
554
555    async fn start_marshal(
556        context: deterministic::Context,
557        oracle: &Oracle<mocks::TestPublicKey, deterministic::Context>,
558        participants: &[mocks::TestPublicKey],
559        schemes: &[mocks::TestScheme],
560        index: usize,
561        boundaries: Vec<(
562            mocks::TestBlock,
563            Finalization<mocks::TestScheme, mocks::TestDigest>,
564        )>,
565    ) -> (mocks::TestMarshalMailbox, Handle<()>) {
566        let public_key = participants[index].clone();
567        let partition_prefix = format!("probe-node-{index}");
568        let page_cache = CacheRef::from_pooler(&context, NZU16!(1024), NZUsize!(16));
569        let control = oracle.control(public_key.clone());
570        let backfill = control
571            .register(BACKFILL_CHANNEL, TEST_QUOTA)
572            .await
573            .expect("failed to register marshal backfill");
574        let resolver = marshal_resolver::init(
575            context.child("marshal_resolver"),
576            marshal_resolver::Config {
577                public_key: public_key.clone(),
578                peer_provider: oracle.manager(),
579                blocker: oracle.control(public_key.clone()),
580                mailbox_size: NZUsize!(16),
581                timeout: Duration::from_secs(2),
582                fetch_retry_timeout: Duration::from_millis(100),
583                priority_requests: false,
584                priority_responses: false,
585            },
586            backfill,
587        );
588        let finalizations_by_height =
589            immutable::Archive::init(context.child("finalizations_by_height"), {
590                let _: () = mocks::TestScheme::certificate_codec_config_unbounded();
591                archive_config(
592                    &partition_prefix,
593                    "finalizations_by_height",
594                    page_cache.clone(),
595                    (),
596                )
597            })
598            .await
599            .expect("failed to initialize finalizations archive");
600        let finalized_blocks = immutable::Archive::init(
601            context.child("finalized_blocks"),
602            archive_config(
603                &partition_prefix,
604                "finalized_blocks",
605                page_cache.clone(),
606                (),
607            ),
608        )
609        .await
610        .expect("failed to initialize finalized blocks archive");
611
612        let (marshal_actor, mut marshal, _) = marshal::core::Actor::init(
613            context.child("marshal"),
614            finalizations_by_height,
615            finalized_blocks,
616            marshal::Config {
617                provider: mocks::TestProvider::new(schemes[index].clone()),
618                epocher: FixedEpocher::new(BLOCKS_PER_EPOCH),
619                start: Start::Genesis(mocks::genesis_block(public_key)),
620                partition_prefix,
621                mailbox_size: NZUsize!(16),
622                view_retention: ViewDelta::new(8),
623                prunable_items_per_section: NZU64!(10),
624                page_cache,
625                replay_buffer: NZUsize!(1024),
626                key_write_buffer: NZUsize!(1024),
627                value_write_buffer: NZUsize!(1024),
628                block_codec_config: (),
629                max_repair: NZUsize!(4),
630                max_pending_acks: NZUsize!(4),
631                strategy: Sequential,
632            },
633        )
634        .await;
635        let handle = marshal_actor.start_unbuffered(mocks::MarshalApplication::default(), resolver);
636
637        for (block, finalization) in boundaries {
638            assert!(marshal.certified(block.context().round, block).await);
639            assert_eq!(
640                marshal.report(Activity::Finalization(finalization)),
641                Feedback::Ok
642            );
643        }
644
645        (marshal, handle)
646    }
647
648    fn archive_config<C>(
649        prefix: &str,
650        name: &str,
651        page_cache: CacheRef,
652        codec_config: C,
653    ) -> immutable::Config<C> {
654        immutable::Config {
655            metadata_partition: format!("{prefix}-{name}-metadata"),
656            freezer_table_partition: format!("{prefix}-{name}-freezer-table"),
657            freezer_table_initial_size: 64,
658            freezer_table_resize_frequency: 10,
659            freezer_table_resize_chunk_size: 10,
660            freezer_key_partition: format!("{prefix}-{name}-freezer-key"),
661            freezer_key_page_cache: page_cache,
662            freezer_value_partition: format!("{prefix}-{name}-freezer-value"),
663            freezer_value_target_size: 1024,
664            freezer_value_compression: None,
665            ordinal_partition: format!("{prefix}-{name}-ordinal"),
666            items_per_section: NZU64!(10),
667            codec_config,
668            replay_buffer: NZUsize!(1024),
669            freezer_key_write_buffer: NZUsize!(1024),
670            freezer_value_write_buffer: NZUsize!(1024),
671            ordinal_write_buffer: NZUsize!(1024),
672        }
673    }
674
675    fn boundary_block(
676        epoch: Epoch,
677        leader: mocks::TestPublicKey,
678        participants: &[mocks::TestPublicKey],
679    ) -> (mocks::TestBlock, Sharing<mocks::TestBlsVariant>) {
680        let height = FixedEpocher::new(BLOCKS_PER_EPOCH)
681            .last(epoch.previous().expect("boundary epoch must be non-zero"))
682            .expect("test epoch must be supported");
683        let parent = if height == Height::zero() {
684            mocks::TestDigest::EMPTY
685        } else {
686            Sha256::hash(&[&height
687                .previous()
688                .expect("non-genesis height")
689                .get()
690                .to_be_bytes()])
691        };
692        let context = mocks::TestContext {
693            round: Round::new(
694                epoch.previous().expect("boundary epoch must be non-zero"),
695                View::new(1),
696            ),
697            leader,
698            parent: (View::zero(), parent),
699        };
700        let participants = Set::from_iter_dedup(participants.iter().cloned());
701        let (output, _) = deal::<mocks::TestBlsVariant, _, N3f1>(
702            TestRng::new(epoch.get()),
703            Mode::NonZeroCounter,
704            participants.clone(),
705        )
706        .expect("failed to create test DKG output");
707        let sharing = output.public().clone();
708        let block = mocks::TestBlock::new::<Sha256>(context, parent, height, epoch.get())
709            .with_payload::<Sha256, mocks::TestBlsVariant, mocks::TestSigner>(
710            NZU32!(16),
711            Payload::EpochInfo(EpochInfo {
712                outcome: EpochOutcome::Success,
713                epoch,
714                output,
715                players: participants.clone(),
716                next_players: participants,
717                directory: Unit,
718            }),
719        );
720        (block, sharing)
721    }
722
723    fn boundary_finalization(
724        epoch: Epoch,
725        digest: mocks::TestDigest,
726        schemes: &[mocks::TestScheme],
727    ) -> Finalization<mocks::TestScheme, mocks::TestDigest> {
728        finalization(
729            Proposal::new(
730                Round::new(
731                    epoch.previous().expect("boundary epoch must be non-zero"),
732                    View::new(1),
733                ),
734                View::zero(),
735                digest,
736            ),
737            schemes,
738        )
739    }
740
741    fn genesis_info(
742        participants: &[mocks::TestPublicKey],
743    ) -> EpochInfo<mocks::TestBlsVariant, mocks::TestPublicKey> {
744        let participants = Set::from_iter_dedup(participants.iter().cloned());
745        let (output, _) = deal::<mocks::TestBlsVariant, _, N3f1>(
746            TestRng::new(0),
747            Mode::NonZeroCounter,
748            participants.clone(),
749        )
750        .expect("failed to create test DKG output");
751        EpochInfo {
752            outcome: EpochOutcome::Success,
753            epoch: Epoch::zero(),
754            output,
755            players: participants.clone(),
756            next_players: participants,
757            directory: Unit,
758        }
759    }
760
761    fn finalization(
762        proposal: Proposal<mocks::TestDigest>,
763        schemes: &[mocks::TestScheme],
764    ) -> Finalization<mocks::TestScheme, mocks::TestDigest> {
765        let finalizes = schemes
766            .iter()
767            .map(|scheme| Finalize::sign(scheme, proposal.clone()).unwrap())
768            .collect::<Vec<_>>();
769        Finalization::from_finalizes(&schemes[0], non_empty![@finalizes.iter()], &Sequential)
770            .expect("finalization quorum")
771    }
772
773    fn assert_artifact(
774        artifact: Artifact<mocks::TestScheme, mocks::TestDigest, mocks::TestBlsVariant>,
775        expected_finalization: &Finalization<mocks::TestScheme, mocks::TestDigest>,
776        expected_sharing: &Sharing<mocks::TestBlsVariant>,
777        participants: &[mocks::TestPublicKey],
778    ) {
779        let expected_epoch = expected_finalization.epoch().next();
780        let participants = Set::from_iter_dedup(participants.iter().cloned());
781        assert_eq!(artifact.finalization.as_ref(), Some(expected_finalization));
782        assert_eq!(artifact.info.epoch, expected_epoch);
783        assert_eq!(artifact.info.output.public(), expected_sharing);
784        assert_eq!(artifact.info.output.players(), &participants);
785        assert_eq!(artifact.info.players, participants);
786        assert_eq!(artifact.floor.epoch(), expected_epoch);
787    }
788
789    #[test]
790    fn discovers_artifact_from_sample() {
791        let runner = deterministic::Runner::timed(Duration::from_secs(30));
792        runner.start(|mut context| async move {
793            let mut harness = Harness::start(&mut context).await;
794            let mut subscription = harness.joiner.subscribe();
795            let target = harness.complete_target_sample();
796
797            context.sleep(Duration::from_millis(100)).await;
798            let artifact = subscription.try_recv().expect("artifact resolved");
799            assert_eq!(artifact.floor, target);
800            assert_artifact(
801                artifact,
802                &harness.boundary_finalization,
803                &harness.boundary_sharing,
804                &harness.participants,
805            );
806        });
807    }
808
809    #[test]
810    fn waits_for_full_sample() {
811        let runner = deterministic::Runner::timed(Duration::from_secs(30));
812        runner.start(|mut context| async move {
813            let mut harness = Harness::start_with(&mut context, false).await;
814            let mut subscription = harness.joiner.subscribe();
815
816            // One reply is below the sample threshold (f + 1 = 2 of 4).
817            let target = harness.target_finalization();
818            harness.reply_latest_from_client(target);
819
820            context.sleep(Duration::from_millis(100)).await;
821            assert!(
822                matches!(
823                    subscription.try_recv(),
824                    Err(oneshot::error::TryRecvError::Empty)
825                ),
826                "a single reply must not complete the sample"
827            );
828        });
829    }
830
831    #[test]
832    fn duplicate_latest_reply_is_ignored() {
833        let runner = deterministic::Runner::timed(Duration::from_secs(30));
834        runner.start(|mut context| async move {
835            let mut harness = Harness::start_with(&mut context, false).await;
836            let mut subscription = harness.joiner.subscribe();
837
838            // Two different valid replies from the same peer must count once.
839            let first = harness.latest_finalization(Epoch::new(1), Sha256::hash(&[b"first"]));
840            let second = harness.latest_finalization(Epoch::new(2), Sha256::hash(&[b"second"]));
841            harness.reply_latest_from_client(first);
842            harness.reply_latest_from_client(second);
843
844            context.sleep(Duration::from_millis(100)).await;
845            assert!(
846                matches!(
847                    subscription.try_recv(),
848                    Err(oneshot::error::TryRecvError::Empty)
849                ),
850                "duplicate replies must not inflate the sample"
851            );
852            let blocked = harness.oracle.blocked().await.unwrap();
853            assert!(
854                blocked.is_empty(),
855                "a duplicate reply must be ignored, not treated as a fault"
856            );
857        });
858    }
859
860    #[test]
861    fn sample_selects_highest_reply() {
862        let runner = deterministic::Runner::timed(Duration::from_secs(30));
863        runner.start(|mut context| async move {
864            // The source can serve the epoch-2 boundary.
865            let mut harness =
866                Harness::start_with_boundaries(&mut context, vec![Epoch::new(2)]).await;
867            let mut subscription = harness.joiner.subscribe();
868
869            let (newer_boundary, newer_sharing) = boundary_block(
870                Epoch::new(2),
871                harness.participants[0].clone(),
872                &harness.participants,
873            );
874            let stale = harness.latest_finalization(Epoch::new(1), Sha256::hash(&[b"stale"]));
875            let newest = harness.latest_finalization(Epoch::new(2), newer_boundary.digest());
876            harness.reply_latest_from_client(stale);
877            harness.reply_latest_from_backup(newest.clone());
878
879            context.sleep(Duration::from_millis(100)).await;
880            let artifact = subscription.try_recv().expect("artifact resolved");
881            assert_eq!(artifact.floor, newest);
882            assert_artifact(
883                artifact,
884                &boundary_finalization(Epoch::new(2), newer_boundary.digest(), &harness.schemes),
885                &newer_sharing,
886                &harness.participants,
887            );
888        });
889    }
890
891    #[test]
892    fn genesis_floor_resolves_locally() {
893        let runner = deterministic::Runner::timed(Duration::from_secs(30));
894        runner.start(|mut context| async move {
895            let mut harness = Harness::start_with(&mut context, false).await;
896            let mut subscription = harness.joiner.subscribe();
897
898            // The whole sample reports epoch-zero finalizations: the artifact
899            // resolves from the locally known genesis info without any
900            // boundary fetch.
901            let floor =
902                harness.latest_finalization(Epoch::zero(), Sha256::hash(&[b"genesis floor"]));
903            harness.reply_latest_from_client(floor.clone());
904            harness.reply_latest_from_backup(floor.clone());
905
906            context.sleep(Duration::from_millis(100)).await;
907            let artifact = subscription.try_recv().expect("genesis resolved");
908            assert_eq!(artifact.info.epoch, Epoch::zero());
909            assert!(artifact.finalization.is_none());
910            assert_eq!(artifact.info, genesis_info(&harness.participants));
911            assert_eq!(artifact.floor, floor);
912        });
913    }
914
915    #[test]
916    fn resolicits_when_sample_incomplete() {
917        let runner = deterministic::Runner::timed(Duration::from_secs(30));
918        runner.start(|mut context| async move {
919            let mut harness = Harness::start_with(&mut context, false).await;
920            let _subscription = harness.joiner.subscribe();
921
922            // The first solicitation reaches the client, goes unanswered, and
923            // is re-issued after the retry timeout.
924            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
925            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
926        });
927    }
928
929    #[test]
930    fn ignores_latest_reply_below_bootstrap_epoch() {
931        let runner = deterministic::Runner::timed(Duration::from_secs(30));
932        runner.start(|mut context| async move {
933            let mut harness =
934                Harness::start_full(&mut context, vec![Epoch::new(1)], Epoch::new(1)).await;
935            let mut subscription = harness.joiner.subscribe();
936
937            // Valid replies below the bootstrap epoch are stale by definition
938            // and must be ignored without blocking.
939            let stale = harness.latest_finalization(Epoch::zero(), Sha256::hash(&[b"stale"]));
940            harness.reply_latest_from_client(stale.clone());
941            harness.reply_latest_from_backup(stale);
942            context.sleep(Duration::from_millis(100)).await;
943            assert!(
944                matches!(
945                    subscription.try_recv(),
946                    Err(oneshot::error::TryRecvError::Empty)
947                ),
948                "below-bootstrap replies must not complete the sample"
949            );
950            let blocked = harness.oracle.blocked().await.unwrap();
951            assert!(blocked.is_empty(), "stale replies must not block peers");
952
953            // The same peers may still contribute accepted replies.
954            let target = harness.complete_target_sample();
955            context.sleep(Duration::from_millis(100)).await;
956            let artifact = subscription.try_recv().expect("artifact resolved");
957            assert_eq!(artifact.floor, target);
958        });
959    }
960
961    #[test]
962    fn invalid_latest_reply_blocks_peer() {
963        let runner = deterministic::Runner::timed(Duration::from_secs(30));
964        runner.start(|mut context| async move {
965            let mut harness = Harness::start_with(&mut context, false).await;
966            let _subscription = harness.joiner.subscribe();
967
968            // A finalization signed by a foreign key set decodes cleanly but
969            // fails verification against the all-epoch verifier.
970            let foreign = mocks::scheme_fixture_n(&mut context, 4);
971            let invalid = finalization(
972                Proposal::new(
973                    Round::new(Epoch::new(1), View::new(2)),
974                    View::new(1),
975                    Sha256::hash(&[b"foreign"]),
976                ),
977                &foreign.schemes,
978            );
979            harness.reply_latest_from_client(invalid);
980
981            context.sleep(Duration::from_millis(100)).await;
982            let blocked = harness.oracle.blocked().await.unwrap();
983            assert!(
984                blocked.contains(&(
985                    harness.participants[1].clone(),
986                    harness.participants[2].clone(),
987                )),
988                "joiner should block the sender of an invalid reply"
989            );
990        });
991    }
992
993    #[test]
994    fn fetches_boundary_block_from_one_responder() {
995        let runner = deterministic::Runner::timed(Duration::from_secs(30));
996        runner.start(|mut context| async move {
997            let mut harness = Harness::start_with(&mut context, false).await;
998            let mut subscription = harness.joiner.subscribe();
999
1000            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
1001            Harness::expect_latest_request(&mut harness.backup_boundary_receiver).await;
1002            harness.complete_target_sample();
1003
1004            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1005            assert_eq!(
1006                Harness::next_boundary_request(&mut harness.backup_boundary_receiver).await,
1007                Epoch::new(1)
1008            );
1009
1010            harness.client_boundary_sender.send(
1011                Recipients::One(harness.participants[1].clone()),
1012                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1013                    harness.boundary_finalization.clone(),
1014                )
1015                .encode()
1016                .to_vec(),
1017                false,
1018            );
1019            assert_eq!(
1020                Harness::next_block_request(&mut harness.client_boundary_receiver).await,
1021                Epoch::new(1)
1022            );
1023
1024            select! {
1025                _ = harness.backup_boundary_receiver.recv() => {
1026                    panic!("block request sent to an unselected responder");
1027                },
1028                _ = context.sleep(Duration::from_millis(100)) => {},
1029            }
1030
1031            harness.client_boundary_sender.send(
1032                Recipients::One(harness.participants[1].clone()),
1033                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BlockResponse {
1034                    epoch: Epoch::new(1),
1035                    block: harness.boundary.clone(),
1036                }
1037                .encode()
1038                .to_vec(),
1039                false,
1040            );
1041            context.sleep(Duration::from_millis(100)).await;
1042
1043            let artifact = subscription.try_recv().expect("artifact resolved");
1044            assert_artifact(
1045                artifact,
1046                &harness.boundary_finalization,
1047                &harness.boundary_sharing,
1048                &harness.participants,
1049            );
1050        });
1051    }
1052
1053    #[test]
1054    fn retries_boundary_block_with_another_responder() {
1055        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1056        runner.start(|mut context| async move {
1057            let mut harness = Harness::start_with(&mut context, false).await;
1058            let mut subscription = harness.joiner.subscribe();
1059
1060            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
1061            Harness::expect_latest_request(&mut harness.backup_boundary_receiver).await;
1062            harness.complete_target_sample();
1063
1064            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1065            assert_eq!(
1066                Harness::next_boundary_request(&mut harness.backup_boundary_receiver).await,
1067                Epoch::new(1)
1068            );
1069
1070            harness.client_boundary_sender.send(
1071                Recipients::One(harness.participants[1].clone()),
1072                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1073                    harness.boundary_finalization.clone(),
1074                )
1075                .encode()
1076                .to_vec(),
1077                false,
1078            );
1079            assert_eq!(
1080                Harness::next_block_request(&mut harness.client_boundary_receiver).await,
1081                Epoch::new(1)
1082            );
1083
1084            harness.backup_boundary_sender.send(
1085                Recipients::One(harness.participants[1].clone()),
1086                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1087                    harness.boundary_finalization.clone(),
1088                )
1089                .encode()
1090                .to_vec(),
1091                false,
1092            );
1093            assert_eq!(
1094                Harness::next_block_request(&mut harness.backup_boundary_receiver).await,
1095                Epoch::new(1)
1096            );
1097
1098            harness.backup_boundary_sender.send(
1099                Recipients::One(harness.participants[1].clone()),
1100                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BlockResponse {
1101                    epoch: Epoch::new(1),
1102                    block: harness.boundary.clone(),
1103                }
1104                .encode()
1105                .to_vec(),
1106                false,
1107            );
1108            context.sleep(Duration::from_millis(100)).await;
1109
1110            let artifact = subscription.try_recv().expect("artifact resolved");
1111            assert_artifact(
1112                artifact,
1113                &harness.boundary_finalization,
1114                &harness.boundary_sharing,
1115                &harness.participants,
1116            );
1117            let blocked = harness.oracle.blocked().await.unwrap();
1118            assert!(blocked.is_empty(), "silent responders must not be blocked");
1119        });
1120    }
1121
1122    #[test]
1123    fn invalid_boundary_block_tries_another_responder_immediately() {
1124        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1125        runner.start(|mut context| async move {
1126            let mut harness = Harness::start_with(&mut context, false).await;
1127            let mut subscription = harness.joiner.subscribe();
1128
1129            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
1130            Harness::expect_latest_request(&mut harness.backup_boundary_receiver).await;
1131            harness.complete_target_sample();
1132
1133            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1134            assert_eq!(
1135                Harness::next_boundary_request(&mut harness.backup_boundary_receiver).await,
1136                Epoch::new(1)
1137            );
1138
1139            harness.client_boundary_sender.send(
1140                Recipients::One(harness.participants[1].clone()),
1141                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1142                    harness.boundary_finalization.clone(),
1143                )
1144                .encode()
1145                .to_vec(),
1146                false,
1147            );
1148            assert_eq!(
1149                Harness::next_block_request(&mut harness.client_boundary_receiver).await,
1150                Epoch::new(1)
1151            );
1152
1153            harness.backup_boundary_sender.send(
1154                Recipients::One(harness.participants[1].clone()),
1155                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1156                    harness.boundary_finalization.clone(),
1157                )
1158                .encode()
1159                .to_vec(),
1160                false,
1161            );
1162            context.sleep(Duration::from_millis(100)).await;
1163
1164            let (wrong_block, _) = boundary_block(
1165                Epoch::new(2),
1166                harness.participants[0].clone(),
1167                &harness.participants,
1168            );
1169            harness.client_boundary_sender.send(
1170                Recipients::One(harness.participants[1].clone()),
1171                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BlockResponse {
1172                    epoch: Epoch::new(1),
1173                    block: wrong_block,
1174                }
1175                .encode()
1176                .to_vec(),
1177                false,
1178            );
1179
1180            select! {
1181                epoch = Harness::next_block_request(&mut harness.backup_boundary_receiver) => {
1182                    assert_eq!(epoch, Epoch::new(1));
1183                },
1184                _ = context.sleep(Duration::from_millis(100)) => {
1185                    panic!("invalid block did not trigger immediate failover");
1186                },
1187            }
1188
1189            harness.backup_boundary_sender.send(
1190                Recipients::One(harness.participants[1].clone()),
1191                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BlockResponse {
1192                    epoch: Epoch::new(1),
1193                    block: harness.boundary.clone(),
1194                }
1195                .encode()
1196                .to_vec(),
1197                false,
1198            );
1199            context.sleep(Duration::from_millis(100)).await;
1200
1201            let artifact = subscription.try_recv().expect("artifact resolved");
1202            assert_artifact(
1203                artifact,
1204                &harness.boundary_finalization,
1205                &harness.boundary_sharing,
1206                &harness.participants,
1207            );
1208            let blocked = harness.oracle.blocked().await.unwrap();
1209            assert!(blocked.contains(&(
1210                harness.participants[1].clone(),
1211                harness.participants[2].clone()
1212            )));
1213        });
1214    }
1215
1216    #[test]
1217    fn rebroadcasts_finalization_request_when_unanswered() {
1218        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1219        runner.start(|mut context| async move {
1220            // The source has no boundary block, so the joiner's request goes
1221            // unanswered and it must re-request rather than wedging.
1222            let mut harness = Harness::start_with(&mut context, false).await;
1223            let mut subscription = harness.joiner.subscribe();
1224
1225            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
1226            harness.complete_target_sample();
1227
1228            // First broadcast: a peer observes the request, but nobody answers.
1229            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1230            assert!(matches!(
1231                subscription.try_recv(),
1232                Err(oneshot::error::TryRecvError::Empty)
1233            ));
1234
1235            // After the retry timeout the joiner re-broadcasts the same request.
1236            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1237            assert!(matches!(
1238                subscription.try_recv(),
1239                Err(oneshot::error::TryRecvError::Empty)
1240            ));
1241        });
1242    }
1243
1244    #[test]
1245    fn terminal_epoch_boundary_response_does_not_panic() {
1246        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1247        runner.start(|mut context| async move {
1248            let mut harness = Harness::start_with_boundaries(&mut context, Vec::new()).await;
1249            let mut subscription = harness.joiner.subscribe();
1250
1251            Harness::expect_latest_request(&mut harness.client_boundary_receiver).await;
1252            harness.complete_target_sample();
1253            assert_eq!(harness.next_client_boundary_request().await, Epoch::new(1));
1254
1255            let terminal_finalization = finalization(
1256                Proposal::new(
1257                    Round::new(Epoch::new(u64::MAX), View::new(1)),
1258                    View::zero(),
1259                    harness.boundary.digest(),
1260                ),
1261                &harness.schemes,
1262            );
1263            let message =
1264                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryResponse(
1265                    terminal_finalization,
1266                )
1267                .encode()
1268                .to_vec();
1269            let decoded = wire::read_response::<mocks::TestScheme, mocks::TestMarshalVariant, _>(
1270                message.as_slice(),
1271                &harness.schemes[2].certificate_codec_config(),
1272            )
1273            .expect("terminal response decoded")
1274            .expect("terminal response tag");
1275            let wire::Response::Boundary(decoded) = decoded else {
1276                panic!("expected finalization response");
1277            };
1278            assert_eq!(decoded.epoch(), Epoch::new(u64::MAX));
1279
1280            harness.source_boundary_sender.send(
1281                Recipients::One(harness.participants[1].clone()),
1282                message,
1283                false,
1284            );
1285            context.sleep(Duration::from_millis(100)).await;
1286
1287            let blocked = harness.oracle.blocked().await.unwrap();
1288            assert!(
1289                blocked.contains(&(
1290                    harness.participants[1].clone(),
1291                    harness.participants[0].clone()
1292                )),
1293                "terminal-epoch response should block source peer"
1294            );
1295            assert!(matches!(
1296                subscription.try_recv(),
1297                Err(oneshot::error::TryRecvError::Empty)
1298            ));
1299        });
1300    }
1301
1302    #[test]
1303    fn does_not_solicit_without_subscriber() {
1304        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1305        runner.start(|mut context| async move {
1306            let mut harness = Harness::start(&mut context).await;
1307            select! {
1308                _ = harness.client_boundary_receiver.recv() => {
1309                    panic!("solicitation sent before any subscriber");
1310                },
1311                _ = context.sleep(Duration::from_millis(700)) => {},
1312            }
1313        });
1314    }
1315
1316    #[test]
1317    fn late_subscriber_receives_cached_artifact() {
1318        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1319        runner.start(|mut context| async move {
1320            let mut harness = Harness::start(&mut context).await;
1321            let mut first = harness.joiner.subscribe();
1322            harness.complete_target_sample();
1323
1324            context.sleep(Duration::from_millis(100)).await;
1325            let artifact = first.try_recv().expect("artifact resolved");
1326            assert_artifact(
1327                artifact,
1328                &harness.boundary_finalization,
1329                &harness.boundary_sharing,
1330                &harness.participants,
1331            );
1332
1333            let mut second = harness.joiner.subscribe();
1334            context.sleep(Duration::from_millis(10)).await;
1335            let artifact = second.try_recv().expect("cached artifact resolved");
1336            assert_artifact(
1337                artifact,
1338                &harness.boundary_finalization,
1339                &harness.boundary_sharing,
1340                &harness.participants,
1341            );
1342        });
1343    }
1344
1345    #[test]
1346    fn serving_answers_latest_request_from_marshal() {
1347        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1348        runner.start(|mut context| async move {
1349            let mut harness = Harness::start(&mut context).await;
1350            harness.client_boundary_sender.send(
1351                Recipients::One(harness.participants[0].clone()),
1352                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::LatestRequest
1353                    .encode()
1354                    .to_vec(),
1355                false,
1356            );
1357
1358            let (_peer, message) = harness
1359                .client_boundary_receiver
1360                .recv()
1361                .await
1362                .expect("latest response delivered");
1363            let response = wire::read_response::<mocks::TestScheme, mocks::TestMarshalVariant, _>(
1364                message,
1365                &harness.schemes[2].certificate_codec_config(),
1366            )
1367            .expect("latest response decoded")
1368            .expect("latest response");
1369            let wire::Response::Latest(finalization) = response else {
1370                panic!("expected latest response");
1371            };
1372            assert_eq!(finalization, harness.boundary_finalization);
1373        });
1374    }
1375
1376    #[test]
1377    fn serving_answers_finalization_and_block_requests_from_marshal() {
1378        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1379        runner.start(|mut context| async move {
1380            let mut harness = Harness::start(&mut context).await;
1381            harness.client_boundary_sender.send(
1382                Recipients::One(harness.participants[0].clone()),
1383                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryRequest(
1384                    Epoch::new(1),
1385                )
1386                .encode()
1387                .to_vec(),
1388                false,
1389            );
1390
1391            let (_peer, message) = harness
1392                .client_boundary_receiver
1393                .recv()
1394                .await
1395                .expect("boundary response delivered");
1396            let response = wire::read_response::<mocks::TestScheme, mocks::TestMarshalVariant, _>(
1397                message,
1398                &harness.schemes[2].certificate_codec_config(),
1399            )
1400            .expect("boundary response decoded")
1401            .expect("boundary response");
1402            let wire::Response::Boundary(finalization) = response else {
1403                panic!("expected finalization response");
1404            };
1405            let commitment = finalization.proposal.payload;
1406            assert_eq!(finalization, harness.boundary_finalization);
1407
1408            harness.client_boundary_sender.send(
1409                Recipients::One(harness.participants[0].clone()),
1410                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BlockRequest(
1411                    Epoch::new(1),
1412                )
1413                .encode()
1414                .to_vec(),
1415                false,
1416            );
1417            let (_peer, message) = harness
1418                .client_boundary_receiver
1419                .recv()
1420                .await
1421                .expect("block response delivered");
1422            let response = wire::read_response::<mocks::TestScheme, mocks::TestMarshalVariant, _>(
1423                message,
1424                &harness.schemes[2].certificate_codec_config(),
1425            )
1426            .expect("block response decoded")
1427            .expect("block response");
1428            let wire::Response::Block { epoch, body } = response else {
1429                panic!("expected block response");
1430            };
1431            assert_eq!(epoch, Epoch::new(1));
1432            let block = wire::read_block::<mocks::TestMarshalVariant>(body, commitment, &())
1433                .expect("boundary block decoded");
1434
1435            assert_eq!(block.digest(), harness.boundary.digest());
1436            assert_eq!(block.height(), Height::new(1));
1437        });
1438    }
1439
1440    #[test]
1441    fn serving_ignores_epoch_without_boundary() {
1442        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1443        runner.start(|mut context| async move {
1444            let mut harness = Harness::start(&mut context).await;
1445            harness.client_boundary_sender.send(
1446                Recipients::One(harness.participants[0].clone()),
1447                wire::Message::<mocks::TestScheme, mocks::TestMarshalVariant>::BoundaryRequest(
1448                    Epoch::zero(),
1449                )
1450                .encode()
1451                .to_vec(),
1452                false,
1453            );
1454
1455            select! {
1456                _ = harness.client_boundary_receiver.recv() => {
1457                    panic!("boundary response delivered");
1458                },
1459                _ = context.sleep(Duration::from_millis(100)) => {},
1460            };
1461        });
1462    }
1463
1464    #[test]
1465    fn address_failure_stops_probe_and_drops_subscriber() {
1466        let runner = deterministic::Runner::timed(Duration::from_secs(5));
1467        runner.start(|mut context| async move {
1468            let fixture = mocks::scheme_fixture_n(&mut context, 1);
1469            let participants = fixture.participants.clone();
1470            let (network, oracle) = Network::new_with_peers(
1471                context.child("network"),
1472                NetworkConfig {
1473                    max_size: 1024 * 1024,
1474                    max_peers_per_set: NZUsize!(participants.len()),
1475                    disconnect_on_block: true,
1476                    tracked_peer_sets: NZUsize!(1),
1477                },
1478                participants.clone(),
1479            )
1480            .await;
1481            let network = network.start();
1482            let control = oracle.control(participants[0].clone());
1483            let boundaries = control
1484                .register(BOUNDARY_CHANNEL, TEST_QUOTA)
1485                .await
1486                .expect("failed to register boundaries");
1487            let genesis = genesis_info(&participants);
1488            let (actor, mailbox): (
1489                _,
1490                super::Mailbox<mocks::TestScheme, mocks::TestMarshalVariant>,
1491            ) = Actor::new(Config {
1492                context: context.child("probe"),
1493                manager: mocks::FailingManager(oracle.manager()),
1494                bootstrap: Bootstrap {
1495                    epoch: Epoch::zero(),
1496                    participants: genesis.participants(),
1497                    directory: Unit,
1498                },
1499                verifier: fixture.schemes[0].clone(),
1500                genesis,
1501                strategy: Sequential,
1502                blocker: control,
1503                blocks_per_epoch: BLOCKS_PER_EPOCH,
1504                retry_timeout: NZDuration!(Duration::from_millis(500)),
1505                mailbox_size: NZUsize!(16),
1506                block_codec_config: (),
1507            });
1508            let handle = actor.start(boundaries);
1509
1510            assert!(mailbox.subscribe().await.is_err());
1511            handle.await.expect("probe should stop cleanly");
1512            network.abort();
1513        });
1514    }
1515}