Skip to main content

commonware_glue/dkg/reshare/
application.rs

1use crate::dkg::{
2    ReshareBlock,
3    network::Directory,
4    reshare::{EpochInfoResponse, Mailbox},
5    types::Payload,
6};
7use commonware_consensus::{
8    Application as ConsensusApplication, CertifiableBlock,
9    marshal::ancestry::Ancestry,
10    types::{EpochPhase, Epocher as _, FixedEpocher, Height},
11};
12use commonware_cryptography::{Signer, bls12381::primitives::variant::Variant};
13use commonware_runtime::{Clock, Metrics, Spawner, telemetry::traces::TracedExt as _};
14use commonware_utils::sequence::Unit;
15use rand_core::Rng;
16use std::{future, num::NonZeroU64};
17use tracing::{debug, field};
18
19/// Per-proposal input handed to an application wrapped by [`Application`].
20///
21/// Carries the wrapper's upstream input alongside the reshare `payload` selected
22/// and fetched for the block being proposed. The wrapped application attaches
23/// `payload` to the block it builds and uses `upstream` for its own purposes.
24pub struct Input<Upstream, V: Variant, C: Signer, D: Directory<C::PublicKey> = Unit> {
25    /// Input forwarded from the application wrapping the reshare wrapper.
26    pub upstream: Upstream,
27
28    /// The reshare payload selected for this proposal, if any.
29    pub payload: Option<Payload<V, C, D>>,
30}
31
32/// An [`Application`](commonware_consensus::Application) wrapper that enforces the
33/// reshare block-validity contract and drives the reshare payload for proposals.
34///
35/// When the reshare actor tracks an epoch's ceremony, the wrapper rejects a
36/// final block whose payload differs from the independently reconstructed
37/// [`EpochInfo`](crate::dkg::types::EpochInfo). An actor that starts following
38/// mid-epoch lacks the protocol history required for that comparison, so its
39/// final-block verification remains pending. The wrapper always rejects stray
40/// payloads carried by non-final blocks in the early dealing window.
41///
42/// For proposals, the wrapper selects and fetches the payload for the block being
43/// built (a dealer log from the midpoint onward, the epoch info on the final
44/// block) and hands it to the inner application through [`Input`], so the
45/// inner application neither talks to the reshare mailbox nor tracks epoch
46/// boundaries. It only attaches the handed-over payload to the block it builds,
47/// because the wrapper cannot build the application's block type itself.
48///
49/// The wrapper is a plain [`Application`](commonware_consensus::Application), so
50/// it composes with any consensus application, including one adapted through
51/// [`stateful`](crate::stateful). It forwards its own upstream input to the inner
52/// application as [`Input::upstream`], so nesting under another
53/// input-providing application still works.
54pub struct Application<A, B, V, C>
55where
56    B: ReshareBlock<Variant = V, Signer = C>,
57    V: Variant,
58    C: Signer,
59{
60    inner: A,
61    reshare: Mailbox<B, V, C>,
62    epocher: FixedEpocher,
63}
64
65impl<A, B, V, C> Application<A, B, V, C>
66where
67    B: ReshareBlock<Variant = V, Signer = C>,
68    V: Variant,
69    C: Signer,
70{
71    /// Wraps `inner`, using `reshare` to select final-block epoch info and dealer
72    /// logs and `blocks_per_epoch` to locate epoch boundaries and phases.
73    pub const fn new(inner: A, reshare: Mailbox<B, V, C>, blocks_per_epoch: NonZeroU64) -> Self {
74        Self {
75            inner,
76            reshare,
77            epocher: FixedEpocher::new(blocks_per_epoch),
78        }
79    }
80
81    fn final_block(&self, height: Height) -> bool {
82        self.epocher
83            .containing(height)
84            .is_some_and(|info| info.last() == height)
85    }
86
87    fn phase(&self, height: Height) -> Option<EpochPhase> {
88        self.epocher.containing(height).map(|info| info.phase())
89    }
90}
91
92impl<A, B, V, C> Clone for Application<A, B, V, C>
93where
94    A: Clone,
95    B: ReshareBlock<Variant = V, Signer = C>,
96    V: Variant,
97    C: Signer,
98{
99    fn clone(&self) -> Self {
100        Self {
101            inner: self.inner.clone(),
102            reshare: self.reshare.clone(),
103            epocher: self.epocher.clone(),
104        }
105    }
106}
107
108impl<A, E, B, V, C, I> ConsensusApplication<E> for Application<A, B, V, C>
109where
110    E: Rng + Spawner + Metrics + Clock,
111    A: ConsensusApplication<E, Block = B, Input = Input<I, V, C, B::Directory>>,
112    A::Context: Send,
113    B: ReshareBlock<Variant = V, Signer = C> + CertifiableBlock + Clone,
114    V: Variant,
115    C: Signer,
116    I: Send,
117{
118    type SigningScheme = A::SigningScheme;
119    type Context = A::Context;
120    type Block = A::Block;
121    type Input = I;
122
123    #[tracing::instrument(
124        name = "dkg.reshare.application.propose",
125        level = "info",
126        skip_all,
127        fields(
128            height = field::Empty,
129            phase = field::Empty,
130            has_payload = field::Empty
131        )
132    )]
133    async fn propose(
134        &mut self,
135        context: (E, Self::Context),
136        ancestry: impl Ancestry<Self::Block>,
137        input: Self::Input,
138    ) -> Option<Self::Block> {
139        // Select and fetch the payload for the block being built, then hand it to
140        // the inner application alongside its own input.
141        let Some(parent) = ancestry.peek() else {
142            debug!("proposal rejected: missing parent ancestry");
143            return None;
144        };
145        let height = parent.height().next();
146        let phase = self.phase(height);
147        let span = tracing::Span::current();
148        span.record("height", height.traced());
149        span.record("phase", field::debug(phase));
150
151        let (payload, log_reservation) = if self.final_block(height) {
152            match self.reshare.epoch_info(ancestry.clone()).await {
153                EpochInfoResponse::Available(payload) => (payload, None),
154                EpochInfoResponse::Pending => {
155                    debug!("proposal skipped: final block epoch info is not ready");
156                    return None;
157                }
158                EpochInfoResponse::Following => {
159                    debug!("proposal skipped: follower has no final block epoch info");
160                    return None;
161                }
162                EpochInfoResponse::Unavailable => {
163                    debug!("proposal skipped: final block epoch info is unavailable");
164                    return None;
165                }
166            }
167        } else if matches!(phase, Some(EpochPhase::Midpoint | EpochPhase::Late)) {
168            let mut reservation = self.reshare.next_log(height).await;
169            let payload = reservation
170                .as_mut()
171                .and_then(|reservation| reservation.take_payload());
172            (payload, reservation)
173        } else {
174            (None, None)
175        };
176        span.record("has_payload", payload.is_some());
177        let proposed = self
178            .inner
179            .propose(
180                context,
181                ancestry,
182                Input {
183                    upstream: input,
184                    payload,
185                },
186            )
187            .await;
188        if proposed.is_some()
189            && let Some(reservation) = log_reservation
190        {
191            reservation.included();
192        }
193        proposed
194    }
195
196    #[tracing::instrument(
197        name = "dkg.reshare.application.verify",
198        level = "info",
199        skip_all,
200        fields(
201            height = field::Empty,
202            phase = field::Empty,
203            has_payload = field::Empty
204        )
205    )]
206    async fn verify(
207        &mut self,
208        context: (E, Self::Context),
209        ancestry: impl Ancestry<Self::Block>,
210    ) -> bool {
211        let Some(tip) = ancestry.peek().cloned() else {
212            return self.inner.verify(context, ancestry).await;
213        };
214        let height = tip.height();
215        let phase = self.phase(height);
216        let tip_payload = tip.payload();
217        let span = tracing::Span::current();
218        span.record("height", height.traced());
219        span.record("phase", field::debug(phase));
220        span.record("has_payload", tip_payload.is_some());
221
222        if self.final_block(height) {
223            match self.reshare.epoch_info(ancestry.clone()).await {
224                EpochInfoResponse::Available(derived) => {
225                    if derived != tip_payload {
226                        debug!("verification rejected: final block payload mismatch");
227                        return false;
228                    }
229                }
230                response @ (EpochInfoResponse::Pending | EpochInfoResponse::Following) => {
231                    // Pending or follower state provides no stable validity
232                    // verdict. Keep the request unresolved so consensus
233                    // cancellation owns termination.
234                    debug!(
235                        following = matches!(response, EpochInfoResponse::Following),
236                        "verification pending: final block epoch info cannot be derived locally"
237                    );
238                    future::pending::<()>().await;
239                    unreachable!("pending future must not resolve");
240                }
241                EpochInfoResponse::Unavailable => {
242                    debug!("verification rejected: final block epoch info is unavailable");
243                    return false;
244                }
245            }
246        } else if matches!(phase, Some(EpochPhase::Early)) && tip_payload.is_some() {
247            // Dealer logs are only posted from the midpoint onward, so an early
248            // block must not carry a reshare payload.
249            debug!("verification rejected: early block carried reshare payload");
250            return false;
251        }
252        self.inner.verify(context, ancestry).await
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::dkg::{
260        reshare::{LogReservation, Message},
261        tests::mocks::{self, TestBlock, TestBlsVariant, TestContext, TestScheme},
262        types::{EpochInfo, EpochOutcome},
263    };
264    use commonware_actor::mailbox;
265    use commonware_consensus::{
266        CertifiableBlock, Heightable,
267        marshal::ancestry,
268        types::{Epoch, Height, Round, View},
269    };
270    use commonware_cryptography::{
271        Digestible, Signer,
272        bls12381::{
273            dkg::feldman_desmedt::deal,
274            primitives::{sharing::Mode, variant::MinPk},
275        },
276        ed25519::{PrivateKey, PublicKey},
277        sha256::Sha256,
278    };
279    use commonware_runtime::{Clock, Metrics, Runner, Spawner, Supervisor, deterministic};
280    use commonware_utils::{
281        Acknowledgement, N3f1, NZU32, NZU64, NZUsize, TestRng, channel::oneshot, ordered::Set,
282        sync::Mutex,
283    };
284    use futures::{
285        FutureExt, StreamExt,
286        future::{Either, select},
287        pin_mut,
288    };
289    use rand_core::Rng;
290    use std::{sync::Arc, time::Duration};
291
292    type TestPayload = Payload<TestBlsVariant, PrivateKey>;
293    type TestResponse = EpochInfoResponse<TestBlsVariant, PrivateKey>;
294    type TestWrapper = Application<RecordingApp, TestBlock, TestBlsVariant, PrivateKey>;
295
296    impl CertifiableBlock for TestBlock {
297        type Context = TestContext;
298
299        fn context(&self) -> Self::Context {
300            self.context().clone()
301        }
302    }
303
304    #[derive(Clone, Copy, PartialEq, Eq)]
305    enum ProposalBehavior {
306        Accept,
307        Reject,
308        Pending,
309    }
310
311    #[derive(Clone)]
312    struct RecordingApp {
313        proposed: Arc<Mutex<Vec<Option<TestPayload>>>>,
314        proposal_behavior: ProposalBehavior,
315        proposal_entered: Arc<Mutex<Option<oneshot::Sender<()>>>>,
316        verify_count: Arc<Mutex<usize>>,
317        verify_result: bool,
318    }
319
320    impl RecordingApp {
321        fn accepting() -> Self {
322            Self {
323                proposed: Arc::new(Mutex::new(Vec::new())),
324                proposal_behavior: ProposalBehavior::Accept,
325                proposal_entered: Arc::new(Mutex::new(None)),
326                verify_count: Arc::new(Mutex::new(0)),
327                verify_result: true,
328            }
329        }
330
331        fn rejecting() -> Self {
332            Self {
333                proposal_behavior: ProposalBehavior::Reject,
334                ..Self::accepting()
335            }
336        }
337
338        fn pending(proposal_entered: oneshot::Sender<()>) -> Self {
339            Self {
340                proposal_behavior: ProposalBehavior::Pending,
341                proposal_entered: Arc::new(Mutex::new(Some(proposal_entered))),
342                ..Self::accepting()
343            }
344        }
345
346        fn proposed(&self) -> Vec<Option<TestPayload>> {
347            self.proposed.lock().clone()
348        }
349
350        fn verify_count(&self) -> usize {
351            *self.verify_count.lock()
352        }
353    }
354
355    impl<E> ConsensusApplication<E> for RecordingApp
356    where
357        E: Rng + Spawner + Metrics + Clock,
358    {
359        type SigningScheme = TestScheme;
360        type Context = TestContext;
361        type Block = TestBlock;
362        type Input = Input<(), TestBlsVariant, PrivateKey>;
363
364        async fn propose(
365            &mut self,
366            (_, context): (E, Self::Context),
367            ancestry: impl Ancestry<Self::Block>,
368            input: Self::Input,
369        ) -> Option<Self::Block> {
370            let parent = ancestry.peek()?.clone();
371            self.proposed.lock().push(input.payload.clone());
372            if let Some(entered) = self.proposal_entered.lock().take() {
373                let _ = entered.send(());
374            }
375
376            if self.proposal_behavior == ProposalBehavior::Reject {
377                return None;
378            }
379
380            if self.proposal_behavior == ProposalBehavior::Pending {
381                future::pending().await
382            }
383
384            let block =
385                TestBlock::new::<Sha256>(context, parent.digest(), parent.height().next(), 0);
386            Some(match input.payload {
387                Some(payload) => {
388                    block.with_payload::<Sha256, TestBlsVariant, PrivateKey>(NZU32!(16), payload)
389                }
390                None => block,
391            })
392        }
393
394        async fn verify(&mut self, _: (E, Self::Context), _: impl Ancestry<Self::Block>) -> bool {
395            *self.verify_count.lock() += 1;
396            self.verify_result
397        }
398    }
399
400    fn wrapper(context: &deterministic::Context, response: TestResponse) -> TestWrapper {
401        wrapper_with_inner(context, response, RecordingApp::accepting())
402    }
403
404    fn wrapper_with_inner(
405        context: &deterministic::Context,
406        response: TestResponse,
407        inner: RecordingApp,
408    ) -> TestWrapper {
409        let (sender, mut receiver) = mailbox::new::<Message<TestBlock, TestBlsVariant, PrivateKey>>(
410            context.child("mailbox"),
411            NZUsize!(1),
412        );
413        context.child("fake_actor").spawn(|_| async move {
414            let Some(Message::EpochInfo {
415                response: reply, ..
416            }) = receiver.recv().await
417            else {
418                return;
419            };
420            let _ = reply.send(response);
421        });
422
423        Application::new(inner, Mailbox::new(sender), NZU64!(2))
424    }
425
426    fn log_wrapper(
427        context: &deterministic::Context,
428        payload: TestPayload,
429        inner: RecordingApp,
430    ) -> (TestWrapper, oneshot::Receiver<Height>) {
431        let (sender, mut receiver) = mailbox::new::<Message<TestBlock, TestBlsVariant, PrivateKey>>(
432            context.child("mailbox"),
433            NZUsize!(4),
434        );
435        let (release_tx, release_rx) = oneshot::channel();
436        context.child("fake_actor").spawn(|_| async move {
437            let mut served_at = None;
438            let mut release_tx = Some(release_tx);
439            while let Some(message) = receiver.recv().await {
440                match message {
441                    Message::NextLog {
442                        height,
443                        release,
444                        response,
445                        ..
446                    } => {
447                        let reservation = served_at.is_none().then(|| {
448                            served_at = Some(height);
449                            LogReservation::new(height, payload.clone(), release)
450                        });
451                        let _ = response.send(reservation);
452                    }
453                    Message::ReleaseLog { height } => {
454                        if served_at == Some(height) {
455                            served_at = None;
456                        }
457                        if let Some(release_tx) = release_tx.take() {
458                            let _ = release_tx.send(height);
459                        }
460                    }
461                    Message::EpochInfo { response, .. } => {
462                        let _ = response.send(EpochInfoResponse::Unavailable);
463                    }
464                    Message::Finalized { response, .. } => {
465                        response.acknowledge();
466                    }
467                }
468            }
469        });
470
471        (
472            Application::new(inner, Mailbox::new(sender), NZU64!(4)),
473            release_rx,
474        )
475    }
476
477    fn leader() -> PrivateKey {
478        PrivateKey::from_seed(99)
479    }
480
481    fn block_context(parent: &TestBlock, view: u64) -> TestContext {
482        TestContext {
483            round: Round::new(Epoch::zero(), View::new(view)),
484            leader: leader().public_key(),
485            parent: (View::zero(), parent.digest()),
486        }
487    }
488
489    fn signers() -> Vec<PrivateKey> {
490        (0..4).map(PrivateKey::from_seed).collect()
491    }
492
493    fn players() -> Set<PublicKey> {
494        Set::from_iter_dedup(signers().iter().map(Signer::public_key))
495    }
496
497    fn epoch_payload(seed: u64) -> TestPayload {
498        let (output, _) =
499            deal::<MinPk, _, N3f1>(TestRng::new(seed), Mode::NonZeroCounter, players())
500                .expect("trusted deal");
501        Payload::EpochInfo(EpochInfo {
502            outcome: EpochOutcome::Success,
503            epoch: Epoch::new(1),
504            output,
505            players: Set::default(),
506            next_players: Set::default(),
507            directory: Unit,
508        })
509    }
510
511    fn final_block(parent: &TestBlock, payload: Option<TestPayload>) -> Arc<TestBlock> {
512        let block = TestBlock::new::<Sha256>(
513            block_context(parent, 1),
514            parent.digest(),
515            parent.height().next(),
516            0,
517        );
518        let block = match payload {
519            Some(payload) => {
520                block.with_payload::<Sha256, TestBlsVariant, PrivateKey>(NZU32!(16), payload)
521            }
522            None => block,
523        };
524        Arc::new(block)
525    }
526
527    fn midpoint_parent() -> TestBlock {
528        let genesis = mocks::genesis_block(leader().public_key());
529        TestBlock::new::<Sha256>(
530            block_context(&genesis, 1),
531            genesis.digest(),
532            genesis.height().next(),
533            0,
534        )
535    }
536
537    #[test]
538    fn proposal_none_releases_dealer_log_reservation() {
539        let executor = deterministic::Runner::default();
540        executor.start(|context| async move {
541            let parent = midpoint_parent();
542            let payload = epoch_payload(10);
543            let inner = RecordingApp::rejecting();
544            let (mut app, release_rx) = log_wrapper(&context, payload.clone(), inner.clone());
545
546            let proposed = app
547                .propose(
548                    (context.child("app"), block_context(&parent, 2)),
549                    ancestry::from_iter([Arc::new(parent.clone())]),
550                    (),
551                )
552                .await;
553            assert!(proposed.is_none());
554            assert_eq!(
555                release_rx.await.expect("reservation should be released"),
556                Height::new(2)
557            );
558
559            let proposed = app
560                .propose(
561                    (context.child("app_retry"), block_context(&parent, 3)),
562                    ancestry::from_iter([Arc::new(parent)]),
563                    (),
564                )
565                .await;
566            assert!(proposed.is_none());
567            let proposed_payloads = inner.proposed();
568            assert_eq!(proposed_payloads.len(), 2);
569            assert!(proposed_payloads[0] == Some(payload.clone()));
570            assert!(proposed_payloads[1] == Some(payload));
571        });
572    }
573
574    #[test]
575    fn dropped_proposal_future_releases_dealer_log_reservation() {
576        let executor = deterministic::Runner::default();
577        executor.start(|context| async move {
578            let parent = midpoint_parent();
579            let payload = epoch_payload(11);
580            let (entered_tx, entered_rx) = oneshot::channel();
581            let mut entered_rx = entered_rx;
582            let inner = RecordingApp::pending(entered_tx);
583            let (sender, mut receiver) = mailbox::new::<
584                Message<TestBlock, TestBlsVariant, PrivateKey>,
585            >(context.child("mailbox"), NZUsize!(4));
586            let mut app = Application::new(inner.clone(), Mailbox::new(sender), NZU64!(4));
587
588            let mut propose = Box::pin(app.propose(
589                (context.child("app"), block_context(&parent, 2)),
590                ancestry::from_iter([Arc::new(parent)]),
591                (),
592            ));
593            assert!(propose.as_mut().now_or_never().is_none());
594
595            let Some(Message::NextLog {
596                height,
597                release,
598                response,
599                ..
600            }) = receiver.recv().await
601            else {
602                panic!("proposal should request a dealer log");
603            };
604            assert_eq!(height, Height::new(2));
605            let reservation = LogReservation::new(height, payload.clone(), release);
606            assert!(
607                response.send(Some(reservation)).is_ok(),
608                "proposal should still be waiting for log"
609            );
610
611            assert!(propose.as_mut().now_or_never().is_none());
612            entered_rx
613                .try_recv()
614                .expect("proposal should enter inner application");
615            drop(propose);
616
617            let Some(Message::ReleaseLog { height }) = receiver.recv().await else {
618                panic!("dropped proposal should release reservation");
619            };
620            assert_eq!(height, Height::new(2));
621
622            if let Ok(Message::ReleaseLog { height }) = receiver.try_recv() {
623                panic!("reservation released more than once at {height:?}");
624            }
625            let proposed_payloads = inner.proposed();
626            assert_eq!(proposed_payloads.len(), 1);
627            assert!(proposed_payloads[0] == Some(payload));
628        });
629    }
630
631    #[test]
632    fn successful_proposal_keeps_dealer_log_reserved() {
633        let executor = deterministic::Runner::default();
634        executor.start(|context| async move {
635            let parent = midpoint_parent();
636            let payload = epoch_payload(12);
637            let inner = RecordingApp::accepting();
638            let (mut app, release_rx) = log_wrapper(&context, payload.clone(), inner.clone());
639
640            let proposed = app
641                .propose(
642                    (context.child("app"), block_context(&parent, 2)),
643                    ancestry::from_iter([Arc::new(parent)]),
644                    (),
645                )
646                .await
647                .expect("proposal should be built");
648            assert!(proposed.payload() == Some(payload.clone()));
649            let proposed_payloads = inner.proposed();
650            assert_eq!(proposed_payloads.len(), 1);
651            assert!(proposed_payloads[0] == Some(payload));
652
653            let timeout = context.sleep(Duration::from_millis(1));
654            pin_mut!(release_rx);
655            pin_mut!(timeout);
656            match select(release_rx, timeout).await {
657                Either::Left((released, _)) => {
658                    panic!("successful proposal released reservation: {released:?}");
659                }
660                Either::Right(((), _)) => {}
661            }
662        });
663    }
664
665    #[test]
666    fn proposal_skips_unavailable_final_epoch_info() {
667        let executor = deterministic::Runner::default();
668        executor.start(|context| async move {
669            let parent = mocks::genesis_block(leader().public_key());
670            let mut app = wrapper(&context, EpochInfoResponse::Unavailable);
671            let inner = app.inner.clone();
672
673            let proposed = app
674                .propose(
675                    (context.child("app"), block_context(&parent, 1)),
676                    ancestry::from_iter([Arc::new(parent)]),
677                    (),
678                )
679                .await;
680
681            assert!(proposed.is_none());
682            assert!(inner.proposed().is_empty());
683        });
684    }
685
686    #[test]
687    fn proposal_preserves_legitimate_no_artifact_final_block() {
688        let executor = deterministic::Runner::default();
689        executor.start(|context| async move {
690            let parent = mocks::genesis_block(leader().public_key());
691            let mut app = wrapper(&context, EpochInfoResponse::Available(None));
692            let inner = app.inner.clone();
693
694            let proposed = app
695                .propose(
696                    (context.child("app"), block_context(&parent, 1)),
697                    ancestry::from_iter([Arc::new(parent)]),
698                    (),
699                )
700                .await
701                .expect("proposal should be built");
702
703            assert!(proposed.payload().is_none());
704            let proposed_payloads = inner.proposed();
705            assert_eq!(proposed_payloads.len(), 1);
706            assert!(proposed_payloads[0].is_none());
707        });
708    }
709
710    #[test]
711    fn proposal_includes_available_final_epoch_info() {
712        let executor = deterministic::Runner::default();
713        executor.start(|context| async move {
714            let parent = mocks::genesis_block(leader().public_key());
715            let payload = epoch_payload(7);
716            let mut app = wrapper(
717                &context,
718                EpochInfoResponse::Available(Some(payload.clone())),
719            );
720            let inner = app.inner.clone();
721
722            let proposed = app
723                .propose(
724                    (context.child("app"), block_context(&parent, 1)),
725                    ancestry::from_iter([Arc::new(parent)]),
726                    (),
727                )
728                .await
729                .expect("proposal should be built");
730
731            assert!(proposed.payload() == Some(payload.clone()));
732            let proposed_payloads = inner.proposed();
733            assert_eq!(proposed_payloads.len(), 1);
734            assert!(proposed_payloads[0] == Some(payload));
735        });
736    }
737
738    #[test]
739    fn verification_rejects_unavailable_final_epoch_info() {
740        let executor = deterministic::Runner::default();
741        executor.start(|context| async move {
742            let parent = Arc::new(mocks::genesis_block(leader().public_key()));
743            let tip = final_block(&parent, Some(epoch_payload(1)));
744            let mut app = wrapper(&context, EpochInfoResponse::Unavailable);
745            let inner = app.inner.clone();
746
747            let verified = app
748                .verify(
749                    (context.child("app"), block_context(&parent, 1)),
750                    ancestry::from_iter([tip, parent]),
751                )
752                .await;
753
754            assert!(!verified);
755            assert_eq!(inner.verify_count(), 0);
756        });
757    }
758
759    #[test]
760    fn verification_stays_pending_without_final_epoch_info() {
761        let executor = deterministic::Runner::default();
762        executor.start(|context| async move {
763            for response in [EpochInfoResponse::Following, EpochInfoResponse::Pending] {
764                let parent = Arc::new(mocks::genesis_block(leader().public_key()));
765                let tip = final_block(&parent, Some(epoch_payload(1)));
766                let expected_tip = tip.digest();
767                let expected_parent = parent.digest();
768                let inner = RecordingApp::accepting();
769                let (sender, mut receiver) = mailbox::new::<
770                    Message<TestBlock, TestBlsVariant, PrivateKey>,
771                >(
772                    context.child("mailbox"), NZUsize!(1)
773                );
774                let mut app = Application::new(inner.clone(), Mailbox::new(sender), NZU64!(2));
775                let mut verify = Box::pin(app.verify(
776                    (context.child("app"), block_context(&parent, 1)),
777                    ancestry::from_iter([tip, parent]),
778                ));
779
780                assert!(verify.as_mut().now_or_never().is_none());
781                let Some(Message::EpochInfo {
782                    mut ancestry,
783                    response: reply,
784                    ..
785                }) = receiver.recv().await
786                else {
787                    panic!("verification should request final epoch info");
788                };
789                assert_eq!(
790                    ancestry
791                        .next()
792                        .await
793                        .expect("verification ancestry should retain the candidate")
794                        .digest(),
795                    expected_tip
796                );
797                assert_eq!(
798                    ancestry
799                        .next()
800                        .await
801                        .expect("candidate should be followed by its parent")
802                        .digest(),
803                    expected_parent
804                );
805                assert!(reply.send(response).is_ok());
806                assert!(
807                    verify.as_mut().now_or_never().is_none(),
808                    "verification resolved without final epoch info"
809                );
810                assert_eq!(inner.verify_count(), 0);
811            }
812        });
813    }
814
815    #[test]
816    fn verification_accepts_legitimate_no_artifact_final_block() {
817        let executor = deterministic::Runner::default();
818        executor.start(|context| async move {
819            let parent = Arc::new(mocks::genesis_block(leader().public_key()));
820            let tip = final_block(&parent, None);
821            let mut app = wrapper(&context, EpochInfoResponse::Available(None));
822            let inner = app.inner.clone();
823
824            let verified = app
825                .verify(
826                    (context.child("app"), block_context(&parent, 1)),
827                    ancestry::from_iter([tip, parent]),
828                )
829                .await;
830
831            assert!(verified);
832            assert_eq!(inner.verify_count(), 1);
833        });
834    }
835
836    #[test]
837    fn verification_accepts_equal_final_epoch_info() {
838        let executor = deterministic::Runner::default();
839        executor.start(|context| async move {
840            let parent = Arc::new(mocks::genesis_block(leader().public_key()));
841            let payload = epoch_payload(2);
842            let tip = final_block(&parent, Some(payload.clone()));
843            let mut app = wrapper(&context, EpochInfoResponse::Available(Some(payload)));
844            let inner = app.inner.clone();
845
846            let verified = app
847                .verify(
848                    (context.child("app"), block_context(&parent, 1)),
849                    ancestry::from_iter([tip, parent]),
850                )
851                .await;
852
853            assert!(verified);
854            assert_eq!(inner.verify_count(), 1);
855        });
856    }
857
858    #[test]
859    fn verification_rejects_mismatched_final_epoch_info() {
860        let executor = deterministic::Runner::default();
861        executor.start(|context| async move {
862            let parent = Arc::new(mocks::genesis_block(leader().public_key()));
863            let tip = final_block(&parent, Some(epoch_payload(3)));
864            let response = EpochInfoResponse::Available(Some(epoch_payload(4)));
865            let mut app = wrapper(&context, response);
866            let inner = app.inner.clone();
867
868            let verified = app
869                .verify(
870                    (context.child("app"), block_context(&parent, 1)),
871                    ancestry::from_iter([tip, parent]),
872                )
873                .await;
874
875            assert!(!verified);
876            assert_eq!(inner.verify_count(), 0);
877        });
878    }
879}