Skip to main content

commonware_consensus/aggregation/
mod.rs

1//! Recover quorum certificates over an externally synchronized sequencer of items.
2//!
3//! This module allows a dynamic set of participants to collectively produce quorum certificates
4//! for any ordered sequence of items.
5//!
6//! The primary use case for this primitive is to allow blockchain validators to agree on a series
7//! of state roots emitted from an opaque consensus process. Because some chains may finalize transaction
8//! data but not the output of said transactions during consensus, agreement must be achieved asynchronously
9//! over the output of consensus to support state sync and client balance proofs.
10//!
11//! _For applications that want to collect quorum certificates over concurrent, sequencer-driven broadcast,
12//! check out [crate::ordered_broadcast]._
13//!
14//! # Pluggable Cryptography
15//!
16//! The aggregation module is generic over the signing scheme, allowing users to choose the
17//! cryptographic scheme best suited for their requirements:
18//!
19//! - [`ed25519`][scheme::ed25519]: Attributable signatures with individual verification.
20//!   HSM-friendly, no trusted setup required. Certificates contain individual signatures.
21//!
22//! - [`secp256r1`][scheme::secp256r1]: Attributable signatures with individual verification.
23//!   HSM-friendly, no trusted setup required. Certificates contain individual signatures.
24//!
25//! - [`bls12381_multisig`][scheme::bls12381_multisig]: Attributable signatures with aggregated
26//!   verification. Produces compact certificates while preserving signer attribution.
27//!
28//! - [`bls12381_threshold`][scheme::bls12381_threshold]: Non-attributable threshold signatures.
29//!   Produces succinct constant-size certificates. Requires trusted setup (DKG).
30//!
31//! # Architecture
32//!
33//! The core of the module is the [Engine]. It manages the agreement process by:
34//! - Requesting externally synchronized [commonware_cryptography::Digest]s
35//! - Signing said digests with the configured scheme's signature type
36//! - Multicasting signatures/shares to other validators
37//! - Assembling certificates from a quorum of signatures
38//! - Monitoring recovery progress and notifying the application layer of recoveries
39//!
40//! The engine interacts with four main components:
41//! - [crate::Automaton]: Provides external digests
42//! - [crate::Reporter]: Receives agreement confirmations
43//! - [crate::Monitor]: Tracks epoch transitions
44//! - [commonware_cryptography::certificate::Provider]: Manages validator sets and network identities
45//!
46//! # Design Decisions
47//!
48//! ## Missing Certificate Resolution
49//!
50//! The engine does not try to "fill gaps" when certificates are missing. When validators
51//! fall behind or miss signatures for certain indices, the tip may skip ahead and those
52//! certificates may never be emitted by the local engine. Before skipping ahead, we ensure that
53//! at-least-one honest validator has the certificate for any skipped height.
54//!
55//! Like other consensus primitives, aggregation's design prioritizes doing useful work at tip and
56//! minimal complexity over providing a comprehensive recovery mechanism. As a result, applications that need
57//! to build a complete history of all formed [types::Certificate]s must implement their own mechanism to synchronize
58//! historical results.
59//!
60//! ## Recovering Certificates
61//!
62//! In aggregation, participants never gossip recovered certificates. Rather, they gossip [types::TipAck]s
63//! with signatures over some height and their latest tip. This approach reduces the overhead of running aggregation
64//! concurrently with a consensus mechanism and consistently results in local recovery on stable networks. To increase
65//! the likelihood of local recovery, participants should tune the [Config::activity_timeout] to a value larger than the expected
66//! drift of online participants (even if all participants are synchronous the tip advancement logic will advance to the `f+1`th highest
67//! reported tip and drop all work below that tip minus the [Config::activity_timeout]).
68//!
69//! ## Epoch-Independent Signatures
70//!
71//! The attestation in a [types::Ack] covers only the [types::Item] (height and digest), not the
72//! epoch, and the ack namespace does not rotate per epoch. This is intentional: participants
73//! attest to an externally agreed-upon log that does not change across epochs, so an attestation
74//! to an item is valid regardless of the epoch in which it was produced. [types::Certificate]s
75//! likewise do not bind an epoch, keeping them verifiable across epoch transitions.
76//!
77//! The epoch field on [types::Ack] is unauthenticated metadata that selects the scheme used to
78//! verify the attestation and assemble the certificate. This is safe because the engine
79//! only accepts an ack delivered by its signer (the authenticated sender must match the
80//! attestation's signer index), and a signer gains nothing by relabeling its own ack that it could
81//! not achieve by signing the item directly in the target epoch. Integrations that perform
82//! per-epoch accounting from [types::Activity] should not treat the epoch as signed intent.
83
84pub mod scheme;
85pub mod types;
86
87cfg_if::cfg_if! {
88    if #[cfg(not(target_arch = "wasm32"))] {
89        mod config;
90        pub use config::Config;
91        mod engine;
92        pub use engine::Engine;
93        mod metrics;
94        mod safe_tip;
95
96        #[cfg(test)]
97        pub mod mocks;
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{mocks, Config, Engine};
104    use crate::{
105        aggregation::scheme::{bls12381_multisig, bls12381_threshold, ed25519, secp256r1, Scheme},
106        types::{Epoch, EpochDelta, Height, HeightDelta},
107    };
108    use commonware_cryptography::{
109        bls12381::primitives::variant::{MinPk, MinSig},
110        certificate::mocks::Fixture,
111        ed25519::PublicKey,
112        sha256::Digest as Sha256Digest,
113    };
114    use commonware_macros::{select, test_group, test_traced};
115    use commonware_p2p::simulated::{Link, Network, Oracle, Receiver, Sender};
116    use commonware_parallel::Sequential;
117    use commonware_runtime::{
118        buffer::paged::CacheRef,
119        deterministic::{self, Context},
120        Clock, Quota, Runner, Spawner, Supervisor as _,
121    };
122    use commonware_utils::{
123        channel::{fallible::OneshotExt, oneshot},
124        test_rng, NZUsize, NonZeroDuration, TestRng, NZU16,
125    };
126    use futures::future::join_all;
127    use rand::RngExt as _;
128    use std::{
129        collections::BTreeMap,
130        num::{NonZeroU16, NonZeroU32, NonZeroUsize},
131        time::Duration,
132    };
133    use tracing::debug;
134
135    // Invoke `$cb!($($args)*, $suffix, $fixture)` once per canonical scheme fixture.
136    macro_rules! for_each_fixture {
137        ($cb:ident!($($args:tt)*)) => {
138            $cb!($($args)*, bls12381_threshold_min_pk, bls12381_threshold::fixture::<MinPk, _>);
139            $cb!($($args)*, bls12381_threshold_min_sig, bls12381_threshold::fixture::<MinSig, _>);
140            $cb!($($args)*, bls12381_multisig_min_pk, bls12381_multisig::fixture::<MinPk, _>);
141            $cb!($($args)*, bls12381_multisig_min_sig, bls12381_multisig::fixture::<MinSig, _>);
142            $cb!($($args)*, ed25519, ed25519::fixture);
143            $cb!($($args)*, secp256r1, secp256r1::fixture);
144        };
145    }
146
147    // Generate one `#[test_traced("INFO")]` test per scheme fixture, named
148    // `test_<callee>_<suffix>`, calling `callee(fixture)`. Prefix the callee with
149    // `slow` to additionally tag each generated test with `#[test_group("slow")]`.
150    macro_rules! test_for_all_fixtures {
151        ($callee:ident) => {
152            for_each_fixture!(test_for_all_fixtures!(@emit [] $callee));
153        };
154        (slow $callee:ident) => {
155            for_each_fixture!(test_for_all_fixtures!(@emit [#[test_group("slow")]] $callee));
156        };
157        (@emit [$(#[$attr:meta])*] $callee:ident, $suffix:ident, $fixture:expr) => {
158            paste::paste! {
159                $(#[$attr])*
160                #[test_traced("INFO")]
161                fn [<test_ $callee _ $suffix>]() {
162                    $callee($fixture);
163                }
164            }
165        };
166    }
167
168    type Registrations<P> = BTreeMap<P, (Sender<P, deterministic::Context>, Receiver<P>)>;
169
170    const PAGE_SIZE: NonZeroU16 = NZU16!(1024);
171    const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(10);
172    const TEST_QUOTA: Quota = Quota::per_second(NonZeroU32::MAX);
173    const TEST_NAMESPACE: &[u8] = b"my testing namespace";
174
175    /// Reliable network link configuration for testing.
176    const RELIABLE_LINK: Link = Link {
177        latency: Duration::from_millis(10),
178        jitter: Duration::from_millis(1),
179        success_rate: 1.0,
180    };
181
182    /// Register all participants with the network oracle.
183    async fn register_participants(
184        oracle: &mut Oracle<PublicKey, deterministic::Context>,
185        participants: &[PublicKey],
186    ) -> Registrations<PublicKey> {
187        let mut registrations = BTreeMap::new();
188        for participant in participants.iter() {
189            let (sender, receiver) = oracle
190                .control(participant.clone())
191                .register(0, TEST_QUOTA)
192                .await
193                .unwrap();
194            registrations.insert(participant.clone(), (sender, receiver));
195        }
196        registrations
197    }
198
199    /// Establish network links between all participants.
200    async fn link_participants(
201        oracle: &mut Oracle<PublicKey, deterministic::Context>,
202        participants: &[PublicKey],
203        link: Link,
204    ) {
205        for v1 in participants.iter() {
206            for v2 in participants.iter() {
207                if v2 == v1 {
208                    continue;
209                }
210                oracle
211                    .add_link(v1.clone(), v2.clone(), link.clone())
212                    .await
213                    .unwrap();
214            }
215        }
216    }
217
218    /// Initialize a simulated network environment.
219    async fn initialize_simulation<S: Scheme<Sha256Digest, PublicKey = PublicKey>>(
220        context: Context,
221        fixture: &Fixture<S>,
222        link: Link,
223    ) -> (
224        Oracle<PublicKey, deterministic::Context>,
225        Registrations<PublicKey>,
226    ) {
227        let (network, mut oracle) = Network::new_with_peers(
228            context.child("network"),
229            commonware_p2p::simulated::Config {
230                max_size: 1024 * 1024,
231                disconnect_on_block: true,
232                tracked_peer_sets: NZUsize!(1),
233            },
234            fixture.participants.clone(),
235        )
236        .await;
237        network.start();
238
239        let registrations = register_participants(&mut oracle, &fixture.participants).await;
240        link_participants(&mut oracle, &fixture.participants, link).await;
241
242        (oracle, registrations)
243    }
244
245    /// Spawn aggregation engines for all validators.
246    fn spawn_validator_engines<S: Scheme<Sha256Digest, PublicKey = PublicKey>>(
247        context: Context,
248        fixture: &Fixture<S>,
249        registrations: &mut Registrations<PublicKey>,
250        oracle: &mut Oracle<PublicKey, deterministic::Context>,
251        epoch: Epoch,
252        rebroadcast_timeout: Duration,
253        incorrect: Vec<usize>,
254    ) -> BTreeMap<PublicKey, mocks::ReporterMailbox<S, Sha256Digest>> {
255        let mut reporters = BTreeMap::new();
256
257        for (idx, participant) in fixture.participants.iter().enumerate() {
258            let context = context
259                .child("participant")
260                .with_attribute("public_key", participant);
261
262            // Create Provider and register scheme for epoch
263            let provider = mocks::Provider::new();
264            assert!(provider.register(epoch, fixture.schemes[idx].clone()));
265
266            // Create monitor
267            let monitor = mocks::Monitor::new(epoch);
268
269            // Create automaton with Incorrect strategy for byzantine validators
270            let strategy = if incorrect.contains(&idx) {
271                mocks::Strategy::Incorrect
272            } else {
273                mocks::Strategy::Correct
274            };
275            let automaton = mocks::Application::new(strategy);
276
277            // Create reporter with verifier scheme
278            let (reporter, reporter_mailbox) =
279                mocks::Reporter::new(context.child("reporter"), fixture.verifier.clone());
280            reporter.start();
281            reporters.insert(participant.clone(), reporter_mailbox.clone());
282
283            // Create blocker
284            let blocker = oracle.control(participant.clone());
285
286            // Create and start engine
287            let engine = Engine::new(
288                context.child("engine"),
289                Config {
290                    monitor,
291                    provider,
292                    automaton,
293                    reporter: reporter_mailbox,
294                    blocker,
295                    priority_acks: false,
296                    rebroadcast_timeout: NonZeroDuration::new_panic(rebroadcast_timeout),
297                    epoch_bounds: (EpochDelta::new(1), EpochDelta::new(1)),
298                    window: std::num::NonZeroU64::new(10).unwrap(),
299                    activity_timeout: HeightDelta::new(100),
300                    journal_partition: format!("aggregation-{participant}"),
301                    journal_write_buffer: NZUsize!(4096),
302                    journal_replay_buffer: NZUsize!(4096),
303                    journal_heights_per_section: std::num::NonZeroU64::new(6).unwrap(),
304                    journal_compression: Some(3),
305                    journal_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
306                    strategy: Sequential,
307                },
308            );
309
310            let (sender, receiver) = registrations.remove(participant).unwrap();
311            engine.start((sender, receiver));
312        }
313
314        reporters
315    }
316
317    /// Wait for all reporters to reach the specified consensus threshold.
318    async fn await_reporters<S: Scheme<Sha256Digest, PublicKey = PublicKey>>(
319        context: Context,
320        reporters: &BTreeMap<PublicKey, mocks::ReporterMailbox<S, Sha256Digest>>,
321        threshold_height: Height,
322        threshold_epoch: Epoch,
323    ) {
324        let mut receivers = Vec::new();
325        for (reporter, mailbox) in reporters.iter() {
326            // Create a oneshot channel to signal when the reporter has reached the threshold.
327            let (tx, rx) = oneshot::channel();
328            receivers.push(rx);
329
330            context
331                .child("reporter_watcher")
332                .with_attribute("reporter", reporter)
333                .spawn({
334                    let reporter = reporter.clone();
335                    let mut mailbox = mailbox.clone();
336                    move |context| async move {
337                        loop {
338                            let (height, epoch) = mailbox
339                                .get_tip()
340                                .await
341                                .unwrap_or((Height::zero(), Epoch::zero()));
342                            debug!(
343                                %height,
344                                epoch = %epoch,
345                                %threshold_height,
346                                threshold_epoch = %threshold_epoch,
347                                ?reporter,
348                                "reporter status"
349                            );
350                            if height >= threshold_height && epoch >= threshold_epoch {
351                                debug!(
352                                    ?reporter,
353                                    "reporter reached threshold, signaling completion"
354                                );
355                                tx.send_lossy(reporter.clone());
356                                break;
357                            }
358                            context.sleep(Duration::from_millis(100)).await;
359                        }
360                    }
361                });
362        }
363
364        // Wait for all oneshot receivers to complete.
365        let results = join_all(receivers).await;
366        assert_eq!(results.len(), reporters.len());
367
368        // Check that none were cancelled.
369        for result in results {
370            assert!(result.is_ok(), "reporter was cancelled");
371        }
372    }
373
374    /// Test aggregation consensus with all validators online.
375    fn all_online<S, F>(fixture: F)
376    where
377        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
378        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
379    {
380        let runner = deterministic::Runner::timed(Duration::from_secs(30));
381
382        runner.start(|mut context| async move {
383            let num_validators = 4;
384            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
385            let epoch = Epoch::new(111);
386
387            let (mut oracle, mut registrations) =
388                initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK).await;
389
390            let reporters = spawn_validator_engines(
391                context.child("validator"),
392                &fixture,
393                &mut registrations,
394                &mut oracle,
395                epoch,
396                Duration::from_secs(5),
397                vec![],
398            );
399
400            await_reporters(
401                context.child("reporter"),
402                &reporters,
403                Height::new(100),
404                epoch,
405            )
406            .await;
407        });
408    }
409
410    test_for_all_fixtures!(slow all_online);
411
412    /// Test consensus resilience to Byzantine behavior.
413    fn byzantine_proposer<S, F>(fixture: F)
414    where
415        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
416        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
417    {
418        let runner = deterministic::Runner::timed(Duration::from_secs(30));
419
420        runner.start(|mut context| async move {
421            let num_validators = 4;
422            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
423            let epoch = Epoch::new(111);
424
425            let (mut oracle, mut registrations) =
426                initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK).await;
427
428            let reporters = spawn_validator_engines(
429                context.child("validator"),
430                &fixture,
431                &mut registrations,
432                &mut oracle,
433                epoch,
434                Duration::from_secs(5),
435                vec![0],
436            );
437
438            await_reporters(
439                context.child("reporter"),
440                &reporters,
441                Height::new(100),
442                epoch,
443            )
444            .await;
445        });
446    }
447
448    test_for_all_fixtures!(byzantine_proposer);
449
450    fn unclean_byzantine_shutdown<S, F>(fixture: F)
451    where
452        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
453        F: Fn(&mut TestRng, &[u8], u32) -> Fixture<S>,
454    {
455        // Test parameters
456        let num_validators = 4;
457        let target_height = Height::new(200); // Target multiple rounds of signing
458        let min_shutdowns = 4; // Minimum number of shutdowns per validator
459        let max_shutdowns = 10; // Maximum number of shutdowns per validator
460        let shutdown_range_min = Duration::from_millis(100);
461        let shutdown_range_max = Duration::from_millis(1_000);
462        let rebroadcast_timeout = NonZeroDuration::new_panic(Duration::from_millis(20));
463
464        let mut prev_checkpoint = None;
465
466        // Generate fixture once (persists across restarts)
467        let mut rng = test_rng();
468        let fixture = fixture(&mut rng, TEST_NAMESPACE, num_validators);
469
470        // Continue until shared reporter reaches target or max shutdowns exceeded
471        let mut shutdown_count = 0;
472        while shutdown_count < max_shutdowns {
473            let fixture = fixture.clone();
474            let f = move |mut context: Context| {
475                async move {
476                    let epoch = Epoch::new(111);
477
478                    let (oracle, mut registrations) =
479                        initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK)
480                            .await;
481
482                    // Create a shared reporter
483                    //
484                    // We rely on replay to populate this reporter with a contiguous history of certificates.
485                    let (reporter, mut reporter_mailbox) =
486                        mocks::Reporter::new(context.child("reporter"), fixture.verifier.clone());
487                    reporter.start();
488
489                    // Spawn validator engines
490                    for (idx, participant) in fixture.participants.iter().enumerate() {
491                        let validator_context = context
492                            .child("participant")
493                            .with_attribute("public_key", participant);
494
495                        // Create Provider and register scheme for epoch
496                        let provider = mocks::Provider::new();
497                        assert!(provider.register(epoch, fixture.schemes[idx].clone()));
498
499                        // Create monitor
500                        let monitor = mocks::Monitor::new(epoch);
501
502                        // Create automaton (validator 0 is Byzantine)
503                        let strategy = if idx == 0 {
504                            mocks::Strategy::Incorrect
505                        } else {
506                            mocks::Strategy::Correct
507                        };
508                        let automaton = mocks::Application::new(strategy);
509
510                        // Create blocker
511                        let blocker = oracle.control(participant.clone());
512
513                        // Create and start engine
514                        let engine = Engine::new(
515                            validator_context.child("engine"),
516                            Config {
517                                monitor,
518                                provider,
519                                automaton,
520                                reporter: reporter_mailbox.clone(),
521                                blocker,
522                                priority_acks: false,
523                                rebroadcast_timeout,
524                                epoch_bounds: (EpochDelta::new(1), EpochDelta::new(1)),
525                                window: std::num::NonZeroU64::new(10).unwrap(),
526                                activity_timeout: HeightDelta::new(1_024), // ensure we don't drop any certificates
527                                journal_partition: format!("unclean_shutdown_test_{participant}"),
528                                journal_write_buffer: NZUsize!(4096),
529                                journal_replay_buffer: NZUsize!(4096),
530                                journal_heights_per_section: std::num::NonZeroU64::new(6).unwrap(),
531                                journal_compression: Some(3),
532                                journal_page_cache: CacheRef::from_pooler(
533                                    &context,
534                                    PAGE_SIZE,
535                                    PAGE_CACHE_SIZE,
536                                ),
537                                strategy: Sequential,
538                            },
539                        );
540
541                        let (sender, receiver) = registrations.remove(participant).unwrap();
542                        engine.start((sender, receiver));
543                    }
544
545                    // Create a single completion watcher for the shared reporter
546                    let completion =
547                        context
548                            .child("completion_watcher")
549                            .spawn(move |context| async move {
550                                loop {
551                                    if let Some(tip_height) =
552                                        reporter_mailbox.get_contiguous_tip().await
553                                    {
554                                        if tip_height >= target_height {
555                                            break;
556                                        }
557                                    }
558                                    context.sleep(Duration::from_millis(50)).await;
559                                }
560                            });
561
562                    // Random shutdown timing to simulate unclean shutdown
563                    let shutdown_wait =
564                        context.random_range(shutdown_range_min..shutdown_range_max);
565                    select! {
566                        _ = context.sleep(shutdown_wait) => {
567                            debug!(shutdown_wait = ?shutdown_wait, "Simulating unclean shutdown");
568                            false // Unclean shutdown
569                        },
570                        _ = completion => {
571                            debug!("Shared reporter completed normally");
572                            true // Clean completion
573                        },
574                    }
575                }
576            };
577
578            let (complete, checkpoint) = prev_checkpoint
579                .map_or_else(
580                    || {
581                        debug!("Starting initial run");
582                        deterministic::Runner::timed(Duration::from_secs(45))
583                    },
584                    |prev_checkpoint| {
585                        debug!(shutdown_count, "Restarting from previous context");
586                        deterministic::Runner::from(prev_checkpoint)
587                    },
588                )
589                .start_and_recover(f);
590
591            if complete && shutdown_count >= min_shutdowns {
592                debug!("Test completed successfully");
593                break;
594            }
595
596            prev_checkpoint = Some(checkpoint);
597            shutdown_count += 1;
598        }
599    }
600
601    test_for_all_fixtures!(slow unclean_byzantine_shutdown);
602
603    fn unclean_shutdown_with_unsigned_height<S, F>(fixture: F)
604    where
605        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
606        F: Fn(&mut TestRng, &[u8], u32) -> Fixture<S>,
607    {
608        // Test parameters
609        let num_validators = 4;
610        let skip_height = Height::new(50); // Height where no one will sign
611        let window = HeightDelta::new(10);
612        let target_height = Height::new(100);
613
614        // Generate fixture once (persists across restarts)
615        let mut rng = test_rng();
616        let fixture = fixture(&mut rng, TEST_NAMESPACE, num_validators);
617
618        // First run: let validators skip signing at skip_height and reach beyond it
619        let f = |context: Context| {
620            let fixture = fixture.clone();
621            async move {
622                let epoch = Epoch::new(111);
623
624                // Set up simulated network
625                let (oracle, mut registrations) =
626                    initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK)
627                        .await;
628
629                // Create a shared reporter
630                let (reporter, mut reporter_mailbox) =
631                    mocks::Reporter::new(context.child("reporter"), fixture.verifier.clone());
632                reporter.start();
633
634                // Start validator engines with Skip strategy for skip_height
635                for (idx, participant) in fixture.participants.iter().enumerate() {
636                    let validator_context = context
637                        .child("participant")
638                        .with_attribute("public_key", participant);
639
640                    // Create Provider and register scheme for epoch
641                    let provider = mocks::Provider::new();
642                    assert!(provider.register(epoch, fixture.schemes[idx].clone()));
643
644                    // Create monitor
645                    let monitor = mocks::Monitor::new(epoch);
646
647                    // All validators use Skip strategy for skip_height
648                    let automaton = mocks::Application::new(mocks::Strategy::Skip {
649                        height: skip_height,
650                    });
651
652                    // Create blocker
653                    let blocker = oracle.control(participant.clone());
654
655                    // Create and start engine
656                    let engine = Engine::new(
657                        validator_context.child("engine"),
658                        Config {
659                            monitor,
660                            provider,
661                            automaton,
662                            reporter: reporter_mailbox.clone(),
663                            blocker,
664                            priority_acks: false,
665                            rebroadcast_timeout: NonZeroDuration::new_panic(Duration::from_millis(
666                                100,
667                            )),
668                            epoch_bounds: (EpochDelta::new(1), EpochDelta::new(1)),
669                            window: std::num::NonZeroU64::new(window.get()).unwrap(),
670                            activity_timeout: HeightDelta::new(100),
671                            journal_partition: format!("unsigned_height_test_{participant}"),
672                            journal_write_buffer: NZUsize!(4096),
673                            journal_replay_buffer: NZUsize!(4096),
674                            journal_heights_per_section: std::num::NonZeroU64::new(6).unwrap(),
675                            journal_compression: Some(3),
676                            journal_page_cache: CacheRef::from_pooler(
677                                &context,
678                                PAGE_SIZE,
679                                PAGE_CACHE_SIZE,
680                            ),
681                            strategy: Sequential,
682                        },
683                    );
684
685                    let (sender, receiver) = registrations.remove(participant).unwrap();
686                    engine.start((sender, receiver));
687                }
688
689                // Wait for validators to reach target_height (past skip_height)
690                loop {
691                    if let Some((tip_height, _)) = reporter_mailbox.get_tip().await {
692                        debug!(%tip_height, %skip_height, %target_height, "reporter status");
693                        if tip_height >= skip_height.saturating_add(window).previous().unwrap() {
694                            // max we can proceed before item confirmed
695                            return;
696                        }
697                    }
698                    context.sleep(Duration::from_millis(50)).await;
699                }
700            }
701        };
702
703        let (_, checkpoint) =
704            deterministic::Runner::timed(Duration::from_secs(60)).start_and_recover(f);
705
706        // Second run: restart and verify the skip_height gets confirmed
707        let f2 = |context: Context| {
708            async move {
709                let epoch = Epoch::new(111);
710
711                // Set up simulated network
712                let (oracle, mut registrations) =
713                    initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK)
714                        .await;
715
716                // Create a shared reporter
717                let (reporter, mut reporter_mailbox) =
718                    mocks::Reporter::new(context.child("reporter"), fixture.verifier.clone());
719                reporter.start();
720
721                // Start validator engines with Correct strategy (will sign everything now)
722                for (idx, participant) in fixture.participants.iter().enumerate() {
723                    let validator_context = context
724                        .child("participant")
725                        .with_attribute("public_key", participant);
726
727                    // Create Provider and register scheme for epoch
728                    let provider = mocks::Provider::new();
729                    assert!(provider.register(epoch, fixture.schemes[idx].clone()));
730
731                    // Create monitor
732                    let monitor = mocks::Monitor::new(epoch);
733
734                    // Now all validators use Correct strategy
735                    let automaton = mocks::Application::new(mocks::Strategy::Correct);
736
737                    // Create blocker
738                    let blocker = oracle.control(participant.clone());
739
740                    // Create and start engine
741                    let engine = Engine::new(
742                        validator_context.child("engine"),
743                        Config {
744                            monitor,
745                            provider,
746                            automaton,
747                            reporter: reporter_mailbox.clone(),
748                            blocker,
749                            priority_acks: false,
750                            rebroadcast_timeout: NonZeroDuration::new_panic(Duration::from_millis(
751                                100,
752                            )),
753                            epoch_bounds: (EpochDelta::new(1), EpochDelta::new(1)),
754                            window: std::num::NonZeroU64::new(10).unwrap(),
755                            activity_timeout: HeightDelta::new(100),
756                            journal_partition: format!("unsigned_height_test_{participant}"),
757                            journal_write_buffer: NZUsize!(4096),
758                            journal_replay_buffer: NZUsize!(4096),
759                            journal_heights_per_section: std::num::NonZeroU64::new(6).unwrap(),
760                            journal_compression: Some(3),
761                            journal_page_cache: CacheRef::from_pooler(
762                                &context,
763                                PAGE_SIZE,
764                                PAGE_CACHE_SIZE,
765                            ),
766                            strategy: Sequential,
767                        },
768                    );
769
770                    let (sender, receiver) = registrations.remove(participant).unwrap();
771                    engine.start((sender, receiver));
772                }
773
774                // Wait for skip_height to be confirmed (should happen on replay)
775                loop {
776                    if let Some(tip_height) = reporter_mailbox.get_contiguous_tip().await {
777                        debug!(
778                            %tip_height,
779                            %skip_height, %target_height, "reporter status on restart"
780                        );
781                        if tip_height >= target_height {
782                            break;
783                        }
784                    }
785                    context.sleep(Duration::from_millis(50)).await;
786                }
787            }
788        };
789
790        deterministic::Runner::from(checkpoint).start(f2);
791    }
792
793    test_for_all_fixtures!(slow unclean_shutdown_with_unsigned_height);
794
795    fn slow_and_lossy_links_seeded<S, F>(fixture: F, seed: u64) -> String
796    where
797        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
798        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
799    {
800        let cfg = deterministic::Config::new()
801            .with_seed(seed)
802            .with_timeout(Some(Duration::from_secs(120)));
803        let runner = deterministic::Runner::new(cfg);
804
805        runner.start(|mut context| async move {
806            let num_validators = 4;
807            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
808            let epoch = Epoch::new(111);
809
810            // Use degraded network links with realistic conditions
811            let degraded_link = Link {
812                latency: Duration::from_millis(200),
813                jitter: Duration::from_millis(150),
814                success_rate: 0.5,
815            };
816
817            let (mut oracle, mut registrations) =
818                initialize_simulation(context.child("simulation"), &fixture, degraded_link).await;
819
820            let reporters = spawn_validator_engines(
821                context.child("validator"),
822                &fixture,
823                &mut registrations,
824                &mut oracle,
825                epoch,
826                Duration::from_secs(2),
827                vec![],
828            );
829
830            await_reporters(
831                context.child("reporter"),
832                &reporters,
833                Height::new(100),
834                epoch,
835            )
836            .await;
837
838            context.auditor().state()
839        })
840    }
841
842    fn slow_and_lossy_links<S, F>(fixture: F)
843    where
844        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
845        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
846    {
847        slow_and_lossy_links_seeded(fixture, 0);
848    }
849
850    test_for_all_fixtures!(slow slow_and_lossy_links);
851
852    fn determinism<S, F>(fixture: F)
853    where
854        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
855        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S> + Copy,
856    {
857        // We use slow and lossy links as the deterministic test
858        // because it is the most complex test.
859        for seed in 1..6 {
860            assert_eq!(
861                slow_and_lossy_links_seeded(fixture, seed),
862                slow_and_lossy_links_seeded(fixture, seed),
863            );
864        }
865    }
866
867    test_for_all_fixtures!(slow determinism);
868
869    #[test_group("slow")]
870    #[test_traced("INFO")]
871    fn test_distinct_states() {
872        // Sanity check that different schemes produce different audit states.
873        macro_rules! collect {
874            ($vec:ident, $suffix:ident, $fixture:expr) => {
875                $vec.push((
876                    stringify!($suffix),
877                    slow_and_lossy_links_seeded($fixture, 7),
878                ));
879            };
880        }
881        let mut states = Vec::new();
882        for_each_fixture!(collect!(states));
883        for pair in states.windows(2) {
884            assert_ne!(
885                pair[0].1, pair[1].1,
886                "state {} equals state {}",
887                pair[0].0, pair[1].0
888            );
889        }
890    }
891
892    fn one_offline<S, F>(fixture: F)
893    where
894        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
895        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
896    {
897        let runner = deterministic::Runner::timed(Duration::from_secs(30));
898
899        runner.start(|mut context| async move {
900            let num_validators = 5;
901            let mut fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
902            let epoch = Epoch::new(111);
903
904            // Truncate to only 4 validators (one offline)
905            fixture.participants.truncate(4);
906            fixture.schemes.truncate(4);
907
908            let (mut oracle, mut registrations) =
909                initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK).await;
910
911            let reporters = spawn_validator_engines(
912                context.child("validator"),
913                &fixture,
914                &mut registrations,
915                &mut oracle,
916                epoch,
917                Duration::from_secs(5),
918                vec![],
919            );
920
921            await_reporters(
922                context.child("reporter"),
923                &reporters,
924                Height::new(100),
925                epoch,
926            )
927            .await;
928        });
929    }
930
931    test_for_all_fixtures!(slow one_offline);
932
933    /// Test consensus recovery after a network partition.
934    fn network_partition<S, F>(fixture: F)
935    where
936        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
937        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
938    {
939        let runner = deterministic::Runner::timed(Duration::from_secs(60));
940
941        runner.start(|mut context| async move {
942            let num_validators = 4;
943            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
944            let epoch = Epoch::new(111);
945
946            let (mut oracle, mut registrations) =
947                initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK).await;
948
949            let reporters = spawn_validator_engines(
950                context.child("validator"),
951                &fixture,
952                &mut registrations,
953                &mut oracle,
954                epoch,
955                Duration::from_secs(5),
956                vec![],
957            );
958
959            // Partition network (remove all links)
960            for v1 in fixture.participants.iter() {
961                for v2 in fixture.participants.iter() {
962                    if v2 == v1 {
963                        continue;
964                    }
965                    oracle.remove_link(v1.clone(), v2.clone()).await.unwrap();
966                }
967            }
968            context.sleep(Duration::from_secs(20)).await;
969
970            // Restore network links
971            for v1 in fixture.participants.iter() {
972                for v2 in fixture.participants.iter() {
973                    if v2 == v1 {
974                        continue;
975                    }
976                    oracle
977                        .add_link(v1.clone(), v2.clone(), RELIABLE_LINK)
978                        .await
979                        .unwrap();
980                }
981            }
982
983            await_reporters(
984                context.child("reporter"),
985                &reporters,
986                Height::new(100),
987                epoch,
988            )
989            .await;
990        });
991    }
992
993    test_for_all_fixtures!(network_partition);
994
995    /// Test insufficient validator participation (below quorum).
996    fn insufficient_validators<S, F>(fixture: F)
997    where
998        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
999        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
1000    {
1001        let runner = deterministic::Runner::timed(Duration::from_secs(15));
1002
1003        runner.start(|mut context| async move {
1004            let num_validators = 5;
1005            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
1006            let epoch = Epoch::new(111);
1007
1008            // Set up simulated network
1009            let (oracle, mut registrations) =
1010                initialize_simulation(context.child("simulation"), &fixture, RELIABLE_LINK)
1011                    .await;
1012
1013            // Create reporters (one per online validator)
1014            let mut reporters =
1015                BTreeMap::<PublicKey, mocks::ReporterMailbox<S, Sha256Digest>>::new();
1016
1017            // Start only 2 out of 5 validators (below quorum of 3)
1018            for (idx, participant) in fixture.participants.iter().take(2).enumerate() {
1019                let context = context.child("participant").with_attribute("public_key", participant);
1020
1021                // Create Provider and register scheme for epoch
1022                let provider = mocks::Provider::new();
1023                assert!(provider.register(epoch, fixture.schemes[idx].clone()));
1024
1025                // Create monitor
1026                let monitor = mocks::Monitor::new(epoch);
1027
1028                // Create automaton with Correct strategy
1029                let automaton = mocks::Application::new(mocks::Strategy::Correct);
1030
1031                // Create reporter with verifier scheme
1032                let (reporter, reporter_mailbox) =
1033                    mocks::Reporter::new(context.child("reporter"), fixture.verifier.clone());
1034                reporter.start();
1035                reporters.insert(participant.clone(), reporter_mailbox.clone());
1036
1037                // Create blocker
1038                let blocker = oracle.control(participant.clone());
1039
1040                // Create and start engine
1041                let engine = Engine::new(
1042                    context.child("engine"),
1043                    Config {
1044                        monitor,
1045                        provider,
1046                        automaton,
1047                        reporter: reporter_mailbox,
1048                        blocker,
1049                        priority_acks: false,
1050                        rebroadcast_timeout: NonZeroDuration::new_panic(Duration::from_secs(3)),
1051                        epoch_bounds: (EpochDelta::new(1), EpochDelta::new(1)),
1052                        window: std::num::NonZeroU64::new(10).unwrap(),
1053                        activity_timeout: HeightDelta::new(100),
1054                        journal_partition: format!("aggregation-{participant}"),
1055                        journal_write_buffer: NZUsize!(4096),
1056                        journal_replay_buffer: NZUsize!(4096),
1057                        journal_heights_per_section: std::num::NonZeroU64::new(6).unwrap(),
1058                        journal_compression: Some(3),
1059                        journal_page_cache: CacheRef::from_pooler(
1060                            &context,
1061                            PAGE_SIZE,
1062                            PAGE_CACHE_SIZE,
1063                        ),
1064                        strategy: Sequential,
1065                    },
1066                );
1067
1068                let (sender, receiver) = registrations.remove(participant).unwrap();
1069                engine.start((sender, receiver));
1070            }
1071
1072            // With insufficient validators, consensus should not be achievable
1073            // Wait long enough for any potential consensus attempts to complete
1074            context.sleep(Duration::from_secs(12)).await;
1075
1076            // Check that no validator achieved consensus
1077            let mut any_consensus = false;
1078            for (validator_pk, mut reporter_mailbox) in reporters {
1079                let (tip, _) = reporter_mailbox
1080                    .get_tip()
1081                    .await
1082                    .unwrap_or((Height::zero(), Epoch::zero()));
1083                if !tip.is_zero() {
1084                    any_consensus = true;
1085                    tracing::warn!(
1086                        ?validator_pk,
1087                        %tip,
1088                        "Unexpected consensus with insufficient validators"
1089                    );
1090                }
1091            }
1092
1093            // With only 2 out of 5 validators (below quorum of 3), consensus should not succeed
1094            assert!(
1095                !any_consensus,
1096                "Consensus should not be achieved with insufficient validator participation (below quorum)"
1097            );
1098        });
1099    }
1100
1101    test_for_all_fixtures!(insufficient_validators);
1102
1103    fn run_1k<S, F>(fixture: F)
1104    where
1105        S: Scheme<Sha256Digest, PublicKey = PublicKey>,
1106        F: FnOnce(&mut deterministic::Context, &[u8], u32) -> Fixture<S>,
1107    {
1108        let cfg = deterministic::Config::new();
1109        let runner = deterministic::Runner::new(cfg);
1110
1111        runner.start(|mut context| async move {
1112            // Create validators
1113            let num_validators = 10;
1114            let fixture = fixture(&mut context, TEST_NAMESPACE, num_validators);
1115            let epoch = Epoch::new(111);
1116
1117            // Configure a delayed, lossy link
1118            let delayed_link = Link {
1119                latency: Duration::from_millis(80),
1120                jitter: Duration::from_millis(10),
1121                success_rate: 0.98,
1122            };
1123
1124            // Initialize the simulated network
1125            let (mut oracle, mut registrations) =
1126                initialize_simulation(context.child("simulation"), &fixture, delayed_link).await;
1127
1128            // Start all validators
1129            let reporters = spawn_validator_engines(
1130                context.child("validator"),
1131                &fixture,
1132                &mut registrations,
1133                &mut oracle,
1134                epoch,
1135                Duration::from_secs(5),
1136                vec![],
1137            );
1138
1139            // Wait for every validator to recover 1,000 certificates
1140            await_reporters(
1141                context.child("reporter"),
1142                &reporters,
1143                Height::new(1_000),
1144                epoch,
1145            )
1146            .await;
1147        });
1148    }
1149
1150    #[test_group("slow")]
1151    #[test_traced]
1152    fn test_1k() {
1153        run_1k(mocks::scheme::fixture);
1154    }
1155}