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}