Skip to content

Commit d2e7d69

Browse files
[examples/reshare] Set is_dkg Correctly after Restart (#3615)
1 parent 1846e42 commit d2e7d69

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

examples/reshare/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ thiserror.workspace = true
3838
tracing.workspace = true
3939

4040
[dev-dependencies]
41+
commonware-p2p = { workspace = true, features = ["mocks"] }
4142
tracing-subscriber.workspace = true
4243

4344
[[bin]]

examples/reshare/src/dkg/actor.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
}

examples/reshare/src/orchestrator/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ mod actor;
44
pub use actor::{Actor, Config};
55

66
mod ingress;
7+
#[cfg(test)]
8+
pub use ingress::Message;
79
pub use ingress::{EpochTransition, Mailbox};

0 commit comments

Comments
 (0)