Skip to main content

commonware_consensus/marshal/coding/
marshaled.rs

1//! Wrapper for consensus applications that handles epochs, erasure coding, and block dissemination.
2//!
3//! # Overview
4//!
5//! [`Marshaled`] is an adapter that wraps any [`Application`] implementation to handle
6//! epoch transitions and erasure coded broadcast automatically. It intercepts consensus
7//! operations (propose, verify, certify) and ensures blocks are only produced within valid epoch boundaries.
8//!
9//! # Epoch Boundaries
10//!
11//! An epoch is a fixed number of blocks (the `epoch_length`). When the last block in an epoch
12//! is reached, this wrapper prevents new blocks from being built & proposed until the next epoch begins.
13//! Instead, it re-proposes the boundary block to avoid producing blocks that would be pruned
14//! by the epoch transition.
15//!
16//! # Erasure Coding
17//!
18//! This wrapper integrates with a variant of marshal that supports erasure coded broadcast. When a leader
19//! proposes a new block, it is automatically erasure encoded and its shards are broadcasted to active
20//! participants. When verifying a proposed block (the precondition for notarization), the wrapper
21//! ensures the commitment's context digest matches the consensus context and waits for validation of
22//! the shard assigned to this participant by the proposer. If that shard is valid, the assigned shard is
23//! relayed to all other participants to aid in block reconstruction.
24//!
25//! A participant may still reconstruct the full block from gossiped shards before its assigned shard
26//! arrives. That is sufficient for later certification and repair flows, but it is not treated as
27//! notarization readiness: a participant only helps form a notarization once it has validated the
28//! shard it is supposed to echo.
29//!
30//! During certification (the phase between notarization and finalization), the wrapper subscribes to
31//! block reconstruction and validates epoch boundaries, parent commitment, height contiguity, and
32//! that the block's embedded context matches the consensus context before allowing the block to be
33//! certified. If certification fails, the voter can still emit a nullify vote to advance the view.
34//!
35//! # Usage
36//!
37//! Wrap your [`Application`] implementation with [`Marshaled::new`] and provide it to your
38//! consensus engine for the [`Automaton`] and [`Relay`]. The wrapper handles all epoch logic transparently.
39//!
40//! ```rust,ignore
41//! let cfg = MarshaledConfig {
42//!     application: my_application,
43//!     marshal: marshal_mailbox,
44//!     shards: shard_mailbox,
45//!     scheme_provider,
46//!     epocher,
47//!     strategy,
48//! };
49//! let application = Marshaled::new(context, cfg);
50//! ```
51//!
52//! # Implementation Notes
53//!
54//! - Genesis blocks are handled specially: epoch 0 returns the application's genesis block,
55//!   while subsequent epochs use the last block of the previous epoch as genesis
56//! - Blocks are automatically verified to be within the current epoch
57//!
58//! # Notarization and Data Availability
59//!
60//! In rare crash cases, it is possible for a notarization certificate to exist without a block being
61//! available to the honest parties (e.g., if the whole network crashed before receiving `f+1` shards
62//! and the proposer went permanently offline). In this case, `certify` may remain pending while it
63//! waits for the unavailable block. Simplex may time out and nullify the view, but that timeout does
64//! not resolve the certification request.
65//!
66//! For this reason, it should not be expected that every notarized payload will be certifiable due
67//! to the lack of an available block. However, if even one honest and online party has the block,
68//! they will attempt to forward it to others via marshal's resolver. This case is already present
69//! in the event of a block that was proposed with invalid codec; Marshal will not be able to reconstruct
70//! the block, and therefore won't serve it.
71//!
72//! ```text
73//!                                      ┌───────────────────────────────────────────────────┐
74//!                                      ▼                                                   │
75//! ┌─────────────────────┐   ┌─────────────────────┐   ┌─────────────────────┐   ┌─────────────────────┐
76//! │          B1         │◀──│          B2         │◀──│          B3         │XXX│          B4         │
77//! └─────────────────────┘   └─────────────────────┘   └──────────┬──────────┘   └─────────────────────┘
78//!                                                                │
79//!                                                         Pending Certify
80//! ```
81
82use crate::{
83    Application, Automaton, Block, CertifiableAutomaton, CertifiableBlock, Epochable, Heightable,
84    Relay, Reporter,
85    marshal::{
86        Update,
87        application::{
88            gates::{self, GateOutcome, Gates},
89            validation::{Stage, is_inferred_reproposal_at_certify, is_valid_reproposal_at_verify},
90        },
91        coding::{
92            Coding, shards,
93            types::{CodedBlock, coding_config_for_participants, hash_context},
94            validation::{ProposalError, validate_block, validate_proposal},
95        },
96        core,
97    },
98    simplex::{Plan, scheme::Scheme, types::Context},
99    types::{Epoch, Epocher, Round, coding::Commitment},
100};
101use commonware_actor::Feedback;
102use commonware_coding::Scheme as CodingScheme;
103use commonware_cryptography::{
104    Committable, Digestible, Hasher,
105    certificate::{Provider, Scheme as _, Verifier},
106};
107use commonware_macros::select;
108use commonware_p2p::Recipients;
109use commonware_parallel::Strategy;
110use commonware_runtime::{
111    Clock, Metrics, Spawner, Storage,
112    telemetry::{
113        metrics::{
114            MetricsExt as _,
115            histogram::{Buckets, Timed},
116        },
117        traces::TracedExt as _,
118    },
119};
120use commonware_utils::{
121    channel::{fallible::OneshotExt, oneshot},
122    sync::TracedAsyncMutex,
123};
124use rand_core::Rng;
125use std::sync::Arc;
126use tracing::{Instrument as _, debug, info_span, warn};
127
128/// Configuration for initializing [`Marshaled`].
129#[allow(clippy::type_complexity)]
130pub struct MarshaledConfig<A, B, C, H, Z, S, ES>
131where
132    B: CertifiableBlock<Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>>,
133    C: CodingScheme,
134    H: Hasher,
135    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
136    S: Strategy,
137    ES: Epocher,
138{
139    /// The underlying application to wrap.
140    pub application: A,
141    /// Mailbox for communicating with the marshal engine.
142    pub marshal: core::Mailbox<Z::Scheme, Coding<B, C, H, <Z::Scheme as Verifier>::PublicKey>>,
143    /// Mailbox for communicating with the shards engine.
144    pub shards: shards::Mailbox<B, C, H, <Z::Scheme as Verifier>::PublicKey>,
145    /// Provider for signing schemes scoped by epoch.
146    pub scheme_provider: Z,
147    /// Strategy for parallel operations.
148    pub strategy: S,
149    /// Strategy for determining epoch boundaries.
150    pub epocher: ES,
151}
152
153/// An [`Application`] adapter that handles epoch transitions and erasure coded broadcast.
154///
155/// This wrapper intercepts consensus operations to enforce epoch boundaries. It prevents
156/// blocks from being produced outside their valid epoch and handles the special case of
157/// re-proposing boundary blocks during epoch transitions.
158#[allow(clippy::type_complexity)]
159pub struct Marshaled<E, A, B, C, H, Z, S, ES>
160where
161    E: Rng + Storage + Spawner + Metrics + Clock,
162    A: Application<E>,
163    B: CertifiableBlock<Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>>,
164    C: CodingScheme,
165    H: Hasher,
166    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
167    S: Strategy,
168    ES: Epocher,
169{
170    context: Arc<TracedAsyncMutex<E>>,
171    application: A,
172    marshal: core::Mailbox<Z::Scheme, Coding<B, C, H, <Z::Scheme as Verifier>::PublicKey>>,
173    shards: shards::Mailbox<B, C, H, <Z::Scheme as Verifier>::PublicKey>,
174    scheme_provider: Z,
175    epocher: ES,
176    strategy: S,
177    gates: Gates<Commitment<B, C, H>, CodedBlock<B, C, H>>,
178
179    build_duration: Timed,
180    verify_duration: Timed,
181    proposal_parent_fetch_duration: Timed,
182    ancestor_fetch_duration: Timed,
183    erasure_encode_duration: Timed,
184}
185
186impl<E, A, B, C, H, Z, S, ES> Clone for Marshaled<E, A, B, C, H, Z, S, ES>
187where
188    E: Rng + Storage + Spawner + Metrics + Clock,
189    A: Application<E>,
190    B: CertifiableBlock<Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>>,
191    C: CodingScheme,
192    H: Hasher,
193    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
194    S: Strategy,
195    ES: Epocher,
196{
197    fn clone(&self) -> Self {
198        Self {
199            context: self.context.clone(),
200            application: self.application.clone(),
201            marshal: self.marshal.clone(),
202            shards: self.shards.clone(),
203            scheme_provider: self.scheme_provider.clone(),
204            epocher: self.epocher.clone(),
205            strategy: self.strategy.clone(),
206            gates: self.gates.clone(),
207            build_duration: self.build_duration.clone(),
208            verify_duration: self.verify_duration.clone(),
209            proposal_parent_fetch_duration: self.proposal_parent_fetch_duration.clone(),
210            ancestor_fetch_duration: self.ancestor_fetch_duration.clone(),
211            erasure_encode_duration: self.erasure_encode_duration.clone(),
212        }
213    }
214}
215
216impl<E, A, B, C, H, Z, S, ES> Marshaled<E, A, B, C, H, Z, S, ES>
217where
218    E: Rng + Storage + Spawner + Metrics + Clock,
219    A: Application<
220            E,
221            Block = B,
222            SigningScheme = Z::Scheme,
223            Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
224            Input = (),
225        >,
226    B: CertifiableBlock<Context = <A as Application<E>>::Context>,
227    C: CodingScheme,
228    H: Hasher,
229    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
230    S: Strategy,
231    ES: Epocher,
232{
233    /// Creates a new [`Marshaled`] wrapper.
234    ///
235    /// # Panics
236    ///
237    /// Panics if the marshal metadata store cannot be initialized.
238    pub fn new(context: E, cfg: MarshaledConfig<A, B, C, H, Z, S, ES>) -> Self {
239        let MarshaledConfig {
240            application,
241            marshal,
242            shards,
243            scheme_provider,
244            strategy,
245            epocher,
246        } = cfg;
247
248        let build_histogram = context.histogram(
249            "build_duration",
250            "Histogram of time taken for the application to build a new block, in seconds",
251            Buckets::LOCAL,
252        );
253        let build_duration = Timed::new(build_histogram);
254
255        let verify_histogram = context.histogram(
256            "verify_duration",
257            "Histogram of time taken for the application to verify a block, in seconds",
258            Buckets::LOCAL,
259        );
260        let verify_duration = Timed::new(verify_histogram);
261
262        let parent_fetch_histogram = context.histogram(
263            "parent_fetch_duration",
264            "Histogram of time taken to fetch a parent block in proposal, in seconds",
265            Buckets::LOCAL,
266        );
267        let proposal_parent_fetch_duration = Timed::new(parent_fetch_histogram);
268
269        let ancestor_fetch_histogram = context.histogram(
270            "ancestor_fetch_duration",
271            "Histogram of time taken to fetch a block via the ancestry stream, in seconds",
272            Buckets::LOCAL,
273        );
274        let ancestor_fetch_duration = Timed::new(ancestor_fetch_histogram);
275
276        let erasure_histogram = context.histogram(
277            "erasure_encode_duration",
278            "Histogram of time taken to erasure encode a block, in seconds",
279            Buckets::LOCAL,
280        );
281        let erasure_encode_duration = Timed::new(erasure_histogram);
282
283        Self {
284            context: Arc::new(TracedAsyncMutex::new("marshal.context", context)),
285            application,
286            marshal,
287            shards,
288            scheme_provider,
289            strategy,
290            epocher,
291            gates: Gates::new(),
292
293            build_duration,
294            verify_duration,
295            proposal_parent_fetch_duration,
296            ancestor_fetch_duration,
297            erasure_encode_duration,
298        }
299    }
300
301    /// Verifies a proposed block within epoch boundaries.
302    ///
303    /// This method validates that:
304    /// 1. The block is within the current epoch (unless it's a boundary block re-proposal)
305    /// 2. Re-proposals are only allowed for the last block in an epoch
306    /// 3. The block's parent digest matches the consensus context's expected parent
307    /// 4. The block's height is exactly one greater than the parent's height
308    /// 5. The block's embedded context digest matches the commitment
309    /// 6. The block's embedded context matches the consensus context
310    /// 7. The underlying application's verification logic passes
311    ///
312    /// Verification is spawned in a background task and returns a receiver that will contain
313    /// the verification result.
314    ///
315    /// If `prefetched_block` is provided, it will be used directly instead of fetching from
316    /// the marshal. This is useful in `certify` when we've already fetched the block to
317    /// extract its embedded context.
318    async fn deferred_verify(
319        &mut self,
320        consensus_context: Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
321        commitment: Commitment<B, C, H>,
322        prefetched_block: Option<Arc<CodedBlock<B, C, H>>>,
323        stage: Stage,
324    ) -> oneshot::Receiver<GateOutcome> {
325        let marshal = self.marshal.clone();
326        let mut application = self.application.clone();
327        let epocher = self.epocher.clone();
328        let verify_duration = self.verify_duration.clone();
329        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
330
331        let (mut tx, rx) = oneshot::channel();
332        let context = self
333            .context
334            .lock()
335            .await
336            .child("deferred_verify")
337            .with_attribute("round", consensus_context.round);
338        let span = info_span!(
339            "marshal.coding.verify.deferred",
340            round = %consensus_context.round,
341            commitment = %commitment
342        );
343        context.spawn(move |runtime_context| {
344            async move {
345                let round = consensus_context.round;
346                let (parent_view, parent_commitment) = consensus_context.parent;
347
348                // Start the parent fetch immediately so it can proceed in parallel
349                // with candidate reconstruction. The parent round comes from the
350                // caller's context (the certified consensus context in verify, the
351                // quorum-defended embedded context in certify), never from the
352                // unverified child block.
353                let parent_request = marshal.subscribe_by_commitment(
354                    parent_commitment,
355                    core::CommitmentFallback::FetchByRound {
356                        round: Round::new(consensus_context.epoch(), parent_view),
357                    },
358                );
359
360                // Get the candidate block either from the caller or by waiting for
361                // local reconstruction. Candidate data remains local-only: a
362                // notarization is not sufficient reason to request it from peers.
363                let block = if let Some(block) = prefetched_block {
364                    block
365                } else {
366                    let block_request =
367                        marshal.subscribe_by_commitment(commitment, core::CommitmentFallback::Wait);
368                    select! {
369                        _ = tx.closed() => {
370                            debug!(
371                                reason = "consensus dropped receiver",
372                                "skipping verification"
373                            );
374                            return;
375                        },
376                        result = block_request => match result {
377                            Ok(block) => block,
378                            Err(_) => {
379                                debug!(reason = "block unavailable", "skipping verification");
380                                return;
381                            }
382                        },
383                    }
384                };
385
386                // Start the candidate store immediately: it depends on neither the
387                // parent fetch (which may hit the network) nor the verdict below.
388                // Storing before validation is intentional: these caches provide
389                // candidate availability/recovery, not a validity decision. This
390                // task gates the finalize vote by resolving true only after both
391                // app verification succeeds and the store is durable.
392                let store = stage.store(&marshal, round, Arc::clone(&block));
393                let verify = async {
394                    // Await the parent fetch we started above.
395                    let parent = select! {
396                        _ = tx.closed() => {
397                            debug!(
398                                reason = "consensus dropped receiver",
399                                "skipping verification"
400                            );
401                            return None;
402                        },
403                        result = parent_request => match result {
404                            Ok(parent) => parent,
405                            Err(_) => {
406                                debug!(reason = "failed to fetch parent", "skipping verification");
407                                return None;
408                            }
409                        },
410                    };
411
412                    if let Err(err) = validate_block(
413                        &epocher,
414                        block.as_ref(),
415                        parent.as_ref(),
416                        &consensus_context,
417                        commitment,
418                        parent_commitment,
419                    ) {
420                        debug!(
421                            ?err,
422                            expected_commitment = %commitment,
423                            block_commitment = %block.commitment(),
424                            expected_parent_commitment = %parent_commitment,
425                            parent_commitment = %parent.commitment(),
426                            expected_parent = %parent.digest(),
427                            block_parent = %block.parent(),
428                            parent_height = %parent.height(),
429                            block_height = %block.height(),
430                            "block failed coded invariant validation"
431                        );
432                        return Some(false);
433                    }
434
435                    let ancestry_stream = marshal.ancestor_stream(
436                        Arc::new(runtime_context.child("ancestor_stream")),
437                        [block.inner_shared(), parent.inner_shared()],
438                        ancestor_fetch_duration,
439                    );
440                    let validity_request = application
441                        .verify(
442                            (
443                                runtime_context.child("app_verify"),
444                                consensus_context.clone(),
445                            ),
446                            ancestry_stream,
447                        )
448                        .instrument(info_span!(
449                            "marshal.coding.application.verify",
450                            round = %consensus_context.round,
451                            commitment = %commitment,
452                            parent_view = parent_view.traced(),
453                            parent = %parent_commitment
454                        ));
455
456                    // If consensus drops the receiver, we can stop work early.
457                    let timer = verify_duration.timer(&runtime_context);
458                    let result = select! {
459                        _ = tx.closed() => {
460                            debug!(
461                                reason = "consensus dropped receiver",
462                                "skipping verification"
463                            );
464                            None
465                        },
466                        is_valid = validity_request => Some(is_valid),
467                    };
468                    timer.observe(&runtime_context);
469                    result
470                };
471                let (verdict, durable) = futures::join!(verify, store);
472
473                // Publish only when the block is both valid and durable. App-invalid
474                // candidates may already be in the cache from the concurrent store above,
475                // so the gate verdict is the authority for consensus progress.
476                if let Some(application_valid) = gates::resolve(verdict, durable) {
477                    tx.send_lossy(GateOutcome::Ready(application_valid));
478                }
479            }
480            .instrument(span)
481        });
482
483        rx
484    }
485
486    async fn certify_from_embedded_context(
487        &mut self,
488        round: Round,
489        payload: Commitment<B, C, H>,
490    ) -> oneshot::Receiver<bool> {
491        // Certify may be reached without an earlier `verify`, so the shard
492        // engine may not know the leader yet. A notarized commitment is still
493        // enough to start reconstruction from sender-indexed gossip shards
494        // already buffered for the commitment.
495        self.shards.notarized(payload, round);
496
497        // No in-progress task means we never verified this proposal locally.
498        // We can use the block's embedded context to move to the next view. If a Byzantine
499        // proposer embedded a malicious context, the f+1 honest validators from the notarizing quorum
500        // will verify against the proper context and reject the mismatch, preventing a 2f+1
501        // finalization quorum.
502        //
503        // We must fetch here rather than only wait for local reconstruction. A Byzantine
504        // leader can send enough shards to just f+1 honest validators, collect enough honest
505        // notarize votes to form a notarization, and leave the remaining honest validators
506        // unable to reconstruct the block. Those validators need the notarized round to
507        // recover and certify; otherwise they can remain stuck if the Byzantine validators
508        // stop participating in the next view.
509        //
510        // Subscribe to the block and verify using its embedded context once available.
511        debug!(
512            ?round,
513            ?payload,
514            "subscribing to block for certification using embedded context"
515        );
516        let block_rx = self
517            .marshal
518            .subscribe_by_commitment(payload, core::CommitmentFallback::FetchByRound { round });
519        let mut marshaled = self.clone();
520        let shards = self.shards.clone();
521        let (mut tx, rx) = oneshot::channel();
522        let context = self
523            .context
524            .lock()
525            .await
526            .child("certify")
527            .with_attribute("round", round);
528        context.spawn(move |_| {
529            async move {
530                let block = select! {
531                    _ = tx.closed() => {
532                        debug!(
533                            reason = "consensus dropped receiver",
534                            "skipping certification"
535                        );
536                        return;
537                    },
538                    result = block_rx => match result {
539                        Ok(block) => block,
540                        Err(_) => {
541                            debug!(
542                                ?payload,
543                                reason = "failed to fetch block for certification",
544                                "skipping certification"
545                            );
546                            return;
547                        }
548                    },
549                };
550
551                // Re-proposal detection for certify path: we don't have the consensus
552                // context, only the block's embedded context from original proposal.
553                // Infer re-proposal from:
554                // 1. Block is at epoch boundary (only boundary blocks can be re-proposed)
555                // 2. Certification round's view > embedded context's view (re-proposals
556                //    retain their original embedded context, so a later view indicates
557                //    the block was re-proposed)
558                // 3. Same epoch (re-proposals don't cross epoch boundaries)
559                let embedded_context = block.context();
560                let is_reproposal = is_inferred_reproposal_at_certify(
561                    &marshaled.epocher,
562                    block.height(),
563                    embedded_context.round,
564                    round,
565                );
566                if is_reproposal {
567                    // Certifier holds a notarization for this block, so route
568                    // the write to the notarized cache. `certified` is
569                    // idempotent, so crash-recovery double-invocation is safe.
570                    if !marshaled.marshal.certified(round, block).await {
571                        return;
572                    }
573                    tx.send_lossy(true);
574                    return;
575                }
576
577                // Inform the shard engine of an externally proposed commitment.
578                shards.discovered(
579                    payload,
580                    embedded_context.leader.clone(),
581                    embedded_context.round,
582                );
583
584                // Use the block's embedded context for verification, passing the
585                // prefetched block to avoid fetching it again inside deferred_verify.
586                let verify_rx = marshaled
587                    .deferred_verify(embedded_context, payload, Some(block), Stage::Certified)
588                    .await;
589                gates::forward(tx, verify_rx, |result| match result {
590                    GateOutcome::Ready(result) => Some(result),
591                    GateOutcome::Recover => None,
592                })
593                .await;
594            }
595            .instrument(info_span!(
596                "marshal.coding.certify.embedded",
597                round = %round,
598                commitment = %payload
599            ))
600        });
601        rx
602    }
603
604    #[allow(clippy::async_yields_async)]
605    async fn certify_from_existing_task(
606        &mut self,
607        round: Round,
608        payload: Commitment<B, C, H>,
609        task: oneshot::Receiver<GateOutcome>,
610    ) -> oneshot::Receiver<bool> {
611        // `verify()` intentionally waits only for local candidate data. Once
612        // certification starts, a notarization exists and the same pending
613        // verifier must be unblocked by round-bound recovery if local
614        // reconstruction never completes.
615        self.shards.notarized(payload, round);
616        self.marshal.hint_notarized(round, payload);
617
618        // A completed gate either carries an applicable local verdict or requests
619        // recovery. After an unclean restart the in-memory task is gone, which also
620        // recovers via the embedded-context fetch path.
621        let mut marshaled = self.clone();
622        let (tx, rx) = oneshot::channel();
623        let context = self
624            .context
625            .lock()
626            .await
627            .child("certify_existing")
628            .with_attribute("round", round);
629        context.spawn(move |_| {
630            gates::drive(tx, task, round, payload, move || async move {
631                marshaled
632                    .certify_from_embedded_context(round, payload)
633                    .await
634            })
635            .instrument(info_span!(
636                "marshal.coding.certify.existing",
637                round = %round,
638                commitment = %payload
639            ))
640        });
641        rx
642    }
643}
644
645impl<E, A, B, C, H, Z, S, ES> Automaton for Marshaled<E, A, B, C, H, Z, S, ES>
646where
647    E: Rng + Storage + Spawner + Metrics + Clock,
648    A: Application<
649            E,
650            Block = B,
651            SigningScheme = Z::Scheme,
652            Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
653            Input = (),
654        >,
655    B: CertifiableBlock<Context = <A as Application<E>>::Context>,
656    C: CodingScheme,
657    H: Hasher,
658    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
659    S: Strategy,
660    ES: Epocher,
661{
662    type Digest = Commitment<B, C, H>;
663    type Context = Context<Self::Digest, <Z::Scheme as Verifier>::PublicKey>;
664
665    /// Proposes a new block or re-proposes the epoch boundary block.
666    ///
667    /// This method builds a new block from the underlying application unless the parent block
668    /// is the last block in the current epoch. When at an epoch boundary, it re-proposes the
669    /// boundary block to avoid creating blocks that would be invalidated by the epoch transition.
670    ///
671    /// The proposal operation is spawned in a background task and returns a receiver that will
672    /// contain the proposed block's commitment when ready. The block is staged before the
673    /// commitment is delivered and handed to marshal when consensus requests the relay
674    /// broadcast, which persists it after the shards are sent. The resulting sync handle is
675    /// awaited only at certification so it overlaps consensus voting. The commitment does not
676    /// imply durability on its own. [`CertifiableAutomaton::certify`] awaits the registered
677    /// certification gate before the finalize vote.
678    #[allow(clippy::async_yields_async)]
679    #[tracing::instrument(name = "marshal.coding.propose", level = "info", skip_all, fields(round = %consensus_context.round))]
680    async fn propose(
681        &mut self,
682        consensus_context: Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
683    ) -> oneshot::Receiver<Self::Digest> {
684        let marshal = self.marshal.clone();
685        let mut application = self.application.clone();
686        let epocher = self.epocher.clone();
687        let strategy = self.strategy.clone();
688        let gates = self.gates.clone();
689
690        // If there's no scheme for the current epoch, we cannot verify the proposal.
691        // Send back a receiver with a dropped sender.
692        let Some(scheme) = self.scheme_provider.scheme(consensus_context.epoch()) else {
693            debug!(
694                round = %consensus_context.round,
695                "no scheme for epoch, skipping propose"
696            );
697            let (_, rx) = oneshot::channel();
698            return rx;
699        };
700
701        let n_participants =
702            u16::try_from(scheme.participants().len()).expect("too many participants");
703        let coding_config = coding_config_for_participants(n_participants);
704
705        // Metrics
706        let build_duration = self.build_duration.clone();
707        let proposal_parent_fetch_duration = self.proposal_parent_fetch_duration.clone();
708        let ancestor_fetch_duration = self.ancestor_fetch_duration.clone();
709        let erasure_encode_duration = self.erasure_encode_duration.clone();
710
711        let (mut tx, rx) = oneshot::channel();
712        let context = self
713            .context
714            .lock()
715            .await
716            .child("propose")
717            .with_attribute("round", consensus_context.round);
718        let span = info_span!(
719            "marshal.coding.propose.task",
720            round = %consensus_context.round
721        );
722        context.spawn(move |runtime_context| {
723            async move {
724                // On leader recovery, marshal may already hold a verified block
725                // for this round (persisted by a pre-crash propose that reached
726                // its relay broadcast).
727                //
728                // The pre-crash commitment may already have been broadcast,
729                // so building a fresh block would equivocate. The stored
730                // block is the only proposal we can broadcast for this round.
731                //
732                // The recovered block is safe to reuse only if its embedded
733                // context matches the context simplex just recovered, or if it
734                // is the parent re-proposed at the epoch boundary: that stores the
735                // parent under its original context, whose round is the parent's own.
736                // Otherwise the cached block was built against a different
737                // parent and cannot be broadcast under the current header, so
738                // drop the receiver and let the voter nullify the view via
739                // timeout.
740                let last_in_epoch = epocher
741                    .last(consensus_context.epoch())
742                    .expect("current epoch should exist");
743                if let Some(block) = marshal.get_verified(consensus_context.round).await {
744                    let block_context = block.context();
745                    let commitment = block.commitment();
746                    let reproposal =
747                        commitment == consensus_context.parent.1 && block.height() == last_in_epoch;
748                    if !reproposal && block_context != consensus_context {
749                        debug!(
750                            round = ?consensus_context.round,
751                            ?consensus_context,
752                            ?block_context,
753                            "skipping proposal: cached verified block context no longer matches"
754                        );
755                        return;
756                    }
757                    // Stage the recovered block so the relay broadcast re-sends
758                    // its shards through the same handshake as a fresh
759                    // proposal. The relay-time persist deduplicates against the
760                    // pre-crash write, with the handle covering the original.
761                    let round = consensus_context.round;
762                    debug!(
763                        ?round,
764                        ?commitment,
765                        reproposal,
766                        "reusing verified block from marshal on leader recovery"
767                    );
768                    gates
769                        .stage(round, commitment, Arc::new(block), tx, "recovered block")
770                        .await;
771                    return;
772                }
773
774                // The parent for any consensus context is in the same epoch: the
775                // boundary block of the previous epoch is the genesis block of the
776                // current epoch.
777                //
778                // Proposal context carries the certified parent view/commitment but
779                // not the parent height. The parent may be certified above the
780                // finalized tip, so this must stay round-bound until the block is
781                // returned.
782                let (parent_view, parent_commitment) = consensus_context.parent;
783                let parent_request = marshal.subscribe_by_commitment(
784                    parent_commitment,
785                    core::CommitmentFallback::FetchByRound {
786                        round: Round::new(consensus_context.epoch(), parent_view),
787                    },
788                );
789
790                let parent_timer = proposal_parent_fetch_duration.timer(&runtime_context);
791                let parent = select! {
792                    _ = tx.closed() => {
793                        debug!(reason = "consensus dropped receiver", "skipping proposal");
794                        return;
795                    },
796                    result = parent_request => match result {
797                        Ok(parent) => parent,
798                        Err(_) => {
799                            debug!(
800                                ?parent_commitment,
801                                reason = "failed to fetch parent block",
802                                "skipping proposal"
803                            );
804                            return;
805                        }
806                    },
807                };
808                parent_timer.observe(&runtime_context);
809
810                // Special case: If the parent block is the last block in the epoch,
811                // re-propose it as to not produce any blocks that will be cut out
812                // by the epoch transition.
813                if parent.height() == last_in_epoch {
814                    let commitment = parent.commitment();
815                    let round = consensus_context.round;
816
817                    gates
818                        .stage(round, commitment, parent, tx, "re-proposed boundary block")
819                        .await;
820                    return;
821                }
822
823                let ancestor_stream = marshal.ancestor_stream(
824                    Arc::new(runtime_context.child("ancestor_stream")),
825                    [parent.inner_shared()],
826                    ancestor_fetch_duration,
827                );
828                let build_request = application
829                    .propose(
830                        (
831                            runtime_context.child("app_propose"),
832                            consensus_context.clone(),
833                        ),
834                        ancestor_stream,
835                        (),
836                    )
837                    .instrument(info_span!(
838                        "marshal.coding.application.propose",
839                        round = %consensus_context.round,
840                        parent_view = parent_view.traced(),
841                        parent = %parent_commitment
842                    ));
843
844                let build_timer = build_duration.timer(&runtime_context);
845                let built_block = select! {
846                    _ = tx.closed() => {
847                        debug!(reason = "consensus dropped receiver", "skipping proposal");
848                        return;
849                    },
850                    result = build_request => match result {
851                        Some(block) => block,
852                        None => {
853                            debug!(
854                                ?parent_commitment,
855                                reason = "block building failed",
856                                "skipping proposal"
857                            );
858                            return;
859                        }
860                    },
861                };
862                build_timer.observe(&runtime_context);
863
864                let erasure_timer = erasure_encode_duration.timer(&runtime_context);
865                let coded_block = CodedBlock::<B, C, H>::new(built_block, coding_config, &strategy);
866                erasure_timer.observe(&runtime_context);
867
868                let commitment = coded_block.commitment();
869                let round = consensus_context.round;
870
871                gates
872                    .stage(
873                        round,
874                        commitment,
875                        Arc::new(coded_block),
876                        tx,
877                        "proposed block",
878                    )
879                    .await;
880            }
881            .instrument(span)
882        });
883        rx
884    }
885
886    /// Verifies a received shard for a given round.
887    ///
888    /// This method validates that:
889    /// 1. The coding configuration matches the expected configuration for the current scheme.
890    /// 2. The commitment's context digest matches the consensus context (unless this is a re-proposal).
891    /// 3. The shard is contained within the consensus commitment.
892    ///
893    /// Verification is spawned in a background task and returns a receiver that will contain
894    /// the verification result. Additionally, this method kicks off deferred verification to
895    /// start block verification early (hidden behind shard validity and network latency).
896    #[allow(clippy::async_yields_async)]
897    #[tracing::instrument(name = "marshal.coding.verify", level = "info", skip_all, fields(round = %consensus_context.round, commitment = %payload))]
898    async fn verify(
899        &mut self,
900        consensus_context: Context<Self::Digest, <Z::Scheme as Verifier>::PublicKey>,
901        payload: Self::Digest,
902    ) -> oneshot::Receiver<bool> {
903        // If there's no scheme for the current epoch, we cannot vote on the proposal.
904        // Send back a receiver with a dropped sender.
905        let Some(scheme) = self.scheme_provider.scheme(consensus_context.epoch()) else {
906            debug!(
907                round = %consensus_context.round,
908                "no scheme for epoch, skipping verify"
909            );
910            let (_, rx) = oneshot::channel();
911            return rx;
912        };
913
914        let n_participants =
915            u16::try_from(scheme.participants().len()).expect("too many participants");
916        let coding_config = coding_config_for_participants(n_participants);
917        let is_reproposal = payload == consensus_context.parent.1;
918
919        // Validate proposal-level invariants:
920        // - coding config must match active participant set
921        // - context digest must match unless this is a re-proposal
922        let proposal_context = (!is_reproposal).then_some(&consensus_context);
923        if let Err(err) = validate_proposal(payload, coding_config, proposal_context) {
924            match err {
925                ProposalError::CodingConfig => {
926                    warn!(
927                        round = %consensus_context.round,
928                        got = ?payload.config(),
929                        expected = ?coding_config,
930                        "rejected proposal with unexpected coding configuration"
931                    );
932                }
933                ProposalError::ContextDigest => {
934                    let expected = hash_context::<H, _>(&consensus_context);
935                    let got = payload.context();
936                    warn!(
937                        round = %consensus_context.round,
938                        expected = ?expected,
939                        got = ?got,
940                        "rejected proposal with mismatched context digest"
941                    );
942                }
943            }
944
945            let (tx, rx) = oneshot::channel();
946            tx.send_lossy(false);
947            return rx;
948        }
949
950        // Re-proposals skip context-digest validation because the consensus context will point
951        // at the prior epoch-boundary block while the embedded block context is from the
952        // original proposal view.
953        //
954        // Re-proposals also skip shard-validity and deferred verification because:
955        // 1. Consensus settles the block's validity when certifying the view that first carried it
956        // 2. The parent-child height check would fail (parent IS the block)
957        // 3. Waiting for shards could stall if the leader doesn't rebroadcast
958        if is_reproposal {
959            // Fetch the block to verify it's at the epoch boundary. This should be fast
960            // since the parent block is typically already cached. A re-proposal names its
961            // own parent, so the parent round is a certified round for this commitment and
962            // lets a participant that never received the original proposal acquire it
963            // instead of waiting for shards it cannot yet classify.
964            let (parent_view, _) = consensus_context.parent;
965            let block_rx = self.marshal.subscribe_by_commitment(
966                payload,
967                core::CommitmentFallback::FetchByRound {
968                    round: Round::new(consensus_context.epoch(), parent_view),
969                },
970            );
971            let marshal = self.marshal.clone();
972            let shards = self.shards.clone();
973            let epocher = self.epocher.clone();
974            let round = consensus_context.round;
975            let leader = consensus_context.leader;
976            let gates = self.gates.clone();
977
978            // Register a certification gate task synchronously before spawning work so
979            // `certify` can always find it (no race with task startup).
980            let (task_tx, task_rx) = oneshot::channel();
981            gates.insert(round, payload, task_rx);
982
983            let (mut tx, rx) = oneshot::channel();
984            let context = self
985                .context
986                .lock()
987                .await
988                .child("verify_reproposal")
989                .with_attribute("round", round);
990            context.spawn(move |_| {
991                async move {
992                    let block = select! {
993                        _ = tx.closed() => {
994                            debug!(
995                                reason = "consensus dropped receiver",
996                                "skipping re-proposal verification"
997                            );
998                            return;
999                        },
1000                        block = block_rx => match block {
1001                            Ok(block) => block,
1002                            Err(_) => {
1003                                debug!(
1004                                    ?payload,
1005                                    reason = "failed to fetch block for re-proposal verification",
1006                                    "skipping re-proposal verification"
1007                                );
1008                                // Fetch failure is an availability issue, not an explicit
1009                                // invalidity proof. Do not synthesize `false` here.
1010                                return;
1011                            }
1012                        },
1013                    };
1014
1015                    // A rejection here is safe to publish as a gate verdict because
1016                    // the boundary check is intrinsic to `(round, commitment)`: it
1017                    // reads only the block's height and the round's epoch. The
1018                    // commitment also binds the original proposal context, so no
1019                    // honest notarization can form for this key under a conflicting
1020                    // header.
1021                    if !is_valid_reproposal_at_verify(&epocher, block.height(), round.epoch()) {
1022                        debug!(
1023                            height = %block.height(),
1024                            "re-proposal is not at epoch boundary"
1025                        );
1026                        task_tx.send_lossy(GateOutcome::Ready(false));
1027                        tx.send_lossy(false);
1028                        return;
1029                    }
1030
1031                    // Announce the re-proposal only after the boundary check. A
1032                    // re-proposal's consensus round is not bound to the commitment, and
1033                    // the shard engine reads the participant set from the round's epoch,
1034                    // so announcing an unvalidated round would classify this block's
1035                    // shards against the wrong epoch.
1036                    shards.discovered(payload, leader, round);
1037
1038                    // Valid re-proposal: notify the marshal and complete the
1039                    // certification gate task for `certify`.
1040                    let durable = marshal.verified(round, block).await;
1041                    if !durable {
1042                        return;
1043                    }
1044                    task_tx.send_lossy(GateOutcome::Ready(true));
1045                    tx.send_lossy(true);
1046                }
1047                .instrument(info_span!(
1048                    "marshal.coding.verify.reproposal",
1049                    round = %round,
1050                    commitment = %payload
1051                ))
1052            });
1053            return rx;
1054        }
1055
1056        // Inform the shard engine of an externally proposed commitment. The context
1057        // digest validated above binds this round and leader to the commitment.
1058        self.shards.discovered(
1059            payload,
1060            consensus_context.leader.clone(),
1061            consensus_context.round,
1062        );
1063
1064        // Kick off deferred verification early to hide verification latency behind
1065        // shard validity checks and network latency for collecting votes.
1066        //
1067        // The task's cancellation signal is the gate receiver registered below,
1068        // not consensus's verify receiver. Nullification advances the view and
1069        // drops the verify receiver without cancelling certification for it, so
1070        // deferred verification must survive that drop for certify to consume.
1071        let round = consensus_context.round;
1072        let task = self
1073            .deferred_verify(consensus_context, payload, None, Stage::Verified)
1074            .await;
1075        self.gates.insert(round, payload, task);
1076
1077        match scheme.me() {
1078            Some(_) => {
1079                // Subscribe to assigned shard verification. For participants, this
1080                // only completes once the shard for our assigned index has been
1081                // verified. Reconstructing the block from peer gossip is useful for
1082                // certification later, but is not enough to emit a notarize vote.
1083                let validity_rx = self.shards.subscribe_assigned_shard_verified(payload);
1084                let (tx, rx) = oneshot::channel();
1085                let context = self
1086                    .context
1087                    .lock()
1088                    .await
1089                    .child("shard_validity_wait")
1090                    .with_attribute("round", round);
1091                context.spawn(move |_| {
1092                    async move {
1093                        gates::forward(tx, validity_rx, |()| Some(true)).await;
1094                    }
1095                    .instrument(info_span!(
1096                        "marshal.coding.verify.shard_validity",
1097                        round = %round,
1098                        commitment = %payload
1099                    ))
1100                });
1101                rx
1102            }
1103            None => {
1104                // If we are not participating, there's no shard to verify; just accept the proposal.
1105                //
1106                // Later, when certifying, we will wait to receive the block from the network.
1107                let (tx, rx) = oneshot::channel();
1108                tx.send_lossy(true);
1109                rx
1110            }
1111        }
1112    }
1113}
1114
1115impl<E, A, B, C, H, Z, S, ES> CertifiableAutomaton for Marshaled<E, A, B, C, H, Z, S, ES>
1116where
1117    E: Rng + Storage + Spawner + Metrics + Clock,
1118    A: Application<
1119            E,
1120            Block = B,
1121            SigningScheme = Z::Scheme,
1122            Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
1123            Input = (),
1124        >,
1125    B: CertifiableBlock<Context = <A as Application<E>>::Context>,
1126    C: CodingScheme,
1127    H: Hasher,
1128    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
1129    S: Strategy,
1130    ES: Epocher,
1131{
1132    #[allow(clippy::async_yields_async)]
1133    #[tracing::instrument(name = "marshal.coding.certify", level = "info", skip_all, fields(round = %round, commitment = %payload))]
1134    async fn certify(&mut self, round: Round, payload: Self::Digest) -> oneshot::Receiver<bool> {
1135        self.gates.flush_unrelayed(&self.marshal, round, payload);
1136
1137        // First, check for an in-progress certification gate task.
1138        let task = self.gates.take(round, payload);
1139        if let Some(task) = task {
1140            return self.certify_from_existing_task(round, payload, task).await;
1141        }
1142
1143        self.certify_from_embedded_context(round, payload).await
1144    }
1145}
1146
1147impl<E, A, B, C, H, Z, S, ES> Relay for Marshaled<E, A, B, C, H, Z, S, ES>
1148where
1149    E: Rng + Storage + Spawner + Metrics + Clock,
1150    A: Application<
1151            E,
1152            Block = B,
1153            Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
1154        >,
1155    B: CertifiableBlock<Context = <A as Application<E>>::Context>,
1156    C: CodingScheme,
1157    H: Hasher,
1158    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
1159    S: Strategy,
1160    ES: Epocher,
1161{
1162    type Digest = Commitment<B, C, H>;
1163    type PublicKey = <Z::Scheme as Verifier>::PublicKey;
1164    type Plan = Plan<Self::PublicKey>;
1165
1166    fn broadcast(&mut self, commitment: Self::Digest, plan: Self::Plan) -> Feedback {
1167        // Coding variant does not support targeted forwarding;
1168        // peers reconstruct blocks from erasure-coded shards.
1169        //
1170        // TODO(#3389): Support checked data forwarding for PhasedScheme.
1171        let Plan::Propose { round } = plan else {
1172            return Feedback::Ok;
1173        };
1174
1175        let Some((block, ack)) = self.gates.take_staged(round, commitment) else {
1176            debug!(%round, %commitment, "no staged proposal to relay, attempting forwarding");
1177            return self.marshal.forward(round, commitment, Recipients::All);
1178        };
1179        self.marshal.proposed(round, block, Recipients::All, ack)
1180    }
1181}
1182
1183impl<E, A, B, C, H, Z, S, ES> Reporter for Marshaled<E, A, B, C, H, Z, S, ES>
1184where
1185    E: Rng + Storage + Spawner + Metrics + Clock,
1186    A: Application<
1187            E,
1188            Block = B,
1189            Context = Context<Commitment<B, C, H>, <Z::Scheme as Verifier>::PublicKey>,
1190        > + Reporter<Activity = Update<B>>,
1191    B: CertifiableBlock<Context = <A as Application<E>>::Context>,
1192    C: CodingScheme,
1193    H: Hasher,
1194    Z: Provider<Scope = Epoch, Scheme: Scheme<Commitment<B, C, H>>>,
1195    S: Strategy,
1196    ES: Epocher,
1197{
1198    type Activity = A::Activity;
1199
1200    /// Relays a report to the underlying [`Application`] and cleans up old certification gate data.
1201    fn report(&mut self, update: Self::Activity) -> Feedback {
1202        // Clean up certification gate tasks and contexts for rounds <= the finalized round.
1203        if let Update::Tip(round, _, _) = &update {
1204            self.gates.retain_after(round);
1205        }
1206        self.application.report(update)
1207    }
1208}