Skip to main content

commonware_consensus/marshal/standard/
inline.rs

1//! Wrapper for standard marshal with inline verification.
2//!
3//! # Overview
4//!
5//! [`Inline`] adapts any [`Application`] to the marshal/consensus interfaces
6//! while keeping block validation in the [`Automaton::verify`] path. Unlike
7//! [`super::Deferred`], it does not defer application verification to certification.
8//! Instead, it only reports `true` from `verify` after parent/height checks and
9//! application verification complete.
10//!
11//! # Epoch Boundaries
12//!
13//! As with [`super::Deferred`], when the parent is the last block of the epoch,
14//! [`Inline`] re-proposes that boundary block instead of building a new block.
15//! This prevents proposing blocks that would be excluded by epoch transition.
16//!
17//! # Verification Model
18//!
19//! Inline mode intentionally avoids relying on embedded block context. This allows
20//! usage with block types that implement [`crate::Block`] but not
21//! [`crate::CertifiableBlock`].
22//!
23//! Because verification is completed inline, `certify` must only wait for data
24//! availability in marshal. No additional deferred verification state needs to
25//! be awaited at certify time.
26//!
27//! # Usage
28//!
29//! ```rust,ignore
30//! let application = Inline::new(
31//!     context,
32//!     my_application,
33//!     marshal_mailbox,
34//!     epocher,
35//! );
36//! ```
37//!
38//! # When to Use
39//!
40//! Prefer this wrapper when:
41//! - Your application block type is not certifiable.
42//! - You prefer simpler verification semantics over deferred verification latency hiding.
43//! - You are willing to perform full application verification before casting a notarize vote.
44
45use crate::{
46    marshal::{
47        application::gates::{self, Gates},
48        core::{CommitmentFallback, DigestFallback, Mailbox},
49        standard::{
50            relay,
51            validation::{
52                await_and_validate_parent, precheck_epoch_and_reproposal, run_app_verify, Decision,
53                ParentCheck,
54            },
55            Standard,
56        },
57        Update,
58    },
59    simplex::{types::Context, Plan},
60    types::{Epocher, Round},
61    Application, Automaton, Block, CertifiableAutomaton, Epochable, Relay, Reporter,
62};
63use commonware_actor::Feedback;
64use commonware_cryptography::certificate::Scheme;
65use commonware_macros::select;
66use commonware_runtime::{
67    telemetry::{
68        metrics::{
69            histogram::{Buckets, Timed},
70            MetricsExt as _,
71        },
72        traces::TracedExt as _,
73    },
74    Clock, Metrics, Spawner,
75};
76use commonware_utils::{
77    channel::{fallible::OneshotExt, oneshot},
78    sync::TracedAsyncMutex,
79};
80use rand_core::Rng;
81use std::sync::Arc;
82use tracing::{debug, info_span, Instrument as _};
83
84/// Waits for a marshal block subscription while allowing consensus to cancel the work.
85async fn await_block_subscription<T, D>(
86    tx: &mut oneshot::Sender<bool>,
87    block_rx: oneshot::Receiver<T>,
88    digest: &D,
89    stage: &'static str,
90) -> Option<T>
91where
92    D: std::fmt::Debug,
93{
94    select! {
95        _ = tx.closed() => {
96            debug!(
97                stage,
98                reason = "consensus dropped receiver",
99                "skipping block wait"
100            );
101            None
102        },
103        result = block_rx => {
104            if result.is_err() {
105                debug!(
106                    stage,
107                    ?digest,
108                    reason = "failed to fetch block",
109                    "skipping block wait"
110                );
111            }
112            result.ok()
113        },
114    }
115}
116
117/// Standard marshal wrapper that verifies blocks inline in `verify`.
118///
119/// # Ancestry Validation
120///
121/// [`Inline`] always validates immediate ancestry before invoking application
122/// verification:
123/// - Parent digest matches consensus context's expected parent
124/// - Child height is exactly parent height plus one
125///
126/// This is sufficient because the parent must have already been accepted by consensus.
127///
128/// # Certifiability
129///
130/// This wrapper requires only [`crate::Block`] for `B`, not
131/// [`crate::CertifiableBlock`]. It is designed for applications that cannot
132/// recover consensus context directly from block payloads.
133pub struct Inline<E, S, A, B, ES>
134where
135    E: Rng + Spawner + Metrics + Clock,
136    S: Scheme,
137    A: Application<E>,
138    B: Block + Clone,
139    ES: Epocher,
140{
141    context: Arc<TracedAsyncMutex<E>>,
142    application: A,
143    marshal: Mailbox<S, Standard<B>>,
144    epocher: ES,
145    gates: Gates<B::Digest, B>,
146
147    build_duration: Timed,
148    proposal_parent_fetch_duration: Timed,
149    ancestor_fetch_duration: Timed,
150}
151
152impl<E, S, A, B, ES> Clone for Inline<E, S, A, B, ES>
153where
154    E: Rng + Spawner + Metrics + Clock,
155    S: Scheme,
156    A: Application<E>,
157    B: Block + Clone,
158    ES: Epocher,
159{
160    fn clone(&self) -> Self {
161        Self {
162            context: self.context.clone(),
163            application: self.application.clone(),
164            marshal: self.marshal.clone(),
165            epocher: self.epocher.clone(),
166            gates: self.gates.clone(),
167            build_duration: self.build_duration.clone(),
168            proposal_parent_fetch_duration: self.proposal_parent_fetch_duration.clone(),
169            ancestor_fetch_duration: self.ancestor_fetch_duration.clone(),
170        }
171    }
172}
173
174impl<E, S, A, B, ES> Inline<E, S, A, B, ES>
175where
176    E: Rng + Spawner + Metrics + Clock,
177    S: Scheme,
178    A: Application<E, Block = B, SigningScheme = S, Context = Context<B::Digest, S::PublicKey>>,
179    B: Block + Clone,
180    ES: Epocher,
181{
182    /// Creates a new inline-verification wrapper.
183    ///
184    /// Registers a `build_duration` histogram for proposal latency.
185    pub fn new(context: E, application: A, marshal: Mailbox<S, Standard<B>>, epocher: ES) -> Self {
186        let build_histogram = context.histogram(
187            "build_duration",
188            "Histogram of time taken for the application to build a new block, in seconds",
189            Buckets::LOCAL,
190        );
191        let build_duration = Timed::new(build_histogram);
192        let parent_fetch_histogram = context.histogram(
193            "parent_fetch_duration",
194            "Histogram of time taken to fetch a parent block in propose, in seconds",
195            Buckets::LOCAL,
196        );
197        let proposal_parent_fetch_duration = Timed::new(parent_fetch_histogram);
198        let ancestor_fetch_histogram = context.histogram(
199            "ancestor_fetch_duration",
200            "Histogram of time taken to fetch a block via the ancestry stream, in seconds",
201            Buckets::LOCAL,
202        );
203        let ancestor_fetch_duration = Timed::new(ancestor_fetch_histogram);
204
205        Self {
206            context: Arc::new(TracedAsyncMutex::new("marshal.context", context)),
207            application,
208            marshal,
209            epocher,
210            gates: Gates::new(),
211            build_duration,
212            proposal_parent_fetch_duration,
213            ancestor_fetch_duration,
214        }
215    }
216}
217
218impl<E, S, A, B, ES> Automaton for Inline<E, S, A, B, ES>
219where
220    E: Rng + Spawner + Metrics + Clock,
221    S: Scheme,
222    A: Application<E, Block = B, SigningScheme = S, Context = Context<B::Digest, S::PublicKey>>,
223    B: Block + Clone,
224    ES: Epocher,
225{
226    type Digest = B::Digest;
227    type Context = Context<Self::Digest, S::PublicKey>;
228
229    /// Proposes a new block or re-proposes an epoch boundary block.
230    ///
231    /// Proposal runs in a spawned task and returns a receiver for the resulting digest. The
232    /// block is staged before the digest is delivered and handed to marshal when consensus
233    /// requests the relay broadcast, which persists it after the send. The resulting sync
234    /// handle is awaited only at certification so it overlaps consensus voting. The digest does
235    /// not imply durability on its own. [`CertifiableAutomaton::certify`] awaits the registered
236    /// certification gate before the finalize vote.
237    #[allow(clippy::async_yields_async)]
238    #[tracing::instrument(name = "marshal.inline.propose", level = "info", skip_all, fields(round = %consensus_context.round))]
239    async fn propose(
240        &mut self,
241        consensus_context: Context<Self::Digest, S::PublicKey>,
242    ) -> oneshot::Receiver<Self::Digest> {
243        let marshal = self.marshal.clone();
244        let mut application = self.application.clone();
245        let epocher = self.epocher.clone();
246        let gates = self.gates.clone();
247        let build_duration = self.build_duration.clone();
248        let proposal_parent_fetch_duration = self.proposal_parent_fetch_duration.clone();
249        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
250
251        let (mut tx, rx) = oneshot::channel();
252        let context = self
253            .context
254            .lock()
255            .await
256            .child("propose")
257            .with_attribute("round", consensus_context.round);
258        let span = info_span!(
259            "marshal.inline.propose.task",
260            round = %consensus_context.round
261        );
262        context.spawn(move |runtime_context| {
263            async move {
264                // On leader recovery, marshal may already hold a verified block
265                // for this round (persisted by a pre-crash propose that reached
266                // its relay broadcast while the notarize vote never reached the
267                // journal).
268                //
269                // The parent context recovered by simplex may differ from the one
270                // the cached block was built against, so the stored block is not
271                // safe to reuse, and proposing a fresh block for a round whose
272                // digest may already have been broadcast would equivocate.
273                //
274                // Skip this view and let the voter nullify it via timeout.
275                if marshal
276                    .get_verified(consensus_context.round)
277                    .await
278                    .is_some()
279                {
280                    debug!(
281                        round = ?consensus_context.round,
282                        "skipping proposal: verified block already exists for round on restart"
283                    );
284                    return;
285                }
286
287                // The parent for any consensus context is in the same epoch: the
288                // boundary block of the previous epoch is the genesis block of the
289                // current epoch.
290                //
291                // Proposal context carries the certified parent view/commitment but
292                // not the parent height. The parent may be certified above the
293                // finalized tip, so this must stay round-bound until the block is
294                // returned.
295                let (parent_view, parent_commitment) = consensus_context.parent;
296                let parent_request = marshal.subscribe_by_commitment(
297                    parent_commitment,
298                    CommitmentFallback::FetchByRound {
299                        round: Round::new(consensus_context.epoch(), parent_view),
300                    },
301                );
302
303                let parent_timer = proposal_parent_fetch_duration.timer(&runtime_context);
304                let parent = select! {
305                    _ = tx.closed() => {
306                        debug!(reason = "consensus dropped receiver", "skipping proposal");
307                        return;
308                    },
309                    result = parent_request => match result {
310                        Ok(parent) => parent,
311                        Err(_) => {
312                            debug!(
313                                ?parent_commitment,
314                                reason = "failed to fetch parent block",
315                                "skipping proposal"
316                            );
317                            return;
318                        }
319                    },
320                };
321                parent_timer.observe(&runtime_context);
322
323                // At epoch boundary, re-propose the parent block.
324                let last_in_epoch = epocher
325                    .last(consensus_context.epoch())
326                    .expect("current epoch should exist");
327                if parent.height() == last_in_epoch {
328                    let digest = parent.digest();
329                    gates
330                        .stage(
331                            consensus_context.round,
332                            digest,
333                            parent,
334                            tx,
335                            "re-proposed boundary block",
336                        )
337                        .await;
338                    return;
339                }
340
341                let ancestor_stream = marshal.ancestor_stream(
342                    Arc::new(runtime_context.child("ancestor_stream")),
343                    [parent],
344                    ancestor_fetch_duration,
345                );
346                let build_request = application
347                    .propose(
348                        (
349                            runtime_context.child("app_propose"),
350                            consensus_context.clone(),
351                        ),
352                        ancestor_stream,
353                    )
354                    .instrument(info_span!(
355                        "marshal.inline.application.propose",
356                        round = %consensus_context.round,
357                        parent_view = parent_view.traced(),
358                        parent = %parent_commitment
359                    ));
360
361                let build_timer = build_duration.timer(&runtime_context);
362                let built_block = select! {
363                    _ = tx.closed() => {
364                        debug!(reason = "consensus dropped receiver", "skipping proposal");
365                        return;
366                    },
367                    result = build_request => match result {
368                        Some(block) => block,
369                        None => {
370                            debug!(
371                                ?parent_commitment,
372                                reason = "block building failed",
373                                "skipping proposal"
374                            );
375                            return;
376                        }
377                    },
378                };
379                build_timer.observe(&runtime_context);
380
381                let digest = built_block.digest();
382                gates
383                    .stage(
384                        consensus_context.round,
385                        digest,
386                        Arc::new(built_block),
387                        tx,
388                        "proposed block",
389                    )
390                    .await;
391            }
392            .instrument(span)
393        });
394        rx
395    }
396
397    /// Performs complete verification inline.
398    ///
399    /// This method:
400    /// 1. Waits for the block by digest
401    /// 2. Enforces epoch/re-proposal rules
402    /// 3. Fetches and validates the parent relationship
403    /// 4. Runs application verification over ancestry
404    ///
405    /// The notarize vote is cast as soon as application verification completes. The block's
406    /// durable sync is deferred (it runs concurrently with consensus voting) and its
407    /// completion is registered in `gates` for [`Self::certify`] to await before
408    /// the finalize vote.
409    #[allow(clippy::async_yields_async)]
410    #[tracing::instrument(name = "marshal.inline.verify", level = "info", skip_all, fields(round = %context.round, digest = %digest))]
411    async fn verify(
412        &mut self,
413        context: Context<Self::Digest, S::PublicKey>,
414        digest: Self::Digest,
415    ) -> oneshot::Receiver<bool> {
416        // Register the certification gate synchronously so `certify` always finds it, even
417        // while the block subscription / durable sync is still in flight. A `true` result means
418        // the block is durably persisted; a `false` result is a live local verdict; a dropped
419        // sender means verification did not complete and certification should use recovery fetch.
420        let round = context.round;
421        let (durable_tx, durable_rx) = oneshot::channel();
422        self.gates.insert(round, digest, durable_rx);
423
424        let marshal = self.marshal.clone();
425        let mut application = self.application.clone();
426        let epocher = self.epocher.clone();
427        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
428
429        let (mut tx, rx) = oneshot::channel();
430        let runtime_context = self
431            .context
432            .lock()
433            .await
434            .child("inline_verify")
435            .with_attribute("round", round);
436        let span = info_span!(
437            "marshal.inline.verify.task",
438            round = %round,
439            digest = %digest
440        );
441        runtime_context.spawn(move |runtime_context| {
442            async move {
443                // Start the parent fetch immediately: its commitment and certified
444                // round are known from the consensus context, so it can proceed in
445                // parallel with broadcast delivery of the candidate block.
446                // Reproposals (digest == context.parent.1) skip parent validation
447                // entirely, so they must not fetch: the "parent" is the candidate
448                // itself, and candidate acquisition is deliberately local-only.
449                let parent_request = (digest != context.parent.1).then(|| {
450                    let (parent_view, parent_commitment) = context.parent;
451                    marshal.subscribe_by_commitment(
452                        parent_commitment,
453                        CommitmentFallback::FetchByRound {
454                            round: Round::new(context.epoch(), parent_view),
455                        },
456                    )
457                });
458
459                let block_request = marshal.subscribe_by_digest(digest, DigestFallback::Wait);
460                let Some(block) =
461                    await_block_subscription(&mut tx, block_request, &digest, "verification").await
462                else {
463                    return;
464                };
465
466                // Shared pre-checks:
467                // - Blocks are invalid if they are not in the expected epoch and are
468                //   not a valid boundary re-proposal.
469                // - Re-proposals are detected when `digest == context.parent.1`.
470                // - Re-proposals skip normal parent/height checks because:
471                //   1) the block was already verified when originally proposed
472                //   2) parent-child checks would fail by construction when parent == block
473                let Some(decision) =
474                    precheck_epoch_and_reproposal(&epocher, &marshal, &context, digest, block)
475                        .await
476                else {
477                    return;
478                };
479                let block = match decision {
480                    Decision::Complete(valid) => {
481                        // Re-proposal: precheck already persisted the block (durable) when
482                        // valid; epoch-reject when invalid. Hand the verdict to certify.
483                        tx.send_lossy(valid);
484                        durable_tx.send_lossy(valid);
485                        return;
486                    }
487                    Decision::Continue(block) => block,
488                };
489
490                // `Continue` implies a non-reproposal, so the parent subscription
491                // was started above.
492                let parent_request =
493                    parent_request.expect("non-reproposal has a parent subscription");
494
495                // Start the candidate store immediately: it depends on neither the
496                // parent fetch (which may hit the network) nor the verdict below.
497                // Storing before validation is intentional: these caches provide
498                // candidate availability/recovery, not a validity decision. The
499                // notarize vote follows the app verdict, while certify awaits the
500                // registered gate that resolves true only after both app
501                // verification succeeds and the store is durable.
502                //
503                // The verify future below aborts when consensus drops its receiver
504                // (the view exited via nullification or finalization), even though
505                // certification can still fire for a nullified view. That is
506                // deliberate: inline's certify fallback does not need the app
507                // verdict (a notarization implies f+1 honest validators already
508                // verified), and the store still completes through the join, so
509                // the fallback rides the verified write instead of re-persisting.
510                let store = marshal.verified(round, Arc::clone(&block));
511                let verify_then_vote = async {
512                    // Non-reproposal path: validate the parent we already started
513                    // fetching.
514                    let parent = match await_and_validate_parent(
515                        context.parent.1,
516                        block.as_ref(),
517                        parent_request,
518                        &mut tx,
519                    )
520                    .await
521                    {
522                        Some(ParentCheck::Valid(parent)) => parent,
523                        Some(ParentCheck::Invalid) => {
524                            tx.send_lossy(false);
525                            return Some(false);
526                        }
527                        None => return None,
528                    };
529                    let valid = run_app_verify(
530                        runtime_context,
531                        context,
532                        Arc::clone(&block),
533                        parent,
534                        &mut application,
535                        &marshal,
536                        &mut tx,
537                        ancestor_fetch_duration,
538                    )
539                    .await;
540                    if let Some(valid) = valid {
541                        tx.send_lossy(valid);
542                    }
543                    valid
544                };
545                let (verdict, durable) = futures::join!(verify_then_vote, store);
546                if let Some(valid) = gates::resolve(verdict, durable) {
547                    durable_tx.send_lossy(valid);
548                }
549            }
550            .instrument(span)
551        });
552        rx
553    }
554}
555
556/// Inline certification consumes a registered certification gate when present, and
557/// falls back to a round-bound fetch/persist path after restart.
558impl<E, S, A, B, ES> CertifiableAutomaton for Inline<E, S, A, B, ES>
559where
560    E: Rng + Spawner + Metrics + Clock,
561    S: Scheme,
562    A: Application<E, Block = B, SigningScheme = S, Context = Context<B::Digest, S::PublicKey>>,
563    B: Block + Clone,
564    ES: Epocher,
565{
566    #[allow(clippy::async_yields_async)]
567    #[tracing::instrument(name = "marshal.inline.certify", level = "info", skip_all, fields(round = %round, digest = %digest))]
568    async fn certify(&mut self, round: Round, digest: Self::Digest) -> oneshot::Receiver<bool> {
569        self.gates.flush_unrelayed(&self.marshal, round, digest);
570
571        // `propose`/`verify` register an in-flight certification gate whose result resolves
572        // once the block's sync handle completes. Awaiting it here is the durability barrier
573        // for the finalize vote, and it lets the sync overlap consensus voting
574        // instead of freezing certify with a fresh fsync.
575        let task = self.gates.take(round, digest);
576
577        // `verify()` waits only on local broadcast delivery, so nudge a
578        // round-bound notarized fetch that can unblock the existing waiter
579        // if local broadcast never arrives. For the standard variant, the
580        // digest is also the variant commitment.
581        if task.is_some() {
582            self.marshal.hint_notarized(round, digest);
583        }
584        let marshal = self.marshal.clone();
585        let (mut tx, rx) = oneshot::channel();
586        let context = self
587            .context
588            .lock()
589            .await
590            .child("inline_certify")
591            .with_attribute("round", round);
592        context.spawn(move |_| {
593            async move {
594                // Preserve a live local verdict. Missing local state after an unclean restart
595                // has no task and falls through to the round-bound fetch path below.
596                if let Some(task) = task {
597                    let result = select! {
598                        _ = tx.closed() => {
599                            debug!(reason = "consensus dropped receiver", "skipping certification");
600                            return;
601                        },
602                        result = task => result,
603                    };
604                    if let Ok(verdict) = result {
605                        tx.send_lossy(verdict);
606                        return;
607                    }
608                }
609
610                // No local certification gate task (for example after an unclean restart):
611                // fetch the notarized block and persist it. A Byzantine leader can form a
612                // notarization after sending the proposal to only f+1 honest validators, so
613                // the validators left without the block must fetch it here to certify and
614                // avoid getting stuck.
615                let block_rx =
616                    marshal.subscribe_by_digest(digest, DigestFallback::FetchByRound { round });
617                let Some(block) =
618                    await_block_subscription(&mut tx, block_rx, &digest, "certification").await
619                else {
620                    return;
621                };
622                if !marshal.certified(round, block).await {
623                    return;
624                }
625                tx.send_lossy(true);
626            }
627            .instrument(info_span!(
628                "marshal.inline.certify.task",
629                round = %round,
630                digest = %digest
631            ))
632        });
633
634        rx
635    }
636}
637
638impl<E, S, A, B, ES> Relay for Inline<E, S, A, B, ES>
639where
640    E: Rng + Spawner + Metrics + Clock,
641    S: Scheme,
642    A: Application<E, Block = B, Context = Context<B::Digest, S::PublicKey>>,
643    B: Block + Clone,
644    ES: Epocher,
645{
646    type Digest = B::Digest;
647    type PublicKey = S::PublicKey;
648    type Plan = Plan<S::PublicKey>;
649
650    fn broadcast(&mut self, commitment: Self::Digest, plan: Plan<S::PublicKey>) -> Feedback {
651        relay::broadcast(&self.gates, &self.marshal, commitment, plan)
652    }
653}
654
655impl<E, S, A, B, ES> Reporter for Inline<E, S, A, B, ES>
656where
657    E: Rng + Spawner + Metrics + Clock,
658    S: Scheme,
659    A: Application<E, Block = B, Context = Context<B::Digest, S::PublicKey>>
660        + Reporter<Activity = Update<B>>,
661    B: Block + Clone,
662    ES: Epocher,
663{
664    type Activity = A::Activity;
665
666    /// Forwards consensus activity to the wrapped application reporter.
667    fn report(&mut self, update: Self::Activity) -> Feedback {
668        if let Update::Tip(tip_round, _, _) = &update {
669            self.gates.retain_after(tip_round);
670        }
671        self.application.report(update)
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::Inline;
678    use crate::{
679        marshal::mocks::{
680            harness::{
681                default_leader, make_raw_block, setup_network_with_participants, Ctx,
682                StandardHarness, TestHarness, B, BLOCKS_PER_EPOCH, NAMESPACE, NUM_VALIDATORS, S, V,
683            },
684            verifying::{GatedVerifyingApp, MockVerifyingApp},
685        },
686        simplex::{scheme::bls12381_threshold::vrf as bls12381_threshold_vrf, types::Context},
687        types::{Epoch, FixedEpocher, Height, Round, View},
688        Application, Automaton, Block, CertifiableAutomaton, Relay,
689    };
690    use commonware_broadcast::Broadcaster;
691    use commonware_cryptography::{
692        certificate::{mocks::Fixture, ConstantProvider, Scheme},
693        sha256::Sha256,
694        Digestible, Hasher as _,
695    };
696    use commonware_macros::{select, test_traced};
697    use commonware_runtime::{deterministic, Clock, Metrics, Runner, Spawner, Supervisor as _};
698    use commonware_utils::{channel::fallible::OneshotExt, NZUsize};
699    use rand::Rng;
700    use std::time::Duration;
701
702    // Compile-time assertion only: inline standard wrapper must not require `CertifiableBlock`.
703    #[allow(dead_code)]
704    fn assert_non_certifiable_block_supported<E, S, A, B, ES>()
705    where
706        E: Rng + Spawner + Metrics + Clock,
707        S: Scheme,
708        A: Application<E, Block = B, SigningScheme = S, Context = Context<B::Digest, S::PublicKey>>,
709        B: Block + Clone,
710        ES: crate::types::Epocher,
711    {
712        fn assert_automaton<T: Automaton>() {}
713        fn assert_certifiable<T: CertifiableAutomaton>() {}
714        fn assert_relay<T: Relay>() {}
715
716        assert_automaton::<Inline<E, S, A, B, ES>>();
717        assert_certifiable::<Inline<E, S, A, B, ES>>();
718        assert_relay::<Inline<E, S, A, B, ES>>();
719    }
720
721    #[test_traced("INFO")]
722    fn test_certify_returns_immediately_after_verify_fetches_block() {
723        let runner = deterministic::Runner::timed(Duration::from_secs(30));
724        runner.start(|mut context| async move {
725            let Fixture {
726                participants,
727                schemes,
728                ..
729            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
730            let mut oracle = setup_network_with_participants(
731                context.child("network"),
732                NZUsize!(1),
733                participants.clone(),
734            )
735            .await;
736
737            let me = participants[0].clone();
738            let setup = StandardHarness::setup_validator(
739                context.child("validator").with_attribute("index", 0),
740                &mut oracle,
741                me.clone(),
742                ConstantProvider::new(schemes[0].clone()),
743            )
744            .await;
745            let marshal = setup.mailbox;
746
747            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
748            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
749            let mut inline = Inline::new(
750                context.child("inline"),
751                mock_app,
752                marshal.clone(),
753                FixedEpocher::new(BLOCKS_PER_EPOCH),
754            );
755
756            // Seed the parent and child blocks in marshal so verify can fetch locally.
757            let parent_round = Round::new(Epoch::zero(), View::new(1));
758            let parent_ctx = Ctx {
759                round: parent_round,
760                leader: default_leader(),
761                parent: (View::zero(), genesis.digest()),
762            };
763            let parent = B::new::<Sha256>(parent_ctx, genesis.digest(), Height::new(1), 100);
764            let parent_digest = parent.digest();
765            assert!(marshal.verified(parent_round, parent).await);
766
767            let round = Round::new(Epoch::zero(), View::new(2));
768            let verify_context = Ctx {
769                round,
770                leader: me,
771                parent: (View::new(1), parent_digest),
772            };
773            let block =
774                B::new::<Sha256>(verify_context.clone(), parent_digest, Height::new(2), 200);
775            let digest = block.digest();
776            assert!(marshal.verified(round, block).await);
777
778            // Complete verify first so the block is already available locally.
779            let verify_rx = inline.verify(verify_context, digest).await;
780            assert!(
781                verify_rx.await.unwrap(),
782                "verify should complete successfully before certify"
783            );
784
785            // Certify should return immediately instead of waiting on marshal.
786            let certify_rx = inline.certify(round, digest).await;
787
788            select! {
789                result = certify_rx => {
790                    assert!(
791                        result.unwrap(),
792                        "certify should return immediately once verify has fetched the block"
793                    );
794                },
795                _ = context.sleep(Duration::from_secs(5)) => {
796                    panic!("certify should not hang after local verify completed");
797                },
798            }
799        });
800    }
801
802    #[test_traced("INFO")]
803    fn test_certify_succeeds_without_verify_task() {
804        let runner = deterministic::Runner::timed(Duration::from_secs(30));
805        runner.start(|mut context| async move {
806            let Fixture {
807                participants,
808                schemes,
809                ..
810            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
811            let mut oracle = setup_network_with_participants(
812                context.child("network"),
813                NZUsize!(1),
814                participants.clone(),
815            )
816            .await;
817
818            let me = participants[0].clone();
819            let setup = StandardHarness::setup_validator(
820                context.child("validator").with_attribute("index", 0),
821                &mut oracle,
822                me.clone(),
823                ConstantProvider::new(schemes[0].clone()),
824            )
825            .await;
826            let marshal = setup.mailbox;
827
828            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
829            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
830            let mut inline = Inline::new(
831                context.child("inline"),
832                mock_app,
833                marshal.clone(),
834                FixedEpocher::new(BLOCKS_PER_EPOCH),
835            );
836
837            // Seed the parent and child blocks in marshal without starting a verify task.
838            let parent_round = Round::new(Epoch::zero(), View::new(1));
839            let parent_ctx = Ctx {
840                round: parent_round,
841                leader: default_leader(),
842                parent: (View::zero(), genesis.digest()),
843            };
844            let parent = B::new::<Sha256>(parent_ctx, genesis.digest(), Height::new(1), 100);
845            let parent_digest = parent.digest();
846            assert!(marshal.verified(parent_round, parent).await);
847
848            let round = Round::new(Epoch::zero(), View::new(2));
849            let verify_context = Ctx {
850                round,
851                leader: me,
852                parent: (View::new(1), parent_digest),
853            };
854            let block =
855                B::new::<Sha256>(verify_context.clone(), parent_digest, Height::new(2), 200);
856            let digest = block.digest();
857            assert!(marshal.verified(round, block).await);
858
859            // Certify should still resolve by waiting on marshal block availability directly.
860            let certify_rx = inline.certify(round, digest).await;
861
862            select! {
863                result = certify_rx => {
864                    assert!(
865                        result.unwrap(),
866                        "certify should resolve once block availability is known"
867                    );
868                },
869                _ = context.sleep(Duration::from_secs(5)) => {
870                    panic!("certify should not hang when block is already available in marshal");
871                },
872            }
873        });
874    }
875
876    #[test_traced("INFO")]
877    fn test_certify_reproposal_uses_available_blocks_after_verify() {
878        let runner = deterministic::Runner::timed(Duration::from_secs(30));
879        runner.start(|mut context| async move {
880            let Fixture {
881                participants,
882                schemes,
883                ..
884            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
885            let mut oracle =
886                setup_network_with_participants(context.child("network"), NZUsize!(1), participants.clone())
887                    .await;
888
889            let me = participants[0].clone();
890            let setup = StandardHarness::setup_validator(
891                context.child("validator").with_attribute("index", 0),
892                &mut oracle,
893                me.clone(),
894                ConstantProvider::new(schemes[0].clone()),
895            )
896            .await;
897            let marshal = setup.mailbox;
898            let marshal_actor_handle = setup.actor_handle;
899
900            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
901            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
902            let mut inline = Inline::new(context.child("inline"),
903                mock_app,
904                marshal.clone(),
905                FixedEpocher::new(BLOCKS_PER_EPOCH),
906            );
907
908            let boundary_height = Height::new(BLOCKS_PER_EPOCH.get() - 1);
909            let boundary_round = Round::new(Epoch::zero(), View::new(boundary_height.get()));
910            let boundary_block = B::new::<Sha256>(
911                Ctx {
912                    round: boundary_round,
913                    leader: default_leader(),
914                    parent: (View::zero(), genesis.digest()),
915                },
916                genesis.digest(),
917                boundary_height,
918                1900,
919            );
920            let boundary_digest = boundary_block.digest();
921            assert!(
922                marshal.verified(boundary_round, boundary_block).await
923            );
924
925            let reproposal_round = Round::new(Epoch::zero(), View::new(boundary_height.get() + 1));
926            let reproposal_context = Ctx {
927                round: reproposal_round,
928                leader: me,
929                parent: (View::new(boundary_height.get()), boundary_digest),
930            };
931
932            let verify_rx = inline.verify(reproposal_context, boundary_digest).await;
933            assert!(
934                verify_rx.await.unwrap(),
935                "verify should accept a valid boundary re-proposal"
936            );
937
938            marshal_actor_handle.abort();
939            drop(marshal);
940            context.sleep(Duration::from_millis(1)).await;
941
942            let certify_rx = inline.certify(reproposal_round, boundary_digest).await;
943            select! {
944                result = certify_rx => {
945                    assert!(
946                        result.unwrap(),
947                        "certify should use the available_blocks fast path for verified re-proposals"
948                    );
949                },
950                _ = context.sleep(Duration::from_secs(5)) => {
951                    panic!("certify should not depend on marshal after verify cached a re-proposal");
952                },
953            }
954        });
955    }
956
957    /// Regression: `certify` resolving true drives the finalize vote in inline
958    /// mode, so it must imply the block is durably persisted even when the
959    /// certify path subscribed before `verify()` finished.
960    #[test_traced("WARN")]
961    fn test_inline_certify_persists_block_before_resolving() {
962        for seed in 0u64..16 {
963            inline_certify_persists_block_before_resolving_at(seed);
964        }
965    }
966
967    fn inline_certify_persists_block_before_resolving_at(seed: u64) {
968        let runner = deterministic::Runner::new(
969            deterministic::Config::new()
970                .with_seed(seed)
971                .with_timeout(Some(Duration::from_secs(60))),
972        );
973        runner.start(|mut context| async move {
974            let Fixture {
975                participants,
976                schemes,
977                ..
978            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
979            let mut oracle = setup_network_with_participants(
980                context.child("network"),
981                NZUsize!(1),
982                participants.clone(),
983            )
984            .await;
985
986            let me = participants[0].clone();
987
988            let setup = StandardHarness::setup_validator(
989                context.child("validator").with_attribute("index", 0),
990                &mut oracle,
991                me.clone(),
992                ConstantProvider::new(schemes[0].clone()),
993            )
994            .await;
995            let marshal = setup.mailbox;
996            let buffer = setup.extra;
997            let actor_handle = setup.actor_handle;
998
999            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
1000            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
1001            let mut inline = Inline::new(
1002                context.child("inline"),
1003                mock_app,
1004                marshal.clone(),
1005                FixedEpocher::new(BLOCKS_PER_EPOCH),
1006            );
1007
1008            let parent = make_raw_block(genesis.digest(), Height::new(1), 100);
1009            let parent_digest = parent.digest();
1010
1011            let child_round = Round::new(Epoch::zero(), View::new(2));
1012            let child_ctx = Ctx {
1013                round: child_round,
1014                leader: me.clone(),
1015                parent: (View::new(1), parent_digest),
1016            };
1017            let child = B::new::<Sha256>(child_ctx.clone(), parent_digest, Height::new(2), 200);
1018            let child_digest = child.digest();
1019
1020            assert!(
1021                buffer
1022                    .broadcast(commonware_p2p::Recipients::Some(vec![]), parent.clone())
1023                    .accepted(),
1024                "buffer broadcast for parent should be accepted"
1025            );
1026            assert!(
1027                buffer
1028                    .broadcast(commonware_p2p::Recipients::Some(vec![]), child.clone())
1029                    .accepted(),
1030                "buffer broadcast for child should be accepted"
1031            );
1032
1033            let verify_rx = inline.verify(child_ctx, child_digest).await;
1034            let certify_result = inline
1035                .certify(child_round, child_digest)
1036                .await
1037                .await
1038                .expect("certify result missing");
1039            assert!(certify_result, "certify should succeed");
1040
1041            actor_handle.abort();
1042            drop(verify_rx);
1043            drop(inline);
1044            drop(marshal);
1045            drop(buffer);
1046
1047            let setup2 = StandardHarness::setup_validator(
1048                context
1049                    .child("validator_restart")
1050                    .with_attribute("index", 0),
1051                &mut oracle,
1052                me.clone(),
1053                ConstantProvider::new(schemes[0].clone()),
1054            )
1055            .await;
1056            let marshal2 = setup2.mailbox;
1057
1058            let post_restart = marshal2.get_block(&child_digest).await;
1059            assert!(
1060                post_restart.is_some(),
1061                "certify resolved true so block must be durably persisted (seed={seed})"
1062            );
1063        });
1064    }
1065
1066    /// Regression: in inline mode `propose` registers a certification gate for the
1067    /// built block that `certify` awaits. After the leader certifies its own proposal,
1068    /// the block must be durably recoverable. This is the >=f+1 guarantee: the leader
1069    /// certifies its own block through marshal so it awaits durability before the
1070    /// finalize vote.
1071    #[test_traced("WARN")]
1072    fn test_inline_propose_then_certify_persists_block() {
1073        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1074        runner.start(|mut context| async move {
1075            let Fixture {
1076                participants,
1077                schemes,
1078                ..
1079            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1080            let mut oracle = setup_network_with_participants(
1081                context.child("network"),
1082                NZUsize!(1),
1083                participants.clone(),
1084            )
1085            .await;
1086
1087            let me = participants[0].clone();
1088            let setup = StandardHarness::setup_validator(
1089                context.child("validator").with_attribute("index", 0),
1090                &mut oracle,
1091                me.clone(),
1092                ConstantProvider::new(schemes[0].clone()),
1093            )
1094            .await;
1095            let marshal = setup.mailbox;
1096            let actor_handle = setup.actor_handle;
1097
1098            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
1099
1100            // Seed the parent at its round so `propose` can fetch it locally.
1101            let parent_round = Round::new(Epoch::zero(), View::new(1));
1102            let parent_ctx = Ctx {
1103                round: parent_round,
1104                leader: default_leader(),
1105                parent: (View::zero(), genesis.digest()),
1106            };
1107            let parent = B::new::<Sha256>(parent_ctx, genesis.digest(), Height::new(1), 100);
1108            let parent_digest = parent.digest();
1109            assert!(marshal.verified(parent_round, parent).await);
1110
1111            // The leader builds the child via `app.propose`.
1112            let round = Round::new(Epoch::zero(), View::new(2));
1113            let ctx = Ctx {
1114                round,
1115                leader: me.clone(),
1116                parent: (View::new(1), parent_digest),
1117            };
1118            let child = B::new::<Sha256>(ctx.clone(), parent_digest, Height::new(2), 200);
1119            let child_digest = child.digest();
1120            let mock_app: MockVerifyingApp<B, S> =
1121                MockVerifyingApp::new().with_propose_result(child);
1122            let mut inline = Inline::new(
1123                context.child("inline"),
1124                mock_app,
1125                marshal.clone(),
1126                FixedEpocher::new(BLOCKS_PER_EPOCH),
1127            );
1128
1129            let digest = inline
1130                .propose(ctx)
1131                .await
1132                .await
1133                .expect("propose must return a digest");
1134            assert_eq!(
1135                digest, child_digest,
1136                "propose must return the built block's digest"
1137            );
1138
1139            // The leader certifies its own proposal, which awaits the deferred sync handle.
1140            assert!(
1141                inline
1142                    .certify(round, child_digest)
1143                    .await
1144                    .await
1145                    .expect("certify result missing"),
1146                "certify must succeed for the leader's own proposal"
1147            );
1148
1149            // After certify, the block must be durable across an unclean restart.
1150            actor_handle.abort();
1151            drop(inline);
1152            drop(marshal);
1153
1154            let setup2 = StandardHarness::setup_validator(
1155                context
1156                    .child("validator_restart")
1157                    .with_attribute("index", 0),
1158                &mut oracle,
1159                me,
1160                ConstantProvider::new(schemes[0].clone()),
1161            )
1162            .await;
1163            let marshal2 = setup2.mailbox;
1164
1165            assert!(
1166                marshal2.get_block(&child_digest).await.is_some(),
1167                "certify resolved true for the leader's own proposal so the block must be durable"
1168            );
1169        });
1170    }
1171
1172    /// Dropping the verify receiver before the block is available closes the
1173    /// synchronously-registered certification gate. `certify` must recover through
1174    /// the fetch/certified path instead of returning the closed gate to consensus.
1175    #[test_traced("WARN")]
1176    fn test_inline_certify_recovers_after_verify_receiver_drop() {
1177        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1178        runner.start(|mut context| async move {
1179            let Fixture {
1180                participants,
1181                schemes,
1182                ..
1183            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1184            let mut oracle = setup_network_with_participants(
1185                context.child("network"),
1186                NZUsize!(1),
1187                participants.clone(),
1188            )
1189            .await;
1190
1191            let me = participants[0].clone();
1192            let setup = StandardHarness::setup_validator(
1193                context.child("validator").with_attribute("index", 0),
1194                &mut oracle,
1195                me.clone(),
1196                ConstantProvider::new(schemes[0].clone()),
1197            )
1198            .await;
1199            let marshal = setup.mailbox;
1200
1201            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
1202            let mock_app: MockVerifyingApp<B, S> = MockVerifyingApp::new();
1203            let mut inline = Inline::new(
1204                context.child("inline"),
1205                mock_app,
1206                marshal.clone(),
1207                FixedEpocher::new(BLOCKS_PER_EPOCH),
1208            );
1209
1210            let round = Round::new(Epoch::zero(), View::new(1));
1211            let block_context = Ctx {
1212                round,
1213                leader: me,
1214                parent: (View::zero(), genesis.digest()),
1215            };
1216            let block =
1217                B::new::<Sha256>(block_context.clone(), genesis.digest(), Height::new(1), 100);
1218            let digest = block.digest();
1219
1220            let verify_rx = inline.verify(block_context, digest).await;
1221            drop(verify_rx);
1222
1223            // Give the verify task a chance to observe the dropped receiver while its
1224            // block subscription is still pending.
1225            context.sleep(Duration::from_millis(10)).await;
1226
1227            assert!(marshal.verified(round, block).await);
1228            let certify_rx = inline.certify(round, digest).await;
1229            select! {
1230                result = certify_rx => {
1231                    assert!(
1232                        result.expect("certify result missing"),
1233                        "certify should recover after verify receiver drop"
1234                    );
1235                },
1236                _ = context.sleep(Duration::from_secs(5)) => {
1237                    panic!("certify should recover promptly after verify drop");
1238                },
1239            }
1240        });
1241    }
1242
1243    /// The store request runs concurrently with `app.verify`, not after the
1244    /// notarize vote: while gated application verification is still blocked, the
1245    /// block has already reached marshal and is locally queryable even though the
1246    /// sync handle may still be pending. Releasing verification then lets the
1247    /// notarize vote resolve and certification await the registered certification
1248    /// gate. Separate restart tests cover durable recovery after certification.
1249    #[test_traced("WARN")]
1250    fn test_inline_store_overlaps_app_verify() {
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            )
1263            .await;
1264
1265            let me = participants[0].clone();
1266
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            let buffer = setup.extra;
1276
1277            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
1278            let (mock_app, verify_started, release_verify): (GatedVerifyingApp<B, S>, _, _) =
1279                GatedVerifyingApp::new();
1280            let mut inline = Inline::new(
1281                context.child("inline"),
1282                mock_app,
1283                marshal.clone(),
1284                FixedEpocher::new(BLOCKS_PER_EPOCH),
1285            );
1286
1287            let parent = make_raw_block(genesis.digest(), Height::new(1), 100);
1288            let parent_digest = parent.digest();
1289
1290            let child_round = Round::new(Epoch::zero(), View::new(2));
1291            let child_ctx = Ctx {
1292                round: child_round,
1293                leader: me.clone(),
1294                parent: (View::new(1), parent_digest),
1295            };
1296            let child = B::new::<Sha256>(child_ctx.clone(), parent_digest, Height::new(2), 200);
1297            let child_digest = child.digest();
1298
1299            assert!(
1300                buffer
1301                    .broadcast(commonware_p2p::Recipients::Some(vec![]), parent)
1302                    .accepted(),
1303                "buffer broadcast for parent should be accepted"
1304            );
1305            assert!(
1306                buffer
1307                    .broadcast(commonware_p2p::Recipients::Some(vec![]), child)
1308                    .accepted(),
1309                "buffer broadcast for child should be accepted"
1310            );
1311
1312            let verify_rx = inline.verify(child_ctx, child_digest).await;
1313
1314            // Application verification is now blocked. The store request runs concurrently
1315            // with it, so the block is locally queryable even though the notarize vote has
1316            // not been cast and the sync handle may still be pending.
1317            verify_started
1318                .await
1319                .expect("verify should reach the gated application");
1320            assert!(
1321                marshal.get_block(&child_digest).await.is_some(),
1322                "the store request runs concurrently with app.verify, so the block is locally queryable while verification is still gated"
1323            );
1324
1325            // Releasing verification resolves the notarize vote and lets certification
1326            // succeed (valid and durable).
1327            release_verify.send_lossy(());
1328            assert!(
1329                verify_rx.await.expect("verify result missing"),
1330                "inline verify should pass once verification is released"
1331            );
1332            let certify_rx = inline.certify(child_round, child_digest).await;
1333            select! {
1334                result = certify_rx => {
1335                    assert!(
1336                        result.expect("certify result missing"),
1337                        "certify should succeed once verification passes"
1338                    );
1339                },
1340                _ = context.sleep(Duration::from_secs(5)) => {
1341                    panic!("certify should resolve after verification is released");
1342                },
1343            }
1344        });
1345    }
1346
1347    /// Regression: if marshal persisted a verified block for a round before
1348    /// a crash (via a prior `propose` call) but the simplex notarize artifact
1349    /// never reached the journal, the restarted leader must skip proposing
1350    /// for that round. The cached block was built against a parent context
1351    /// that replay may have changed, so reusing it can broadcast a proposal
1352    /// whose payload no longer matches the recovered header. Building a
1353    /// fresh block would also be unsafe because the pre-crash digest may
1354    /// already have been broadcast, so a second proposal for the round would
1355    /// equivocate. Dropping the receiver lets the voter nullify the view via
1356    /// `MissingProposal`.
1357    #[test_traced("WARN")]
1358    fn test_propose_skips_when_verified_block_exists_on_restart() {
1359        let runner = deterministic::Runner::timed(Duration::from_secs(30));
1360        runner.start(|mut context| async move {
1361            let Fixture {
1362                participants,
1363                schemes,
1364                ..
1365            } = bls12381_threshold_vrf::fixture::<V, _>(&mut context, NAMESPACE, NUM_VALIDATORS);
1366            let mut oracle = setup_network_with_participants(
1367                context.child("network"),
1368                NZUsize!(1),
1369                participants.clone(),
1370            )
1371            .await;
1372
1373            let me = participants[0].clone();
1374            let round = Round::new(Epoch::zero(), View::new(1));
1375            let genesis = make_raw_block(Sha256::hash(b""), Height::zero(), 0);
1376            let ctx = Ctx {
1377                round,
1378                leader: me.clone(),
1379                parent: (View::zero(), genesis.digest()),
1380            };
1381
1382            // Pre-crash: seed `verified_blocks[V=1]` through the live mailbox,
1383            // mirroring an aborted pre-crash `Inline::propose` that persisted
1384            // its verified block before the voter could journal a notarize.
1385            let pre_setup = StandardHarness::setup_validator(
1386                context.child("validator").with_attribute("index", 0),
1387                &mut oracle,
1388                me.clone(),
1389                ConstantProvider::new(schemes[0].clone()),
1390            )
1391            .await;
1392            let pre_marshal = pre_setup.mailbox;
1393            let pre_actor = pre_setup.actor_handle;
1394            let pre_extra = pre_setup.extra;
1395            let pre_application = pre_setup.application;
1396
1397            let stale_block = B::new::<Sha256>(ctx.clone(), genesis.digest(), Height::new(1), 100);
1398            assert!(pre_marshal.verified(round, stale_block).await);
1399
1400            // Simulate a crash: abort the actor and drop every handle so the
1401            // storage partition is fully released before reopening.
1402            pre_actor.abort();
1403            drop(pre_marshal);
1404            drop(pre_extra);
1405            drop(pre_application);
1406
1407            // Post-crash: reopen the same partition. The verified block must
1408            // be recovered from storage during archive restore so that
1409            // `Message::GetVerified` on the new mailbox observes it.
1410            let post_setup = StandardHarness::setup_validator(
1411                context
1412                    .child("validator_restart")
1413                    .with_attribute("index", 0),
1414                &mut oracle,
1415                me.clone(),
1416                ConstantProvider::new(schemes[0].clone()),
1417            )
1418            .await;
1419            let post_marshal = post_setup.mailbox;
1420
1421            let fresh_block = B::new::<Sha256>(ctx.clone(), genesis.digest(), Height::new(1), 200);
1422            let mock_app: MockVerifyingApp<B, S> =
1423                MockVerifyingApp::new().with_propose_result(fresh_block);
1424            let mut inline = Inline::new(
1425                context.child("inline"),
1426                mock_app,
1427                post_marshal.clone(),
1428                FixedEpocher::new(BLOCKS_PER_EPOCH),
1429            );
1430
1431            let digest_rx = inline.propose(ctx).await;
1432            assert!(
1433                digest_rx.await.is_err(),
1434                "propose must drop the receiver so the voter nullifies the round via timeout"
1435            );
1436        });
1437    }
1438}