1mod utils;
2
3use hopr_types::{
4 crypto::{
5 crypto_traits::elliptic_curve::Field,
6 prelude::{HalfKey, HalfKeyChallenge, OffchainPublicKey},
7 },
8 internal::prelude::Acknowledgement,
9};
10use utils::{AddShareOutcome, SsaCommitmentBuilder, SsaCycle};
11use validator::Validate;
12
13use crate::{
14 CoefficientIndex, ExitAcknowledgementShareProcessor, Group, MAX_POLY_THRESHOLD, MAX_POLYS_PER_SSA, PixGroup,
15 PixGroupRepr, PixScalar, PixSpec, PolynomialIndex, RecoveredSsa, ShareResolution, SsaCommitmentProof,
16 SsaCommitmentState, SsaPolynomialId, SsaRecoveryProgress, TaggedEncryptedPartialSsaShare, errors::PixError,
17 types::SsaId,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, validator::Validate)]
22pub struct SsaReconstructorConfig {
23 #[default(Self::DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME)]
27 pub incomplete_commitment_lifetime: std::time::Duration,
28 #[default(Self::DEFAULT_UNUSED_VERIFIER_LIFETIME)]
36 pub unused_verifier_lifetime: std::time::Duration,
37 #[validate(range(min = 10))]
47 #[default(Self::DEFAULT_MAX_TRACKED_PEERS)]
48 pub max_tracked_peers: usize,
49 #[default(Self::DEFAULT_MAX_AWAITING_ACKS)]
60 #[validate(range(min = 10000))]
61 pub max_awaiting_acks: usize,
62 #[default(Self::DEFAULT_MAX_ACK_AWAIT_TIME)]
70 pub max_ack_await_time: std::time::Duration,
71 #[default(Self::DEFAULT_USE_BATCH_VERIFICATION)]
108 pub use_batch_verification: bool,
109 #[default(Self::DEFAULT_EARLY_RECOVERY_THRESHOLD)]
114 #[validate(range(min = 0.0, max = 1.0))]
115 pub early_recovery_threshold: f64,
116 #[default(Self::DEFAULT_MAX_ACK_BUFFER_BYTES)]
138 #[validate(range(min = 25_600))]
139 pub max_ack_buffer_bytes: usize,
140}
141
142pub const AWAITING_ACK_ENTRY_BYTES: usize = 400;
155
156impl SsaReconstructorConfig {
163 pub const DEFAULT_EARLY_RECOVERY_THRESHOLD: f64 = 0.85;
165 pub const DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME: std::time::Duration = std::time::Duration::from_secs(120);
167 pub const DEFAULT_MAX_ACK_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(30);
169 pub const DEFAULT_MAX_ACK_BUFFER_BYTES: usize = 1024 * 1024 * 1024;
171 pub const DEFAULT_MAX_AWAITING_ACKS: usize = 1_000_000;
173 pub const DEFAULT_MAX_TRACKED_PEERS: usize = 2000;
175 pub const DEFAULT_UNUSED_VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_secs(1800);
177 pub const DEFAULT_USE_BATCH_VERIFICATION: bool = false;
179}
180
181type EncryptedShareCache<S> =
182 moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>;
183
184type DeferredAck = (OffchainPublicKey, HalfKeyChallenge, HalfKey);
189
190#[derive(Default)]
197struct DeferredAcks {
198 by_poly: std::collections::HashMap<PolynomialIndex, Vec<DeferredAck>>,
199 total: usize,
208 drained: bool,
216}
217
218type DeferredAckBucket = std::sync::Arc<parking_lot::Mutex<DeferredAcks>>;
219
220#[derive(Clone, Copy)]
225enum Deferral {
226 Buffered,
228 Orphaned,
231 Dropped,
233}
234
235pub const MAX_DEFERRED_ACKS_PER_POLYNOMIAL: usize = 128;
251
252pub const MAX_DEFERRED_ACKS_PER_CYCLE: usize = 8192;
265
266pub struct SsaReconstructor<S: PixSpec> {
278 commitment_builder:
279 moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<parking_lot::Mutex<SsaCommitmentBuilder<S>>>>,
280 ssa_cycles: moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<SsaCycle<S>>>,
283 awaiting_acks: moka::sync::Cache<OffchainPublicKey, EncryptedShareCache<S>>,
284 pending_acks: moka::sync::Cache<SsaId<S::Pseudonym>, DeferredAckBucket>,
312 ready_resolutions: parking_lot::Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>,
321 ready_resolutions_len: std::sync::atomic::AtomicUsize,
324 retired_ssas: moka::sync::Cache<SsaId<S::Pseudonym>, ()>,
327 ack_buffer_entries: std::sync::Arc<std::sync::atomic::AtomicUsize>,
336 max_ack_buffer_entries: usize,
339 ack_buffer_resync: parking_lot::Mutex<Option<std::time::Instant>>,
345 cfg: SsaReconstructorConfig,
346}
347
348enum ProcessedAckResult<S: PixSpec> {
356 NoProgress,
359 VerifierNotReady(SsaPolynomialId<<S as PixSpec>::Pseudonym>),
363 Progressed(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
365 InvalidShare(SsaId<<S as PixSpec>::Pseudonym>, u64),
367 EarlyRecovery(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
369 FullRecovery(
372 RecoveredSsa<<S as PixSpec>::Pseudonym, <S as PixSpec>::AddressPrivateKey>,
373 SsaRecoveryProgress<<S as PixSpec>::Pseudonym>,
374 ),
375}
376
377fn record_progress<P: PartialEq>(acc: &mut Vec<SsaRecoveryProgress<P>>, snapshot: SsaRecoveryProgress<P>) {
382 match acc.iter_mut().find(|p| p.ssa_id == snapshot.ssa_id) {
383 Some(existing) if existing.useful_shares >= snapshot.useful_shares => {}
384 Some(existing) => *existing = snapshot,
385 None => acc.push(snapshot),
386 }
387}
388
389type FaultObservation<P> = (Box<OffchainPublicKey>, SsaId<P>, u64);
391
392fn record_fault<P: PartialEq>(acc: &mut Vec<FaultObservation<P>>, observation: FaultObservation<P>) {
398 match acc.iter_mut().find(|(_, id, _)| *id == observation.1) {
399 Some(existing) if existing.2 >= observation.2 => {}
400 Some(existing) => *existing = observation,
401 None => acc.push(observation),
402 }
403}
404
405fn push_unique<P: PartialEq, A>(acc: &mut Vec<ShareResolution<P, A>>, resolution: ShareResolution<P, A>) {
407 if !acc.contains(&resolution) {
408 acc.push(resolution);
409 }
410}
411
412impl<S: PixSpec + Clone> Default for SsaReconstructor<S> {
413 fn default() -> Self {
414 Self::new(Default::default())
415 }
416}
417
418impl<S: PixSpec + Clone> SsaReconstructor<S> {
419 pub fn try_new(cfg: SsaReconstructorConfig) -> Result<Self, PixError<S::Pseudonym>> {
425 cfg.validate()?;
426 Ok(Self {
427 commitment_builder: moka::sync::Cache::builder()
428 .time_to_idle(cfg.incomplete_commitment_lifetime)
429 .build(),
430 ssa_cycles: moka::sync::Cache::builder()
439 .time_to_idle(cfg.unused_verifier_lifetime)
440 .build(),
441 awaiting_acks: moka::sync::CacheBuilder::new(cfg.max_tracked_peers as u64)
442 .time_to_idle(cfg.max_ack_await_time)
443 .eviction_listener(|_, shares: EncryptedShareCache<S>, _| shares.invalidate_all())
453 .build(),
454 pending_acks: moka::sync::CacheBuilder::new(MAX_POLYS_PER_SSA as u64)
463 .time_to_live(cfg.max_ack_await_time)
464 .build(),
465 ready_resolutions: parking_lot::Mutex::new(Vec::new()),
466 ready_resolutions_len: std::sync::atomic::AtomicUsize::new(0),
467 retired_ssas: moka::sync::Cache::builder()
479 .time_to_idle(cfg.unused_verifier_lifetime)
480 .build(),
481 ack_buffer_entries: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
482 max_ack_buffer_entries: (cfg.max_ack_buffer_bytes / AWAITING_ACK_ENTRY_BYTES).max(1),
485 ack_buffer_resync: parking_lot::Mutex::new(None),
486 cfg,
487 })
488 }
489
490 pub fn new(cfg: SsaReconstructorConfig) -> Self {
496 Self::try_new(cfg).expect("invalid SsaReconstructorConfig")
497 }
498
499 #[inline]
501 pub fn config(&self) -> &SsaReconstructorConfig {
502 &self.cfg
503 }
504
505 pub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool {
510 self.ssa_cycles.contains_key(ssa_id) || self.commitment_builder.contains_key(ssa_id)
511 }
512
513 fn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>) {
517 self.ssa_cycles.invalidate(&ssa_id);
518 self.pending_acks.invalidate(&ssa_id);
521 self.commitment_builder.invalidate(&ssa_id);
522 }
523
524 fn process_verified_ack(
525 &self,
526 ack: HalfKey,
527 ack_challenge: HalfKeyChallenge,
528 awaiting_ack_from_peer: &moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S>>,
529 ) -> Result<ProcessedAckResult<S>, PixError<S::Pseudonym>> {
530 let Some(share) = awaiting_ack_from_peer.get(&ack_challenge) else {
531 tracing::trace!(?ack_challenge, "received ack for unknown share");
532 return Ok(ProcessedAckResult::NoProgress);
533 };
534
535 let spi = share.ssa_polynomial_id().ok_or(PixError::ShareIsEmpty)?;
536
537 let Some(cycle) = self.ssa_cycles.get(spi.as_ref()) else {
542 return Ok(ProcessedAckResult::VerifierNotReady(spi));
546 };
547
548 let Some(part) = cycle.part(spi.poly_index()) else {
552 tracing::error!(%spi, num_polys = cycle.num_polys(), "share names a polynomial outside the cycle");
553 return Err(PixError::InvalidInput);
554 };
555
556 awaiting_ack_from_peer.remove(&ack_challenge);
558
559 let partial_share = share.partial_share.decrypt(spi.pseudonym(), &ack)?;
561
562 let ssa_id = *spi.as_ref();
563
564 let ssa_part = match part.lock().add_share(share.nonce, partial_share) {
567 Ok(AddShareOutcome::Completed(share)) => {
568 tracing::trace!(%spi, "ssa part complete");
569 cycle.record_useful_share();
570 cycle.record_completed_part();
571 share
572 }
573 Ok(AddShareOutcome::Useful) => {
574 tracing::trace!(%spi, "ssa part not yet complete, waiting for more shares");
575 cycle.record_useful_share();
576 return Ok(ProcessedAckResult::Progressed(cycle.progress()));
580 }
581 Ok(AddShareOutcome::Surplus) => {
584 tracing::trace!(%spi, "share arrived after its polynomial was reconstructed");
585 return Ok(ProcessedAckResult::NoProgress);
586 }
587 Ok(AddShareOutcome::Duplicate) => {
588 tracing::trace!(%spi, "duplicate evaluation identifier");
589 return Ok(ProcessedAckResult::NoProgress);
590 }
591 Ok(AddShareOutcome::Absorbed) => {
592 tracing::trace!(%spi, "share for a polynomial that already failed its commitment");
593 return Ok(ProcessedAckResult::NoProgress);
594 }
595 Err(PixError::VsssError(vsss_rs::Error::InvalidShare)) => {
596 let observed_total = cycle.record_invalid_share();
604 tracing::error!(%spi, observed_total, "ssa part failed to open its commitment");
605 return Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total));
606 }
607 Err(e) => return Err(e),
608 };
609
610 let mut builder_guard = cycle.builder().lock();
611 let ssa = match builder_guard.add_recovered_ssa_part(spi.poly_index(), ssa_part) {
612 Ok(ssa) => ssa,
613 Err(error) => {
614 drop(builder_guard);
622 tracing::error!(%spi, %error, "ssa part could not be added to its accumulator");
623 self.remove_cycle(ssa_id);
624 return Err(error);
625 }
626 };
627 match ssa {
628 Some(scalar) => {
629 let progress = cycle.progress();
632 drop(builder_guard);
635 let Some(ssa) = S::scalar_to_private_key(scalar) else {
636 tracing::error!(%spi, "ssa reconstruction failed");
637 self.remove_cycle(ssa_id);
638 return Err(PixError::InvalidSsa);
639 };
640 self.remove_cycle(ssa_id);
642 tracing::info!(%ssa_id, "ssa recovered");
643 Ok(ProcessedAckResult::FullRecovery(RecoveredSsa { ssa_id, ssa }, progress))
644 }
645 None => {
646 tracing::trace!(%spi, "ssa not yet complete, waiting for more ssa parts");
647 let early = builder_guard.check_early_threshold(self.cfg.early_recovery_threshold);
649 drop(builder_guard);
650 let progress = cycle.progress();
651 if early {
652 tracing::info!(%ssa_id, "early recovery threshold reached");
653 Ok(ProcessedAckResult::EarlyRecovery(progress))
654 } else {
655 Ok(ProcessedAckResult::Progressed(progress))
656 }
657 }
658 }
659 }
660
661 fn defer_ack(&self, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
665 let bucket = self.pending_acks.get_with(*spi.as_ref(), || {
666 std::sync::Arc::new(parking_lot::Mutex::new(Default::default()))
667 });
668 self.defer_ack_into(&bucket, spi, deferred);
669 }
670
671 fn defer_ack_into(&self, bucket: &DeferredAckBucket, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
678 let ssa_id = *spi.as_ref();
679 let outcome = {
680 let mut bucket = bucket.lock();
681 if bucket.drained {
682 Deferral::Orphaned
683 } else if bucket.total >= MAX_DEFERRED_ACKS_PER_CYCLE {
684 tracing::warn!(
687 %ssa_id,
688 cap = MAX_DEFERRED_ACKS_PER_CYCLE,
689 "dropping deferred acknowledgement: cycle bucket is full"
690 );
691 Deferral::Dropped
692 } else {
693 let bucket = &mut *bucket;
695 let per_poly = bucket.by_poly.entry(spi.poly_index()).or_default();
696 if per_poly.len() >= MAX_DEFERRED_ACKS_PER_POLYNOMIAL {
697 tracing::warn!(
701 %spi,
702 cap = MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
703 "dropping deferred acknowledgement: polynomial bucket is full"
704 );
705 Deferral::Dropped
706 } else {
707 per_poly.push(deferred);
708 bucket.total += 1;
709 Deferral::Buffered
710 }
711 }
712 };
713
714 match outcome {
715 Deferral::Orphaned => {
719 tracing::trace!(%spi, "redeeming an acknowledgement deferred into a drained bucket");
720 self.redeem_deferred_acks(&ssa_id, std::iter::once(deferred));
721 }
722 Deferral::Buffered => {
726 if self.ssa_cycles.contains_key(&ssa_id) {
727 self.drain_deferred_acks(&ssa_id);
728 }
729 }
730 Deferral::Dropped => {}
731 }
732 }
733
734 fn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>) {
741 let Some(bucket) = self.pending_acks.get(ssa_id) else {
742 return;
743 };
744 self.pending_acks.invalidate(ssa_id);
745
746 let deferred = {
750 let mut bucket = bucket.lock();
751 bucket.drained = true;
752 bucket.total = 0;
753 std::mem::take(&mut bucket.by_poly)
754 };
755 if deferred.is_empty() {
756 return;
757 }
758
759 self.redeem_deferred_acks(ssa_id, deferred.into_values().flatten());
760 }
761
762 fn redeem_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>, deferred: impl IntoIterator<Item = DeferredAck>) {
769 let mut resolved = Vec::new();
770 let mut progress = Vec::new();
774 for (peer, challenge, ack) in deferred {
775 let Some(awaiting) = self.awaiting_acks.get(&peer) else {
778 continue;
779 };
780 match self.process_verified_ack(ack, challenge, &awaiting) {
781 Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
782 record_progress(&mut progress, snapshot);
783 resolved.push(ShareResolution::RecoveredSsa(ssa));
784 }
785 Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
786 let id = snapshot.ssa_id;
787 record_progress(&mut progress, snapshot);
788 resolved.push(ShareResolution::AlmostRecoveredSsa(id));
789 }
790 Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
791 Ok(ProcessedAckResult::InvalidShare(id, observed_total)) => {
792 tracing::error!(%id, observed_total, "deferred share could not be verified");
793 resolved.push(ShareResolution::InvalidShares {
794 peer: peer.into(),
795 ssa_id: id,
796 observed_total,
797 });
798 }
799 Ok(ProcessedAckResult::NoProgress) => {}
800 Ok(ProcessedAckResult::VerifierNotReady(_)) => {
801 tracing::trace!(%ssa_id, "cycle withdrawn while draining deferred acknowledgements");
804 }
805 Err(error) => tracing::debug!(%ssa_id, %error, "failed to process deferred acknowledgement"),
806 }
807 }
808
809 if !progress.is_empty() {
813 let mut ordered = progress.into_iter().map(ShareResolution::Progress).collect::<Vec<_>>();
814 ordered.append(&mut resolved);
815 resolved = ordered;
816 }
817
818 if !resolved.is_empty() {
819 tracing::debug!(%ssa_id, num = resolved.len(), "redeemed deferred acknowledgements");
820 let mut ready = self.ready_resolutions.lock();
821 ready.extend(resolved);
822 self.ready_resolutions_len
823 .store(ready.len(), std::sync::atomic::Ordering::Release);
824 }
825 }
826
827 fn take_ready_resolutions(&self) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> {
832 if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
833 return Vec::new();
834 }
835 let mut ready = self.ready_resolutions.lock();
836 self.ready_resolutions_len
837 .store(0, std::sync::atomic::Ordering::Release);
838 std::mem::take(&mut *ready)
839 }
840
841 fn ack_buffer_resync_interval(&self) -> std::time::Duration {
857 (self.cfg.max_ack_await_time / 16).max(std::time::Duration::from_millis(1))
858 }
859
860 fn resync_ack_buffer(&self) {
883 let Some(mut last_run) = self.ack_buffer_resync.try_lock() else {
884 return;
885 };
886 if last_run.is_some_and(|at| at.elapsed() < self.ack_buffer_resync_interval()) {
887 return;
888 }
889
890 let held = self.count_ack_buffer_entries();
891 let previous = self.ack_buffer_entries.swap(held, std::sync::atomic::Ordering::Relaxed);
892 *last_run = Some(std::time::Instant::now());
893
894 if previous != held {
895 tracing::debug!(
896 previous,
897 held,
898 "resynchronised the awaiting-acknowledgement buffer count"
899 );
900 }
901 }
902
903 fn count_ack_buffer_entries(&self) -> usize {
912 self.awaiting_acks.run_pending_tasks();
913 self.awaiting_acks
914 .iter()
915 .map(|(_, shares)| {
916 shares.run_pending_tasks();
917 shares.entry_count() as usize
918 })
919 .sum()
920 }
921
922 #[cfg(test)]
924 fn cycle(&self, ssa_id: &SsaId<S::Pseudonym>) -> Option<std::sync::Arc<SsaCycle<S>>> {
925 self.ssa_cycles.get(ssa_id)
926 }
927
928 #[cfg(test)]
930 fn live_cycles(&self) -> u64 {
931 self.ssa_cycles.run_pending_tasks();
932 self.ssa_cycles.entry_count()
933 }
934
935 #[cfg(test)]
941 fn installed_parts(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
942 self.ssa_cycles.get(ssa_id).map(|c| c.num_polys()).unwrap_or(0)
943 }
944
945 #[cfg(test)]
951 fn deferred_ack_count(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
952 self.pending_acks
953 .get(ssa_id)
954 .map(|b| b.lock().by_poly.values().map(Vec::len).sum())
955 .unwrap_or(0)
956 }
957}
958
959#[must_use = "dropping the guard immediately releases the SSA it owns"]
974pub struct SsaCommitmentGuard<S: PixSpec + Clone> {
975 owned: Option<OwnedCommitment<S>>,
977}
978
979type OwnedCommitment<S> = (std::sync::Arc<SsaReconstructor<S>>, SsaId<<S as PixSpec>::Pseudonym>);
981
982type GuardedExitCommitment<S> = (PixGroup<S>, SsaCommitmentGuard<S>);
984
985impl<S: PixSpec + Clone> std::fmt::Debug for SsaCommitmentGuard<S> {
986 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
987 f.debug_struct("SsaCommitmentGuard")
988 .field("ssa_id", &self.owned.as_ref().map(|(_, id)| id))
989 .finish()
990 }
991}
992
993impl<S: PixSpec + Clone> SsaCommitmentGuard<S> {
994 pub fn ssa_id(&self) -> Option<&SsaId<S::Pseudonym>> {
996 self.owned.as_ref().map(|(_, id)| id)
997 }
998
999 pub fn disarm(mut self) -> Option<SsaId<S::Pseudonym>> {
1003 self.owned.take().map(|(_, ssa_id)| ssa_id)
1004 }
1005}
1006
1007impl<S: PixSpec + Clone> Drop for SsaCommitmentGuard<S> {
1008 fn drop(&mut self) {
1009 if let Some((reconstructor, ssa_id)) = self.owned.take() {
1010 reconstructor.release_abandoned_commitment(ssa_id);
1011 }
1012 }
1013}
1014
1015impl<S: PixSpec + Clone> SsaReconstructor<S> {
1016 fn release_abandoned_commitment(&self, ssa_id: SsaId<S::Pseudonym>) {
1034 if self.ssa_cycles.contains_key(&ssa_id) {
1035 tracing::warn!(%ssa_id, "abandoned ssa commitment was already live — retiring it");
1036 self.retire_ssa(ssa_id);
1037 } else {
1038 tracing::debug!(%ssa_id, "releasing ssa commitment abandoned by its owner");
1039 self.remove_cycle(ssa_id);
1040 }
1041 }
1042
1043 pub fn new_guarded_exit_commitment(
1049 self: &std::sync::Arc<Self>,
1050 id: SsaId<S::Pseudonym>,
1051 polys_per_ssa: usize,
1052 shares_per_poly: usize,
1053 ) -> Result<GuardedExitCommitment<S>, PixError<S::Pseudonym>> {
1054 let exit_commitment = self.new_exit_commitment(id, polys_per_ssa, shares_per_poly)?;
1055 Ok((
1056 exit_commitment,
1057 SsaCommitmentGuard {
1058 owned: Some((self.clone(), id)),
1059 },
1060 ))
1061 }
1062}
1063
1064impl<S: PixSpec> Drop for SsaReconstructor<S> {
1065 fn drop(&mut self) {
1083 if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
1084 return;
1085 }
1086 for resolution in self.ready_resolutions.lock().drain(..) {
1087 tracing::error!(
1088 ?resolution,
1089 "pix resolution was never collected and is lost with the reconstructor"
1090 );
1091 }
1092 }
1093}
1094
1095impl<S: PixSpec + Clone> ExitAcknowledgementShareProcessor<S> for SsaReconstructor<S> {
1096 type Error = PixError<S::Pseudonym>;
1097
1098 fn has_pending_shares(&self, peer: &OffchainPublicKey) -> bool {
1099 self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) > 0
1107 || self.awaiting_acks.contains_key(peer)
1108 }
1109
1110 fn is_expected_error(&self, error: &Self::Error) -> bool {
1111 matches!(error, PixError::UnexpectedShare)
1112 }
1113
1114 fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>) {
1115 self.retired_ssas.insert(ssa_id, ());
1118
1119 self.remove_cycle(ssa_id);
1122 }
1123
1124 fn new_exit_commitment(
1125 &self,
1126 id: SsaId<S::Pseudonym>,
1127 polys_per_ssa: usize,
1128 shares_per_poly: usize,
1129 ) -> Result<PixGroup<S>, Self::Error> {
1130 if !(1..=MAX_POLYS_PER_SSA as usize).contains(&polys_per_ssa)
1131 || !(2..=MAX_POLY_THRESHOLD as usize).contains(&shares_per_poly)
1132 {
1133 return Err(PixError::InvalidInput);
1134 }
1135
1136 let exit_commitment_secret = PixScalar::<S>::random(&mut hopr_types::crypto_random::rng());
1137 let exit_commitment_public = PixGroup::<S>::mul_by_generator(&exit_commitment_secret);
1138
1139 self.commitment_builder
1140 .entry(id)
1141 .and_try_compute_with(|entry| match entry {
1142 Some(_) => Err(PixError::DuplicateCommitment),
1143 None => Ok(moka::ops::compute::Op::Put(std::sync::Arc::new(
1144 parking_lot::Mutex::new(SsaCommitmentBuilder::new(
1145 id,
1146 shares_per_poly,
1147 polys_per_ssa,
1148 exit_commitment_secret,
1149 exit_commitment_public,
1150 )),
1151 ))),
1152 })?;
1153
1154 Ok(exit_commitment_public)
1155 }
1156
1157 fn insert_coefficient_commitments(
1158 &self,
1159 ssa_id: SsaId<S::Pseudonym>,
1160 index: CoefficientIndex,
1161 proof: Option<SsaCommitmentProof<S>>,
1162 commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
1163 ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error> {
1164 let mut res = SsaCommitmentState::new(ssa_id);
1165
1166 let Some(builder) = self.commitment_builder.get(&ssa_id) else {
1168 return Err(PixError::MissingSsaCommitment);
1169 };
1170
1171 let progress = {
1172 let mut builder = builder.lock();
1173 res.is_first_encountered = builder.is_empty();
1174 res.ssa_deposit_address = builder.get_deposit_address().copied();
1175 builder.add_transposed(index, proof, commitments)?
1176 };
1177
1178 res.deposit_address_first_encountered = res.ssa_deposit_address.is_none();
1181
1182 let Some(full_ssa_commitment) = progress.full_commitment else {
1183 res.deposit_address_first_encountered = false; tracing::trace!(%ssa_id, "ssa commitment not yet complete, waiting for more constant terms");
1185 return Ok(res);
1186 };
1187 res.ssa_deposit_address = Some(S::group_to_deposit_address(full_ssa_commitment).ok_or(PixError::InvalidSsa)?);
1188
1189 let installed = if let Some(ssa_builder) = progress.ssa_builder {
1193 let num_polys = ssa_builder.num_polys();
1194 let cycle = SsaCycle::new(ssa_id, ssa_builder, progress.new_verifiers)?;
1195 self.ssa_cycles.insert(ssa_id, std::sync::Arc::new(cycle));
1196 tracing::debug!(%ssa_id, num_polys, "ssa commitment known — cycle is live");
1197 true
1198 } else {
1199 false
1200 };
1201
1202 if installed && self.retired_ssas.contains_key(&ssa_id) {
1206 self.remove_cycle(ssa_id);
1207 tracing::trace!(%ssa_id, "ssa commitment progressed but cycle was retired — dropped published state");
1208 res.deposit_address_first_encountered = false;
1209 return Ok(res);
1210 }
1211
1212 if installed {
1216 self.drain_deferred_acks(&ssa_id);
1217 }
1218
1219 res.is_verifiable = progress.fully_committed;
1220 if progress.fully_committed {
1221 tracing::trace!(%ssa_id, "ssa commitment completed");
1222 }
1223
1224 Ok(res)
1225 }
1226
1227 fn insert_encrypted_share(
1259 &self,
1260 peer: &OffchainPublicKey,
1261 challenge: HalfKeyChallenge,
1262 tagged_enc_share: TaggedEncryptedPartialSsaShare<S>,
1263 ) -> Result<(), Self::Error> {
1264 if tagged_enc_share.partial_share.is_empty() {
1265 return Err(PixError::ShareIsEmpty);
1266 }
1267
1268 if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1271 self.resync_ack_buffer();
1272 if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1273 tracing::error!(
1274 %peer,
1275 budget_bytes = self.cfg.max_ack_buffer_bytes,
1276 "awaiting-acknowledgement buffer is full — dropping share"
1277 );
1278 return Err(PixError::AckBufferFull);
1279 }
1280 }
1281
1282 self.ack_buffer_entries
1285 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1286 self.awaiting_acks
1287 .get_with_by_ref(peer, || {
1288 let released = self.ack_buffer_entries.clone();
1291 moka::sync::CacheBuilder::new(self.cfg.max_awaiting_acks as u64)
1292 .time_to_live(self.cfg.max_ack_await_time)
1293 .eviction_listener(move |_, _, _| {
1297 released.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1298 })
1299 .build()
1300 })
1301 .insert(challenge, tagged_enc_share);
1302
1303 Ok(())
1304 }
1305
1306 fn acknowledge_shares(
1307 &self,
1308 peer: OffchainPublicKey,
1309 acks: Vec<Acknowledgement>,
1310 ) -> Result<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>, Self::Error> {
1311 let Some((awaiting_ack_from_peer, half_keys_challenges)) = crate::ack_verify::verify_expected_acknowledgements(
1312 peer,
1313 acks,
1314 &self.awaiting_acks,
1315 self.cfg.use_batch_verification,
1316 ) else {
1317 return Err(PixError::UnexpectedShare);
1318 };
1319
1320 let mut progress: Vec<SsaRecoveryProgress<S::Pseudonym>> = Vec::new();
1328 let mut faults: Vec<FaultObservation<S::Pseudonym>> = Vec::new();
1329 let mut terminal: Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> = Vec::new();
1330
1331 for resolution in self.take_ready_resolutions() {
1335 match resolution {
1336 ShareResolution::Progress(snapshot) => record_progress(&mut progress, snapshot),
1337 ShareResolution::InvalidShares {
1338 peer,
1339 ssa_id,
1340 observed_total,
1341 } => record_fault(&mut faults, (peer, ssa_id, observed_total)),
1342 other => push_unique(&mut terminal, other),
1343 }
1344 }
1345
1346 for (ack, ack_challenge) in half_keys_challenges {
1347 match self.process_verified_ack(ack, ack_challenge, &awaiting_ack_from_peer) {
1348 Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
1349 record_progress(&mut progress, snapshot);
1350 push_unique(&mut terminal, ShareResolution::RecoveredSsa(ssa));
1351 }
1352 Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
1353 let ssa_id = snapshot.ssa_id;
1354 record_progress(&mut progress, snapshot);
1355 push_unique(&mut terminal, ShareResolution::AlmostRecoveredSsa(ssa_id));
1356 }
1357 Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
1358 Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total)) => {
1359 tracing::error!(%ssa_id, observed_total, "encountered share that could not be verified");
1360 record_fault(&mut faults, (Box::new(peer), ssa_id, observed_total));
1361 }
1362 Ok(ProcessedAckResult::NoProgress) => {}
1363 Ok(ProcessedAckResult::VerifierNotReady(spi)) => {
1364 tracing::trace!(%peer, %spi, "verifier not yet installed, deferring acknowledgement");
1367 self.defer_ack(spi, (peer, ack_challenge, ack));
1368 }
1369 Err(PixError::ShareIsEmpty) => tracing::trace!(%peer, "received empty share"),
1370 Err(error) => {
1371 tracing::error!(%error, "failed to process acknowledgement");
1372 }
1373 }
1374 }
1375
1376 let mut res = Vec::with_capacity(progress.len() + faults.len() + terminal.len());
1377 res.extend(progress.into_iter().map(ShareResolution::Progress));
1378 res.extend(
1379 faults
1380 .into_iter()
1381 .map(|(peer, ssa_id, observed_total)| ShareResolution::InvalidShares {
1382 peer,
1383 ssa_id,
1384 observed_total,
1385 }),
1386 );
1387 res.append(&mut terminal);
1388 Ok(res)
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use std::collections::HashMap;
1395
1396 use hopr_types::{
1397 crypto::{crypto_traits, prelude::*},
1398 crypto_random::Randomizable,
1399 internal::prelude::VerifiedAcknowledgement,
1400 };
1401 use vsss_rs::elliptic_curve::Field;
1402
1403 use super::{utils::SsaBuilder, *};
1404 use crate::{
1405 DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, GroupEncoding, PartialSsaShare, SsaGeneratorConfig, SsaIndex,
1406 SsaShareGenerator,
1407 tests::TestSpec,
1408 traits::{EntryShareGenerator, ExitAcknowledgementShareProcessor},
1409 };
1410
1411 #[test]
1412 fn ssa_reconstructor_try_new_should_reject_an_invalid_config_without_panicking() {
1413 let cfg = SsaReconstructorConfig {
1414 early_recovery_threshold: 1.5,
1416 ..Default::default()
1417 };
1418
1419 assert!(matches!(
1420 SsaReconstructor::<TestSpec>::try_new(cfg),
1421 Err(PixError::InvalidConfiguration(_))
1422 ));
1423 }
1424
1425 #[test]
1426 #[should_panic(expected = "invalid SsaReconstructorConfig")]
1427 fn ssa_reconstructor_new_should_still_panic_on_an_invalid_config() {
1428 let _ = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1429 early_recovery_threshold: 1.5,
1430 ..Default::default()
1431 });
1432 }
1433
1434 fn budget_share(
1440 spi: &SsaPolynomialId<SimplePseudonym>,
1441 ) -> anyhow::Result<(HalfKey, HalfKeyChallenge, TaggedEncryptedPartialSsaShare<TestSpec>)> {
1442 let ack_key = HalfKey::random();
1443 let challenge = ack_key.to_challenge()?;
1444 Ok((
1445 ack_key,
1446 challenge,
1447 TaggedEncryptedPartialSsaShare {
1448 pseudonym: *spi.pseudonym(),
1449 nonce: crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng()),
1450 partial_share: PartialSsaShare::default().encrypt(spi, &ack_key)?,
1451 },
1452 ))
1453 }
1454
1455 fn reconstructor_with_ack_budget(entries: usize) -> SsaReconstructor<TestSpec> {
1457 SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1458 max_ack_buffer_bytes: entries * AWAITING_ACK_ENTRY_BYTES,
1459 ..Default::default()
1460 })
1461 }
1462
1463 #[test]
1464 fn ack_buffer_refuses_shares_past_its_byte_budget() -> anyhow::Result<()> {
1465 const BUDGET: usize = 64;
1467
1468 let reconstructor = reconstructor_with_ack_budget(BUDGET);
1469 let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1470 let peer = OffchainKeypair::random();
1471
1472 for i in 0..BUDGET {
1473 let (_, challenge, share) = budget_share(&spi)?;
1474 reconstructor
1475 .insert_encrypted_share(peer.public(), challenge, share)
1476 .map_err(|error| anyhow::anyhow!("insertion {i} must fit the budget: {error}"))?;
1477 }
1478
1479 let (_, challenge, share) = budget_share(&spi)?;
1480 assert!(
1481 matches!(
1482 reconstructor.insert_encrypted_share(peer.public(), challenge, share),
1483 Err(PixError::AckBufferFull)
1484 ),
1485 "the share past the budget must be refused rather than buffered"
1486 );
1487 assert_eq!(
1488 BUDGET,
1489 reconstructor.count_ack_buffer_entries(),
1490 "the buffer must hold exactly its budget — no more, and the refusal must not have dropped any"
1491 );
1492
1493 Ok(())
1494 }
1495
1496 #[test]
1503 fn redeeming_an_acknowledgement_returns_its_budget() -> anyhow::Result<()> {
1504 const BUDGET: usize = 64;
1505
1506 let reconstructor = reconstructor_with_ack_budget(BUDGET);
1507 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1508 let spi = SsaPolynomialId::new(ssa_id, 0);
1509 let peer = OffchainKeypair::random();
1510 reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1511
1512 let mut redeemable = None;
1513 for _ in 0..BUDGET {
1514 let (ack_key, challenge, share) = budget_share(&spi)?;
1515 reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1516 redeemable = Some((ack_key, challenge));
1517 }
1518
1519 let (ack_key, challenge) = redeemable.expect("the loop ran at least once");
1520 let (_, refused, share) = budget_share(&spi)?;
1521 assert!(
1522 matches!(
1523 reconstructor.insert_encrypted_share(peer.public(), refused, share),
1524 Err(PixError::AckBufferFull)
1525 ),
1526 "precondition: the buffer is full"
1527 );
1528
1529 let peer_cache = reconstructor
1532 .awaiting_acks
1533 .get(peer.public())
1534 .expect("the peer holds shares");
1535 assert!(matches!(
1536 reconstructor.process_verified_ack(ack_key, challenge, &peer_cache),
1537 Ok(ProcessedAckResult::VerifierNotReady(_))
1538 ));
1539 assert_eq!(
1540 BUDGET,
1541 reconstructor.count_ack_buffer_entries(),
1542 "a deferral retains the share, so it must still be charged for"
1543 );
1544
1545 peer_cache.remove(&challenge);
1547 assert_eq!(
1548 BUDGET - 1,
1549 reconstructor.count_ack_buffer_entries(),
1550 "redeeming must free the entry"
1551 );
1552 reconstructor
1553 .insert_encrypted_share(peer.public(), refused, share)
1554 .map_err(|error| anyhow::anyhow!("the freed slot must be reusable: {error}"))?;
1555
1556 Ok(())
1557 }
1558
1559 #[test]
1564 fn expiring_shares_return_their_budget() -> anyhow::Result<()> {
1565 const BUDGET: usize = 64;
1566 const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
1567
1568 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1569 max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
1570 max_ack_await_time: WINDOW,
1571 ..Default::default()
1572 });
1573 let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1574 let peer = OffchainKeypair::random();
1575
1576 for _ in 0..BUDGET {
1577 let (_, challenge, share) = budget_share(&spi)?;
1578 reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1579 }
1580 let (_, challenge, share) = budget_share(&spi)?;
1581 assert!(
1582 matches!(
1583 reconstructor.insert_encrypted_share(peer.public(), challenge, share),
1584 Err(PixError::AckBufferFull)
1585 ),
1586 "precondition: the buffer is full"
1587 );
1588
1589 std::thread::sleep(WINDOW * 2);
1590
1591 let (_, challenge, share) = budget_share(&spi)?;
1595 reconstructor
1596 .insert_encrypted_share(peer.public(), challenge, share)
1597 .map_err(|error| anyhow::anyhow!("expired entries must free their budget: {error}"))?;
1598
1599 assert_eq!(
1600 1,
1601 reconstructor.count_ack_buffer_entries(),
1602 "only the share inserted after the window should remain"
1603 );
1604
1605 Ok(())
1606 }
1607
1608 #[test]
1617 fn the_ack_buffer_counter_does_not_inflate_as_peers_churn() -> anyhow::Result<()> {
1618 const PEERS: usize = 40;
1619 const TRACKED: usize = 10;
1620 const PER_PEER: usize = 5;
1621
1622 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1623 max_ack_buffer_bytes: 4096 * AWAITING_ACK_ENTRY_BYTES,
1625 max_tracked_peers: TRACKED,
1628 ..Default::default()
1629 });
1630 let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1631
1632 for _ in 0..PEERS {
1633 let peer = OffchainKeypair::random();
1634 for _ in 0..PER_PEER {
1635 let (_, challenge, share) = budget_share(&spi)?;
1636 reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1637 }
1638 }
1639
1640 let held = reconstructor.count_ack_buffer_entries();
1641 assert!(
1642 held <= TRACKED * PER_PEER,
1643 "the outer cache bounds what can be held: {held} entries across at most {TRACKED} peers"
1644 );
1645
1646 reconstructor.resync_ack_buffer();
1649 assert_eq!(
1650 held,
1651 reconstructor
1652 .ack_buffer_entries
1653 .load(std::sync::atomic::Ordering::Relaxed),
1654 "the counter must agree with what the caches actually hold after churn"
1655 );
1656
1657 Ok(())
1658 }
1659
1660 #[test]
1667 fn the_ack_buffer_budget_binds_regardless_of_the_configured_caps() -> anyhow::Result<()> {
1668 const BUDGET: usize = 64;
1669 const PEERS: usize = 16;
1672
1673 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1674 max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
1675 max_tracked_peers: 2000,
1676 max_awaiting_acks: 1_000_000,
1677 max_ack_await_time: std::time::Duration::from_secs(3600),
1680 ..Default::default()
1681 });
1682 let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1683
1684 let peers = (0..PEERS).map(|_| OffchainKeypair::random()).collect::<Vec<_>>();
1686 let mut accepted = 0;
1687 for i in 0..(BUDGET * 4) {
1688 let (_, challenge, share) = budget_share(&spi)?;
1689 if reconstructor
1690 .insert_encrypted_share(peers[i % PEERS].public(), challenge, share)
1691 .is_ok()
1692 {
1693 accepted += 1;
1694 }
1695 }
1696
1697 assert_eq!(
1698 BUDGET, accepted,
1699 "the byte budget must be what binds, not the caps whose product is 2e9 entries"
1700 );
1701 Ok(())
1702 }
1703
1704 fn next_share_for_poly(
1711 generator: &SsaShareGenerator<TestSpec>,
1712 pseudonym: &SimplePseudonym,
1713 poly_index: PolynomialIndex,
1714 ) -> anyhow::Result<([u8; 20], crate::GeneratedShare<TestSpec>)> {
1715 loop {
1716 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
1717 let share = generator
1718 .next_share(pseudonym, &msg)?
1719 .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
1720 if share.id.poly_index() == poly_index {
1721 return Ok((msg, share));
1722 }
1723 }
1724 }
1725
1726 fn proof_of(
1729 commitment: &crate::SsaCommitment<TestSpec>,
1730 coeff_index: CoefficientIndex,
1731 ) -> Option<SsaCommitmentProof<TestSpec>> {
1732 (coeff_index == 0).then_some(commitment.commitment_proof)
1733 }
1734
1735 fn identity_proof(ssa_id: &SsaId<SimplePseudonym>) -> SsaCommitmentProof<TestSpec> {
1738 let zero = <PixScalar<TestSpec> as Field>::ZERO;
1739 SsaCommitmentProof::prove(ssa_id, &zero, &PixGroup::<TestSpec>::mul_by_generator(&zero))
1740 .expect("identity proof must be constructible")
1741 }
1742
1743 #[test]
1744 fn reconstructor_rejects_invalid_exit_commitment_inputs() -> anyhow::Result<()> {
1745 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1746
1747 let make_ssa_id = || SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
1748
1749 assert!(matches!(
1751 reconstructor.new_exit_commitment(make_ssa_id(), 0, 2),
1752 Err(PixError::InvalidInput)
1753 ));
1754
1755 assert!(matches!(
1757 reconstructor.new_exit_commitment(make_ssa_id(), MAX_POLYS_PER_SSA as usize + 1, 2),
1758 Err(PixError::InvalidInput)
1759 ));
1760
1761 assert!(matches!(
1763 reconstructor.new_exit_commitment(make_ssa_id(), 2, 0),
1764 Err(PixError::InvalidInput)
1765 ));
1766
1767 assert!(matches!(
1769 reconstructor.new_exit_commitment(make_ssa_id(), 2, 1),
1770 Err(PixError::InvalidInput)
1771 ));
1772
1773 assert!(matches!(
1775 reconstructor.new_exit_commitment(make_ssa_id(), 2, MAX_POLY_THRESHOLD as usize + 1),
1776 Err(PixError::InvalidInput)
1777 ));
1778
1779 assert!(reconstructor.new_exit_commitment(make_ssa_id(), 2, 2).is_ok());
1781
1782 Ok(())
1783 }
1784
1785 #[test]
1786 fn reconstructor_invalid_commitment_inputs() -> anyhow::Result<()> {
1787 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1788
1789 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1790
1791 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1792
1793 for coeff_index in [1, 2, CoefficientIndex::MAX] {
1797 let ignored =
1798 reconstructor.insert_coefficient_commitments(ssa_id, coeff_index, None, HashMap::new().into_iter())?;
1799 assert!(ignored.ssa_deposit_address.is_none());
1800 assert!(!ignored.is_verifiable);
1801 }
1802 assert!(
1803 reconstructor
1804 .commitment_builder
1805 .get(&ssa_id)
1806 .ok_or_else(|| anyhow::anyhow!("missing builder"))?
1807 .lock()
1808 .is_empty(),
1809 "ignored commitments must not enter the builder's state"
1810 );
1811
1812 let mut invalid_poly_map = HashMap::new();
1814 invalid_poly_map.insert(2 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1815 let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, invalid_poly_map.into_iter());
1816 assert!(matches!(result, Err(PixError::InvalidInput)));
1817
1818 Ok(())
1819 }
1820
1821 #[test]
1822 fn reconstructor_should_not_accept_client_commitments_without_priod_exit_commitment() -> anyhow::Result<()> {
1823 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1824
1825 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1826
1827 let mut poly_map = HashMap::new();
1828 for poly in 0..2 {
1829 poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1830 }
1831
1832 let res = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map.into_iter());
1833
1834 assert!(matches!(res, Err(PixError::MissingSsaCommitment)));
1835
1836 Ok(())
1837 }
1838
1839 #[test]
1840 fn reconstructor_duplicate_commitments() -> anyhow::Result<()> {
1841 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1842
1843 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1844
1845 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1846
1847 let mut poly_map = HashMap::new();
1849 for poly in 0..2 {
1850 poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1851 }
1852 reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(identity_proof(&ssa_id)), poly_map.into_iter())?;
1853
1854 let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, HashMap::new().into_iter());
1856 assert!(matches!(result, Err(PixError::DuplicateCommitment)));
1857
1858 let mut trailing = HashMap::new();
1862 trailing.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1863 let ignored = reconstructor.insert_coefficient_commitments(ssa_id, 1, None, trailing.into_iter())?;
1864 assert!(
1865 ignored.is_verifiable,
1866 "ignoring a message must not make a completed cycle look incomplete"
1867 );
1868
1869 Ok(())
1870 }
1871
1872 #[test]
1873 fn reconstructor_duplicate_per_polynomial_commitment() -> anyhow::Result<()> {
1874 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1878
1879 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1880
1881 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1882
1883 let mut poly_map_1 = HashMap::new();
1885 poly_map_1.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1886 reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map_1.into_iter())?;
1887
1888 let mut poly_map_2 = HashMap::new();
1890 poly_map_2.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1891 let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map_2.into_iter());
1892 assert!(matches!(result, Err(PixError::DuplicateCommitment)));
1893
1894 let mut poly_map_3 = HashMap::new();
1896 poly_map_3.insert(1 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1897 assert!(
1898 reconstructor
1899 .insert_coefficient_commitments(ssa_id, 0, Some(identity_proof(&ssa_id)), poly_map_3.into_iter())
1900 .is_ok()
1901 );
1902
1903 Ok(())
1904 }
1905
1906 #[test]
1907 fn reconstructor_missing_verifier_retains_share() -> anyhow::Result<()> {
1908 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
1917
1918 let ack_key = HalfKey::random();
1919 let challenge = ack_key.to_challenge()?;
1920
1921 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1922 let spi = SsaPolynomialId::new(ssa_id, 0);
1923
1924 let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
1925 let peer = OffchainKeypair::random();
1926 let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
1927
1928 reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1929
1930 reconstructor.insert_encrypted_share(
1931 peer.public(),
1932 challenge,
1933 TaggedEncryptedPartialSsaShare {
1934 pseudonym: *spi.pseudonym(),
1935 nonce,
1936 partial_share,
1937 },
1938 )?;
1939
1940 let peer_cache = reconstructor.awaiting_acks.get(peer.public());
1942 assert!(peer_cache.is_some(), "share must be inserted before processing");
1943 assert!(
1944 peer_cache.as_ref().unwrap().contains_key(&challenge),
1945 "share must be present in the peer cache before processing"
1946 );
1947
1948 let peer_cache_ref = reconstructor.awaiting_acks.get(peer.public()).unwrap();
1951 let result = reconstructor.process_verified_ack(ack_key, challenge, &peer_cache_ref);
1952 assert!(
1953 matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
1954 "expected deferral naming polynomial {spi:?}"
1955 );
1956
1957 let peer_cache_after = reconstructor.awaiting_acks.get(peer.public());
1960 assert!(peer_cache_after.is_some(), "share must be retained when deferred");
1961 assert!(
1962 peer_cache_after.as_ref().unwrap().contains_key(&challenge),
1963 "share must be retained when deferred"
1964 );
1965
1966 Ok(())
1967 }
1968
1969 #[test]
1970 fn reconstructor_defers_ack_when_verifier_is_not_installed() -> anyhow::Result<()> {
1971 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
1972
1973 let ack_key = HalfKey::random();
1974 let challenge = ack_key.to_challenge()?;
1975
1976 let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1978 let spi = SsaPolynomialId::new(ssa_id, 0);
1979
1980 let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
1983
1984 let peer = OffchainKeypair::random();
1985 let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
1986
1987 reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1988
1989 reconstructor.insert_encrypted_share(
1990 peer.public(),
1991 challenge,
1992 TaggedEncryptedPartialSsaShare {
1993 pseudonym: *spi.pseudonym(),
1994 nonce,
1995 partial_share,
1996 },
1997 )?;
1998
1999 let result = reconstructor.process_verified_ack(
2000 ack_key,
2001 challenge,
2002 reconstructor
2003 .awaiting_acks
2004 .get(peer.public())
2005 .as_ref()
2006 .ok_or(anyhow::anyhow!("missing peer"))?,
2007 );
2008 assert!(
2009 matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
2010 "an ack with no installed verifier must be deferred, not failed"
2011 );
2012
2013 Ok(())
2014 }
2015
2016 #[test]
2017 fn reconstructor_rejects_duplicate_share_via_different_challenges() -> anyhow::Result<()> {
2018 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2020 polynomials_per_ssa: 1,
2021 threshold: 2,
2022 surplus_shares: 0,
2023 });
2024
2025 let pseudonym = SimplePseudonym::random();
2026 let peer = OffchainKeypair::random();
2027 let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
2028
2029 let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2030
2031 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2032 let _server_commitment = reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
2033 commitment_msg.process_into_reconstructor(&reconstructor)?;
2034
2035 let msg1: [u8; 20] = hopr_types::crypto_random::random_bytes();
2037 let Some(first) = generator.next_share(&pseudonym, &msg1)? else {
2038 anyhow::bail!("expected first share");
2039 };
2040 let first_share = first.share.clone();
2042 let ack1 = HalfKey::random();
2043 let challenge1 = ack1.to_challenge()?;
2044 let enc1 = first.share.encrypt(&first.id, &ack1)?;
2045 reconstructor.insert_encrypted_share(
2046 peer.public(),
2047 challenge1,
2048 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc1)?,
2049 )?;
2050
2051 let dup_ack = HalfKey::random();
2055 let dup_challenge = dup_ack.to_challenge()?;
2056 let enc_dup = first_share.encrypt(&first.id, &dup_ack)?;
2057 reconstructor.insert_encrypted_share(
2058 peer.public(),
2059 dup_challenge,
2060 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc_dup)?,
2061 )?;
2062
2063 let resolution1 = reconstructor.process_verified_ack(
2065 ack1,
2066 challenge1,
2067 reconstructor
2068 .awaiting_acks
2069 .get(peer.public())
2070 .as_ref()
2071 .ok_or(anyhow::anyhow!("missing peer"))?,
2072 )?;
2073 assert!(
2074 matches!(resolution1, ProcessedAckResult::Progressed(p) if p.useful_shares == 1),
2075 "first share should count as progress without completing the SSA"
2076 );
2077
2078 let resolution_dup = reconstructor.process_verified_ack(
2086 dup_ack,
2087 dup_challenge,
2088 reconstructor
2089 .awaiting_acks
2090 .get(peer.public())
2091 .as_ref()
2092 .ok_or(anyhow::anyhow!("missing peer"))?,
2093 )?;
2094 assert!(
2095 matches!(resolution_dup, ProcessedAckResult::NoProgress),
2096 "duplicate share must not register as progress"
2097 );
2098 assert_eq!(
2099 1,
2100 reconstructor
2101 .cycle(&ssa_id)
2102 .ok_or(anyhow::anyhow!("cycle went away"))?
2103 .progress()
2104 .useful_shares,
2105 "the duplicate must not have advanced the useful-share count"
2106 );
2107
2108 let msg2: [u8; 20] = hopr_types::crypto_random::random_bytes();
2110 let Some(second) = generator.next_share(&pseudonym, &msg2)? else {
2111 anyhow::bail!("expected second share");
2112 };
2113 let ack2 = HalfKey::random();
2114 let challenge2 = ack2.to_challenge()?;
2115 let enc2 = second.share.encrypt(&second.id, &ack2)?;
2116 reconstructor.insert_encrypted_share(
2117 peer.public(),
2118 challenge2,
2119 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg2, enc2)?,
2120 )?;
2121
2122 let resolution2 = reconstructor.process_verified_ack(
2123 ack2,
2124 challenge2,
2125 reconstructor
2126 .awaiting_acks
2127 .get(peer.public())
2128 .as_ref()
2129 .ok_or(anyhow::anyhow!("missing peer"))?,
2130 )?;
2131 assert!(
2132 matches!(resolution2, ProcessedAckResult::FullRecovery(ref r, _) if r.ssa_id == ssa_id),
2133 "second unique share should complete SSA reconstruction"
2134 );
2135
2136 Ok(())
2137 }
2138
2139 #[test]
2140 fn reconstructor_must_not_accept_empty_encrypted_share() -> anyhow::Result<()> {
2141 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
2142
2143 let ack_key = HalfKey::random();
2144 let challenge = ack_key.to_challenge()?;
2145
2146 let peer = OffchainKeypair::random();
2147
2148 assert!(
2149 reconstructor
2150 .insert_encrypted_share(
2151 peer.public(),
2152 challenge,
2153 TaggedEncryptedPartialSsaShare {
2154 pseudonym: SimplePseudonym::random(),
2155 nonce: Default::default(),
2156 partial_share: Default::default(),
2157 }
2158 )
2159 .is_err()
2160 );
2161
2162 Ok(())
2163 }
2164
2165 fn make_builder(num_polys: usize) -> SsaBuilder<TestSpec> {
2171 let exit_secret = PixScalar::<TestSpec>::default();
2172 let full_commitment = PixGroup::<TestSpec>::default();
2173 SsaBuilder::new(full_commitment, exit_secret, num_polys)
2174 }
2175
2176 fn add_parts(
2179 builder: &mut SsaBuilder<TestSpec>,
2180 n: usize,
2181 ) -> crate::errors::Result<Vec<Option<PixScalar<TestSpec>>>, <TestSpec as PixSpec>::Pseudonym> {
2182 let mut results = Vec::with_capacity(n);
2183 for i in 0..n {
2184 let sub = PixScalar::<TestSpec>::default();
2185 results.push(builder.add_recovered_ssa_part(i as PolynomialIndex, sub)?);
2186 }
2187 Ok(results)
2188 }
2189
2190 #[test]
2191 fn ssa_builder_early_threshold_below() -> anyhow::Result<()> {
2192 let mut builder = make_builder(10);
2195 add_parts(&mut builder, 8)?;
2196 assert!(!builder.check_early_threshold(0.85));
2197 Ok(())
2198 }
2199
2200 #[test]
2201 fn ssa_builder_early_threshold_hits_ceil_at_9() -> anyhow::Result<()> {
2202 let mut builder = make_builder(10);
2205 add_parts(&mut builder, 9)?;
2206 assert!(builder.check_early_threshold(0.85));
2207 assert!(!builder.check_early_threshold(0.85));
2209 Ok(())
2210 }
2211
2212 #[test]
2213 fn ssa_builder_threshold_1_dot_0_fires_at_full_recovery() -> anyhow::Result<()> {
2214 let mut builder = make_builder(10);
2217 add_parts(&mut builder, 9)?;
2218 assert!(!builder.check_early_threshold(1.0));
2219 add_parts(&mut builder, 1)?; assert!(!builder.check_early_threshold(1.0));
2223 Ok(())
2224 }
2225
2226 #[test]
2227 fn process_verified_ack_emits_early_and_full_recovery() -> anyhow::Result<()> {
2228 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2234 polynomials_per_ssa: 4,
2235 threshold: 4,
2236 surplus_shares: 0,
2237 });
2238
2239 let pseudonym = SimplePseudonym::random();
2240 let peer = OffchainKeypair::random();
2241 let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
2242
2243 let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2244
2245 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2246 early_recovery_threshold: 0.5,
2247 ..Default::default()
2248 });
2249
2250 let _server_commitment = reconstructor.new_exit_commitment(ssa_id, 4, 4)?;
2251
2252 commitment_msg.process_into_reconstructor(&reconstructor)?;
2253
2254 assert!(
2256 !reconstructor.has_pending_shares(peer.public()),
2257 "no shares inserted yet"
2258 );
2259
2260 let mut acks = Vec::new();
2262 while let Some((msg, share)) = {
2263 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2264 generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2265 }? {
2266 let ack = HalfKey::random();
2267 let ack_challenge = ack.to_challenge()?;
2268 let enc_share = share.share.encrypt(&share.id, &ack)?;
2269
2270 reconstructor.insert_encrypted_share(
2271 peer.public(),
2272 ack_challenge,
2273 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2274 )?;
2275 acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
2276 }
2277
2278 assert!(
2280 reconstructor.has_pending_shares(peer.public()),
2281 "shares were just inserted"
2282 );
2283
2284 let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2286
2287 let has_early = resolutions
2289 .iter()
2290 .any(|r| matches!(r, ShareResolution::AlmostRecoveredSsa(id) if *id == ssa_id));
2291 let has_full = resolutions
2292 .iter()
2293 .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
2294
2295 assert!(has_early, "expected AlmostRecoveredSsa event");
2296 assert!(has_full, "expected RecoveredSsa event");
2297
2298 Ok(())
2299 }
2300
2301 #[allow(clippy::type_complexity)]
2306 fn cycle_with_pending_acks(
2307 polys: u16,
2308 threshold: u8,
2309 surplus: u8,
2310 peer: &OffchainKeypair,
2311 ) -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>, Vec<Acknowledgement>)> {
2312 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2313 polynomials_per_ssa: polys,
2314 threshold,
2315 surplus_shares: surplus,
2316 });
2317 let pseudonym = SimplePseudonym::random();
2318 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2319
2320 let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2321 let reconstructor = SsaReconstructor::<TestSpec>::default();
2322 reconstructor.new_exit_commitment(ssa_id, polys as usize, threshold as usize)?;
2323 commitment_msg.process_into_reconstructor(&reconstructor)?;
2324
2325 let mut acks = Vec::new();
2326 while let Some((msg, share)) = {
2327 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2328 generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2329 }? {
2330 let ack = HalfKey::random();
2331 let ack_challenge = ack.to_challenge()?;
2332 let enc_share = share.share.encrypt(&share.id, &ack)?;
2333 reconstructor.insert_encrypted_share(
2334 peer.public(),
2335 ack_challenge,
2336 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2337 )?;
2338 acks.push(VerifiedAcknowledgement::new(ack, peer).leak());
2339 }
2340
2341 Ok((reconstructor, ssa_id, acks))
2342 }
2343
2344 #[test]
2345 fn progress_counts_only_the_shares_that_advance_reconstruction() -> anyhow::Result<()> {
2346 const POLYS: u16 = 2;
2350 const THRESHOLD: u8 = 2;
2351 let peer = OffchainKeypair::random();
2352 let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(POLYS, THRESHOLD, 1, &peer)?;
2353 assert_eq!(
2354 6,
2355 acks.len(),
2356 "generator should emit (threshold + surplus) per polynomial"
2357 );
2358
2359 let mut snapshots = Vec::new();
2360 let mut recovered = false;
2361 for ack in acks {
2363 for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
2364 match resolution {
2365 ShareResolution::Progress(p) => snapshots.push(p),
2366 ShareResolution::RecoveredSsa(r) => {
2367 assert_eq!(ssa_id, r.ssa_id);
2368 recovered = true;
2369 }
2370 _ => {}
2371 }
2372 }
2373 }
2374
2375 assert!(recovered, "the cycle must reconstruct from its own shares");
2376
2377 assert_eq!(
2380 vec![1, 2, 3, 4],
2381 snapshots.iter().map(|p| p.useful_shares).collect::<Vec<_>>(),
2382 "each snapshot must advance by exactly one, and surplus shares must emit none at all"
2383 );
2384 let last = snapshots.last().ok_or(anyhow::anyhow!("no progress emitted"))?;
2385 assert_eq!(
2386 (POLYS as u64 * THRESHOLD as u64),
2387 last.target_useful_shares,
2388 "target must be polynomials × threshold, matching the negotiated dimensions"
2389 );
2390 assert_eq!(
2391 last.useful_shares, last.target_useful_shares,
2392 "a completed cycle must report itself as complete"
2393 );
2394 assert_eq!(
2395 POLYS, last.recovered_polynomials,
2396 "every polynomial must be accounted for"
2397 );
2398
2399 Ok(())
2400 }
2401
2402 #[test]
2403 fn progress_precedes_the_terminal_event_it_belongs_to() -> anyhow::Result<()> {
2404 let peer = OffchainKeypair::random();
2409 let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(2, 2, 0, &peer)?;
2410
2411 let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2412
2413 let first_terminal = resolutions
2414 .iter()
2415 .position(|r| {
2416 matches!(
2417 r,
2418 ShareResolution::RecoveredSsa(_) | ShareResolution::AlmostRecoveredSsa(_)
2419 )
2420 })
2421 .ok_or(anyhow::anyhow!("expected a terminal resolution"))?;
2422 let last_progress = resolutions
2423 .iter()
2424 .rposition(|r| matches!(r, ShareResolution::Progress(_)))
2425 .ok_or(anyhow::anyhow!("expected a progress resolution"))?;
2426 assert!(
2427 last_progress < first_terminal,
2428 "every Progress must precede every terminal event, got {resolutions:?}"
2429 );
2430
2431 let progress = resolutions
2433 .iter()
2434 .filter_map(|r| match r {
2435 ShareResolution::Progress(p) => Some(p),
2436 _ => None,
2437 })
2438 .collect::<Vec<_>>();
2439 assert_eq!(1, progress.len(), "one snapshot per SSA per batch, got {progress:?}");
2440 assert_eq!(ssa_id, progress[0].ssa_id);
2441 assert_eq!(
2442 4, progress[0].useful_shares,
2443 "the surviving snapshot must be the furthest-along one"
2444 );
2445
2446 Ok(())
2447 }
2448
2449 #[test]
2450 fn an_abandoned_commitment_guard_releases_its_registration() -> anyhow::Result<()> {
2451 let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2452 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2453
2454 let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2455 assert_eq!(Some(&ssa_id), guard.ssa_id());
2456 assert!(
2457 reconstructor.contains_builder(&ssa_id),
2458 "the commitment must be registered while the guard holds it"
2459 );
2460
2461 drop(guard);
2462 assert!(
2463 !reconstructor.contains_builder(&ssa_id),
2464 "dropping the guard must release the registration it owned"
2465 );
2466
2467 Ok(())
2468 }
2469
2470 #[test]
2477 fn abandoning_a_live_cycle_retires_it_rather_than_just_releasing_it() -> anyhow::Result<()> {
2478 const POLYS: u16 = 2;
2479 const THRESHOLD: u8 = 2;
2480 let pseudonym = SimplePseudonym::random();
2481 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2482 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2483 polynomials_per_ssa: POLYS,
2484 threshold: THRESHOLD,
2485 surplus_shares: 0,
2486 });
2487
2488 let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2489 let (_commitment, guard) =
2490 reconstructor.new_guarded_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2491
2492 generator
2495 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
2496 .process_into_reconstructor(reconstructor.as_ref())?;
2497 assert!(reconstructor.cycle(&ssa_id).is_some(), "the cycle must be live");
2498
2499 drop(guard);
2500 assert!(
2501 reconstructor.cycle(&ssa_id).is_none(),
2502 "abandoning a live cycle must tear it down"
2503 );
2504
2505 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2509 let replacement = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2512 polynomials_per_ssa: POLYS,
2513 threshold: THRESHOLD,
2514 surplus_shares: 0,
2515 })
2516 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2517 let mut final_state = None;
2518 for (coeff_idx, coeffs) in replacement.verifiers.clone() {
2519 let proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(replacement.commitment_proof);
2520 final_state =
2521 Some(reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, proof, coeffs.into_iter())?);
2522 }
2523 assert!(
2524 !final_state
2525 .ok_or(anyhow::anyhow!("commitment carried no batches"))?
2526 .is_verifiable,
2527 "a retired SsaId must stay retired"
2528 );
2529
2530 Ok(())
2531 }
2532
2533 #[test]
2534 fn a_disarmed_commitment_guard_leaves_its_ssa_alone() -> anyhow::Result<()> {
2535 let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2536 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2537
2538 let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2539 assert_eq!(Some(ssa_id), guard.disarm());
2540 assert!(
2541 reconstructor.contains_builder(&ssa_id),
2542 "a disarmed guard must not retire the commitment it handed on"
2543 );
2544
2545 Ok(())
2546 }
2547
2548 #[test]
2556 fn an_ssa_index_stays_usable_after_its_request_is_abandoned() -> anyhow::Result<()> {
2557 const POLYS: u16 = 2;
2558 const THRESHOLD: u8 = 2;
2559 let pseudonym = SimplePseudonym::random();
2560 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2561
2562 let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2563
2564 let (_commitment, guard) =
2566 reconstructor.new_guarded_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2567 drop(guard);
2568
2569 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2571 polynomials_per_ssa: POLYS,
2572 threshold: THRESHOLD,
2573 surplus_shares: 0,
2574 });
2575 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2576 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2577 let mut final_state = None;
2578 for (coeff_idx, coeffs) in commitment.verifiers.clone() {
2579 let proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(commitment.commitment_proof);
2580 final_state =
2581 Some(reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, proof, coeffs.into_iter())?);
2582 }
2583 let state = final_state.ok_or(anyhow::anyhow!("commitment carried no batches"))?;
2584
2585 assert!(
2586 state.is_verifiable,
2587 "the retry must become verifiable — otherwise the peer is asked to fund a cycle that cannot reconstruct"
2588 );
2589 assert!(state.ssa_deposit_address.is_some(), "a verifiable cycle has an address");
2590 assert!(
2591 reconstructor.cycle(&ssa_id).is_some(),
2592 "the retry's cycle must be live, not published and then withdrawn"
2593 );
2594
2595 Ok(())
2596 }
2597
2598 #[test]
2599 fn a_rejected_duplicate_registration_does_not_retire_the_original() -> anyhow::Result<()> {
2600 let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2604 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2605
2606 let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2607
2608 assert!(
2609 matches!(
2610 reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2),
2611 Err(PixError::DuplicateCommitment)
2612 ),
2613 "a second registration at the same index must be rejected"
2614 );
2615 assert!(
2616 reconstructor.contains_builder(&ssa_id),
2617 "the rejected duplicate must leave the original registration intact"
2618 );
2619
2620 drop(guard);
2621 Ok(())
2622 }
2623
2624 #[test]
2638 fn a_polynomial_repeated_within_one_batch_is_rejected() -> anyhow::Result<()> {
2639 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2640 polynomials_per_ssa: 2,
2641 threshold: 2,
2642 surplus_shares: 0,
2643 });
2644 let pseudonym = SimplePseudonym::random();
2645 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2646
2647 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2648 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2649 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2650
2651 let mut batch = coefficient_of(&commitment, 0, Some(0))?;
2654 batch.push(batch[0]);
2655 let result =
2656 reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&commitment, 0), batch.into_iter());
2657 assert!(
2658 matches!(&result, Err(crate::errors::PixError::DuplicateCommitment)),
2659 "a repeat inside the batch must be rejected, got {result:?}"
2660 );
2661
2662 let retry = reconstructor.insert_coefficient_commitments(
2664 ssa_id,
2665 0,
2666 proof_of(&commitment, 0),
2667 coefficient_of(&commitment, 0, None)?.into_iter(),
2668 )?;
2669 assert!(
2670 retry.is_verifiable && retry.ssa_deposit_address.is_some(),
2671 "the corrected batch must complete the commitment"
2672 );
2673
2674 Ok(())
2675 }
2676
2677 #[test]
2678 fn malformed_commitment_does_not_poison_corrected_retransmission() -> anyhow::Result<()> {
2679 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2684 polynomials_per_ssa: 2,
2685 threshold: 2,
2686 surplus_shares: 0,
2687 });
2688 let pseudonym = SimplePseudonym::random();
2689 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2690
2691 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2692
2693 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2694 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2695
2696 let partial = reconstructor.insert_coefficient_commitments(
2699 ssa_id,
2700 0,
2701 proof_of(&commitment, 0),
2702 coefficient_of(&commitment, 0, Some(0))?.into_iter(),
2703 )?;
2704 assert!(
2705 partial.ssa_deposit_address.is_none(),
2706 "the deposit address needs every constant term"
2707 );
2708 assert!(!partial.is_verifiable, "not yet complete");
2709
2710 let mut malformed = PixGroupRepr::<TestSpec>::default(); AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
2714 let malformed_result = reconstructor.insert_coefficient_commitments(
2715 ssa_id,
2716 0,
2717 proof_of(&commitment, 0),
2718 [(1, malformed)].into_iter(),
2719 );
2720 assert!(
2721 matches!(&malformed_result, Err(crate::errors::PixError::InvalidInput)),
2722 "malformed commitment must be rejected, got {malformed_result:?}"
2723 );
2724
2725 let retry = reconstructor.insert_coefficient_commitments(
2727 ssa_id,
2728 0,
2729 proof_of(&commitment, 0),
2730 coefficient_of(&commitment, 0, Some(1))?.into_iter(),
2731 );
2732 assert!(
2733 matches!(&retry, Ok(state) if state.is_verifiable && state.ssa_deposit_address.is_some()),
2734 "corrected retransmission must complete the SSA, got {retry:?}"
2735 );
2736
2737 Ok(())
2738 }
2739
2740 #[test]
2755 fn decode_commitment_is_the_single_validation_point() {
2756 use vsss_rs::elliptic_curve::group::GroupEncoding;
2757
2758 use crate::SsaPartCommitment;
2759
2760 let generator_repr = PixGroup::<TestSpec>::generator().to_bytes();
2762 assert!(
2763 SsaPartCommitment::<TestSpec>::decode_commitment(&generator_repr).is_ok(),
2764 "the generator is a valid constant-term commitment (scalar coefficient 1)"
2765 );
2766
2767 let mut malformed = PixGroupRepr::<TestSpec>::default();
2769 AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
2770 assert!(
2771 matches!(
2772 SsaPartCommitment::<TestSpec>::decode_commitment(&malformed),
2773 Err(PixError::InvalidInput)
2774 ),
2775 "undecodable bytes must be rejected"
2776 );
2777 }
2778
2779 #[test]
2790 fn reconstructed_polynomial_releases_verification_state_but_keeps_its_entry() -> anyhow::Result<()> {
2791 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2794 polynomials_per_ssa: 2,
2795 threshold: 2,
2796 surplus_shares: 0,
2797 });
2798 let pseudonym = SimplePseudonym::random();
2799 let peer = OffchainKeypair::random();
2800 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2801 let poly_0 = SsaPolynomialId::new(ssa_id, 0);
2802
2803 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2804 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
2805 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2806 commitment.process_into_reconstructor(&reconstructor)?;
2807
2808 let cycle = reconstructor
2810 .cycle(&ssa_id)
2811 .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
2812 assert_eq!(
2813 0,
2814 cycle
2815 .part(poly_0.poly_index())
2816 .ok_or_else(|| anyhow::anyhow!("part builder for polynomial 0 must be installed"))?
2817 .lock()
2818 .verification_state_len()
2819 );
2820 drop(cycle);
2821
2822 for _ in 0..2 {
2825 let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2826
2827 let ack = HalfKey::random();
2828 let challenge = ack.to_challenge()?;
2829 let enc = share.share.encrypt(&share.id, &ack)?;
2830 reconstructor.insert_encrypted_share(
2831 peer.public(),
2832 challenge,
2833 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
2834 )?;
2835 reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
2836 }
2837
2838 assert!(
2841 reconstructor.contains_builder(&ssa_id),
2842 "the cycle must still be live while polynomial 1 is outstanding"
2843 );
2844 let cycle = reconstructor
2845 .cycle(&ssa_id)
2846 .ok_or_else(|| anyhow::anyhow!("a reconstructed polynomial must keep its slot"))?;
2847 assert_eq!(
2848 0,
2849 cycle
2850 .part(poly_0.poly_index())
2851 .ok_or_else(|| anyhow::anyhow!("polynomial 0 must keep its slot"))?
2852 .lock()
2853 .verification_state_len(),
2854 "a reconstructed polynomial must hold no shares"
2855 );
2856
2857 assert!(
2859 cycle.part(1).is_some(),
2860 "part builder for polynomial 1 must be installed"
2861 );
2862
2863 Ok(())
2864 }
2865
2866 #[test]
2880 fn a_failed_interpolation_leaves_the_part_terminal() -> anyhow::Result<()> {
2881 use utils::{AddShareOutcome, SsaPartBuilder};
2882
2883 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2884 polynomials_per_ssa: 1,
2885 threshold: 2,
2886 surplus_shares: 2,
2887 });
2888 let pseudonym = SimplePseudonym::random();
2889 let spi = SsaPolynomialId::new(SsaId::new(pseudonym, SsaIndex::MIN), 0);
2890 generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2891
2892 let mut part = SsaPartBuilder::<TestSpec>::new(
2895 crate::SsaPartCommitment::from_decoded_commitment(spi, PixGroup::<TestSpec>::generator()),
2896 1,
2897 );
2898
2899 let mut rng = hopr_types::crypto_random::rng();
2900 let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2901 assert!(
2902 part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)
2903 .is_err(),
2904 "one share is below what `combine` accepts, so the interpolation must fail"
2905 );
2906 assert_eq!(
2907 0,
2908 part.verification_state_len(),
2909 "a part that can never reconstruct must release its share buffer"
2910 );
2911
2912 let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2914 assert!(
2915 matches!(
2916 part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)?,
2917 AddShareOutcome::Absorbed
2918 ),
2919 "a failed part must absorb later shares silently"
2920 );
2921 assert_eq!(0, part.verification_state_len());
2922
2923 Ok(())
2924 }
2925
2926 #[test]
2927 fn full_recovery_retires_all_reconstructor_state() -> anyhow::Result<()> {
2928 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2932 polynomials_per_ssa: 4,
2933 threshold: 4,
2934 surplus_shares: 0,
2935 });
2936 let pseudonym = SimplePseudonym::random();
2937 let peer = OffchainKeypair::random();
2938 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2939
2940 let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2941 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
2942 reconstructor.new_exit_commitment(ssa_id, 4, 4)?;
2943 commitment_msg.process_into_reconstructor(&reconstructor)?;
2944
2945 assert_eq!(
2947 4,
2948 reconstructor.installed_parts(&ssa_id),
2949 "4 part builders present after completion"
2950 );
2951 assert_eq!(1, reconstructor.live_cycles(), "the cycle is live after completion");
2952
2953 let mut acks = Vec::new();
2955 while let Some((msg, share)) = {
2956 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2957 generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2958 }? {
2959 let ack = HalfKey::random();
2960 let ack_challenge = ack.to_challenge()?;
2961 let enc_share = share.share.encrypt(&share.id, &ack)?;
2962 reconstructor.insert_encrypted_share(
2963 peer.public(),
2964 ack_challenge,
2965 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2966 )?;
2967 acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
2968 }
2969 let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2970 assert!(
2971 resolutions
2972 .iter()
2973 .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
2974 "cycle must fully recover"
2975 );
2976
2977 reconstructor.commitment_builder.run_pending_tasks();
2980 assert_eq!(
2981 0,
2982 reconstructor.live_cycles(),
2983 "the cycle must be retired on full recovery"
2984 );
2985 assert!(
2986 !reconstructor.commitment_builder.contains_key(&ssa_id),
2987 "commitment builder must be retired on full recovery"
2988 );
2989
2990 Ok(())
2991 }
2992
2993 #[test]
2994 fn retire_ssa_removes_cycle_state_and_is_idempotent() -> anyhow::Result<()> {
2995 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2997 polynomials_per_ssa: 3,
2998 threshold: 4,
2999 surplus_shares: 0,
3000 });
3001 let pseudonym = SimplePseudonym::random();
3002 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3003
3004 let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3005 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3006 reconstructor.new_exit_commitment(ssa_id, 3, 4)?;
3007 commitment_msg.process_into_reconstructor(&reconstructor)?;
3008
3009 assert_eq!(
3010 3,
3011 reconstructor.installed_parts(&ssa_id),
3012 "3 part builders present after completion"
3013 );
3014
3015 reconstructor.retire_ssa(ssa_id);
3017 reconstructor.commitment_builder.run_pending_tasks();
3018 assert_eq!(
3019 0,
3020 reconstructor.live_cycles(),
3021 "the cycle must be removed by retire_ssa"
3022 );
3023 assert!(!reconstructor.commitment_builder.contains_key(&ssa_id));
3024
3025 reconstructor.retire_ssa(ssa_id);
3027
3028 let never_seen = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3031 reconstructor.retire_ssa(never_seen);
3032 assert_eq!(0, reconstructor.live_cycles());
3033
3034 Ok(())
3035 }
3036
3037 #[test]
3047 fn deferred_acks_are_bucketed_by_cycle_and_polynomial_across_peers() -> anyhow::Result<()> {
3048 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3049
3050 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3051 let other_ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3052 let spi_0 = SsaPolynomialId::new(ssa_id, 0);
3053 let spi_1 = SsaPolynomialId::new(ssa_id, 1);
3054 let spi_other = SsaPolynomialId::new(other_ssa_id, 0);
3055
3056 let peer_a = OffchainKeypair::random();
3057 let peer_b = OffchainKeypair::random();
3058 let ack_a = HalfKey::random();
3059 let ack_b = HalfKey::random();
3060 let ack_other_poly = HalfKey::random();
3061 let ack_other_cycle = HalfKey::random();
3062
3063 reconstructor.defer_ack(spi_0, (*peer_a.public(), ack_a.to_challenge()?, ack_a));
3066 reconstructor.defer_ack(spi_0, (*peer_b.public(), ack_b.to_challenge()?, ack_b));
3067 reconstructor.defer_ack(
3068 spi_1,
3069 (*peer_a.public(), ack_other_poly.to_challenge()?, ack_other_poly),
3070 );
3071 reconstructor.defer_ack(
3072 spi_other,
3073 (*peer_a.public(), ack_other_cycle.to_challenge()?, ack_other_cycle),
3074 );
3075
3076 let bucket = reconstructor
3077 .pending_acks
3078 .get(&ssa_id)
3079 .ok_or_else(|| anyhow::anyhow!("missing bucket for the cycle"))?;
3080 {
3081 let bucket = bucket.lock();
3082 assert_eq!(
3083 2,
3084 bucket.by_poly.get(&0).map(Vec::len).unwrap_or(0),
3085 "one sub-bucket holds both peers' acks"
3086 );
3087 assert_eq!(
3088 1,
3089 bucket.by_poly.get(&1).map(Vec::len).unwrap_or(0),
3090 "a different polynomial keeps its own sub-bucket"
3091 );
3092 }
3093 assert_eq!(3, reconstructor.deferred_ack_count(&ssa_id));
3094 assert_eq!(1, reconstructor.deferred_ack_count(&other_ssa_id));
3095
3096 reconstructor.drain_deferred_acks(&ssa_id);
3099 assert!(
3100 !reconstructor.pending_acks.contains_key(&ssa_id),
3101 "a drained bucket is removed, with all of its polynomials"
3102 );
3103 assert!(
3104 reconstructor.pending_acks.contains_key(&other_ssa_id),
3105 "draining one cycle must not disturb another"
3106 );
3107
3108 Ok(())
3109 }
3110
3111 #[test]
3119 fn verifiers_are_installed_when_the_constant_term_set_completes() -> anyhow::Result<()> {
3120 const POLYS: u16 = 6;
3121 const THRESHOLD: u8 = 4;
3122
3123 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3124 polynomials_per_ssa: POLYS,
3125 threshold: THRESHOLD,
3126 surplus_shares: 0,
3127 });
3128 let pseudonym = SimplePseudonym::random();
3129 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3130 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3131
3132 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3133 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3134
3135 for poly in 0..POLYS as PolynomialIndex {
3137 let state = reconstructor.insert_coefficient_commitments(
3138 ssa_id,
3139 0,
3140 proof_of(&commitment, 0),
3141 coefficient_of(&commitment, 0, Some(poly))?.into_iter(),
3142 )?;
3143
3144 let last = poly == POLYS as PolynomialIndex - 1;
3145 assert_eq!(
3146 state.ssa_deposit_address.is_some(),
3147 last,
3148 "the deposit address is the sum of every constant term (poly {poly})"
3149 );
3150 assert_eq!(
3151 state.is_verifiable, last,
3152 "the cycle becomes verifiable exactly when the constant-term set closes (poly {poly})"
3153 );
3154
3155 let expected = if last { POLYS as usize } else { 0 };
3156 assert_eq!(
3157 expected,
3158 reconstructor.installed_parts(&ssa_id),
3159 "after polynomial {poly} there must be {expected} part builders installed"
3160 );
3161 }
3162
3163 Ok(())
3164 }
3165
3166 #[test]
3170 fn non_constant_coefficients_are_ignored_wherever_they_arrive() -> anyhow::Result<()> {
3171 const POLYS: u16 = 2;
3172 const THRESHOLD: u8 = 2;
3173
3174 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3175 polynomials_per_ssa: POLYS,
3176 threshold: THRESHOLD,
3177 surplus_shares: 0,
3178 });
3179 let pseudonym = SimplePseudonym::random();
3180 let peer = OffchainKeypair::random();
3181 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3182 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3183
3184 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
3185 early_recovery_threshold: 1.0,
3186 ..Default::default()
3187 });
3188 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3189
3190 let higher: Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)> = (0..POLYS as PolynomialIndex)
3193 .map(|poly| {
3194 (
3195 poly,
3196 PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
3197 &mut hopr_types::crypto_random::rng(),
3198 ))
3199 .to_bytes(),
3200 )
3201 })
3202 .collect();
3203
3204 reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
3206
3207 reconstructor.insert_coefficient_commitments(
3209 ssa_id,
3210 0,
3211 proof_of(&commitment, 0),
3212 coefficient_of(&commitment, 0, Some(0))?.into_iter(),
3213 )?;
3214 reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
3215 let state = reconstructor.insert_coefficient_commitments(
3216 ssa_id,
3217 0,
3218 proof_of(&commitment, 0),
3219 coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3220 )?;
3221 assert!(state.is_verifiable, "the constant-term pass alone completes the cycle");
3222
3223 reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.into_iter())?;
3225
3226 let mut acks = Vec::new();
3228 while let Some((msg, share)) = {
3229 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3230 generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3231 }? {
3232 let ack = HalfKey::random();
3233 let enc_share = share.share.encrypt(&share.id, &ack)?;
3234 reconstructor.insert_encrypted_share(
3235 peer.public(),
3236 ack.to_challenge()?,
3237 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3238 )?;
3239 acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
3240 }
3241
3242 let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3243 assert!(
3244 resolutions
3245 .iter()
3246 .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
3247 "the SSA must recover from the constant terms alone, got {resolutions:?}"
3248 );
3249
3250 Ok(())
3251 }
3252
3253 #[test]
3257 fn a_corrupted_share_is_reported_once_at_the_threshold_th_share() -> anyhow::Result<()> {
3258 const POLYS: u16 = 2;
3259 const THRESHOLD: u8 = 4;
3260 const SURPLUS: u8 = 2;
3261
3262 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3263 polynomials_per_ssa: POLYS,
3264 threshold: THRESHOLD,
3265 surplus_shares: SURPLUS,
3266 });
3267 let pseudonym = SimplePseudonym::random();
3268 let peer = OffchainKeypair::random();
3269 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3270
3271 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3272 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3273 generator
3274 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3275 .process_into_reconstructor(&reconstructor)?;
3276
3277 let mut invalid_reports: Vec<u64> = Vec::new();
3279 for i in 0..THRESHOLD as usize + SURPLUS as usize {
3280 let (msg, mut share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3281
3282 if i == 0 {
3283 AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3286 }
3287
3288 let ack = HalfKey::random();
3289 let enc = share.share.clone().encrypt(&share.id, &ack)?;
3290 reconstructor.insert_encrypted_share(
3291 peer.public(),
3292 ack.to_challenge()?,
3293 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3294 )?;
3295 let resolutions = reconstructor
3296 .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
3297
3298 let reported = resolutions
3299 .iter()
3300 .filter_map(|r| match r {
3301 ShareResolution::InvalidShares {
3302 ssa_id: id,
3303 observed_total,
3304 ..
3305 } if *id == ssa_id => Some(*observed_total),
3306 _ => None,
3307 })
3308 .collect::<Vec<_>>();
3309 if i + 1 < THRESHOLD as usize {
3310 assert!(
3311 reported.is_empty(),
3312 "share {i} must pass unremarked — nothing checks it yet"
3313 );
3314 }
3315 invalid_reports.extend(reported);
3316 }
3317
3318 assert_eq!(
3319 vec![1],
3320 invalid_reports,
3321 "the corrupted set must be reported exactly once, at the threshold-th share, as the cycle's first fault"
3322 );
3323
3324 Ok(())
3325 }
3326
3327 #[test]
3333 fn fault_totals_aggregate_over_the_cycle_not_the_peer() -> anyhow::Result<()> {
3334 const POLYS: u16 = 2;
3335 const THRESHOLD: u8 = 2;
3336
3337 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3338 polynomials_per_ssa: POLYS,
3339 threshold: THRESHOLD,
3340 surplus_shares: 0,
3341 });
3342 let pseudonym = SimplePseudonym::random();
3343 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3344 let relayers = [OffchainKeypair::random(), OffchainKeypair::random()];
3347
3348 let reconstructor = SsaReconstructor::<TestSpec>::default();
3349 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3350 generator
3351 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3352 .process_into_reconstructor(&reconstructor)?;
3353
3354 let mut by_poly: std::collections::BTreeMap<PolynomialIndex, Vec<_>> = Default::default();
3357 for _ in 0..POLYS as usize * THRESHOLD as usize {
3358 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3359 let share = generator
3360 .next_share(&pseudonym, &msg)?
3361 .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3362 by_poly.entry(share.id.poly_index()).or_default().push((msg, share));
3363 }
3364
3365 let mut totals = Vec::new();
3366 for poly in 0..POLYS {
3367 let relayer = &relayers[poly as usize];
3368 let shares = by_poly
3369 .remove(&poly)
3370 .ok_or_else(|| anyhow::anyhow!("no shares for polynomial {poly}"))?;
3371 assert_eq!(THRESHOLD as usize, shares.len());
3372
3373 for (i, (msg, mut share)) in shares.into_iter().enumerate() {
3374 if i == 0 {
3376 AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3377 }
3378
3379 let ack = HalfKey::random();
3380 let enc = share.share.clone().encrypt(&share.id, &ack)?;
3381 reconstructor.insert_encrypted_share(
3382 relayer.public(),
3383 ack.to_challenge()?,
3384 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3385 )?;
3386 totals.extend(
3387 reconstructor
3388 .acknowledge_shares(
3389 *relayer.public(),
3390 vec![VerifiedAcknowledgement::new(ack, relayer).leak()],
3391 )?
3392 .into_iter()
3393 .filter_map(|r| match r {
3394 ShareResolution::InvalidShares { observed_total, .. } => Some(observed_total),
3395 _ => None,
3396 }),
3397 );
3398 }
3399 }
3400
3401 assert_eq!(
3402 vec![1, 2],
3403 totals,
3404 "the second relayer's fault must be charged to the cycle's running total, not restart it"
3405 );
3406 assert_eq!(
3407 2,
3408 reconstructor
3409 .cycle(&ssa_id)
3410 .ok_or(anyhow::anyhow!("cycle went away"))?
3411 .invalid_shares(),
3412 "the cycle must hold the aggregate"
3413 );
3414
3415 Ok(())
3416 }
3417
3418 #[test]
3423 fn shares_arriving_before_their_verifier_are_redeemed_on_installation() -> anyhow::Result<()> {
3424 const POLYS: u16 = 2;
3425 const THRESHOLD: u8 = 2;
3426
3427 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3428 polynomials_per_ssa: POLYS,
3429 threshold: THRESHOLD,
3430 surplus_shares: 0,
3431 });
3432 let pseudonym = SimplePseudonym::random();
3433 let peer = OffchainKeypair::random();
3434 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3435 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3436
3437 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3438 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3439
3440 reconstructor.insert_coefficient_commitments(
3443 ssa_id,
3444 0,
3445 proof_of(&commitment, 0),
3446 coefficient_of(&commitment, 0, Some(0))?.into_iter(),
3447 )?;
3448
3449 let mut acks = Vec::new();
3451 while let Some((msg, share)) = {
3452 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3453 generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3454 }? {
3455 let ack = HalfKey::random();
3456 let ack_challenge = ack.to_challenge()?;
3457 let enc_share = share.share.encrypt(&share.id, &ack)?;
3458 reconstructor.insert_encrypted_share(
3459 peer.public(),
3460 ack_challenge,
3461 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3462 )?;
3463 acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
3464 }
3465
3466 let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3468 assert!(
3469 resolutions.is_empty(),
3470 "no share can resolve before its polynomial is committed"
3471 );
3472 assert_eq!(
3473 (POLYS as usize * THRESHOLD as usize),
3474 reconstructor.deferred_ack_count(&ssa_id),
3475 "every early ack must be bucketed under its own cycle"
3476 );
3477
3478 let state = reconstructor.insert_coefficient_commitments(
3481 ssa_id,
3482 0,
3483 proof_of(&commitment, 0),
3484 coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3485 )?;
3486 assert!(state.is_verifiable);
3487
3488 assert!(
3490 !reconstructor.pending_acks.contains_key(&ssa_id),
3491 "the cycle's bucket must be consumed by the drain"
3492 );
3493
3494 let resolutions = reconstructor.acknowledge_shares(*peer.public(), Vec::new())?;
3497 assert!(
3498 resolutions
3499 .iter()
3500 .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
3501 "deferred shares must recover the SSA once their verifiers install, got {resolutions:?}"
3502 );
3503
3504 Ok(())
3505 }
3506
3507 #[test]
3515 fn a_fault_redeemed_from_deferral_keeps_its_own_relayer() -> anyhow::Result<()> {
3516 const POLYS: u16 = 1;
3517 const THRESHOLD: u8 = 2;
3518
3519 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3521 polynomials_per_ssa: POLYS,
3522 threshold: THRESHOLD,
3523 surplus_shares: 1,
3524 });
3525 let pseudonym = SimplePseudonym::random();
3526 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3527 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3528
3529 let carrier = OffchainKeypair::random();
3532 let collector = OffchainKeypair::random();
3533
3534 let reconstructor = SsaReconstructor::<TestSpec>::default();
3535 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3536
3537 let mut acks = Vec::new();
3540 for i in 0..THRESHOLD {
3541 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3542 let mut share = generator
3543 .next_share(&pseudonym, &msg)?
3544 .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3545 if i == 0 {
3546 AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3547 }
3548 let ack = HalfKey::random();
3549 let enc = share.share.clone().encrypt(&share.id, &ack)?;
3550 reconstructor.insert_encrypted_share(
3551 carrier.public(),
3552 ack.to_challenge()?,
3553 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3554 )?;
3555 acks.push(VerifiedAcknowledgement::new(ack, &carrier).leak());
3556 }
3557 assert!(
3558 reconstructor.acknowledge_shares(*carrier.public(), acks)?.is_empty(),
3559 "shares must defer while the commitment is unknown"
3560 );
3561
3562 reconstructor.insert_coefficient_commitments(
3565 ssa_id,
3566 0,
3567 proof_of(&commitment, 0),
3568 coefficient_of(&commitment, 0, None)?.into_iter(),
3569 )?;
3570
3571 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3575 let surplus = generator
3576 .next_share(&pseudonym, &msg)?
3577 .ok_or_else(|| anyhow::anyhow!("generator must yield the surplus share"))?;
3578 let filler = HalfKey::random();
3579 reconstructor.insert_encrypted_share(
3580 collector.public(),
3581 filler.to_challenge()?,
3582 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, surplus.share.encrypt(&surplus.id, &filler)?)?,
3583 )?;
3584
3585 let faults = reconstructor
3586 .acknowledge_shares(*collector.public(), Vec::new())?
3587 .into_iter()
3588 .filter_map(|r| match r {
3589 ShareResolution::InvalidShares { peer, ssa_id, .. } => Some((peer, ssa_id)),
3590 _ => None,
3591 })
3592 .collect::<Vec<_>>();
3593
3594 assert_eq!(1, faults.len(), "the drained fault must surface exactly once");
3595 assert_eq!(ssa_id, faults[0].1);
3596 assert_eq!(
3597 carrier.public(),
3598 faults[0].0.as_ref(),
3599 "the fault must name the relayer that carried the share, not the peer that collected it"
3600 );
3601
3602 Ok(())
3603 }
3604
3605 #[test]
3609 fn deferring_against_an_installed_verifier_drains_immediately() -> anyhow::Result<()> {
3610 const POLYS: u16 = 2;
3611 const THRESHOLD: u8 = 2;
3612
3613 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3614 polynomials_per_ssa: POLYS,
3615 threshold: THRESHOLD,
3616 surplus_shares: 0,
3617 });
3618 let pseudonym = SimplePseudonym::random();
3619 let peer = OffchainKeypair::random();
3620 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3621
3622 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3623 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3624 generator
3625 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3626 .process_into_reconstructor(&reconstructor)?;
3627
3628 let spi = SsaPolynomialId::new(ssa_id, 0);
3630 assert!(reconstructor.cycle(&ssa_id).is_some_and(|c| c.part(0).is_some()));
3631
3632 let ack = HalfKey::random();
3634 reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3635
3636 assert!(
3637 !reconstructor.pending_acks.contains_key(&ssa_id),
3638 "a bucket created after its cycle installed must drain itself, not linger unclaimed"
3639 );
3640
3641 Ok(())
3642 }
3643
3644 #[test]
3647 fn deferred_ack_buckets_are_capped() -> anyhow::Result<()> {
3648 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3649 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3650 let spi = SsaPolynomialId::new(ssa_id, 0);
3651 let peer = OffchainKeypair::random();
3652
3653 for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL + 32 {
3654 let ack = HalfKey::random();
3655 reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3656 }
3657
3658 assert_eq!(
3659 MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
3660 reconstructor.deferred_ack_count(&ssa_id),
3661 "a polynomial's sub-bucket must not grow past the cap"
3662 );
3663
3664 Ok(())
3665 }
3666
3667 #[test]
3673 fn deferred_ack_buckets_are_capped_per_cycle() -> anyhow::Result<()> {
3674 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3675 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3676 let peer = OffchainKeypair::random();
3677
3678 let polys = (MAX_DEFERRED_ACKS_PER_CYCLE / (MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2)) + 8;
3681 'outer: for poly in 0..polys as PolynomialIndex {
3682 let spi = SsaPolynomialId::new(ssa_id, poly);
3683 for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2 {
3684 let ack = HalfKey::random();
3685 reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3686 if reconstructor.deferred_ack_count(&ssa_id) > MAX_DEFERRED_ACKS_PER_CYCLE {
3687 break 'outer;
3688 }
3689 }
3690 }
3691
3692 assert_eq!(
3693 MAX_DEFERRED_ACKS_PER_CYCLE,
3694 reconstructor.deferred_ack_count(&ssa_id),
3695 "the cycle total must not grow past the ceiling"
3696 );
3697
3698 Ok(())
3699 }
3700
3701 #[test]
3716 fn an_acknowledgement_deferred_into_a_drained_bucket_is_still_redeemed() -> anyhow::Result<()> {
3717 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3721 polynomials_per_ssa: 1,
3722 threshold: 2,
3723 surplus_shares: 0,
3724 });
3725 let pseudonym = SimplePseudonym::random();
3726 let peer = OffchainKeypair::random();
3727 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3728
3729 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3730 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3731 reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
3732
3733 let mut pending = Vec::new();
3735 for _ in 0..2 {
3736 let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3737 let ack = HalfKey::random();
3738 let challenge = ack.to_challenge()?;
3739 let enc = share.share.encrypt(&share.id, &ack)?;
3740 reconstructor.insert_encrypted_share(
3741 peer.public(),
3742 challenge,
3743 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3744 )?;
3745 pending.push((share.id, challenge, ack));
3746 }
3747
3748 let (_, _, first_ack) = pending[0];
3750 reconstructor.acknowledge_shares(
3751 *peer.public(),
3752 vec![VerifiedAcknowledgement::new(first_ack, &peer).leak()],
3753 )?;
3754 let bucket = reconstructor
3755 .pending_acks
3756 .get(&ssa_id)
3757 .ok_or_else(|| anyhow::anyhow!("the first acknowledgement must have created a bucket"))?;
3758
3759 commitment.process_into_reconstructor(&reconstructor)?;
3761 assert!(
3762 reconstructor.pending_acks.get(&ssa_id).is_none(),
3763 "the drain must have taken the cache key"
3764 );
3765
3766 let (spi, challenge, ack) = pending[1];
3768 reconstructor.defer_ack_into(&bucket, spi, (*peer.public(), challenge, ack));
3769
3770 assert!(
3773 reconstructor
3774 .take_ready_resolutions()
3775 .iter()
3776 .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
3777 "the acknowledgement must have been redeemed inline, not lost with the bucket"
3778 );
3779
3780 Ok(())
3781 }
3782
3783 fn park_a_recovered_ssa() -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>)> {
3790 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3791 polynomials_per_ssa: 1,
3792 threshold: 2,
3793 surplus_shares: 0,
3794 });
3795 let pseudonym = SimplePseudonym::random();
3796 let peer = OffchainKeypair::random();
3797 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3798
3799 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3800 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3801 reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
3802
3803 for _ in 0..2 {
3804 let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3805 let ack = HalfKey::random();
3806 let challenge = ack.to_challenge()?;
3807 let enc = share.share.encrypt(&share.id, &ack)?;
3808 reconstructor.insert_encrypted_share(
3809 peer.public(),
3810 challenge,
3811 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3812 )?;
3813 reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
3814 }
3815
3816 commitment.process_into_reconstructor(&reconstructor)?;
3817 assert_ne!(
3818 0,
3819 reconstructor
3820 .ready_resolutions_len
3821 .load(std::sync::atomic::Ordering::Acquire),
3822 "the drain must have parked the recovery"
3823 );
3824
3825 Ok((reconstructor, ssa_id))
3826 }
3827
3828 #[test]
3836 fn a_parked_resolution_is_collectable_through_any_peer() -> anyhow::Result<()> {
3837 let (reconstructor, _) = park_a_recovered_ssa()?;
3838
3839 let bystander = *OffchainKeypair::random().public();
3841 assert!(
3842 !reconstructor.awaiting_acks.contains_key(&bystander),
3843 "the bystander must have no pending shares of its own"
3844 );
3845 assert!(
3846 reconstructor.has_pending_shares(&bystander),
3847 "a parked resolution must let any batch through to collect it"
3848 );
3849
3850 assert!(
3852 reconstructor
3853 .take_ready_resolutions()
3854 .iter()
3855 .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_)))
3856 );
3857 assert!(
3858 !reconstructor.has_pending_shares(&bystander),
3859 "with nothing parked the guard must not admit an unrelated peer"
3860 );
3861
3862 Ok(())
3863 }
3864
3865 #[test]
3874 fn retiring_a_cycle_leaves_its_resolution_collectable() -> anyhow::Result<()> {
3875 let (reconstructor, ssa_id) = park_a_recovered_ssa()?;
3876
3877 reconstructor.retire_ssa(ssa_id);
3878
3879 assert!(
3880 reconstructor
3881 .take_ready_resolutions()
3882 .iter()
3883 .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
3884 "retirement must not swallow a recovered deposit key"
3885 );
3886
3887 Ok(())
3888 }
3889
3890 #[test]
3913 fn a_cycle_stays_live_while_a_single_polynomial_goes_untouched() -> anyhow::Result<()> {
3914 const POLYS: u16 = 2;
3915 const THRESHOLD: u8 = 4;
3916 const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(1000);
3929 const SHARE_SPACING: std::time::Duration = std::time::Duration::from_millis(250);
3931
3932 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3933 polynomials_per_ssa: POLYS,
3934 threshold: THRESHOLD,
3935 surplus_shares: 0,
3936 });
3937 let pseudonym = SimplePseudonym::random();
3938 let peer = OffchainKeypair::random();
3939 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3940
3941 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
3942 unused_verifier_lifetime: VERIFIER_LIFETIME,
3943 ..Default::default()
3944 });
3945 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3946
3947 generator
3950 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3951 .process_into_reconstructor(&reconstructor)?;
3952
3953 let installed_at = std::time::Instant::now();
3954 let mut recovered = false;
3955 for i in 0..(POLYS as usize * THRESHOLD as usize) {
3956 std::thread::sleep(SHARE_SPACING);
3957
3958 if i == THRESHOLD as usize {
3961 assert!(
3962 installed_at.elapsed() > VERIFIER_LIFETIME,
3963 "the test must actually outlast the lifetime before polynomial 1's first share, otherwise it \
3964 exercises nothing"
3965 );
3966 }
3967
3968 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3969 let share = generator
3970 .next_share(&pseudonym, &msg)?
3971 .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3972
3973 let ack = HalfKey::random();
3974 let enc = share.share.encrypt(&share.id, &ack)?;
3975 reconstructor.insert_encrypted_share(
3976 peer.public(),
3977 ack.to_challenge()?,
3978 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3979 )?;
3980 recovered |= reconstructor
3981 .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?
3982 .iter()
3983 .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
3984 }
3985
3986 if !recovered {
3987 assert_eq!(
3991 0,
3992 reconstructor.deferred_ack_count(&ssa_id),
3993 "shares were stranded in a deferred-ack bucket — H8 has regressed"
3994 );
3995 panic!("the cycle failed to recover even though it was continuously busy");
3996 }
3997
3998 Ok(())
3999 }
4000
4001 #[test]
4008 fn a_cycle_with_no_shares_at_all_still_expires() -> anyhow::Result<()> {
4009 const POLYS: u16 = 2;
4010 const THRESHOLD: u8 = 2;
4011 const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(500);
4012
4013 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4014 polynomials_per_ssa: POLYS,
4015 threshold: THRESHOLD,
4016 surplus_shares: 0,
4017 });
4018 let pseudonym = SimplePseudonym::random();
4019 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4020
4021 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
4022 unused_verifier_lifetime: VERIFIER_LIFETIME,
4023 ..Default::default()
4024 });
4025 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4026 generator
4027 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4028 .process_into_reconstructor(&reconstructor)?;
4029
4030 assert_eq!(POLYS as usize, reconstructor.installed_parts(&ssa_id));
4031
4032 std::thread::sleep(VERIFIER_LIFETIME * 3);
4033
4034 assert_eq!(
4035 0,
4036 reconstructor.live_cycles(),
4037 "a cycle that never received a share must still be reclaimed"
4038 );
4039
4040 Ok(())
4041 }
4042
4043 #[test]
4047 fn a_share_naming_a_polynomial_outside_the_cycle_is_rejected() -> anyhow::Result<()> {
4048 const POLYS: u16 = 2;
4049 const THRESHOLD: u8 = 2;
4050
4051 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4052 polynomials_per_ssa: POLYS,
4053 threshold: THRESHOLD,
4054 surplus_shares: 0,
4055 });
4056 let pseudonym = SimplePseudonym::random();
4057 let peer = OffchainKeypair::random();
4058 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4059
4060 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4061 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4062 generator
4063 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4064 .process_into_reconstructor(&reconstructor)?;
4065
4066 let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4068 let mut share = generator
4069 .next_share(&pseudonym, &msg)?
4070 .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
4071 share.id = SsaPolynomialId::new(ssa_id, POLYS as PolynomialIndex + 5);
4072
4073 let ack = HalfKey::random();
4074 let enc = share.share.encrypt(&share.id, &ack)?;
4075 reconstructor.insert_encrypted_share(
4076 peer.public(),
4077 ack.to_challenge()?,
4078 TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
4079 )?;
4080
4081 let resolutions =
4084 reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
4085 assert!(resolutions.is_empty(), "an out-of-range share must resolve to nothing");
4086 assert_eq!(
4087 0,
4088 reconstructor.deferred_ack_count(&ssa_id),
4089 "an out-of-range share must be rejected outright, not deferred forever"
4090 );
4091 assert!(
4092 reconstructor.cycle(&ssa_id).is_some(),
4093 "the cycle itself must be unharmed"
4094 );
4095
4096 Ok(())
4097 }
4098
4099 #[test]
4105 fn parts_of_one_cycle_lock_independently() -> anyhow::Result<()> {
4106 const POLYS: u16 = 4;
4107 const THRESHOLD: u8 = 2;
4108
4109 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4110 polynomials_per_ssa: POLYS,
4111 threshold: THRESHOLD,
4112 surplus_shares: 0,
4113 });
4114 let pseudonym = SimplePseudonym::random();
4115 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4116
4117 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4118 reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4119 generator
4120 .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4121 .process_into_reconstructor(&reconstructor)?;
4122
4123 let cycle = reconstructor
4124 .cycle(&ssa_id)
4125 .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
4126
4127 let held = cycle.part(0).ok_or_else(|| anyhow::anyhow!("missing part 0"))?.lock();
4128 for poly in 1..POLYS as PolynomialIndex {
4129 assert!(
4130 cycle
4131 .part(poly)
4132 .ok_or_else(|| anyhow::anyhow!("missing part {poly}"))?
4133 .try_lock()
4134 .is_some(),
4135 "polynomial {poly} must not be blocked by polynomial 0 — the cycle must not share one mutex"
4136 );
4137 }
4138 assert!(
4141 cycle.builder().try_lock().is_some(),
4142 "the accumulator must lock separately"
4143 );
4144 drop(held);
4145
4146 Ok(())
4147 }
4148
4149 #[test]
4154 fn builder_caches_accept_more_entries_than_max_polys_per_ssa() -> anyhow::Result<()> {
4155 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4156 let pseudonym = SimplePseudonym::random();
4157 let exceed = MAX_POLYS_PER_SSA as usize + 5;
4158 let mut ids = Vec::with_capacity(exceed);
4159 for i in 0..exceed {
4160 let ssa_id = SsaId::new(pseudonym, (1u32 + i as u32).try_into()?);
4161 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4162 ids.push(ssa_id);
4163 }
4164 reconstructor.commitment_builder.run_pending_tasks();
4165 for ssa_id in &ids {
4166 assert!(
4167 reconstructor.contains_builder(ssa_id),
4168 "commitment builder must retain every accepted SsaId ({ssa_id:?})"
4169 );
4170 }
4171
4172 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4176 polynomials_per_ssa: 2,
4177 threshold: 2,
4178 surplus_shares: 1,
4179 });
4180 for ssa_id in ids.iter().take(3) {
4181 let commit = generator.new_ssa_commitment(&pseudonym, ssa_id.ssa_index())?;
4182 commit.process_into_reconstructor(&reconstructor)?;
4183 reconstructor.commitment_builder.run_pending_tasks();
4184
4185 assert_eq!(
4186 2,
4187 reconstructor.installed_parts(ssa_id),
4188 "ssa_cycles must contain completed SsaId index {} ({ssa_id:?})",
4189 ssa_id.ssa_index().get(),
4190 );
4191 }
4192
4193 Ok(())
4194 }
4195
4196 fn coefficient_of(
4199 commitment: &crate::SsaCommitment<TestSpec>,
4200 coeff_index: CoefficientIndex,
4201 only_poly: Option<PolynomialIndex>,
4202 ) -> anyhow::Result<Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)>> {
4203 Ok(commitment
4204 .verifiers
4205 .get(&coeff_index)
4206 .ok_or_else(|| anyhow::anyhow!("missing coefficient {coeff_index}"))?
4207 .iter()
4208 .filter(|(poly_index, _)| only_poly.is_none_or(|wanted| *poly_index == wanted))
4209 .map(|(poly_index, repr)| (*poly_index, *repr))
4210 .collect())
4211 }
4212
4213 #[test]
4219 fn retire_ssa_tombstone_prevents_builder_publication() -> anyhow::Result<()> {
4220 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4221 polynomials_per_ssa: 2,
4222 threshold: 2,
4223 surplus_shares: 0,
4224 });
4225 let pseudonym = SimplePseudonym::random();
4226 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4227 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4228
4229 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4230 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4231
4232 let state = reconstructor.insert_coefficient_commitments(
4234 ssa_id,
4235 0,
4236 proof_of(&commitment, 0),
4237 coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4238 )?;
4239 assert!(
4240 state.ssa_deposit_address.is_none(),
4241 "deposit address must not be derivable from a partial constant-term set"
4242 );
4243
4244 reconstructor.retired_ssas.insert(ssa_id, ());
4246
4247 let state = reconstructor.insert_coefficient_commitments(
4249 ssa_id,
4250 0,
4251 proof_of(&commitment, 0),
4252 coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4253 )?;
4254 assert!(!state.is_verifiable, "a retired cycle is never verifiable");
4255
4256 assert_eq!(
4257 0,
4258 reconstructor.live_cycles(),
4259 "tombstone must prevent cycle publication"
4260 );
4261
4262 Ok(())
4263 }
4264
4265 #[test]
4271 fn retire_ssa_tombstone_prevents_verifier_installation() -> anyhow::Result<()> {
4272 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4273 polynomials_per_ssa: 2,
4274 threshold: 2,
4275 surplus_shares: 0,
4276 });
4277 let pseudonym = SimplePseudonym::random();
4278 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4279 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4280
4281 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4282 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4283
4284 reconstructor.insert_coefficient_commitments(
4286 ssa_id,
4287 0,
4288 proof_of(&commitment, 0),
4289 coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4290 )?;
4291 assert_eq!(
4292 0,
4293 reconstructor.live_cycles(),
4294 "no part builder may be installed while the SSA commitment is unknown"
4295 );
4296
4297 reconstructor.retired_ssas.insert(ssa_id, ());
4299
4300 let state = reconstructor.insert_coefficient_commitments(
4302 ssa_id,
4303 0,
4304 proof_of(&commitment, 0),
4305 coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4306 )?;
4307 assert!(!state.is_verifiable, "a retired cycle is never verifiable");
4308
4309 assert_eq!(
4310 0,
4311 reconstructor.live_cycles(),
4312 "tombstone must withdraw the cycle published after retirement — accumulator and every part builder"
4313 );
4314
4315 Ok(())
4316 }
4317
4318 #[test]
4321 fn commitment_proof_must_bind_its_components_the_ssa_index_and_the_commitment() -> anyhow::Result<()> {
4322 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4323 polynomials_per_ssa: 2,
4324 threshold: 2,
4325 surplus_shares: 0,
4326 });
4327 let pseudonym = SimplePseudonym::random();
4328 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4329 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4330 let proof = commitment.commitment_proof;
4331
4332 assert!(
4333 proof.verify(&ssa_id, &commitment.ssa_commitment),
4334 "the generator's own proof must verify"
4335 );
4336
4337 let bytes = proof.to_bytes();
4340 assert_eq!(SsaCommitmentProof::<TestSpec>::SIZE, bytes.len());
4341 for position in [0, bytes.len() / 2, bytes.len() - 1] {
4342 let mut tampered = bytes.clone();
4343 tampered[position] ^= 1;
4344 if let Ok(tampered) = SsaCommitmentProof::<TestSpec>::try_from_bytes(&tampered) {
4345 assert!(
4346 !tampered.verify(&ssa_id, &commitment.ssa_commitment),
4347 "a proof with byte {position} flipped must not verify"
4348 );
4349 }
4350 }
4351
4352 let other_index = SsaId::new(pseudonym, SsaIndex::new(SsaIndex::MIN.get() + 1).expect("non-zero"));
4355 assert!(
4356 !proof.verify(&other_index, &commitment.ssa_commitment),
4357 "a proof must not verify against a different SSA index"
4358 );
4359
4360 let unrelated = PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
4362 &mut hopr_types::crypto_random::rng(),
4363 ));
4364 assert!(
4365 !proof.verify(&ssa_id, &unrelated),
4366 "a proof must not verify against a commitment it does not open"
4367 );
4368
4369 assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&bytes[..bytes.len() - 1]).is_err());
4371 assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&[bytes.as_slice(), &[0u8]].concat()).is_err());
4372
4373 Ok(())
4374 }
4375
4376 #[test]
4379 fn constant_terms_arriving_without_a_proof_are_refused() -> anyhow::Result<()> {
4380 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4381 polynomials_per_ssa: 2,
4382 threshold: 2,
4383 surplus_shares: 0,
4384 });
4385 let pseudonym = SimplePseudonym::random();
4386 let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4387 let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4388
4389 let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4390 reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4391
4392 let refused = reconstructor.insert_coefficient_commitments(
4393 ssa_id,
4394 0,
4395 None,
4396 coefficient_of(&commitment, 0, None)?.into_iter(),
4397 );
4398 assert!(
4399 matches!(refused, Err(PixError::UnprovenSsaCommitment)),
4400 "constant terms without a proof must be refused, got {refused:?}"
4401 );
4402 assert_eq!(0, reconstructor.live_cycles(), "no cycle may be published");
4403
4404 Ok(())
4405 }
4406
4407 #[test]
4429 fn exit_refuses_a_client_commitment_whose_deposit_key_the_entry_knows() -> anyhow::Result<()> {
4430 const POLYS: usize = 3;
4431 const THRESHOLD: usize = 2;
4432
4433 let mut rng = hopr_types::crypto_random::rng();
4434 let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
4435 let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
4436
4437 let exit_public = reconstructor.new_exit_commitment(ssa_id, POLYS, THRESHOLD)?;
4439
4440 let w = PixScalar::<TestSpec>::random(&mut rng);
4442 let target = PixGroup::<TestSpec>::mul_by_generator(&w);
4443
4444 let honest: Vec<PixGroup<TestSpec>> = (0..POLYS - 1)
4446 .map(|_| PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(&mut rng)))
4447 .collect();
4448 let honest_sum: PixGroup<TestSpec> = honest.iter().copied().sum();
4449
4450 let rogue = target - exit_public - honest_sum;
4453
4454 let mut constant_terms: HashMap<PolynomialIndex, PixGroupRepr<TestSpec>> = honest
4455 .iter()
4456 .enumerate()
4457 .map(|(poly_index, c0)| (poly_index as PolynomialIndex, c0.to_bytes()))
4458 .collect();
4459 constant_terms.insert((POLYS - 1) as PolynomialIndex, rogue.to_bytes());
4460
4461 let bogus_proof = SsaCommitmentProof::prove(&ssa_id, &w, &(target - exit_public))?;
4464
4465 let rejected =
4466 reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(bogus_proof), constant_terms.into_iter());
4467 assert!(
4468 matches!(rejected, Err(PixError::UnprovenSsaCommitment)),
4469 "a commitment whose discrete logarithm the sender does not know must be refused, got {rejected:?}"
4470 );
4471
4472 assert!(
4475 reconstructor
4476 .commitment_builder
4477 .get(&ssa_id)
4478 .is_some_and(|b| b.lock().get_deposit_address().is_none()),
4479 "no deposit address may be derived from an unproven commitment"
4480 );
4481 assert_eq!(
4484 0,
4485 reconstructor.live_cycles(),
4486 "no cycle must be published for an unproven commitment"
4487 );
4488
4489 Ok(())
4490 }
4491}