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