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