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