1use crate::dkg::{network::Directory, types::EpochInfo};
10use bytes::{Buf, BufMut};
11use commonware_codec::{Decode as _, Encode as _, EncodeSize, Error as CodecError, Read, Write};
12use commonware_consensus::{
13 Epochable as _,
14 marshal::core::{Mailbox as MarshalMailbox, Variant as MarshalVariant},
15 simplex::{scheme::Scheme, types::Finalization},
16 types::{Epoch, Epocher, FixedEpocher},
17};
18#[cfg(feature = "arbitrary")]
19use commonware_cryptography::bls12381::dkg::feldman_desmedt::Output;
20use commonware_cryptography::{
21 Digest,
22 bls12381::primitives::{sharing::ModeVersion, variant::Variant},
23};
24use commonware_storage::{
25 Context,
26 metadata::{self, Metadata},
27};
28use commonware_utils::{
29 fixed_bytes,
30 sequence::{FixedBytes, Unit},
31 sync::AsyncMutex,
32};
33use std::{fmt, num::NonZeroU32, sync::Arc};
34
35const STATE_SYNC_KEY: FixedBytes<1> = fixed_bytes!("00");
36const STATE_SYNC_SUFFIX: &str = "_dkg_state_sync";
37type EpochInfoCodecConfig = (NonZeroU32, ModeVersion);
38
39#[derive(Clone, Debug)]
41pub struct Config {
42 pub partition_prefix: String,
44
45 pub max_participants: NonZeroU32,
47
48 pub max_supported_mode: ModeVersion,
50}
51
52pub struct StateSync<S, D, V, Dir = Unit>
61where
62 S: Scheme<D>,
63 D: Digest,
64 V: Variant,
65 Dir: Directory<S::PublicKey>,
66{
67 pub info: EpochInfo<V, S::PublicKey, Dir>,
72
73 pub floor: Finalization<S, D>,
75}
76
77impl<S, D, V, Dir> Clone for StateSync<S, D, V, Dir>
78where
79 S: Scheme<D>,
80 D: Digest,
81 V: Variant,
82 Dir: Directory<S::PublicKey>,
83{
84 fn clone(&self) -> Self {
85 Self {
86 info: self.info.clone(),
87 floor: self.floor.clone(),
88 }
89 }
90}
91
92impl<S, D, V, Dir> fmt::Debug for StateSync<S, D, V, Dir>
93where
94 S: Scheme<D>,
95 D: Digest,
96 V: Variant,
97 Dir: Directory<S::PublicKey>,
98{
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter
101 .debug_struct("StateSync")
102 .field("info", &self.info)
103 .field("floor", &self.floor)
104 .finish()
105 }
106}
107
108impl<S, D, V, Dir> PartialEq for StateSync<S, D, V, Dir>
109where
110 S: Scheme<D>,
111 D: Digest,
112 V: Variant,
113 Dir: Directory<S::PublicKey>,
114{
115 fn eq(&self, other: &Self) -> bool {
116 self.info == other.info && self.floor == other.floor
117 }
118}
119
120impl<S, D, V, Dir> Eq for StateSync<S, D, V, Dir>
121where
122 S: Scheme<D>,
123 D: Digest,
124 V: Variant,
125 Dir: Directory<S::PublicKey>,
126{
127}
128
129impl<S, D, V, Dir> Write for StateSync<S, D, V, Dir>
130where
131 S: Scheme<D>,
132 D: Digest,
133 V: Variant,
134 Dir: Directory<S::PublicKey>,
135{
136 fn write(&self, writer: &mut impl BufMut) {
137 self.info.write(writer);
138 self.floor.write(writer);
139 }
140}
141
142impl<S, D, V, Dir> EncodeSize for StateSync<S, D, V, Dir>
143where
144 S: Scheme<D>,
145 D: Digest,
146 V: Variant,
147 Dir: Directory<S::PublicKey>,
148{
149 fn encode_size(&self) -> usize {
150 self.info.encode_size() + self.floor.encode_size()
151 }
152}
153
154impl<S, D, V, Dir> Read for StateSync<S, D, V, Dir>
155where
156 S: Scheme<D>,
157 D: Digest,
158 V: Variant,
159 Dir: Directory<S::PublicKey>,
160{
161 type Cfg = (EpochInfoCodecConfig, <S::Certificate as Read>::Cfg);
162
163 fn read_cfg(
164 reader: &mut impl Buf,
165 (epoch_info, certificate): &Self::Cfg,
166 ) -> Result<Self, CodecError> {
167 Ok(Self {
168 info: EpochInfo::read_cfg(reader, epoch_info)?,
169 floor: Finalization::read_cfg(reader, certificate)?,
170 })
171 }
172}
173
174#[cfg(feature = "arbitrary")]
175impl<S, D, V, Dir> arbitrary::Arbitrary<'_> for StateSync<S, D, V, Dir>
176where
177 S: Scheme<D>,
178 D: Digest + for<'a> arbitrary::Arbitrary<'a>,
179 V: Variant,
180 Dir: Directory<S::PublicKey> + for<'a> arbitrary::Arbitrary<'a>,
181 S::PublicKey: for<'a> arbitrary::Arbitrary<'a>,
182 S::Certificate: for<'a> arbitrary::Arbitrary<'a>,
183 Output<V, S::PublicKey>: for<'a> arbitrary::Arbitrary<'a>,
184{
185 fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
186 Ok(Self {
187 info: u.arbitrary()?,
188 floor: u.arbitrary()?,
189 })
190 }
191}
192
193pub(crate) async fn recovered_epoch<S, V>(
195 marshal: &MarshalMailbox<S, V>,
196 epocher: &FixedEpocher,
197) -> Option<Epoch>
198where
199 S: commonware_cryptography::certificate::Scheme,
200 V: MarshalVariant,
201{
202 let height = marshal.get_processed_height().await?.next();
203 Some(
204 epocher
205 .containing(height)
206 .expect("epocher must know recovered height")
207 .epoch(),
208 )
209}
210
211enum PlanState<S, D, V, Dir>
212where
213 S: Scheme<D>,
214 D: Digest,
215 V: Variant,
216 Dir: Directory<S::PublicKey>,
217{
218 Pending {
219 candidate: Option<StateSync<S, D, V, Dir>>,
220 partition: String,
221 codec_config: EpochInfoCodecConfig,
222 },
223 Resolved(Option<StateSync<S, D, V, Dir>>),
224}
225
226pub struct Plan<S, D, V, Dir = Unit>
233where
234 S: Scheme<D>,
235 D: Digest,
236 V: Variant,
237 Dir: Directory<S::PublicKey>,
238{
239 state: Arc<AsyncMutex<PlanState<S, D, V, Dir>>>,
240}
241
242impl<S, D, V, Dir> Clone for Plan<S, D, V, Dir>
243where
244 S: Scheme<D>,
245 D: Digest,
246 V: Variant,
247 Dir: Directory<S::PublicKey>,
248{
249 fn clone(&self) -> Self {
250 Self {
251 state: self.state.clone(),
252 }
253 }
254}
255
256impl<S, D, V, Dir> fmt::Debug for Plan<S, D, V, Dir>
257where
258 S: Scheme<D>,
259 D: Digest,
260 V: Variant,
261 Dir: Directory<S::PublicKey>,
262{
263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter.debug_struct("Plan").finish_non_exhaustive()
265 }
266}
267
268impl<S, D, V, Dir> Plan<S, D, V, Dir>
269where
270 S: Scheme<D>,
271 D: Digest,
272 V: Variant,
273 Dir: Directory<S::PublicKey>,
274{
275 pub async fn init<E: Context>(
287 context: E,
288 config: Config,
289 provided: Option<StateSync<S, D, V, Dir>>,
290 ) -> Self {
291 let codec_config = (config.max_participants, config.max_supported_mode);
292 if let Some(provided) = &provided {
293 assert_provided(provided, codec_config);
294 }
295 let partition = format!("{}{STATE_SYNC_SUFFIX}", config.partition_prefix);
296 let mut store =
297 open_store::<E, S, D, V, Dir>(context, partition.clone(), codec_config).await;
298 if let Some(provided) = provided {
299 store.put(STATE_SYNC_KEY, provided);
300 store = store
301 .sync()
302 .await
303 .expect("failed to persist DKG state sync metadata");
304 }
305 let candidate = store.get(&STATE_SYNC_KEY).cloned();
306 if let Some(candidate) = &candidate {
307 assert_epoch(candidate);
308 }
309 drop(store);
310
311 Self {
312 state: Arc::new(AsyncMutex::new(PlanState::Pending {
313 candidate,
314 partition,
315 codec_config,
316 })),
317 }
318 }
319
320 pub fn disabled() -> Self {
322 Self {
323 state: Arc::new(AsyncMutex::new(PlanState::Resolved(None))),
324 }
325 }
326
327 pub(crate) async fn resolve<E: Context>(
328 &self,
329 context: E,
330 recovered_epoch: Option<Epoch>,
331 ) -> Option<StateSync<S, D, V, Dir>> {
332 let mut state = self.state.lock().await;
333 let (candidate, partition, codec_config) = match &*state {
334 PlanState::Resolved(resolved) => return resolved.clone(),
335 PlanState::Pending {
336 candidate,
337 partition,
338 codec_config,
339 } => (candidate.clone(), partition.clone(), *codec_config),
340 };
341
342 let Some(candidate) = candidate else {
343 *state = PlanState::Resolved(None);
344 return None;
345 };
346 if recovered_epoch.is_none_or(|epoch| epoch <= candidate.floor.epoch()) {
347 *state = PlanState::Resolved(Some(candidate.clone()));
348 return Some(candidate);
349 }
350
351 let mut store = open_store::<E, S, D, V, Dir>(context, partition, codec_config).await;
352 store.remove(&STATE_SYNC_KEY);
353 store
354 .sync()
355 .await
356 .expect("failed to delete stale DKG state sync metadata");
357 *state = PlanState::Resolved(None);
358 None
359 }
360}
361
362async fn open_store<E, S, D, V, Dir>(
363 context: E,
364 partition: String,
365 epoch_info_codec_config: EpochInfoCodecConfig,
366) -> Metadata<E, FixedBytes<1>, StateSync<S, D, V, Dir>>
367where
368 E: Context,
369 S: Scheme<D>,
370 D: Digest,
371 V: Variant,
372 Dir: Directory<S::PublicKey>,
373{
374 Metadata::init(
375 context,
376 metadata::Config {
377 partition,
378 codec_config: (
379 epoch_info_codec_config,
380 S::certificate_codec_config_unbounded(),
381 ),
382 },
383 )
384 .await
385 .expect("failed to load DKG state sync metadata")
386}
387
388fn assert_provided<S, D, V, Dir>(
389 state_sync: &StateSync<S, D, V, Dir>,
390 codec_config: EpochInfoCodecConfig,
391) where
392 S: Scheme<D>,
393 D: Digest,
394 V: Variant,
395 Dir: Directory<S::PublicKey>,
396{
397 assert_epoch(state_sync);
398 StateSync::<S, D, V, Dir>::decode_cfg(
399 state_sync.encode(),
400 &(codec_config, S::certificate_codec_config_unbounded()),
401 )
402 .expect("provided state sync material must satisfy codec config");
403}
404
405fn assert_epoch<S, D, V, Dir>(state_sync: &StateSync<S, D, V, Dir>)
406where
407 S: Scheme<D>,
408 D: Digest,
409 V: Variant,
410 Dir: Directory<S::PublicKey>,
411{
412 assert!(
413 state_sync.info.epoch == state_sync.floor.epoch(),
414 "state sync artifact and floor must be in the same epoch"
415 );
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::dkg::{network::Addresses, tests::mocks, types::EpochOutcome};
422 use commonware_consensus::{
423 simplex::types::{Finalize, Proposal},
424 types::{Round, View},
425 };
426 use commonware_cryptography::{
427 Hasher as _, Sha256, Signer as _,
428 bls12381::{dkg::feldman_desmedt::deal, primitives::sharing::Mode},
429 ed25519,
430 };
431 use commonware_p2p::Address;
432 use commonware_parallel::Sequential;
433 use commonware_runtime::{Runner as _, Supervisor as _, deterministic};
434 use commonware_utils::{N3f1, NZU32, TestRng, non_empty, ordered::Set};
435 use std::net::SocketAddr;
436
437 type TestStateSync = StateSync<mocks::TestScheme, mocks::TestDigest, mocks::TestBlsVariant>;
438
439 fn state_sync(context: &mut deterministic::Context, epoch: Epoch) -> TestStateSync {
440 let fixture = mocks::scheme_fixture_n(context, 4);
441 let participants = Set::from_iter_dedup(fixture.participants);
442 let (output, _) = deal::<mocks::TestBlsVariant, _, N3f1>(
443 TestRng::new(epoch.get() + 1),
444 Mode::NonZeroCounter,
445 participants.clone(),
446 )
447 .expect("test DKG output");
448 let proposal = Proposal::new(
449 Round::new(epoch, View::new(1)),
450 View::zero(),
451 Sha256::hash(&[b"state sync floor"]),
452 );
453 let finalizes = fixture
454 .schemes
455 .iter()
456 .map(|scheme| Finalize::sign(scheme, proposal.clone()).expect("test finalize"))
457 .collect::<Vec<_>>();
458 let floor = Finalization::from_finalizes(
459 &fixture.schemes[0],
460 non_empty![@finalizes.iter()],
461 &Sequential,
462 )
463 .expect("test finalization quorum");
464 TestStateSync {
465 info: EpochInfo {
466 outcome: EpochOutcome::Success,
467 epoch,
468 output,
469 players: participants.clone(),
470 next_players: participants,
471 directory: Unit,
472 },
473 floor,
474 }
475 }
476
477 type TestPlan = Plan<mocks::TestScheme, mocks::TestDigest, mocks::TestBlsVariant>;
478
479 fn config(partition: &str) -> Config {
480 Config {
481 partition_prefix: partition.into(),
482 max_participants: NZU32!(16),
483 max_supported_mode: crate::dkg::tests::max_supported_mode(),
484 }
485 }
486
487 async fn plan(
488 context: deterministic::Context,
489 partition: &str,
490 provided: Option<TestStateSync>,
491 ) -> TestPlan {
492 Plan::init(context, config(partition), provided).await
493 }
494
495 fn assert_state_sync(actual: Option<TestStateSync>, expected: &TestStateSync) {
496 assert_eq!(actual.as_ref(), Some(expected));
497 }
498
499 #[test]
500 fn init_persists_before_resolve() {
501 deterministic::Runner::default().start(|mut context| async move {
502 let expected = state_sync(&mut context, Epoch::new(2));
503 let initialized = plan(
504 context.child("initialized"),
505 "persist-before-resolve",
506 Some(expected.clone()),
507 )
508 .await;
509 drop(initialized);
510
511 let reopened = plan(context.child("reopened"), "persist-before-resolve", None).await;
512 assert_state_sync(
513 reopened.resolve(context.child("resolve"), None).await,
514 &expected,
515 );
516 });
517 }
518
519 #[test]
520 fn two_clones_resolve_identically() {
521 deterministic::Runner::default().start(|mut context| async move {
522 let expected = state_sync(&mut context, Epoch::new(2));
523 let first = plan(context.child("init"), "clones", Some(expected.clone())).await;
524 let second = first.clone();
525
526 assert_state_sync(first.resolve(context.child("first"), None).await, &expected);
527 assert_state_sync(
528 second.resolve(context.child("second"), None).await,
529 &expected,
530 );
531 });
532 }
533
534 #[test]
535 fn stale_resolution_deletes_once_and_reopen_sees_none() {
536 deterministic::Runner::default().start(|mut context| async move {
537 let expected = state_sync(&mut context, Epoch::new(2));
538 let first = plan(context.child("init"), "stale", Some(expected)).await;
539 let second = first.clone();
540
541 assert!(
542 first
543 .resolve(context.child("first"), Some(Epoch::new(3)))
544 .await
545 .is_none()
546 );
547 assert!(
548 second
549 .resolve(context.child("second"), Some(Epoch::new(3)))
550 .await
551 .is_none()
552 );
553 drop(first);
554 drop(second);
555
556 let reopened = plan(context.child("reopened"), "stale", None).await;
557 assert!(
558 reopened
559 .resolve(context.child("reopened_resolve"), None)
560 .await
561 .is_none()
562 );
563 });
564 }
565
566 #[test]
567 fn provided_material_overwrites_persisted_record() {
568 deterministic::Runner::default().start(|mut context| async move {
569 let first_value = state_sync(&mut context, Epoch::new(1));
570 let replacement = state_sync(&mut context, Epoch::new(2));
571 drop(plan(context.child("first"), "overwrite", Some(first_value)).await);
572 drop(
573 plan(
574 context.child("replacement"),
575 "overwrite",
576 Some(replacement.clone()),
577 )
578 .await,
579 );
580
581 let reopened = plan(context.child("reopened"), "overwrite", None).await;
582 assert_state_sync(
583 reopened.resolve(context.child("resolve"), None).await,
584 &replacement,
585 );
586 });
587 }
588
589 #[test]
590 #[should_panic(expected = "state sync artifact and floor must be in the same epoch")]
591 fn artifact_beyond_floor_panics_at_init() {
592 deterministic::Runner::default().start(|mut context| async move {
593 let mut mismatched = state_sync(&mut context, Epoch::new(2));
594 mismatched.info.epoch = Epoch::new(3);
595 let _ = plan(context.child("init"), "mismatch", Some(mismatched)).await;
596 });
597 }
598
599 #[test]
600 #[should_panic(expected = "state sync artifact and floor must be in the same epoch")]
601 fn artifact_below_floor_panics_at_init() {
602 deterministic::Runner::default().start(|mut context| async move {
603 let mut mismatched = state_sync(&mut context, Epoch::new(3));
604 mismatched.info.epoch = Epoch::new(2);
605 let _ = plan(context.child("init"), "mismatch-below", Some(mismatched)).await;
606 });
607 }
608
609 #[test]
610 #[should_panic(expected = "provided state sync material must satisfy codec config")]
611 fn oversized_provided_material_panics_at_init() {
612 deterministic::Runner::default().start(|mut context| async move {
613 let provided = state_sync(&mut context, Epoch::new(2));
614 let mut config = config("oversized-provided");
615 config.max_participants = NZU32!(3);
616 let _ = TestPlan::init(context.child("init"), config, Some(provided)).await;
617 });
618 }
619
620 #[test]
621 #[should_panic(expected = "provided state sync material must satisfy codec config")]
622 fn invalid_addressed_directory_panics_at_init() {
623 deterministic::Runner::default().start(|mut context| async move {
624 let StateSync { info, floor } = state_sync(&mut context, Epoch::new(2));
625 let peers = info
626 .participants()
627 .tracked_peers()
628 .union()
629 .into_iter()
630 .chain([ed25519::PrivateKey::from_seed(10_000).public_key()]);
631 let directory = peers
632 .enumerate()
633 .map(|(index, peer)| {
634 let socket = SocketAddr::from(([127, 0, 0, 1], index as u16 + 1));
635 (peer, Address::Symmetric(socket))
636 })
637 .collect::<Addresses<_>>();
638 let EpochInfo {
639 outcome,
640 epoch,
641 output,
642 players,
643 next_players,
644 ..
645 } = info;
646 let invalid = StateSync {
647 info: EpochInfo {
648 outcome,
649 epoch,
650 output,
651 players,
652 next_players,
653 directory,
654 },
655 floor,
656 };
657
658 let _ = Plan::<
659 mocks::TestScheme,
660 mocks::TestDigest,
661 mocks::TestBlsVariant,
662 Addresses<mocks::TestPublicKey>,
663 >::init(
664 context.child("init"),
665 config("invalid-directory"),
666 Some(invalid),
667 )
668 .await;
669 });
670 }
671
672 #[test]
673 fn disabled_resolves_none_without_storage() {
674 deterministic::Runner::default().start(|context| async move {
675 let plan = TestPlan::disabled();
676 assert!(plan.resolve(context, None).await.is_none());
677 });
678 }
679}
680
681#[cfg(all(test, feature = "arbitrary"))]
682mod conformance {
683 use super::*;
684 use crate::dkg::{network::Addresses, tests::mocks};
685 use commonware_codec::conformance::CodecConformance;
686
687 commonware_conformance::conformance_tests! {
688 CodecConformance<StateSync<mocks::TestScheme, mocks::TestDigest, mocks::TestBlsVariant>> => 8192,
689 CodecConformance<StateSync<mocks::TestScheme, mocks::TestDigest, mocks::TestBlsVariant, Addresses<mocks::TestPublicKey>>> => 8192,
690 }
691}