1use std::collections::{BTreeMap, BTreeSet};
22
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
24
25use super::super::edition::AuthorityCitation;
26use super::derive::GroupKey;
27use super::kind;
28use super::stream::{self, OpenedStream, SealForm, StreamError};
29
30pub const MAX_FUTURE_MS: u64 = 3_600_000;
34
35pub const SNAPSHOT_CHUNK: usize = 400;
39
40const TAG_INVITE: &str = "invite";
41const TAG_TARGET: &str = "p";
42const TAG_SNAP: &str = "snap";
43
44const VERB_JOIN: &str = "join";
45const VERB_LEAVE: &str = "leave";
46
47#[derive(Debug)]
49pub enum GuestbookError {
50 Stream(StreamError),
51 NotGuestbook(u16),
53 NotEncryptedSealed,
57 BadVerb,
60 MissingTag(&'static str),
61 DuplicateTag(&'static str),
63 BadTag(&'static str),
66 BadSnapshotContent,
68}
69
70impl std::fmt::Display for GuestbookError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 GuestbookError::Stream(e) => write!(f, "stream: {e}"),
74 GuestbookError::NotGuestbook(k) => write!(f, "rumor kind {k} is not a guestbook event"),
75 GuestbookError::NotEncryptedSealed => write!(f, "guestbook events must ride an encrypted seal"),
76 GuestbookError::BadVerb => write!(f, "3306 content is not exactly join/leave"),
77 GuestbookError::MissingTag(t) => write!(f, "missing guestbook tag: {t}"),
78 GuestbookError::DuplicateTag(t) => write!(f, "duplicate guestbook tag: {t}"),
79 GuestbookError::BadTag(t) => write!(f, "unparseable guestbook tag: {t}"),
80 GuestbookError::BadSnapshotContent => write!(f, "snapshot content is not a JSON array"),
81 }
82 }
83}
84
85impl std::error::Error for GuestbookError {}
86
87impl From<StreamError> for GuestbookError {
88 fn from(e: StreamError) -> Self {
89 GuestbookError::Stream(e)
90 }
91}
92
93pub fn build_join_rumor(author: PublicKey, invite_attribution: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent {
98 let mut tags = Vec::new();
99 if let Some((creator, label)) = invite_attribution {
100 tags.push(Tag::custom(
101 TAG_INVITE,
102 [creator.to_string(), label.to_string()],
103 ));
104 }
105 stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_JOIN, tags, at_ms)
106}
107
108pub fn build_leave_rumor(author: PublicKey, at_ms: u64) -> UnsignedEvent {
110 stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_LEAVE, vec![], at_ms)
111}
112
113pub fn build_kick_rumor(
118 admin: PublicKey,
119 target: PublicKey,
120 citation: Option<&AuthorityCitation>,
121 at_ms: u64,
122) -> UnsignedEvent {
123 let mut tags = vec![Tag::public_key(target)];
124 if let Some(c) = citation {
125 tags.push(c.to_tag());
126 }
127 stream::build_rumor_ms(kind::KICK, admin, "", tags, at_ms)
128}
129
130pub fn build_snapshot_rumors(
137 refounder: PublicKey,
138 members: &[PublicKey],
139 snapshot_id: [u8; 32],
140 at_ms: u64,
141) -> Vec<UnsignedEvent> {
142 let id_hex = crate::simd::hex::bytes_to_hex_32(&snapshot_id);
143 let chunks: Vec<&[PublicKey]> = if members.is_empty() {
144 vec![&[]]
145 } else {
146 members.chunks(SNAPSHOT_CHUNK).collect()
147 };
148 let n = chunks.len();
149 chunks
150 .iter()
151 .enumerate()
152 .map(|(idx, chunk)| {
153 let hexes: Vec<String> = chunk.iter().map(|p| p.to_hex()).collect();
154 let content = serde_json::to_string(&hexes).expect("a string array always serializes");
155 let tags = vec![Tag::custom(
156 TAG_SNAP,
157 [id_hex.clone(), (idx + 1).to_string(), n.to_string()],
158 )];
159 stream::build_rumor_ms(kind::SNAPSHOT, refounder, &content, tags, at_ms)
160 })
161 .collect()
162}
163
164pub fn seal_guestbook_rumor(
168 rumor: &UnsignedEvent,
169 group: &GroupKey,
170 author_keys: &Keys,
171 wrap_at: Timestamp,
172) -> Result<(Event, Keys), GuestbookError> {
173 let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
174 Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
175}
176
177pub async fn seal_guestbook_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
181 signer: &S,
182 author: PublicKey,
183 rumor: &UnsignedEvent,
184 group: &GroupKey,
185 wrap_at: Timestamp,
186) -> Result<(Event, Keys), GuestbookError> {
187 Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, stream::KIND_WRAP, wrap_at, &[]).await?)
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
197pub struct GuestbookEvent {
198 pub rumor_id: [u8; 32],
200 pub entry: GuestbookEntry,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub epoch: Option<u64>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub observed_at: Option<u64>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub enum GuestbookEntry {
221 Join {
222 member: PublicKey,
223 invited_by: Option<(String, String)>,
226 at_ms: u64,
227 },
228 Leave {
229 member: PublicKey,
230 at_ms: u64,
231 },
232 Kick {
233 actor: PublicKey,
234 target: PublicKey,
235 citation: Option<AuthorityCitation>,
239 at_ms: u64,
240 },
241 Snapshot {
242 refounder: PublicKey,
243 members: Vec<PublicKey>,
244 snapshot_id: [u8; 32],
245 chunk: (u32, u32),
247 at_ms: u64,
248 },
249}
250
251impl GuestbookEntry {
252 pub fn at_ms(&self) -> u64 {
254 match self {
255 GuestbookEntry::Join { at_ms, .. }
256 | GuestbookEntry::Leave { at_ms, .. }
257 | GuestbookEntry::Kick { at_ms, .. }
258 | GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
259 }
260 }
261}
262
263pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
268 if opened.seal_form != SealForm::Encrypted {
269 return Err(GuestbookError::NotEncryptedSealed);
270 }
271 let rumor = &opened.rumor;
272 let at_ms = opened.at_ms;
273
274 let entry = match rumor.kind.as_u16() {
275 kind::JOIN_LEAVE => match rumor.content.as_str() {
276 VERB_JOIN => {
277 let invited_by = rumor.tags.iter().find_map(|t| {
278 let s = t.as_slice();
279 (s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
280 });
281 GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
282 }
283 VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
284 _ => return Err(GuestbookError::BadVerb),
285 },
286 kind::KICK => {
287 let mut target: Option<PublicKey> = None;
290 for t in rumor.tags.iter() {
291 let s = t.as_slice();
292 if s.len() >= 2 && s[0] == TAG_TARGET {
293 if target.is_some() {
294 return Err(GuestbookError::DuplicateTag(TAG_TARGET));
295 }
296 target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
297 }
298 }
299 let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
300 GuestbookEntry::Kick {
301 actor: opened.author,
302 target,
303 citation: AuthorityCitation::from_tags(&rumor.tags),
304 at_ms,
305 }
306 }
307 kind::SNAPSHOT => {
308 let mut snap: Option<(String, String, String)> = None;
309 for t in rumor.tags.iter() {
310 let s = t.as_slice();
311 if s.len() >= 2 && s[0] == TAG_SNAP {
312 if snap.is_some() {
313 return Err(GuestbookError::DuplicateTag(TAG_SNAP));
314 }
315 if s.len() < 4 {
316 return Err(GuestbookError::BadTag(TAG_SNAP));
317 }
318 snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
319 }
320 }
321 let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
324 if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
325 return Err(GuestbookError::BadTag(TAG_SNAP));
326 }
327 let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
328 let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
329 let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
330 if i < 1 || i > n {
331 return Err(GuestbookError::BadTag(TAG_SNAP));
332 }
333 let raw: Vec<serde_json::Value> =
334 serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
335 let members = raw
340 .iter()
341 .filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
342 .collect();
343 GuestbookEntry::Snapshot {
344 refounder: opened.author,
345 members,
346 snapshot_id,
347 chunk: (i, n),
348 at_ms,
349 }
350 }
351 k => return Err(GuestbookError::NotGuestbook(k)),
352 };
353
354 Ok(GuestbookEvent { epoch: None, observed_at: None, rumor_id: opened.rumor_id.to_bytes(), entry })
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum Verdict {
362 Joined,
363 Left,
364 Kicked,
365 Refounded,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum Source {
374 Firsthand,
375 Snapshot,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct MemberState {
381 pub verdict: Verdict,
382 pub at_ms: u64,
384 pub source: Source,
385 pub invited_by: Option<(String, String)>,
387 pub rumor_id: [u8; 32],
389}
390
391fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
397 if next.at_ms != prev.at_ms {
398 return next.at_ms > prev.at_ms;
399 }
400 if next.source != prev.source {
401 return next.source == Source::Firsthand;
402 }
403 next.rumor_id < prev.rumor_id
404}
405
406fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
407 match fold.get(&member) {
408 Some(prev) if !supersedes(prev, &next) => {}
409 _ => {
410 fold.insert(member, next);
411 }
412 }
413}
414
415pub fn coalesce(
433 events: &[GuestbookEvent],
434 now_ms: u64,
435 snapshot_authority: Option<&PublicKey>,
436 can_kick: &dyn Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
437) -> BTreeMap<PublicKey, MemberState> {
438 let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
439 let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
440
441 for ev in events {
442 if ev.entry.at_ms() > horizon {
443 continue;
444 }
445 match &ev.entry {
446 GuestbookEntry::Join { member, invited_by, at_ms } => apply(
447 &mut fold,
448 *member,
449 MemberState {
450 verdict: Verdict::Joined,
451 at_ms: *at_ms,
452 source: Source::Firsthand,
453 invited_by: invited_by.clone(),
454 rumor_id: ev.rumor_id,
455 },
456 ),
457 GuestbookEntry::Leave { member, at_ms } => apply(
458 &mut fold,
459 *member,
460 MemberState {
461 verdict: Verdict::Left,
462 at_ms: *at_ms,
463 source: Source::Firsthand,
464 invited_by: None,
465 rumor_id: ev.rumor_id,
466 },
467 ),
468 GuestbookEntry::Kick { actor, target, at_ms, citation } => {
469 if !can_kick(actor, target, citation.as_ref()) {
470 continue;
471 }
472 apply(
473 &mut fold,
474 *target,
475 MemberState {
476 verdict: Verdict::Kicked,
477 at_ms: *at_ms,
478 source: Source::Firsthand,
479 invited_by: None,
480 rumor_id: ev.rumor_id,
481 },
482 );
483 }
484 GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
485 if snapshot_authority != Some(refounder) {
486 continue;
487 }
488 for m in members {
497 apply(
498 &mut fold,
499 *m,
500 MemberState {
501 verdict: Verdict::Joined,
502 at_ms: *at_ms,
503 source: Source::Snapshot,
504 invited_by: None,
505 rumor_id: ev.rumor_id,
506 },
507 );
508 }
509 }
510 }
511 }
512
513 supersede_by_refounding(&mut fold, events, horizon, snapshot_authority);
514 fold
515}
516
517pub struct Refound {
538 pub cut: u64,
539 pub base: BTreeSet<PublicKey>,
540 pub rumor_id: [u8; 32],
541}
542
543pub(super) fn live_refound(
544 events: &[GuestbookEvent],
545 snapshot_authority: Option<&PublicKey>,
546 horizon: u64,
547) -> Option<Refound> {
548 let authority = snapshot_authority?;
549 let live = events.iter().filter_map(|e| e.epoch).max()?;
550 let mut base: BTreeSet<PublicKey> = BTreeSet::new();
551 let mut cut: Option<u64> = None;
552 let mut rumor_id: Option<[u8; 32]> = None;
553 for ev in events {
554 if let GuestbookEntry::Snapshot { refounder, members, at_ms, .. } = &ev.entry {
555 if ev.epoch == Some(live) && refounder == authority && *at_ms <= horizon {
556 base.extend(members.iter().copied());
557 cut = Some(cut.map_or(*at_ms, |c| c.min(*at_ms)));
558 rumor_id = Some(rumor_id.map_or(ev.rumor_id, |r| r.min(ev.rumor_id)));
559 }
560 }
561 }
562 Some(Refound { cut: cut?, base, rumor_id: rumor_id? })
563}
564
565fn supersede_by_refounding(
566 fold: &mut BTreeMap<PublicKey, MemberState>,
567 events: &[GuestbookEvent],
568 horizon: u64,
569 snapshot_authority: Option<&PublicKey>,
570) {
571 let Some(Refound { cut, base, rumor_id }) = live_refound(events, snapshot_authority, horizon) else { return };
572 let live = events.iter().filter_map(|e| e.epoch).max();
577 let provenance: BTreeMap<[u8; 32], Option<u64>> = events.iter().map(|e| (e.rumor_id, e.epoch)).collect();
578 for (pk, st) in fold.iter_mut() {
579 if st.verdict != Verdict::Joined || base.contains(pk) {
580 continue;
581 }
582 let older_plane = matches!((provenance.get(&st.rumor_id).copied().flatten(), live), (Some(e), Some(l)) if e < l);
583 if st.at_ms < cut || older_plane {
584 *st = MemberState { verdict: Verdict::Refounded, at_ms: cut, source: Source::Snapshot, invited_by: None, rumor_id };
585 }
586 }
587}
588
589pub fn complete_memberlist(
599 coalesced: &BTreeMap<PublicKey, MemberState>,
600 observed: &BTreeMap<PublicKey, u64>,
601 banlist: &BTreeSet<PublicKey>,
602 banned_at: &BTreeMap<PublicKey, u64>,
603 refound: Option<&Refound>,
604) -> BTreeSet<PublicKey> {
605 let stale_pre_refound = |pk: &PublicKey, ms: u64| {
615 ms != 0 && refound.is_some_and(|r| !r.base.contains(pk) && ms <= r.cut)
616 };
617 let stale_pre_ban = |pk: &PublicKey, ms: u64| banned_at.get(pk).is_some_and(|at| ms <= at.saturating_mul(1000));
626 let mut out = BTreeSet::new();
627 for (pk, st) in coalesced {
628 if st.verdict == Verdict::Joined && !banlist.contains(pk) && !stale_pre_ban(pk, st.at_ms) {
629 out.insert(*pk);
630 }
631 }
632 for (pk, seen_ms) in observed {
633 if banlist.contains(pk) || stale_pre_ban(pk, *seen_ms) || stale_pre_refound(pk, *seen_ms) {
634 continue;
635 }
636 match coalesced.get(pk) {
637 Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
638 _ => {
639 out.insert(*pk);
640 }
641 }
642 }
643 out
644}
645
646#[cfg(test)]
647mod tests {
648 use super::super::super::{CommunityId, Epoch};
649 use super::super::derive::guestbook_group_key;
650 use super::*;
651
652 const NOW: u64 = 1_722_500_000_000;
654
655 fn cid() -> CommunityId {
656 CommunityId([0x33; 32])
657 }
658
659 fn group() -> GroupKey {
660 guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
661 }
662
663 fn pk() -> PublicKey {
664 Keys::generate().public_key()
665 }
666
667 fn always(_: &PublicKey, _: &PublicKey, _: Option<&AuthorityCitation>) -> bool {
668 true
669 }
670
671 fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
673 let g = group();
674 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
675 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
676 }
677
678 fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
679 let g = group();
680 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
681 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
682 }
683
684 fn at_epoch(mut ev: GuestbookEvent, epoch: u64) -> GuestbookEvent {
687 ev.epoch = Some(epoch);
688 ev
689 }
690
691 fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
692 GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
693 }
694
695 fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
696 GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
697 }
698
699 fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
700 GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
701 }
702
703 fn snap_ev(
704 refounder: PublicKey,
705 members: Vec<PublicKey>,
706 snap: u8,
707 chunk: (u32, u32),
708 at_ms: u64,
709 id: u8,
710 ) -> GuestbookEvent {
711 GuestbookEvent { epoch: None, observed_at: None,
712 rumor_id: [id; 32],
713 entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
714 }
715 }
716
717 #[test]
718 fn join_and_leave_round_trip_through_the_stream() {
719 let member = Keys::generate();
720 let creator = pk().to_hex();
721
722 let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
723 let ev = through(&join, &member);
724 assert_eq!(
725 ev.entry,
726 GuestbookEntry::Join {
727 member: member.public_key(),
728 invited_by: Some((creator, "Reddit".into())),
729 at_ms: 1_722_400_000_128,
730 }
731 );
732
733 let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
735 assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
736 let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
737 assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
738 }
739
740 #[test]
741 fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
742 let admin = Keys::generate();
743 let target = pk();
744 let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
745 let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
746
747 let g = group();
748 let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
749 let opened = stream::open_wrap(&wrap, &g).unwrap();
750 let ev = parse_guestbook_event(&opened).unwrap();
751
752 assert_eq!(
753 ev.entry,
754 GuestbookEntry::Kick {
755 actor: admin.public_key(),
756 target,
757 citation: Some(cite),
758 at_ms: 1_722_410_000_301,
759 }
760 );
761 assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
764 assert_ne!(ev.rumor_id, wrap.id.to_bytes());
765
766 let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
768 assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
769 }
770
771 #[test]
772 fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
773 let refounder = Keys::generate();
774 let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
775 let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
776 assert_eq!(rumors.len(), 2);
777
778 let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
779 let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
780 GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
781 (&parsed[0].entry, &parsed[1].entry)
782 else {
783 panic!("expected two snapshot entries");
784 };
785 assert_eq!((m1.len(), m2.len()), (400, 1));
786 assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
787 assert_eq!(id1, &[0x5a; 32]);
788 assert_eq!(id1, id2);
789 assert_eq!(t1, t2, "all chunks share one timestamp");
790 assert_eq!(*r1, refounder.public_key());
791 let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
792 assert_eq!(all, members, "membership survives the chunking intact");
793
794 let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
796 assert_eq!(empty.len(), 1);
797 let ev = through(&empty[0], &refounder);
798 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
799 }
800
801 #[test]
802 fn plaintext_sealed_guestbook_events_are_rejected() {
803 let member = Keys::generate();
806 let g = group();
807 let rumor = build_join_rumor(member.public_key(), None, 1_000);
808 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
809 let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
810 let opened = stream::open_wrap(&wrap, &g).unwrap();
811 assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
812 }
813
814 #[test]
815 fn future_dated_entries_are_dropped_outright() {
816 let m = pk();
817 let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
820 let ok = leave_ev(m, NOW + 59 * 60_000, 2);
821 let fold = coalesce(&[squat, ok], NOW, None, &always);
822 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
823
824 let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
826 let fold = coalesce(&[edge], NOW, None, &always);
827 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
828 }
829
830 #[test]
831 fn latest_wins_per_npub_join_leave_rejoin() {
832 let m = pk();
833 let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
834
835 let fold = coalesce(&evs, NOW, None, &always);
836 let st = fold.get(&m).unwrap();
837 assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
838 assert_eq!(st.at_ms, 3_000);
839
840 let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
842 assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
843
844 let fold = coalesce(&evs[..2], NOW, None, &always);
846 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
847 }
848
849 #[test]
850 fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
851 let m = pk();
852 let auth = pk();
853
854 let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
857 let leave = leave_ev(m, 5_000, 0xff);
858 for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
859 let fold = coalesce(&evs, NOW, Some(&auth), &always);
860 let st = fold.get(&m).unwrap();
861 assert_eq!(st.verdict, Verdict::Left);
862 assert_eq!(st.source, Source::Firsthand);
863 }
864
865 let join = join_ev(m, 6_000, 0x01);
867 let leave = leave_ev(m, 6_000, 0x02);
868 for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
869 let fold = coalesce(&evs, NOW, None, &always);
870 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
871 }
872 let join = join_ev(m, 7_000, 0x02);
874 let leave = leave_ev(m, 7_000, 0x01);
875 let fold = coalesce(&[join, leave], NOW, None, &always);
876 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
877 }
878
879 #[test]
880 fn kick_needs_the_callers_authority_verdict() {
881 let admin = pk();
882 let m = pk();
883 let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
884
885 let denied = coalesce(&evs, NOW, None, &|_, _, _| false);
886 assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
887
888 let granted = coalesce(&evs, NOW, None, &|actor, target, _| *actor == admin && *target == m);
889 let st = granted.get(&m).unwrap();
890 assert_eq!(st.verdict, Verdict::Kicked);
891 assert_eq!(st.at_ms, 2_000);
892 }
893
894 #[test]
895 fn snapshots_are_honored_only_from_the_epochs_refounder() {
896 let refounder = pk();
897 let impostor = pk();
898 let m = pk();
899
900 let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
902 assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
903
904 let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
906 assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
907
908 let fold = coalesce(&[real], NOW, Some(&refounder), &always);
910 let st = fold.get(&m).unwrap();
911 assert_eq!(st.verdict, Verdict::Joined);
912 assert_eq!(st.source, Source::Snapshot);
913 assert_eq!(st.at_ms, 5_000);
914 }
915
916 #[test]
917 fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
918 let refounder = pk();
919 let m = pk();
920 let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
921
922 let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
924 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
925
926 let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
928 let st = fold.get(&m).unwrap();
929 assert_eq!(st.source, Source::Snapshot);
930 assert_eq!(st.at_ms, 5_000);
931 }
932
933 #[test]
934 fn a_refounding_replaces_the_guestbook_it_supersedes() {
935 let refounder = pk();
936 let (kept, stale, wedged, unstamped_current, later) = (pk(), pk(), pk(), pk(), pk());
937 let events = [
938 join_ev(kept, 1_000, 1),
940 join_ev(stale, 1_000, 2),
941 at_epoch(join_ev(wedged, 9_000, 3), 6),
944 join_ev(unstamped_current, 5_500, 4),
947 at_epoch(snap_ev(refounder, vec![kept], 0x01, (1, 1), 5_000, 5), 7),
948 at_epoch(join_ev(later, 6_000, 6), 7),
949 ];
950 let fold = coalesce(&events, NOW, Some(&refounder), &always);
951 assert_eq!(fold.get(&kept).unwrap().verdict, Verdict::Joined, "listed: stays");
952 assert_eq!(fold.get(&later).unwrap().verdict, Verdict::Joined, "joined on the new plane");
953 assert_eq!(fold.get(&unstamped_current).unwrap().verdict, Verdict::Joined, "unstamped but dated after the cut");
954 for (who, why) in [(&stale, "older plane by time"), (&wedged, "older plane by stamp, whatever its clock says")] {
955 let st = fold.get(who).unwrap();
956 assert_eq!(st.verdict, Verdict::Refounded, "{why}");
957 assert_eq!(st.at_ms, 5_000, "departed at the refound's time");
958 }
959
960 let none = BTreeSet::new();
963 let list = complete_memberlist(&fold, &BTreeMap::new(), &none, &BTreeMap::new(), None);
964 assert!(list.contains(&kept) && list.contains(&later) && !list.contains(&stale));
965 let mut seen = BTreeMap::new();
966 seen.insert(stale, 4_000u64);
967 assert!(!complete_memberlist(&fold, &seen, &none, &BTreeMap::new(), None).contains(&stale), "old activity does not resurrect");
968 seen.insert(stale, 7_000u64);
969 assert!(complete_memberlist(&fold, &seen, &none, &BTreeMap::new(), None).contains(&stale), "activity after the cut re-admits");
970 }
971
972 #[test]
973 fn a_refound_suppresses_an_observed_spammer_the_snapshot_omits() {
974 let (kept, spammer, rejoiner) = (pk(), pk(), pk());
979 let refound = Refound { cut: 5_000, base: BTreeSet::from([kept]), rumor_id: [9u8; 32] };
980 let none = BTreeSet::new();
981 let mut observed = BTreeMap::new();
982 observed.insert(kept, 4_000u64); observed.insert(spammer, 4_000u64); observed.insert(rejoiner, 7_000u64); let list = complete_memberlist(&BTreeMap::new(), &observed, &none, &BTreeMap::new(), Some(&refound));
986 assert!(list.contains(&kept), "base member stays");
987 assert!(!list.contains(&spammer), "buried-kick spammer is not re-admitted by old spam");
988 assert!(list.contains(&rejoiner), "posting after the cut needs the new key, so it re-admits");
989 let ungated = complete_memberlist(&BTreeMap::new(), &observed, &none, &BTreeMap::new(), None);
992 assert!(ungated.contains(&spammer), "no refound, no gate");
993
994 let grantee = pk();
997 let mut with_grantee = observed.clone();
998 with_grantee.insert(grantee, 0);
999 let list = complete_memberlist(&BTreeMap::new(), &with_grantee, &none, &BTreeMap::new(), Some(&refound));
1000 assert!(list.contains(&grantee), "a grantee survives the refound gate");
1001 }
1002
1003 #[test]
1004 fn within_one_epoch_snapshots_only_add() {
1005 let refounder = pk();
1008 let (a, b, c) = (pk(), pk(), pk());
1009 let events = [
1010 join_ev(a, 1_000, 1),
1011 join_ev(b, 1_000, 2),
1012 join_ev(c, 1_000, 3),
1013 at_epoch(snap_ev(refounder, vec![a], 0x01, (1, 1), 5_000, 4), 7),
1014 at_epoch(snap_ev(refounder, vec![b], 0x02, (1, 1), 8_000, 5), 7),
1015 ];
1016 let fold = coalesce(&events, NOW, Some(&refounder), &always);
1017 assert_eq!(fold.get(&a).unwrap().verdict, Verdict::Joined);
1018 assert_eq!(fold.get(&b).unwrap().verdict, Verdict::Joined, "the addendum adds, it never evicts");
1019 assert_eq!(fold.get(&c).unwrap().verdict, Verdict::Refounded);
1020 assert_eq!(fold.get(&c).unwrap().at_ms, 5_000, "the cut is the refound, not the addendum");
1021 }
1022
1023 #[test]
1024 fn the_latest_refounding_is_the_one_that_counts() {
1025 let refounder = pk();
1026 let (a, b) = (pk(), pk());
1027 let events = [
1030 join_ev(a, 1_000, 1),
1031 join_ev(b, 1_000, 2),
1032 at_epoch(snap_ev(refounder, vec![a, b], 0x03, (1, 1), 3_000, 3), 3),
1033 at_epoch(snap_ev(refounder, vec![a], 0x07, (1, 1), 7_000, 4), 7),
1034 ];
1035 let fold = coalesce(&events, NOW, Some(&refounder), &always);
1036 assert_eq!(fold.get(&a).unwrap().verdict, Verdict::Joined);
1037 assert_eq!(fold.get(&b).unwrap().verdict, Verdict::Refounded);
1038 assert_eq!(fold.get(&b).unwrap().at_ms, 7_000);
1039 }
1040
1041 #[test]
1042 fn only_the_minters_stamped_snapshot_can_evict() {
1043 let (refounder, impostor, m) = (pk(), pk(), pk());
1044 let forged = [join_ev(m, 1_000, 1), at_epoch(snap_ev(impostor, vec![], 0x01, (1, 1), 5_000, 2), 7)];
1045 assert_eq!(coalesce(&forged, NOW, Some(&refounder), &always).get(&m).unwrap().verdict, Verdict::Joined);
1046 assert_eq!(coalesce(&forged, NOW, None, &always).get(&m).unwrap().verdict, Verdict::Joined);
1047 let unstamped = [join_ev(m, 1_000, 1), snap_ev(refounder, vec![], 0x01, (1, 1), 5_000, 2)];
1050 assert_eq!(coalesce(&unstamped, NOW, Some(&refounder), &always).get(&m).unwrap().verdict, Verdict::Joined);
1051 }
1052
1053 #[test]
1054 fn a_torn_snapshot_coalesces_order_independently() {
1055 let refounder = pk();
1062 let (a, b, c) = (pk(), pk(), pk());
1063 let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
1064 let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
1065 let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
1066
1067 let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
1068 let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
1069 for m in [&a, &b, &c] {
1071 assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
1072 }
1073 assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
1074 }
1075
1076 #[test]
1077 fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
1078 let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
1079 let coalesced = coalesce(
1080 &[
1081 join_ev(joined, 1_000, 1),
1082 join_ev(left, 1_000, 2),
1083 leave_ev(left, 5_000, 3),
1084 join_ev(banned_joined, 1_000, 4),
1085 ],
1086 NOW,
1087 None,
1088 &always,
1089 );
1090 let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
1091
1092 let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
1095 let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None);
1096 assert!(list.contains(&joined));
1097 assert!(list.contains(&silent), "an observed author with no guestbook state is present");
1098 assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
1099 assert!(!list.contains(&banned_joined));
1100 assert!(!list.contains(&banned_observed));
1101
1102 let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
1104 let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None);
1105 assert!(list.contains(&left));
1106 let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
1108 assert!(!complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None).contains(&left));
1109 }
1110
1111 #[test]
1112 fn an_unban_does_not_resurrect_a_pre_ban_join_or_activity() {
1113 let (member, chatty) = (pk(), pk());
1117 let coalesced = coalesce(&[join_ev(member, 1_000, 1), join_ev(chatty, 1_000, 2)], NOW, None, &always);
1118 let banned_at: BTreeMap<PublicKey, u64> = [(member, 5), (chatty, 5)].into();
1120 let empty: BTreeSet<PublicKey> = BTreeSet::new();
1121
1122 let observed: BTreeMap<PublicKey, u64> = [(chatty, 4_000)].into();
1123 let list = complete_memberlist(&coalesced, &observed, &empty, &banned_at, None);
1124 assert!(!list.contains(&member), "a pre-ban Join must not survive the un-ban");
1125 assert!(!list.contains(&chatty), "pre-ban activity must not survive the un-ban either");
1126
1127 let observed: BTreeMap<PublicKey, u64> = [(chatty, 6_000)].into();
1129 assert!(
1130 complete_memberlist(&coalesced, &observed, &empty, &banned_at, None).contains(&chatty),
1131 "activity newer than the ban is a real rejoin"
1132 );
1133 let rejoined = coalesce(&[join_ev(member, 1_000, 1), join_ev(member, 6_000, 3)], NOW, None, &always);
1134 assert!(
1135 complete_memberlist(&rejoined, &BTreeMap::new(), &empty, &banned_at, None).contains(&member),
1136 "a Join newer than the ban re-admits"
1137 );
1138 let banlist: BTreeSet<PublicKey> = [member].into();
1140 assert!(!complete_memberlist(&rejoined, &BTreeMap::new(), &banlist, &banned_at, None).contains(&member));
1141 }
1142
1143 #[test]
1144 fn malformed_verbs_and_foreign_kinds_are_rejected() {
1145 let k = Keys::generate();
1146 for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
1149 let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
1150 assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
1151 }
1152 let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
1153 assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
1154 }
1155
1156 #[test]
1157 fn kick_target_must_be_a_unique_valid_p_tag() {
1158 let k = Keys::generate();
1159
1160 let dup = stream::build_rumor_ms(
1161 kind::KICK,
1162 k.public_key(),
1163 "",
1164 vec![Tag::public_key(pk()), Tag::public_key(pk())],
1165 1_000,
1166 );
1167 assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
1168
1169 let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
1170 assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
1171
1172 let bad = stream::build_rumor_ms(
1173 kind::KICK,
1174 k.public_key(),
1175 "",
1176 vec![Tag::custom("p", ["not-hex".to_string()])],
1177 1_000,
1178 );
1179 assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
1180 }
1181
1182 #[test]
1183 fn snapshot_snap_tag_and_content_are_strict() {
1184 let k = Keys::generate();
1185 let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
1186 let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
1187 stream::build_rumor_ms(
1188 kind::SNAPSHOT,
1189 k.public_key(),
1190 content,
1191 vec![Tag::custom(
1192 "snap",
1193 [id.to_string(), i.to_string(), n.to_string()],
1194 )],
1195 1_000,
1196 )
1197 };
1198
1199 for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
1201 let r = snap_rumor("[]", &id_hex, i, n);
1202 assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
1203 }
1204 let bad_id = snap_rumor("[]", "zz", "1", "1");
1206 assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
1207 let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
1208 assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
1209 let not_array = snap_rumor("{}", &id_hex, "1", "1");
1210 assert!(matches!(through_err(¬_array, &k), GuestbookError::BadSnapshotContent));
1211 }
1212
1213 #[test]
1214 fn snapshot_bad_member_entries_drop_individually() {
1215 let k = Keys::generate();
1216 let good = pk();
1217 let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
1220 let rumor = stream::build_rumor_ms(
1221 kind::SNAPSHOT,
1222 k.public_key(),
1223 &content,
1224 vec![Tag::custom(
1225 "snap",
1226 [crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
1227 )],
1228 1_000,
1229 );
1230 let ev = through(&rumor, &k);
1231 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
1232 }
1233}