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