@@ -233,7 +233,6 @@ where
233233 mut callback : Box < dyn UpdateCallBack < V , C :: PublicKey > > ,
234234 ) {
235235 let max_read_size = NZU32 ! ( self . peer_config. max_participants_per_round( ) ) ;
236- let is_dkg = output. is_none ( ) ;
237236 let epocher = FixedEpocher :: new ( BLOCKS_PER_EPOCH ) ;
238237
239238 // Initialize persistent state
@@ -262,6 +261,7 @@ where
262261 ' actor: loop {
263262 // Get latest epoch and state
264263 let ( epoch, epoch_state) = storage. epoch ( ) . expect ( "epoch should be initialized" ) ;
264+ let is_dkg = epoch_state. output . is_none ( ) ;
265265
266266 // Prune everything older than the previous epoch
267267 if let Some ( prev) = epoch. previous ( ) {
@@ -660,3 +660,163 @@ where
660660 }
661661 }
662662}
663+
664+ #[ cfg( test) ]
665+ mod tests {
666+ use super :: * ;
667+ use crate :: { dkg:: ContinueOnUpdate , orchestrator:: Message , setup:: PeerConfig } ;
668+ use commonware_cryptography:: {
669+ bls12381:: { dkg:: deal, primitives:: variant:: MinSig } ,
670+ ed25519:: { PrivateKey , PublicKey as Ed25519PublicKey } ,
671+ transcript:: Summary ,
672+ Sha256 , Signer ,
673+ } ;
674+ use commonware_macros:: test_traced;
675+ use commonware_math:: algebra:: Random ;
676+ use commonware_p2p:: { utils:: mocks:: inert_channel, PeerSetSubscription , Provider } ;
677+ use commonware_runtime:: { deterministic, Runner } ;
678+ use commonware_utils:: { channel:: mpsc, N3f1 , TryCollect , NZU32 } ;
679+ use core:: marker:: PhantomData ;
680+ use std:: collections:: BTreeMap ;
681+
682+ #[ derive( Clone , Debug ) ]
683+ struct NoopManager < P : PublicKey > ( PhantomData < P > ) ;
684+
685+ impl < P : PublicKey > Default for NoopManager < P > {
686+ fn default ( ) -> Self {
687+ Self ( PhantomData )
688+ }
689+ }
690+
691+ impl < P : PublicKey > Provider for NoopManager < P > {
692+ type PublicKey = P ;
693+
694+ async fn peer_set ( & mut self , _: u64 ) -> Option < TrackedPeers < Self :: PublicKey > > {
695+ None
696+ }
697+
698+ async fn subscribe ( & mut self ) -> PeerSetSubscription < Self :: PublicKey > {
699+ let ( _, rx) = mpsc:: unbounded_channel ( ) ;
700+ rx
701+ }
702+ }
703+
704+ impl < P : PublicKey > Manager for NoopManager < P > {
705+ async fn track < R > ( & mut self , _: u64 , _: R )
706+ where
707+ R : Into < TrackedPeers < Self :: PublicKey > > + Send ,
708+ {
709+ }
710+ }
711+
712+ fn peer_config (
713+ total : u64 ,
714+ per_round : Vec < u32 > ,
715+ ) -> (
716+ PeerConfig < Ed25519PublicKey > ,
717+ BTreeMap < Ed25519PublicKey , PrivateKey > ,
718+ ) {
719+ let participants = ( 0 ..total)
720+ . map ( |seed| {
721+ let signer = PrivateKey :: from_seed ( seed) ;
722+ ( signer. public_key ( ) , signer)
723+ } )
724+ . collect :: < BTreeMap < _ , _ > > ( ) ;
725+ let peer_config = PeerConfig {
726+ num_participants_per_round : per_round,
727+ participants : participants. keys ( ) . cloned ( ) . try_collect ( ) . unwrap ( ) ,
728+ } ;
729+ ( peer_config, participants)
730+ }
731+
732+ #[ test_traced]
733+ fn recovered_storage_controls_dkg_mode_on_restart ( ) {
734+ let executor = deterministic:: Runner :: seeded ( 8 ) ;
735+ executor. start ( |mut context| async move {
736+ // Seed a mid-life state well past the bootstrap epoch so the recovered round is
737+ // unambiguously not the initial DKG. Per production semantics, the stored output
738+ // carries the current round's dealers as its players (produced by the prior
739+ // reshare), so deal with `dealers(RECOVERED_ROUND)`.
740+ const RECOVERED_EPOCH : u64 = 5 ;
741+ const RECOVERED_ROUND : u64 = 5 ;
742+ let ( peer_config, participants) = peer_config ( 6 , vec ! [ 4 ] ) ;
743+ let first_player = peer_config
744+ . dealers ( RECOVERED_ROUND )
745+ . iter ( )
746+ . next ( )
747+ . cloned ( )
748+ . expect ( "recovered dealer exists" ) ;
749+ let signer = participants
750+ . get ( & first_player)
751+ . cloned ( )
752+ . expect ( "signer should exist" ) ;
753+ let ( output, shares) = deal :: < MinSig , _ , N3f1 > (
754+ & mut context,
755+ Default :: default ( ) ,
756+ peer_config. dealers ( RECOVERED_ROUND ) ,
757+ )
758+ . expect ( "deal should succeed" ) ;
759+ let share = shares. get_value ( & first_player) . cloned ( ) ;
760+ let partition_prefix = format ! ( "recovered_restart_{first_player}" ) ;
761+
762+ // Seed durable state that looks like a completed reshare several rounds in, even
763+ // though the restarted actor will be given stale bootstrap inputs below.
764+ let mut storage = Storage :: < _ , MinSig , Ed25519PublicKey > :: init (
765+ context. with_label ( "seed_storage" ) ,
766+ & partition_prefix,
767+ NZU32 ! ( peer_config. max_participants_per_round( ) ) ,
768+ crate :: dkg:: MAX_SUPPORTED_MODE ,
769+ )
770+ . await ;
771+ storage
772+ . set_epoch (
773+ Epoch :: new ( RECOVERED_EPOCH ) ,
774+ EpochState {
775+ round : RECOVERED_ROUND ,
776+ rng_seed : Summary :: random ( & mut context) ,
777+ output : Some ( output) ,
778+ share,
779+ } ,
780+ )
781+ . await ;
782+ drop ( storage) ;
783+
784+ // Restart the actor with stale bootstrap inputs (output=None, share=None). The
785+ // recovered epoch must override these.
786+ let ( actor, _mailbox) = Actor :: < _ , _ , Sha256 , _ , MinSig > :: new (
787+ context. with_label ( "actor" ) ,
788+ Config {
789+ manager : NoopManager :: < Ed25519PublicKey > :: default ( ) ,
790+ signer,
791+ mailbox_size : 8 ,
792+ partition_prefix,
793+ peer_config : peer_config. clone ( ) ,
794+ max_supported_mode : crate :: dkg:: MAX_SUPPORTED_MODE ,
795+ } ,
796+ ) ;
797+ let ( sender, receiver) = inert_channel ( & peer_config. participants ) ;
798+ let ( orchestrator_sender, mut orchestrator_receiver) = mpsc:: channel ( 4 ) ;
799+ actor. start (
800+ None ,
801+ None ,
802+ orchestrator:: Mailbox :: new ( orchestrator_sender) ,
803+ ( sender, receiver) ,
804+ ContinueOnUpdate :: boxed ( ) ,
805+ ) ;
806+
807+ // The first epoch transition the actor emits should describe the recovered reshare
808+ // round. Under the bug, `is_dkg` was computed from the `None` startup output and the
809+ // actor re-entered the bootstrap DKG path, producing a transition with all
810+ // participants as dealers and an empty poly.
811+ let Some ( Message :: Enter ( transition) ) = orchestrator_receiver. recv ( ) . await else {
812+ panic ! ( "actor should emit an epoch transition" ) ;
813+ } ;
814+ assert_eq ! ( transition. epoch, Epoch :: new( RECOVERED_EPOCH ) ) ;
815+ assert ! (
816+ transition. poly. is_some( ) ,
817+ "transition should carry the recovered public polynomial" ,
818+ ) ;
819+ assert_eq ! ( transition. dealers, peer_config. dealers( RECOVERED_ROUND ) ) ;
820+ } ) ;
821+ }
822+ }
0 commit comments