Skip to main content

commonware_consensus/marshal/standard/
deferred.rs

1//! Wrapper for consensus applications that handles epochs and block dissemination.
2//!
3//! # Overview
4//!
5//! [`Deferred`] is an adapter that wraps any [`Application`] implementation to handle
6//! epoch transitions automatically. It intercepts consensus operations (propose, verify) and
7//! ensures blocks are only produced within valid epoch boundaries.
8//!
9//! # Epoch Boundaries
10//!
11//! When the parent is the last block in an epoch (as determined by the [`Epocher`]), this wrapper
12//! re-proposes that boundary block instead of building a new block. This avoids producing blocks
13//! that would be pruned by the epoch transition.
14//!
15//! # Deferred Verification
16//!
17//! Before casting a notarize vote, [`Deferred`] waits for the block to become available and
18//! then verifies that the block's embedded context matches the consensus context. However, it does not
19//! wait for the application to finish verifying the block contents before voting. This enables verification
20//! to run while we wait for a quorum of votes to form a certificate (hiding verification latency behind network
21//! latency). Once a certificate is formed, we wait on the verification result in [`CertifiableAutomaton::certify`]
22//! before voting to finalize (ensuring no invalid blocks are admitted to the canonical chain).
23//!
24//! A validator whose verification stays pending has already voted to notarize the block but
25//! withholds its finalize vote. Peers that voted to finalize cannot nullify the view, so once
26//! `f+1` honest peers have voted to finalize, the view cannot be nullified, and the pending
27//! validator is a fault for that view until the block finalizes without it.
28//!
29//! All validators must run the same wrapper for all views in a given epoch (see
30//! [`crate::marshal::standard`] for more details).
31//!
32//! # Usage
33//!
34//! Wrap your [`Application`] implementation with [`Deferred::new`] and provide it to your
35//! consensus engine for the [`Automaton`], [`Relay`], and [`Reporter`]. As the reporter, the
36//! wrapper forwards consensus activity to marshal and releases the state it keeps for each round
37//! once consensus finalizes that round. The wrapper handles all epoch logic transparently.
38//!
39//! ```rust,ignore
40//! let application = Deferred::new(
41//!     context,
42//!     my_application,
43//!     marshal_mailbox,
44//!     epocher,
45//! );
46//! ```
47//!
48//! # Implementation Notes
49//!
50//! - Genesis blocks are handled specially: epoch 0 returns the application's genesis block,
51//!   while subsequent epochs use the last block of the previous epoch as genesis
52//! - Blocks are automatically verified to be within the current epoch
53//!
54//! # Notarization and Data Availability
55//!
56//! In rare crash cases, it is possible for a notarization certificate to exist without a block being
57//! available to the honest parties. [`CertifiableAutomaton::certify`] may then remain pending while
58//! it waits for the block. Simplex may time out and nullify the view without resolving that request.
59//!
60//! For this reason, it should not be expected that every notarized payload will be certifiable due
61//! to the lack of an available block. However, if even one honest and online party has the block,
62//! they will attempt to forward it to others via marshal's resolver.
63//!
64//! ```text
65//!                                      ┌───────────────────────────────────────────────────┐
66//!                                      ▼                                                   │
67//! ┌─────────────────────┐   ┌─────────────────────┐   ┌─────────────────────┐   ┌─────────────────────┐
68//! │          B1         │◀──│          B2         │◀──│          B3         │XXX│          B4         │
69//! └─────────────────────┘   └─────────────────────┘   └──────────┬──────────┘   └─────────────────────┘
70//!                                                                │
71//!                                                         Pending Certify
72//! ```
73//!
74//! # Future Work
75//!
76//! - To further reduce view latency, a participant could optimistically vote for a block prior to
77//!   observing its availability during [`Automaton::verify`]. However, this would require updating
78//!   other components (like [`crate::marshal`]) to handle backfill where notarization does not imply
79//!   a block is fetchable (without modification, a malicious leader that withholds blocks during propose
80//!   could get an honest node to exhaust their network rate limit fetching things that don't exist rather
81//!   than blocks they need AND can fetch).
82
83use crate::{
84    Application, Automaton, Block, CertifiableAutomaton, CertifiableBlock, Epochable, Handoff,
85    Heightable, Relay, Reporter,
86    marshal::{
87        ancestry::Ancestry,
88        application::{
89            build::{self, Resolved},
90            gates::{self, GateOutcome, Gates},
91            relay,
92            validation::{Stage, is_inferred_reproposal_at_certify},
93        },
94        core::{CommitmentFallback, DigestFallback, Mailbox},
95        standard::{
96            Standard,
97            validation::{
98                Decision, ParentCheck, await_and_validate_parent, precheck_epoch_and_reproposal,
99                run_app_verify,
100            },
101        },
102    },
103    simplex::{
104        Plan,
105        types::{Activity, Context},
106    },
107    types::{Epocher, Round, View},
108};
109use commonware_actor::Feedback;
110use commonware_codec::Codec;
111use commonware_cryptography::{Digested, Digestible, certificate::Scheme};
112use commonware_macros::select;
113use commonware_runtime::{
114    Clock, Metrics, Spawner,
115    telemetry::{
116        metrics::{
117            MetricsExt as _,
118            histogram::{Buckets, Timed},
119        },
120        traces::TracedExt as _,
121    },
122};
123use commonware_utils::channel::{fallible::OneshotExt, oneshot};
124use std::{
125    future::{Future, ready},
126    sync::Arc,
127};
128use tracing::{Instrument as _, debug, info_span};
129
130/// An [`Application`] adapter that handles epoch transitions and validates block ancestry.
131///
132/// This wrapper intercepts consensus operations to enforce epoch boundaries and validate
133/// block ancestry. It prevents blocks from being produced outside their valid epoch,
134/// handles the special case of re-proposing boundary blocks at epoch boundaries,
135/// and ensures all blocks have valid parent linkage and contiguous heights.
136///
137/// # Ancestry Validation
138///
139/// Applications wrapped by [`Deferred`] can rely on the following ancestry checks being
140/// performed automatically during verification:
141/// - Parent digest matches the consensus context's expected parent
142/// - Block height is exactly one greater than the parent's height
143///
144/// Verifying only the immediate parent is sufficient since the parent was either notarized by
145/// consensus, whose honest voters verified its context, or verified locally before this
146/// participant voted for it (optimistic validation). Either way the entire ancestry chain back
147/// to genesis is transitively validated.
148///
149/// Applications do not need to re-implement these checks in their own verification logic.
150///
151/// # Context Recovery
152///
153/// With deferred verification, validators wait for data availability (DA) and verify the context
154/// before voting. If a validator crashes after voting but before certification, they lose their in-memory
155/// certification gate task. When recovering, validators extract context from a [`CertifiableBlock`].
156///
157/// _This embedded context is trustworthy because the notarizing quorum (which contains at least f+1 honest
158/// validators) verified that the block's context matched the consensus context before voting._
159pub struct Deferred<E, S, A, B, ES>
160where
161    S: Scheme,
162    A: Application<E>,
163    B: CertifiableBlock + Codec,
164    ES: Epocher,
165{
166    context: Arc<E>,
167    application: A,
168    marshal: Mailbox<S, Standard<B>>,
169    epocher: ES,
170    gates: Gates<<B as Digestible>::Digest, Digested<B>>,
171
172    build_duration: Timed,
173    proposal_parent_fetch_duration: Timed,
174    ancestor_fetch_duration: Timed,
175}
176
177impl<E, S, A, B, ES> Clone for Deferred<E, S, A, B, ES>
178where
179    S: Scheme,
180    A: Application<E>,
181    B: CertifiableBlock + Codec,
182    ES: Epocher,
183{
184    fn clone(&self) -> Self {
185        Self {
186            context: self.context.clone(),
187            application: self.application.clone(),
188            marshal: self.marshal.clone(),
189            epocher: self.epocher.clone(),
190            gates: self.gates.clone(),
191            build_duration: self.build_duration.clone(),
192            proposal_parent_fetch_duration: self.proposal_parent_fetch_duration.clone(),
193            ancestor_fetch_duration: self.ancestor_fetch_duration.clone(),
194        }
195    }
196}
197
198impl<E, S, A, B, ES> Deferred<E, S, A, B, ES>
199where
200    E: Spawner + Metrics + Clock,
201    S: Scheme,
202    A: Application<
203            E,
204            Block = B,
205            SigningScheme = S,
206            Context = Context<B::Digest, S::PublicKey>,
207            Input = (),
208        >,
209    B: CertifiableBlock<Context = <A as Application<E>>::Context> + Codec,
210    ES: Epocher,
211{
212    /// Creates a new [`Deferred`] wrapper.
213    pub fn new(context: E, application: A, marshal: Mailbox<S, Standard<B>>, epocher: ES) -> Self {
214        let build_histogram = context.histogram(
215            "build_duration",
216            "Histogram of time taken for the application to build a new block, in seconds",
217            Buckets::LOCAL,
218        );
219        let build_duration = Timed::new(build_histogram);
220        let parent_fetch_histogram = context.histogram(
221            "parent_fetch_duration",
222            "Histogram of time taken to fetch a parent block in proposal, in seconds",
223            Buckets::LOCAL,
224        );
225        let proposal_parent_fetch_duration = Timed::new(parent_fetch_histogram);
226        let ancestor_fetch_histogram = context.histogram(
227            "ancestor_fetch_duration",
228            "Histogram of time taken to fetch a block via the ancestry stream, in seconds",
229            Buckets::LOCAL,
230        );
231        let ancestor_fetch_duration = Timed::new(ancestor_fetch_histogram);
232
233        Self {
234            context: Arc::new(context),
235            application,
236            marshal,
237            epocher,
238            gates: Gates::new(),
239
240            build_duration,
241            proposal_parent_fetch_duration,
242            ancestor_fetch_duration,
243        }
244    }
245
246    /// Verifies a proposed block's application-level validity.
247    ///
248    /// This method validates that:
249    /// 1. The block's parent digest matches the expected parent
250    /// 2. The block's height is exactly one greater than the parent's height
251    /// 3. The underlying application's verification logic passes
252    ///
253    /// The `parent_request` must be a subscription to the parent named by `context.parent`,
254    /// started by the caller so the parent fetch can overlap work that precedes this call.
255    ///
256    /// Verification is spawned in a background task and returns a receiver that will contain
257    /// the verification result. Valid blocks are reported to the marshal as verified.
258    #[inline]
259    fn deferred_verify(
260        &self,
261        context: <Self as Automaton>::Context,
262        block: Digested<B>,
263        parent_request: oneshot::Receiver<Digested<B>>,
264        stage: Stage,
265    ) -> oneshot::Receiver<GateOutcome> {
266        let marshal = self.marshal.clone();
267        let mut application = self.application.clone();
268        let (mut tx, rx) = oneshot::channel();
269        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
270        let runtime_context = self
271            .context
272            .child("deferred_verify")
273            .with_attribute("round", context.round);
274        let span = info_span!(
275            "marshal.deferred.verify.deferred",
276            round = %context.round
277        );
278        runtime_context.spawn(move |runtime_context| {
279            async move {
280                let round = context.round;
281
282                // Start the candidate store immediately: it depends on neither the
283                // parent fetch (which may hit the network) nor the verdict below.
284                // Storing before validation is intentional: these caches provide
285                // candidate availability/recovery, not a validity decision. This
286                // task gates the finalize vote by resolving true only after both
287                // app verification succeeds and the store is durable.
288                let store = stage.store(&marshal, round, block.clone());
289                let verify = async {
290                    // Validate the parent we already started fetching.
291                    let parent = match await_and_validate_parent(
292                        context.parent.1,
293                        &block,
294                        parent_request,
295                        &mut tx,
296                    )
297                    .await
298                    {
299                        Some(ParentCheck::Valid(parent)) => parent,
300                        Some(ParentCheck::Invalid) => return Some(false),
301                        None => return None,
302                    };
303                    run_app_verify(
304                        runtime_context,
305                        context,
306                        block.clone(),
307                        parent,
308                        &mut application,
309                        &marshal,
310                        &mut tx,
311                        ancestor_fetch_duration,
312                    )
313                    .await
314                };
315                let (verdict, durable) = futures::join!(verify, store);
316
317                // Publish only when the block is both valid and durable. App-invalid
318                // candidates may already be in the cache from the concurrent store above,
319                // so the gate verdict is the authority for consensus progress.
320                if let Some(application_valid) = gates::resolve(verdict, durable) {
321                    tx.send_lossy(GateOutcome::Ready(application_valid));
322                }
323            }
324            .instrument(span)
325        });
326
327        rx
328    }
329
330    fn certify_from_embedded_context(
331        &self,
332        round: Round,
333        digest: B::Digest,
334    ) -> oneshot::Receiver<bool> {
335        // No in-progress task means we never verified this proposal locally. We can use the
336        // block's embedded context to help complete finalization when Byzantine validators
337        // withhold their finalize votes. If a Byzantine proposer embedded a malicious context,
338        // the f+1 honest validators from the notarizing quorum will verify against the proper
339        // context and reject the mismatch, preventing a 2f+1 finalization quorum.
340        //
341        // We must fetch here rather than only wait for local broadcast delivery. A Byzantine
342        // leader can send a proposal to just f+1 honest validators, collect enough honest
343        // notarize votes to form a notarization, and leave the remaining honest validators
344        // without the block. Those validators need the notarized round to recover the block
345        // and certify; otherwise they can remain stuck if the Byzantine validators stop
346        // participating in the next view.
347        //
348        // Subscribe to the block and verify using its embedded context once available.
349        debug!(
350            ?round,
351            ?digest,
352            "subscribing to block for certification using embedded context"
353        );
354        let block_rx = self
355            .marshal
356            .subscribe_by_digest(digest, DigestFallback::FetchByRound { round });
357        let marshaled = self.clone();
358        let epocher = self.epocher.clone();
359        let (mut tx, rx) = oneshot::channel();
360        let context = self.context.child("certify").with_attribute("round", round);
361        context.spawn(move |_| {
362            async move {
363                let block = select! {
364                    _ = tx.closed() => {
365                        debug!(
366                            reason = "consensus dropped receiver",
367                            "skipping certification"
368                        );
369                        return;
370                    },
371                    result = block_rx => match result {
372                        Ok(block) => block,
373                        Err(_) => {
374                            debug!(
375                                ?digest,
376                                reason = "failed to fetch block for certification",
377                                "skipping certification"
378                            );
379                            return;
380                        }
381                    },
382                };
383
384                // Re-proposal detection for certify path: we don't have the consensus context,
385                // only the block's embedded context from original proposal. Infer re-proposal from:
386                // 1. Block is at epoch boundary (only boundary blocks can be re-proposed)
387                // 2. Certification round's view > embedded context's view (re-proposals retain their
388                //    original embedded context, so a later view indicates the block was re-proposed)
389                // 3. Same epoch (re-proposals don't cross epoch boundaries)
390                let embedded_context = block.context();
391                let is_reproposal = is_inferred_reproposal_at_certify(
392                    &epocher,
393                    block.height(),
394                    embedded_context.round,
395                    round,
396                );
397                if is_reproposal {
398                    // Certifier holds a notarization for this block, so route
399                    // the write to the notarized cache. `certified` is
400                    // idempotent, so crash-recovery double-invocation is safe.
401                    if !marshaled.marshal.certified(round, block).await {
402                        return;
403                    }
404                    tx.send_lossy(true);
405                    return;
406                }
407
408                // Start the parent fetch for the deferred verification below,
409                // which expects a caller-started subscription. Certify does not
410                // carry the consensus context, so the parent round comes from
411                // the block's embedded context. That context is trustworthy
412                // because the digest is notarized and the notarizing quorum's
413                // f+1 honest validators verified it against the consensus
414                // context before voting.
415                let (parent_view, parent_commitment) = embedded_context.parent;
416                let parent_request = marshaled.marshal.subscribe_by_commitment(
417                    parent_commitment,
418                    CommitmentFallback::FetchByRound {
419                        round: Round::new(embedded_context.epoch(), parent_view),
420                    },
421                );
422
423                let verify_rx = marshaled.deferred_verify(
424                    embedded_context,
425                    block,
426                    parent_request,
427                    Stage::Certified,
428                );
429                gates::forward(tx, verify_rx, |result| match result {
430                    GateOutcome::Ready(result) => Some(result),
431                    GateOutcome::Recover => None,
432                })
433                .await;
434            }
435            .instrument(info_span!(
436                "marshal.deferred.certify.embedded",
437                round = %round,
438                digest = %digest
439            ))
440        });
441        rx
442    }
443    fn certify_from_existing_task(
444        &self,
445        round: Round,
446        digest: B::Digest,
447        task: oneshot::Receiver<GateOutcome>,
448    ) -> oneshot::Receiver<bool> {
449        // `verify()` waits only on local broadcast delivery, so nudge a
450        // round-bound notarized fetch that can unblock the existing waiter
451        // if local broadcast never arrives. For the standard variant, the
452        // digest is also the variant commitment.
453        self.marshal.hint_notarized(round, digest);
454
455        // A completed gate either carries an applicable local verdict or requests
456        // recovery. After an unclean restart the in-memory task is gone, which also
457        // recovers via the embedded-context fetch path.
458        let marshaled = self.clone();
459        let (tx, rx) = oneshot::channel();
460        let context = self
461            .context
462            .child("certify_existing")
463            .with_attribute("round", round);
464        context.spawn(move |_| {
465            gates::drive(tx, task, round, digest, move || {
466                marshaled.certify_from_embedded_context(round, digest)
467            })
468            .instrument(info_span!(
469                "marshal.deferred.certify.existing",
470                round = %round,
471                digest = %digest
472            ))
473        });
474        rx
475    }
476
477    /// Runs the proposal checks for `consensus_context` and yields what the proposal path does
478    /// next: re-propose the epoch boundary block, decline a parent the marshal cannot build on,
479    /// or build on the fetched parent.
480    ///
481    /// The returned future borrows nothing from `self`, so it can move into the spawned prepare
482    /// or propose task.
483    fn checks(
484        &self,
485        consensus_context: Context<B::Digest, S::PublicKey>,
486    ) -> impl Future<Output = Resolved<B::Digest, Digested<B>, impl Ancestry<B>>> + Send + 'static
487    {
488        let context = self.context.clone();
489        let marshal = self.marshal.clone();
490        let epocher = self.epocher.clone();
491        let proposal_parent_fetch_duration = self.proposal_parent_fetch_duration.clone();
492        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
493        let build_duration = self.build_duration.clone();
494        async move {
495            // The parent for any consensus context is in the same epoch: the
496            // boundary block of the previous epoch is the genesis block of the
497            // current epoch.
498            //
499            // Proposal context carries the parent view and commitment but not
500            // the parent height. The parent may sit above the finalized tip and
501            // may still be uncertified, so this must stay round-bound until the
502            // block is returned.
503            let (parent_view, parent_commitment) = consensus_context.parent;
504            let parent_request = marshal.subscribe_by_commitment(
505                parent_commitment,
506                CommitmentFallback::FetchByRound {
507                    round: Round::new(consensus_context.epoch(), parent_view),
508                },
509            );
510            let parent_timer = proposal_parent_fetch_duration.timer(&*context);
511            let Ok(parent) = parent_request.await else {
512                debug!(
513                    ?parent_commitment,
514                    reason = "failed to fetch parent block",
515                    "skipping proposal"
516                );
517                return Resolved::Skip;
518            };
519            parent_timer.observe(&*context);
520
521            // Deferred verification votes before it checks that a block's parent field
522            // matches the parent named by its consensus context, so an uncertified
523            // handoff parent may be unlinked. Build only on a linked parent. A parent at
524            // view zero is the epoch's genesis block, which needs no link check. The first
525            // epoch's genesis has no context parent to match, and a later epoch's genesis
526            // is the previous epoch's finalized boundary block.
527            //
528            // The coding variant needs no such check: it fetches each parent by the
529            // commitment in its child's consensus context, and its ancestry stream ends
530            // at a fetched parent that does not match the child's link.
531            if parent_view != View::zero() && parent.parent() != parent.context().parent.1 {
532                debug!(
533                    ?parent_commitment,
534                    reason = "parent does not link to its context parent",
535                    "skipping proposal"
536                );
537                return Resolved::Skip;
538            }
539
540            // Special case: If the parent block is the last block in the epoch,
541            // re-propose it so as not to produce any blocks that will be cut out
542            // by the epoch transition.
543            let last_in_epoch = epocher
544                .last(consensus_context.epoch())
545                .expect("current epoch should exist");
546            if parent.height() == last_in_epoch {
547                return Resolved::Reuse(parent.digest(), parent);
548            }
549
550            let ancestor_stream =
551                marshal.ancestor_stream(context.clone(), [parent], ancestor_fetch_duration);
552            Resolved::Build(ancestor_stream, build_duration.timer(&*context), ())
553        }
554    }
555}
556
557impl<E, S, A, B, ES> Automaton for Deferred<E, S, A, B, ES>
558where
559    E: Spawner + Metrics + Clock,
560    S: Scheme,
561    A: Application<
562            E,
563            Block = B,
564            SigningScheme = S,
565            Context = Context<B::Digest, S::PublicKey>,
566            Input = (),
567        >,
568    B: CertifiableBlock<Context = <A as Application<E>>::Context> + Codec,
569    ES: Epocher,
570{
571    type Digest = B::Digest;
572    type Context = Context<Self::Digest, S::PublicKey>;
573
574    /// Proposes a new block or re-proposes the epoch boundary block.
575    ///
576    /// This method builds a new block from the underlying application unless the parent block
577    /// is the last block in the current epoch. When at an epoch boundary, it re-proposes the
578    /// boundary block to avoid creating blocks that would be invalidated by the epoch transition.
579    ///
580    /// The proposal operation is spawned in a background task and returns a receiver that will
581    /// contain the proposed block's digest when ready. The block is staged before the digest is
582    /// delivered and handed to marshal when consensus requests the relay broadcast, which
583    /// persists it after the send. The resulting sync handle is awaited only at certification so
584    /// it overlaps consensus voting. The digest does not imply durability on
585    /// its own; [`CertifiableAutomaton::certify`] awaits the registered certification gate before
586    /// the finalize vote.
587    #[allow(clippy::async_yields_async)]
588    #[tracing::instrument(name = "marshal.deferred.propose", level = "info", skip_all, fields(round = %consensus_context.round))]
589    async fn propose(
590        &mut self,
591        consensus_context: Context<Self::Digest, S::PublicKey>,
592    ) -> oneshot::Receiver<Self::Digest> {
593        let checks = self.checks(consensus_context.clone());
594        let span = info_span!(
595            "marshal.deferred.propose.task",
596            round = %consensus_context.round
597        );
598        let round = consensus_context.round;
599        let (parent_view, parent_commitment) = consensus_context.parent;
600        build::propose(
601            self.context.as_ref(),
602            &self.application,
603            self.gates.clone(),
604            consensus_context,
605            checks,
606            span,
607            move || {
608                info_span!(
609                    "marshal.deferred.application.propose",
610                    round = %round,
611                    parent_view = parent_view.traced(),
612                    parent = %parent_commitment
613                )
614            },
615            |block, ()| ready((block.digest(), block)),
616        )
617    }
618
619    #[allow(clippy::async_yields_async)]
620    #[tracing::instrument(name = "marshal.deferred.verify", level = "info", skip_all, fields(round = %context.round, digest = %digest))]
621    async fn verify(
622        &mut self,
623        context: Context<Self::Digest, S::PublicKey>,
624        digest: Self::Digest,
625    ) -> oneshot::Receiver<bool> {
626        let marshal = self.marshal.clone();
627        let marshaled = self.clone();
628        let round = context.round;
629
630        // Verification needs the full block but waits only for local delivery. Certification starts
631        // recovery only when the block is not buffered. If a buffered block is evicted before
632        // verification registers its wait, verification is left with neither the block nor an
633        // active fetch. Register the wait before publishing the gate so it receives the buffered
634        // block or is waiting when recovery delivers it.
635        let block_request = marshal.subscribe_by_digest(digest, DigestFallback::Wait);
636        let (task_tx, task_rx) = oneshot::channel();
637        self.gates.insert(round, digest, task_rx);
638
639        let (mut tx, rx) = oneshot::channel();
640        let runtime_context = self
641            .context
642            .child("optimistic_verify")
643            .with_attribute("round", round);
644        runtime_context.spawn(move |_| {
645            async move {
646                // Start the parent fetch immediately: its commitment and certified
647                // round are known from the consensus context, so it can proceed in
648                // parallel with broadcast delivery of the candidate block.
649                // Reproposals (digest == context.parent.1) skip parent validation
650                // entirely, so they must not fetch: the "parent" is the candidate
651                // itself, and candidate acquisition is deliberately local-only.
652                let parent_request = (digest != context.parent.1).then(|| {
653                    let (parent_view, parent_commitment) = context.parent;
654                    marshal.subscribe_by_commitment(
655                        parent_commitment,
656                        CommitmentFallback::FetchByRound {
657                            round: Round::new(context.epoch(), parent_view),
658                        },
659                    )
660                });
661
662                // Stop waiting for the block if consensus drops the verification request.
663                let block = select! {
664                    _ = tx.closed() => {
665                        debug!(
666                            reason = "consensus dropped receiver",
667                            "skipping optimistic verification"
668                        );
669                        return;
670                    },
671                    result = block_request => match result {
672                        Ok(block) => block,
673                        Err(_) => {
674                            debug!(
675                                ?digest,
676                                reason = "failed to fetch block for optimistic verification",
677                                "skipping optimistic verification"
678                            );
679                            return;
680                        }
681                    },
682                };
683
684                // Shared pre-checks enforce:
685                // - Block epoch membership.
686                // - Re-proposal detection via `digest == context.parent.1`.
687                //
688                // Re-proposals return early and skip normal parent/height checks
689                // because consensus settles their validity when certifying the view
690                // that first carried the block, and parent-child checks would fail by
691                // construction when parent == block.
692                let Some(decision) = precheck_epoch_and_reproposal(
693                    &marshaled.epocher,
694                    &marshal,
695                    &context,
696                    digest,
697                    block,
698                )
699                .await
700                else {
701                    return;
702                };
703                let block = match decision {
704                    Decision::Complete(valid) => {
705                        // `Complete` means either immediate rejection or successful
706                        // re-proposal handling with no further ancestry validation.
707                        //
708                        // A rejection is safe to publish as a gate verdict because the
709                        // precheck depends only on the block's height and the gate key's
710                        // epoch, never on the declared parent. A conflicting header for
711                        // the same `(round, digest)` cannot produce an honest
712                        // notarization: reading the digest as a re-proposal requires it
713                        // to be the recorded payload of an earlier view, so its embedded
714                        // context fails the mismatch check under any normal header.
715                        task_tx.send_lossy(GateOutcome::Ready(valid));
716                        tx.send_lossy(valid);
717                        return;
718                    }
719                    Decision::Continue(block) => block,
720                };
721
722                // `Continue` implies a non-reproposal, so the parent subscription
723                // was started above.
724                let parent_request =
725                    parent_request.expect("non-reproposal has a parent subscription");
726
727                // Before casting a notarize vote, ensure the block's embedded context matches
728                // the consensus context.
729                //
730                // The notarize quorum is guaranteed to have at least f+1 honest validators who will
731                // verify against this context, so a Byzantine proposer cannot embed a malicious
732                // context. The other f honest validators who did not vote will later use the
733                // block-embedded context to help finalize if Byzantine validators withhold their
734                // finalize votes.
735                if block.context() != context {
736                    debug!(
737                        ?context,
738                        block_context = ?block.context(),
739                        "block-embedded context does not match consensus context during optimistic verification"
740                    );
741                    task_tx.send_lossy(GateOutcome::Recover);
742                    tx.send_lossy(false);
743                    return;
744                }
745
746                // Optimistic verify returns immediately; the deferred_verify task
747                // runs in the background and forwards its final verdict to
748                // `task_tx` so `certify` observes the same result via the
749                // synchronously-registered `task_rx`.
750                //
751                // Once the optimistic verdict is delivered, the certification
752                // gate owns the deferred work. Nullification keeps that gate
753                // alive, while finalization drops it.
754                let deferred_rx = marshaled
755                    .deferred_verify(context, block, parent_request, Stage::Verified);
756                tx.send_lossy(true);
757                gates::forward(task_tx, deferred_rx, Some).await;
758            }
759            .instrument(info_span!(
760                "marshal.deferred.verify.optimistic",
761                round = %round,
762                digest = %digest
763            ))
764        });
765        rx
766    }
767}
768
769impl<E, S, A, B, ES> CertifiableAutomaton for Deferred<E, S, A, B, ES>
770where
771    E: Spawner + Metrics + Clock,
772    S: Scheme,
773    A: Application<
774            E,
775            Block = B,
776            SigningScheme = S,
777            Context = Context<B::Digest, S::PublicKey>,
778            Input = (),
779        >,
780    B: CertifiableBlock<Context = <A as Application<E>>::Context> + Codec,
781    ES: Epocher,
782{
783    /// Prepares a term-start proposal on an uncertified parent.
784    ///
785    /// Runs the same checks as [`Self::propose`], in the same order, and hands the application the
786    /// fetched ancestry. When the checks find the epoch boundary block, the marshal re-proposes it
787    /// without the application and answers [`Handoff::Stage`].
788    #[allow(clippy::async_yields_async)]
789    #[tracing::instrument(name = "marshal.deferred.prepare", level = "info", skip_all, fields(round = %consensus_context.round))]
790    async fn prepare(
791        &mut self,
792        consensus_context: Context<Self::Digest, S::PublicKey>,
793    ) -> oneshot::Receiver<Handoff<Self::Digest>> {
794        let checks = self.checks(consensus_context.clone());
795        let span = info_span!(
796            "marshal.deferred.prepare.task",
797            round = %consensus_context.round
798        );
799        let round = consensus_context.round;
800        let (parent_view, parent_commitment) = consensus_context.parent;
801        build::prepare(
802            self.context.as_ref(),
803            &self.application,
804            self.gates.clone(),
805            consensus_context,
806            checks,
807            span,
808            move || {
809                info_span!(
810                    "marshal.deferred.application.prepare",
811                    round = %round,
812                    parent_view = parent_view.traced(),
813                    parent = %parent_commitment
814                )
815            },
816            |block, ()| ready((block.digest(), block)),
817        )
818    }
819
820    #[allow(clippy::async_yields_async)]
821    #[tracing::instrument(name = "marshal.deferred.certify", level = "info", skip_all, fields(round = %round, digest = %digest))]
822    async fn certify(&mut self, round: Round, digest: Self::Digest) -> oneshot::Receiver<bool> {
823        // Attempt to retrieve the existing certification gate task for this round/digest.
824        let task = self.gates.claim(round, digest, |block, ack| {
825            self.marshal.verified_deferred(round, block, ack)
826        });
827        if let Some(task) = task {
828            return self.certify_from_existing_task(round, digest, task);
829        }
830
831        self.certify_from_embedded_context(round, digest)
832    }
833}
834
835impl<E, S, A, B, ES> Relay for Deferred<E, S, A, B, ES>
836where
837    E: Send + Sync + 'static,
838    S: Scheme,
839    A: Application<E, Block = B, Context = Context<B::Digest, S::PublicKey>>,
840    B: CertifiableBlock<Context = <A as Application<E>>::Context> + Codec,
841    ES: Epocher,
842{
843    type Digest = B::Digest;
844    type PublicKey = S::PublicKey;
845    type Plan = Plan<S::PublicKey>;
846
847    fn broadcast(&mut self, commitment: Self::Digest, plan: Plan<S::PublicKey>) -> Feedback {
848        relay::broadcast(&self.gates, &self.marshal, commitment, plan)
849    }
850}
851
852/// The wrapper is the consensus engine's reporter, as it is its automaton and relay: it forwards
853/// every activity to marshal and first releases the state it keeps per round (certification
854/// gates, staged proposals, and sent marks). A finalization releases its round and the rounds
855/// below it. Any activity of a later epoch releases every earlier epoch, whose engine has been
856/// retired, which covers finalized progress that marshal imported while no engine reported it.
857/// The voter reports from the same task that requests the lock-in broadcast and certification,
858/// so pruning is ordered with both.
859impl<E, S, A, B, ES> Reporter for Deferred<E, S, A, B, ES>
860where
861    E: Send + Sync + 'static,
862    S: Scheme,
863    A: Application<E, Block = B, Context = Context<B::Digest, S::PublicKey>>,
864    B: CertifiableBlock<Context = <A as Application<E>>::Context> + Codec,
865    ES: Epocher,
866{
867    type Activity = Activity<S, B::Digest>;
868
869    fn report(&mut self, activity: Self::Activity) -> Feedback {
870        let retired = match &activity {
871            Activity::Finalization(finalization) => finalization.round(),
872            activity => Round::new(activity.epoch(), View::zero()),
873        };
874        self.gates.retain_after(retired);
875        self.marshal.report(activity)
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::Deferred;
882    use crate::{
883        Automaton, CertifiableAutomaton, Relay,
884        marshal::{
885            application::gates::Gates,
886            mocks::{
887                harness::{
888                    B, BLOCKS_PER_EPOCH, Ctx, D, NAMESPACE, NUM_VALIDATORS, S, StandardHarness,
889                    TestHarness, V, default_leader, make_raw_block,
890                    setup_network_with_participants,
891                },
892                verifying::{GatedVerifyingApp, MockVerifyingApp},
893            },
894            standard::tests::ready,
895        },
896        simplex::{Plan, scheme::bls12381_threshold::vrf as bls12381_threshold_vrf},
897        types::{Epoch, Epocher, FixedEpocher, Height, Round, View},
898    };
899    use commonware_broadcast::Broadcaster;
900    use commonware_cryptography::{
901        Digested, Digestible, Hasher as _,
902        certificate::{ConstantProvider, mocks::Fixture},
903        sha256::Sha256,
904    };
905    use commonware_macros::{select, test_traced};
906    use commonware_runtime::{Clock, Runner, Supervisor as _, deterministic};
907    use commonware_utils::{NZUsize, channel::fallible::OneshotExt};
908    use std::time::Duration;
909
910    #[test_traced("INFO")]
911    fn test_certify_lower_view_after_higher_view() {
912        let runner = deterministic::Runner::timed(Duration::from_secs(60));
913        runner.start(|mut context| async move {
914            let Fixture {
915                participants,
916                schemes,
917                ..
918            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
919            let mut oracle = setup_network_with_participants(
920                context.child("network"),
921                NZUsize!(1),
922                participants.clone(),
923                1024 * 1024,
924            )
925            .await;
926
927            let me = participants[0].clone();
928
929            let setup = StandardHarness::setup_validator(
930                context.child("validator").with_attribute("index", 0),
931                &mut oracle,
932                me.clone(),
933                ConstantProvider::new(schemes[0].clone()),
934            )
935            .await;
936            let marshal = setup.mailbox;
937
938            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
939            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
940
941            let mut marshaled = Deferred::new(
942                context.child("deferred"),
943                mock_app,
944                marshal.clone(),
945                FixedEpocher::new(BLOCKS_PER_EPOCH),
946            );
947
948            // Create parent block at height 1
949            let parent = make_raw_block(genesis.digest(), Height::new(1), 100);
950            let parent_digest = parent.digest();
951
952            assert!(
953                marshal
954                    .verified(
955                        Round::new(Epoch::new(0), View::new(1)),
956                        ready(parent.clone())
957                    )
958                    .await
959            );
960
961            // Block A at view 5 (height 2)
962            let round_a = Round::new(Epoch::new(0), View::new(5));
963            let context_a = Ctx {
964                round: round_a,
965                leader: me.clone(),
966                parent: (View::new(1), parent_digest),
967            };
968            let block_a = B::new::<Sha256>(context_a.clone(), parent_digest, Height::new(2), 200);
969            let commitment_a = block_a.digest();
970            assert!(marshal.verified(round_a, ready(block_a.clone())).await);
971
972            // Block B at view 10 (height 2, different block same height)
973            let round_b = Round::new(Epoch::new(0), View::new(10));
974            let context_b = Ctx {
975                round: round_b,
976                leader: me.clone(),
977                parent: (View::new(1), parent_digest),
978            };
979            let block_b = B::new::<Sha256>(context_b.clone(), parent_digest, Height::new(2), 300);
980            let commitment_b = block_b.digest();
981            assert!(marshal.verified(round_b, ready(block_b.clone())).await);
982
983            context.sleep(Duration::from_millis(10)).await;
984
985            // Step 1: Verify block A at view 5
986            let _ = marshaled.verify(context_a, commitment_a).await.await;
987
988            // Step 2: Verify block B at view 10
989            let _ = marshaled.verify(context_b, commitment_b).await.await;
990
991            // Step 3: Certify block B at view 10 FIRST
992            let certify_b = marshaled.certify(round_b, commitment_b).await;
993            assert!(
994                certify_b.await.unwrap(),
995                "Block B certification should succeed"
996            );
997
998            // Step 4: Certify block A at view 5 and expect success
999            let certify_a = marshaled.certify(round_a, commitment_a).await;
1000
1001            select! {
1002                result = certify_a => {
1003                    assert!(result.unwrap(), "Block A certification should succeed");
1004                },
1005                _ = context.sleep(Duration::from_secs(5)) => {
1006                    panic!("Block A certification timed out");
1007                },
1008            }
1009        })
1010    }
1011
1012    #[test_traced("WARN")]
1013    fn test_marshaled_rejects_unsupported_epoch() {
1014        #[derive(Clone)]
1015        struct LimitedEpocher {
1016            inner: FixedEpocher,
1017            max_epoch: u64,
1018        }
1019
1020        impl Epocher for LimitedEpocher {
1021            fn containing(&self, height: Height) -> Option<crate::types::EpochInfo> {
1022                let bounds = self.inner.containing(height)?;
1023                if bounds.epoch().get() > self.max_epoch {
1024                    None
1025                } else {
1026                    Some(bounds)
1027                }
1028            }
1029
1030            fn first(&self, epoch: Epoch) -> Option<Height> {
1031                if epoch.get() > self.max_epoch {
1032                    None
1033                } else {
1034                    self.inner.first(epoch)
1035                }
1036            }
1037
1038            fn last(&self, epoch: Epoch) -> Option<Height> {
1039                if epoch.get() > self.max_epoch {
1040                    None
1041                } else {
1042                    self.inner.last(epoch)
1043                }
1044            }
1045        }
1046
1047        let runner = deterministic::Runner::timed(Duration::from_secs(60));
1048        runner.start(|mut context| async move {
1049            let Fixture {
1050                participants,
1051                schemes,
1052                ..
1053            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1054            let mut oracle = setup_network_with_participants(
1055                context.child("network"),
1056                NZUsize!(1),
1057                participants.clone(),
1058                1024 * 1024,
1059            )
1060            .await;
1061
1062            let me = participants[0].clone();
1063
1064            let setup = StandardHarness::setup_validator(
1065                context.child("validator").with_attribute("index", 0),
1066                &mut oracle,
1067                me.clone(),
1068                ConstantProvider::new(schemes[0].clone()),
1069            )
1070            .await;
1071            let marshal = setup.mailbox;
1072
1073            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1074            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
1075            let limited_epocher = LimitedEpocher {
1076                inner: FixedEpocher::new(BLOCKS_PER_EPOCH),
1077                max_epoch: 0,
1078            };
1079
1080            let mut marshaled = Deferred::new(
1081                context.child("deferred"),
1082                mock_app,
1083                marshal.clone(),
1084                limited_epocher,
1085            );
1086
1087            // Create a parent block at height 19 (last block in epoch 0, which is supported)
1088            let parent_ctx = Ctx {
1089                round: Round::new(Epoch::zero(), View::new(19)),
1090                leader: default_leader(),
1091                parent: (View::zero(), genesis.digest()),
1092            };
1093            let parent =
1094                B::new::<Sha256>(parent_ctx.clone(), genesis.digest(), Height::new(19), 1000);
1095            let parent_digest = parent.digest();
1096
1097            assert!(
1098                marshal
1099                    .clone()
1100                    .verified(
1101                        Round::new(Epoch::zero(), View::new(19)),
1102                        ready(parent.clone())
1103                    )
1104                    .await
1105            );
1106
1107            // Create a block at height 20 (first block in epoch 1, which is NOT supported)
1108            let unsupported_round = Round::new(Epoch::new(1), View::new(20));
1109            let unsupported_context = Ctx {
1110                round: unsupported_round,
1111                leader: me.clone(),
1112                parent: (View::new(19), parent_digest),
1113            };
1114            let block = B::new::<Sha256>(
1115                unsupported_context.clone(),
1116                parent_digest,
1117                Height::new(20),
1118                2000,
1119            );
1120            let block_commitment = block.digest();
1121
1122            assert!(
1123                marshal
1124                    .clone()
1125                    .verified(unsupported_round, ready(block.clone()))
1126                    .await
1127            );
1128
1129            context.sleep(Duration::from_millis(10)).await;
1130
1131            // Call verify and wait for the result (verify returns optimistic result,
1132            // but also spawns deferred verification)
1133            let verify_result = marshaled
1134                .verify(unsupported_context, block_commitment)
1135                .await;
1136
1137            // Wait for optimistic verify to complete so the certification gate task is registered
1138            let optimistic_result = verify_result.await;
1139
1140            // The optimistic verify should return false because the block is in an unsupported epoch
1141            assert!(
1142                !optimistic_result.unwrap(),
1143                "Optimistic verify should reject block in unsupported epoch"
1144            );
1145        })
1146    }
1147
1148    /// Test that marshaled rejects blocks when consensus context doesn't match block's embedded context.
1149    ///
1150    /// This tests that when verify() is called with a context that doesn't match what's embedded
1151    /// in the block, the verification should fail. A Byzantine proposer could broadcast a block
1152    /// with one embedded context but consensus could call verify() with a different context.
1153    #[test_traced("WARN")]
1154    fn test_marshaled_rejects_mismatched_context() {
1155        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1156        runner.start(|mut context| async move {
1157            let Fixture {
1158                participants,
1159                schemes,
1160                ..
1161            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1162            let mut oracle = setup_network_with_participants(
1163                context.child("network"),
1164                NZUsize!(1),
1165                participants.clone(),
1166                1024 * 1024,
1167            )
1168            .await;
1169
1170            let me = participants[0].clone();
1171
1172            let setup = StandardHarness::setup_validator(
1173                context.child("validator").with_attribute("index", 0),
1174                &mut oracle,
1175                me.clone(),
1176                ConstantProvider::new(schemes[0].clone()),
1177            )
1178            .await;
1179            let marshal = setup.mailbox;
1180
1181            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1182            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
1183
1184            let mut marshaled = Deferred::new(
1185                context.child("deferred"),
1186                mock_app,
1187                marshal.clone(),
1188                FixedEpocher::new(BLOCKS_PER_EPOCH),
1189            );
1190
1191            // Create parent block at height 1 so the commitment is well-formed.
1192            let parent_ctx = Ctx {
1193                round: Round::new(Epoch::zero(), View::new(1)),
1194                leader: default_leader(),
1195                parent: (View::zero(), genesis.digest()),
1196            };
1197            let parent = B::new::<Sha256>(parent_ctx, genesis.digest(), Height::new(1), 100);
1198            let parent_commitment = parent.digest();
1199
1200            assert!(
1201                marshal
1202                    .clone()
1203                    .verified(
1204                        Round::new(Epoch::zero(), View::new(1)),
1205                        ready(parent.clone())
1206                    )
1207                    .await
1208            );
1209
1210            // Build a block with context A (embedded in the block).
1211            let round_a = Round::new(Epoch::zero(), View::new(2));
1212            let context_a = Ctx {
1213                round: round_a,
1214                leader: me.clone(),
1215                parent: (View::new(1), parent_commitment),
1216            };
1217            let block_a = B::new::<Sha256>(context_a, parent.digest(), Height::new(2), 200);
1218            let commitment_a = block_a.digest();
1219            assert!(marshal.verified(round_a, ready(block_a)).await);
1220
1221            context.sleep(Duration::from_millis(10)).await;
1222
1223            // Verify using a different consensus context B (hash mismatch).
1224            let round_b = Round::new(Epoch::zero(), View::new(3));
1225            let context_b = Ctx {
1226                round: round_b,
1227                leader: participants[1].clone(),
1228                parent: (View::new(1), parent_commitment),
1229            };
1230
1231            let verify_rx = marshaled.verify(context_b, commitment_a).await;
1232            select! {
1233                result = verify_rx => {
1234                    assert!(
1235                        !result.unwrap(),
1236                        "mismatched context hash should be rejected"
1237                    );
1238                },
1239                _ = context.sleep(Duration::from_secs(5)) => {
1240                    panic!("verify should reject mismatched context hash promptly");
1241                },
1242            }
1243        })
1244    }
1245
1246    /// Dropping the optimistic verify receiver before the block is available can close the
1247    /// synchronously-registered certification gate task. `certify` must recover through the
1248    /// embedded-context path instead of returning the closed task to consensus.
1249    #[test_traced("WARN")]
1250    fn test_deferred_certify_recovers_after_verify_receiver_drop() {
1251        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1252        runner.start(|mut context| async move {
1253            let Fixture {
1254                participants,
1255                schemes,
1256                ..
1257            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1258            let mut oracle = setup_network_with_participants(
1259                context.child("network"),
1260                NZUsize!(1),
1261                participants.clone(),
1262                1024 * 1024,
1263            )
1264            .await;
1265
1266            let me = participants[0].clone();
1267            let setup = StandardHarness::setup_validator(
1268                context.child("validator").with_attribute("index", 0),
1269                &mut oracle,
1270                me.clone(),
1271                ConstantProvider::new(schemes[0].clone()),
1272            )
1273            .await;
1274            let marshal = setup.mailbox;
1275
1276            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1277            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
1278            let mut marshaled = Deferred::new(
1279                context.child("deferred"),
1280                mock_app,
1281                marshal.clone(),
1282                FixedEpocher::new(BLOCKS_PER_EPOCH),
1283            );
1284
1285            let round = Round::new(Epoch::zero(), View::new(1));
1286            let block_context = Ctx {
1287                round,
1288                leader: me,
1289                parent: (View::zero(), genesis.digest()),
1290            };
1291            let block =
1292                B::new::<Sha256>(block_context.clone(), genesis.digest(), Height::new(1), 100);
1293            let digest = block.digest();
1294
1295            let verify_rx = marshaled.verify(block_context, digest).await;
1296            drop(verify_rx);
1297
1298            // Give the optimistic task a chance to observe the dropped receiver while its
1299            // block subscription is still pending.
1300            context.sleep(Duration::from_millis(10)).await;
1301
1302            assert!(marshal.verified(round, ready(block)).await);
1303            let certify_rx = marshaled.certify(round, digest).await;
1304            select! {
1305                result = certify_rx => {
1306                    assert!(
1307                        result.expect("certify result missing"),
1308                        "certify should recover after verify receiver drop"
1309                    );
1310                },
1311                _ = context.sleep(Duration::from_secs(5)) => {
1312                    panic!("certify should recover promptly after verify drop");
1313                },
1314            }
1315        });
1316    }
1317
1318    /// The store request runs concurrently with `app.verify`, not after it: while
1319    /// gated application verification is still blocked, the block has already
1320    /// reached marshal and is locally queryable even though the sync handle may
1321    /// still be pending. Releasing verification then lets certification await
1322    /// the registered certification gate. Separate restart tests cover durable
1323    /// recovery after certification.
1324    #[test_traced("WARN")]
1325    fn test_deferred_store_overlaps_app_verify() {
1326        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1327        runner.start(|mut context| async move {
1328            let Fixture {
1329                participants,
1330                schemes,
1331                ..
1332            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1333            let mut oracle = setup_network_with_participants(
1334                context.child("network"),
1335                NZUsize!(1),
1336                participants.clone(),
1337                1024 * 1024,
1338            )
1339            .await;
1340
1341            let me = participants[0].clone();
1342
1343            let setup = StandardHarness::setup_validator(
1344                context.child("validator").with_attribute("index", 0),
1345                &mut oracle,
1346                me.clone(),
1347                ConstantProvider::new(schemes[0].clone()),
1348            )
1349            .await;
1350            let marshal = setup.mailbox;
1351            let buffer = setup.extra;
1352
1353            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1354            let (mock_app, verify_started, release_verify): (GatedVerifyingApp<B, S>, _, _) =
1355                GatedVerifyingApp::new();
1356            let mut marshaled = Deferred::new(
1357                context.child("deferred"),
1358                mock_app,
1359                marshal.clone(),
1360                FixedEpocher::new(BLOCKS_PER_EPOCH),
1361            );
1362
1363            // Seed parent and child via the buffer (in-memory only) so
1364            // `deferred_verify` can fetch them without going through the
1365            // persisted marshal path.
1366            let parent = make_raw_block(genesis.digest(), Height::new(1), 100);
1367            let parent_digest = parent.digest();
1368
1369            let child_round = Round::new(Epoch::zero(), View::new(2));
1370            let child_ctx = Ctx {
1371                round: child_round,
1372                leader: me,
1373                parent: (View::new(1), parent_digest),
1374            };
1375            let child = B::new::<Sha256>(child_ctx.clone(), parent_digest, Height::new(2), 200);
1376            let child_digest = child.digest();
1377
1378            assert!(
1379                buffer
1380                    .broadcast(commonware_p2p::Recipients::Some(vec![]), ready(parent))
1381                    .accepted(),
1382                "buffer broadcast for parent should be accepted"
1383            );
1384            assert!(
1385                buffer
1386                    .broadcast(commonware_p2p::Recipients::Some(vec![]), ready(child))
1387                    .accepted(),
1388                "buffer broadcast for child should be accepted"
1389            );
1390
1391            // Kick off the optimistic verify, which spawns `deferred_verify`. Its gated
1392            // `app.verify` blocks until we release it.
1393            let optimistic_rx = marshaled.verify(child_ctx, child_digest).await;
1394            assert!(
1395                optimistic_rx
1396                    .await
1397                    .expect("optimistic verify should resolve"),
1398                "optimistic verify should accept the available block"
1399            );
1400
1401            // Application verification is now blocked. The store request runs concurrently
1402            // with it, so the block is locally queryable even though verification has not
1403            // returned and the sync handle may still be pending.
1404            verify_started
1405                .await
1406                .expect("verify should reach the gated application");
1407            assert!(
1408                marshal.get_block(&child_digest).await.is_some(),
1409                "the store request runs concurrently with app.verify, so the block is locally queryable while verification is still gated"
1410            );
1411
1412            // Releasing verification lets certification succeed (valid and durable).
1413            release_verify.send_lossy(());
1414            let certify_rx = marshaled.certify(child_round, child_digest).await;
1415            select! {
1416                result = certify_rx => {
1417                    assert!(
1418                        result.expect("certify result missing"),
1419                        "certify should succeed once verification passes"
1420                    );
1421                },
1422                _ = context.sleep(Duration::from_secs(5)) => {
1423                    panic!("certify should resolve after verification is released");
1424                },
1425            }
1426        });
1427    }
1428
1429    /// A boundary re-proposal stores the parent block itself at the
1430    /// re-proposal round, under the parent's original embedded context. A
1431    /// leader that crashes after that relay broadcast fetches the stored
1432    /// parent by its digest on restart and re-proposes it, and the relay
1433    /// persist deduplicates against the pre-crash write.
1434    #[test_traced("WARN")]
1435    fn test_propose_reuses_reproposed_boundary_block_on_restart() {
1436        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1437        runner.start(|mut context| async move {
1438            let Fixture {
1439                participants,
1440                schemes,
1441                ..
1442            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1443            let mut oracle = setup_network_with_participants(
1444                context.child("network"),
1445                NZUsize!(1),
1446                participants.clone(),
1447                1024 * 1024,
1448            )
1449            .await;
1450
1451            let me = participants[0].clone();
1452            let setup = StandardHarness::setup_validator(
1453                context.child("validator").with_attribute("index", 0),
1454                &mut oracle,
1455                me.clone(),
1456                ConstantProvider::new(schemes[0].clone()),
1457            )
1458            .await;
1459            let marshal = setup.mailbox;
1460
1461            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1462
1463            // Seed the boundary block at the re-proposal round, where the
1464            // pre-crash relay broadcast of the re-proposal persisted it.
1465            let boundary_height = Height::new(BLOCKS_PER_EPOCH.get() - 1);
1466            let boundary_round = Round::new(Epoch::zero(), View::new(boundary_height.get()));
1467            let boundary_block = B::new::<Sha256>(
1468                Ctx {
1469                    round: boundary_round,
1470                    leader: default_leader(),
1471                    parent: (View::zero(), genesis.digest()),
1472                },
1473                genesis.digest(),
1474                boundary_height,
1475                1900,
1476            );
1477            let boundary_digest = boundary_block.digest();
1478            let round = Round::new(Epoch::zero(), View::new(boundary_height.get() + 1));
1479            assert!(marshal.verified(round, ready(boundary_block)).await);
1480
1481            let ctx = Ctx {
1482                round,
1483                leader: me.clone(),
1484                parent: (View::new(boundary_height.get()), boundary_digest),
1485            };
1486
1487            // The app cannot build and its verification never completes, so
1488            // the assertions below hold only if the cached parent is
1489            // re-proposed as-is.
1490            let (mock_app, verify_started, _release_verify): (GatedVerifyingApp<B, S>, _, _) =
1491                GatedVerifyingApp::new();
1492            let mut marshaled = Deferred::new(
1493                context.child("deferred"),
1494                mock_app,
1495                marshal.clone(),
1496                FixedEpocher::new(BLOCKS_PER_EPOCH),
1497            );
1498
1499            let digest_rx = marshaled.propose(ctx).await;
1500            let digest = digest_rx.await.expect("propose must return a digest");
1501            assert_eq!(
1502                digest, boundary_digest,
1503                "propose must re-propose the boundary block marshal already persisted for this round"
1504            );
1505
1506            let _ = marshaled.broadcast(digest, Plan::Propose { round });
1507            let certify_rx = marshaled.certify(round, digest).await;
1508            select! {
1509                result = certify_rx => {
1510                    assert!(
1511                        result.expect("certify result missing"),
1512                        "re-proposed boundary block must certify through the relay handshake"
1513                    );
1514                },
1515                _ = verify_started => {
1516                    panic!("certifying a re-proposed boundary block must not run app verification");
1517                },
1518            }
1519        });
1520    }
1521
1522    /// Regression: in deferred mode `propose` registers a certification gate that
1523    /// `certify` awaits. After the leader certifies its own proposal, the block must be
1524    /// durably recoverable across an unclean restart. This is the >= f+1 guarantee
1525    /// for the leader's own block.
1526    #[test_traced("WARN")]
1527    fn test_deferred_propose_then_certify_persists_block() {
1528        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1529        runner.start(|mut context| async move {
1530            let Fixture {
1531                participants,
1532                schemes,
1533                ..
1534            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1535            let mut oracle = setup_network_with_participants(
1536                context.child("network"),
1537                NZUsize!(1),
1538                participants.clone(),
1539                1024 * 1024,
1540            )
1541            .await;
1542
1543            let me = participants[0].clone();
1544            let setup = StandardHarness::setup_validator(
1545                context.child("validator").with_attribute("index", 0),
1546                &mut oracle,
1547                me.clone(),
1548                ConstantProvider::new(schemes[0].clone()),
1549            )
1550            .await;
1551            let marshal = setup.mailbox;
1552            let actor_handle = setup.actor_handle;
1553
1554            let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1555
1556            // Seed the parent at its round so `propose` can fetch it locally.
1557            let parent_round = Round::new(Epoch::zero(), View::new(1));
1558            let parent_ctx = Ctx {
1559                round: parent_round,
1560                leader: default_leader(),
1561                parent: (View::zero(), genesis.digest()),
1562            };
1563            let parent = B::new::<Sha256>(parent_ctx, genesis.digest(), Height::new(1), 100);
1564            let parent_digest = parent.digest();
1565            assert!(marshal.verified(parent_round, ready(parent)).await);
1566
1567            // The leader builds the child via `app.propose`.
1568            let round = Round::new(Epoch::zero(), View::new(2));
1569            let ctx = Ctx {
1570                round,
1571                leader: me.clone(),
1572                parent: (View::new(1), parent_digest),
1573            };
1574            let child = B::new::<Sha256>(ctx.clone(), parent_digest, Height::new(2), 200);
1575            let child_digest = child.digest();
1576            let mock_app: MockVerifyingApp<B, S> =
1577                MockVerifyingApp::new().with_propose_result(child);
1578            let mut marshaled = Deferred::new(
1579                context.child("deferred"),
1580                mock_app,
1581                marshal.clone(),
1582                FixedEpocher::new(BLOCKS_PER_EPOCH),
1583            );
1584
1585            let digest = marshaled
1586                .propose(ctx)
1587                .await
1588                .await
1589                .expect("propose must return a digest");
1590            assert_eq!(
1591                digest, child_digest,
1592                "propose must return the built block's digest"
1593            );
1594
1595            // The leader certifies its own proposal; this awaits the deferred propose sync handle.
1596            assert!(
1597                marshaled
1598                    .certify(round, child_digest)
1599                    .await
1600                    .await
1601                    .expect("certify result missing"),
1602                "certify must succeed for the leader's own proposal"
1603            );
1604
1605            // After certify, the block must be durable across an unclean restart.
1606            actor_handle.abort();
1607            let _ = actor_handle.await;
1608            drop(marshaled);
1609            drop(marshal);
1610
1611            let setup2 = StandardHarness::setup_validator(
1612                context
1613                    .child("validator_restart")
1614                    .with_attribute("index", 0),
1615                &mut oracle,
1616                me,
1617                ConstantProvider::new(schemes[0].clone()),
1618            )
1619            .await;
1620            let marshal2 = setup2.mailbox;
1621
1622            assert!(
1623                marshal2.get_block(&child_digest).await.is_some(),
1624                "certify resolved true for the leader's own proposal so the block must be durable"
1625            );
1626        });
1627    }
1628
1629    /// Shared scenario for the proposal-parent equivocation tests.
1630    ///
1631    /// The local validator certified the view-1 block but only nullified view 2,
1632    /// while the rest of the network notarized and certified the view-2 block.
1633    /// The view-3 leader builds on the view-2 block and broadcasts it, so the
1634    /// block is buffered locally but was never verified here. The equivocating
1635    /// context names the certified view-1 block as parent, which this
1636    /// validator's parent selection accepts (view 1 certified, view 2 nullified).
1637    struct EquivocationFixture {
1638        marshaled: Deferred<deterministic::Context, S, MockVerifyingApp<B, S>, B, FixedEpocher>,
1639        round: Round,
1640        digest: <B as Digestible>::Digest,
1641        embedded_ctx: Ctx,
1642        equivocating_ctx: Ctx,
1643        _extra: <StandardHarness as TestHarness>::ValidatorExtra,
1644    }
1645
1646    async fn equivocation_fixture(
1647        context: &mut deterministic::Context,
1648        app: MockVerifyingApp<B, S>,
1649    ) -> EquivocationFixture {
1650        let Fixture {
1651            participants,
1652            schemes,
1653            ..
1654        } = bls12381_threshold_vrf::fixture::<V, _>(context, NAMESPACE, NUM_VALIDATORS);
1655        let mut oracle = setup_network_with_participants(
1656            context.child("network"),
1657            NZUsize!(1),
1658            participants.clone(),
1659            1024 * 1024,
1660        )
1661        .await;
1662
1663        let me = participants[0].clone();
1664        let setup = StandardHarness::setup_validator(
1665            context.child("validator").with_attribute("index", 0),
1666            &mut oracle,
1667            me,
1668            ConstantProvider::new(schemes[0].clone()),
1669        )
1670        .await;
1671        let marshal = setup.mailbox;
1672        let buffer = setup.extra;
1673
1674        let genesis = make_raw_block(Sha256::hash(&[b""]), Height::zero(), 0);
1675        let leader = participants[1].clone();
1676
1677        // The view-1 block: the parent this validator last certified.
1678        let certified_round = Round::new(Epoch::zero(), View::new(1));
1679        let certified_ctx = Ctx {
1680            round: certified_round,
1681            leader: default_leader(),
1682            parent: (View::zero(), genesis.digest()),
1683        };
1684        let certified = B::new::<Sha256>(certified_ctx, genesis.digest(), Height::new(1), 100);
1685        let certified_digest = certified.digest();
1686        assert!(marshal.verified(certified_round, ready(certified)).await);
1687
1688        // The view-2 block: notarized by the network but only nullified here,
1689        // so this validator never certified it.
1690        let notarized_round = Round::new(Epoch::zero(), View::new(2));
1691        let notarized_ctx = Ctx {
1692            round: notarized_round,
1693            leader: leader.clone(),
1694            parent: (View::new(1), certified_digest),
1695        };
1696        let notarized = B::new::<Sha256>(notarized_ctx, certified_digest, Height::new(2), 200);
1697        let notarized_digest = notarized.digest();
1698        assert!(marshal.verified(notarized_round, ready(notarized)).await);
1699
1700        // The view-3 block builds on the view-2 block. The leader's broadcast
1701        // delivered it into the local buffer without a local verification.
1702        let round = Round::new(Epoch::zero(), View::new(3));
1703        let embedded_ctx = Ctx {
1704            round,
1705            leader: leader.clone(),
1706            parent: (View::new(2), notarized_digest),
1707        };
1708        let block = B::new::<Sha256>(embedded_ctx.clone(), notarized_digest, Height::new(3), 300);
1709        let digest = block.digest();
1710        assert!(
1711            buffer
1712                .broadcast(commonware_p2p::Recipients::Some(vec![]), ready(block))
1713                .accepted(),
1714            "buffer broadcast for the candidate should be accepted"
1715        );
1716
1717        let equivocating_ctx = Ctx {
1718            round,
1719            leader,
1720            parent: (View::new(1), certified_digest),
1721        };
1722
1723        let marshaled = Deferred::new(
1724            context.child("deferred"),
1725            app,
1726            marshal,
1727            FixedEpocher::new(BLOCKS_PER_EPOCH),
1728        );
1729        context.sleep(Duration::from_millis(10)).await;
1730
1731        EquivocationFixture {
1732            marshaled,
1733            round,
1734            digest,
1735            embedded_ctx,
1736            equivocating_ctx,
1737            _extra: buffer,
1738        }
1739    }
1740
1741    /// A leader can equivocate at the proposal layer: sign one proposal for a
1742    /// digest declaring the notarized view-2 parent (sent to the validators
1743    /// that certified view 2) and another declaring the certified view-1
1744    /// parent (sent to a validator that only nullified view 2). Both pass
1745    /// their recipients' parent selection and carry the same digest because
1746    /// they name the same block.
1747    ///
1748    /// Refusing to notarize the mismatched proposal is correct. That refusal
1749    /// must not outlive the proposal: once the honest notarization for
1750    /// `(round, digest)` arrives, certification must judge the block against
1751    /// its embedded context (defended by the notarizing quorum) and succeed.
1752    /// Adopting the verdict computed under the equivocating context wedges
1753    /// this validator in the view: the other honest validators have certified
1754    /// and advanced, leaving too few validators to form either a nullification
1755    /// or a finalization after the Byzantine validator stops participating.
1756    #[test_traced("WARN")]
1757    fn test_certify_not_poisoned_by_equivocating_parent_verify() {
1758        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1759        runner.start(|mut context| async move {
1760            let mut fixture = equivocation_fixture(&mut context, MockVerifyingApp::new()).await;
1761
1762            let verify_rx = fixture
1763                .marshaled
1764                .verify(fixture.equivocating_ctx.clone(), fixture.digest)
1765                .await;
1766            assert!(
1767                !verify_rx.await.expect("verify result missing"),
1768                "the equivocating proposal must not be notarized"
1769            );
1770
1771            let certify_rx = fixture
1772                .marshaled
1773                .certify(fixture.round, fixture.digest)
1774                .await;
1775            select! {
1776                result = certify_rx => {
1777                    assert!(
1778                        result.expect("certify result missing"),
1779                        "certify of the notarized digest must not adopt the verdict computed under the equivocating context"
1780                    );
1781                },
1782                _ = context.sleep(Duration::from_secs(5)) => {
1783                    panic!("certify should resolve promptly");
1784                },
1785            }
1786        });
1787    }
1788
1789    /// Control for the equivocation tests: a live application rejection under
1790    /// the matching context is a real verdict. Certification must keep
1791    /// honoring it, because deferred voting means a notarization can exist
1792    /// for an application-invalid block.
1793    #[test_traced("WARN")]
1794    fn test_certify_honors_application_rejection() {
1795        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1796        runner.start(|mut context| async move {
1797            let mut fixture =
1798                equivocation_fixture(&mut context, MockVerifyingApp::with_verify_result(false))
1799                    .await;
1800
1801            let verify_rx = fixture
1802                .marshaled
1803                .verify(fixture.embedded_ctx.clone(), fixture.digest)
1804                .await;
1805            assert!(
1806                verify_rx.await.expect("verify result missing"),
1807                "optimistic verify accepts an available block with a matching context"
1808            );
1809
1810            let certify_rx = fixture
1811                .marshaled
1812                .certify(fixture.round, fixture.digest)
1813                .await;
1814            select! {
1815                result = certify_rx => {
1816                    assert!(
1817                        !result.expect("certify result missing"),
1818                        "certify must propagate the application rejection"
1819                    );
1820                },
1821                _ = context.sleep(Duration::from_secs(5)) => {
1822                    panic!("certify should resolve promptly");
1823                },
1824            }
1825        });
1826    }
1827
1828    /// Without a registered gate (never verified locally, or the gate was lost
1829    /// to a restart), certification runs the application against the block's
1830    /// embedded context. A live rejection there must reach consensus too.
1831    #[test_traced("WARN")]
1832    fn test_certify_without_prior_verify_honors_application_rejection() {
1833        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1834        runner.start(|mut context| async move {
1835            let mut fixture =
1836                equivocation_fixture(&mut context, MockVerifyingApp::with_verify_result(false))
1837                    .await;
1838
1839            // No prior verify, so no gate exists and certify falls through to
1840            // the embedded-context path.
1841            let certify_rx = fixture
1842                .marshaled
1843                .certify(fixture.round, fixture.digest)
1844                .await;
1845            select! {
1846                result = certify_rx => {
1847                    assert!(
1848                        !result.expect("certify result missing"),
1849                        "certify must propagate the application rejection"
1850                    );
1851                },
1852                _ = context.sleep(Duration::from_secs(5)) => {
1853                    panic!("certify should resolve promptly");
1854                },
1855            }
1856        });
1857    }
1858
1859    impl Deferred<deterministic::Context, S, MockVerifyingApp<B, S>, B, FixedEpocher> {
1860        /// Exposes the gates this wrapper prunes to the wrapper tests shared by the standard
1861        /// variants.
1862        pub(in crate::marshal::standard) const fn gates(&self) -> &Gates<D, Digested<B>> {
1863            &self.gates
1864        }
1865    }
1866}