Skip to main content

vector_core/community/v2/
guestbook.rs

1//! CORD-02 §5 Guestbook Plane — membership motion, coalesced flat.
2//!
3//! One Stream per Community (community_root-keyed,
4//! [`super::derive::guestbook_group_key`]), carrying ONLY membership motion:
5//! self-signed Joins/Leaves (3306), authorized Kicks (3309), and
6//! refounder-signed post-Refounding Snapshots (3312) — never messages, never
7//! authority (a Ban lives on the Control Plane). The plane is *off-consensus*:
8//! nothing in Control or Chat depends on it, so it loads last and can lag
9//! without harm.
10//!
11//! Everything here is PURE — no DB, no network, no clock reads. The +1h
12//! forward-clock rule takes `now_ms` as a parameter, and the two authority
13//! questions arrive from the caller's Control Plane fold: `can_kick` (the
14//! KICK bit + strict outrank) and `snapshot_authority` (the npub whose
15//! Refounding minted the epoch being folded).
16//!
17//! Guestbook seals are ENCRYPTED (20013) by spec: a plaintext seal would make
18//! a member's signed membership record liftable as a standalone public
19//! artifact, so [`parse_guestbook_event`] rejects the plaintext form outright.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
24
25use super::super::edition::AuthorityCitation;
26use super::derive::GroupKey;
27use super::kind;
28use super::stream::{self, OpenedStream, SealForm, StreamError};
29
30/// Entries dated further than this ahead of the receiver's clock are dropped
31/// outright (CORD-02 §5) — ample for deep clock skew, and the deterrent
32/// against squatting "latest" with a forged future date.
33pub const MAX_FUTURE_MS: u64 = 3_600_000;
34
35/// Snapshot chunk size: 400 members per event (CORD-02 §5). 400 hex pubkeys of
36/// JSON is ~27 KB — comfortably inside the NIP-44 65,535-byte cap at every
37/// nesting layer, with headroom for the envelope.
38pub 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/// Errors from the guestbook layer (envelope errors ride inside).
48#[derive(Debug)]
49pub enum GuestbookError {
50    Stream(StreamError),
51    /// The rumor kind isn't a guestbook kind (3306 / 3309 / 3312).
52    NotGuestbook(u16),
53    /// A guestbook rumor arrived in a plaintext seal — CORD-02 §5 requires the
54    /// encrypted form (a plaintext seal is a liftable, publicly verifiable
55    /// membership record), so a strict reader drops it.
56    NotEncryptedSealed,
57    /// A 3306's content isn't exactly `"join"` or `"leave"` — the verb IS the
58    /// state, so anything else is malformed, never interpreted.
59    BadVerb,
60    MissingTag(&'static str),
61    /// A state-bearing tag appears more than once — ambiguous, rejected.
62    DuplicateTag(&'static str),
63    /// A state-bearing tag is present but unparseable (bad target hex, bad
64    /// snap id / chunk indices).
65    BadTag(&'static str),
66    /// Snapshot content isn't a JSON array.
67    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
93// ── Rumor builders ───────────────────────────────────────────────────────────
94
95/// A self-signed Join, optionally echoing the invite attribution from the
96/// bundle that admitted the author (`["invite", creator, label]`, CORD-05 §1).
97pub 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            TagKind::Custom(TAG_INVITE.into()),
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
108/// A self-signed Leave.
109pub 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
113/// An admin-signed Kick naming its target and citing the Grant it acts under
114/// (the `vac`, CORD-04 §5) — absent when the owner acts (supreme, no grant to
115/// cite). Whether it's *honored* is the reader's call ([`coalesce`]'s
116/// `can_kick`), never the writer's.
117pub 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
130/// Refounder-signed snapshot rumors seeding a new epoch's Guestbook: present
131/// members only, chunked at [`SNAPSHOT_CHUNK`], every chunk carrying
132/// `["snap", <id>, <i>, <n>]` (1-based) and ONE shared timestamp — the
133/// one-id-one-time invariant is what lets readers reject torn chunk sets.
134/// No survivors still yields one empty chunk, so the Refounding's guestbook
135/// step is observable either way.
136pub 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                TagKind::Custom(TAG_SNAP.into()),
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
164/// Seal a guestbook rumor (encrypted form) into a wrap at `guestbook_pk`.
165/// Local-keys convenience; bunker accounts use [`stream::seal_content`] +
166/// their remote signer + [`stream::wrap_seal`] for identical wire output.
167pub 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
177// ── Parse ────────────────────────────────────────────────────────────────────
178
179/// One parsed guestbook event: the entry plus the identity the coalesce
180/// tie-break runs on — the INNER rumor id, never the wrap's (a wrap id differs
181/// per re-wrap, and two clients holding different wraps of one rumor would
182/// fork on ties).
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct GuestbookEvent {
185    /// The verified inner rumor id ([`OpenedStream::rumor_id`]).
186    pub rumor_id: [u8; 32],
187    pub entry: GuestbookEntry,
188}
189
190/// The typed guestbook entries (CORD-02 §5). Authors (`member` / `actor` /
191/// `refounder`) are the seal-verified real keys, proven by
192/// [`stream::open_wrap`] before parsing ever starts.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum GuestbookEntry {
195    Join {
196        member: PublicKey,
197        /// Invite attribution echoed from the bundle: `(creator hex, label)`.
198        /// Advisory metadata, carried verbatim — never validated here.
199        invited_by: Option<(String, String)>,
200        at_ms: u64,
201    },
202    Leave {
203        member: PublicKey,
204        at_ms: u64,
205    },
206    Kick {
207        actor: PublicKey,
208        target: PublicKey,
209        /// The Grant the actor claims to act under; `None` covers both the
210        /// owner (no grant to cite) and a corrupt `vac` — the verifier treats
211        /// either as uncited, never trusting a malformed citation.
212        citation: Option<AuthorityCitation>,
213        at_ms: u64,
214    },
215    Snapshot {
216        refounder: PublicKey,
217        members: Vec<PublicKey>,
218        snapshot_id: [u8; 32],
219        /// `(i, n)` — 1-based chunk index over the chunk count.
220        chunk: (u32, u32),
221        at_ms: u64,
222    },
223}
224
225impl GuestbookEntry {
226    /// The entry's millisecond event time (CORD-02 §4).
227    pub fn at_ms(&self) -> u64 {
228        match self {
229            GuestbookEntry::Join { at_ms, .. }
230            | GuestbookEntry::Leave { at_ms, .. }
231            | GuestbookEntry::Kick { at_ms, .. }
232            | GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
233        }
234    }
235}
236
237/// Parse a guestbook event from an ALREADY-VERIFIED [`OpenedStream`] (one
238/// produced by [`stream::open_wrap`], which proved the seal signature, the
239/// author binding, the rumor id, and the strict `ms`). Strict on the seal
240/// form: guestbook rumors MUST ride encrypted seals (CORD-02 §5).
241pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
242    if opened.seal_form != SealForm::Encrypted {
243        return Err(GuestbookError::NotEncryptedSealed);
244    }
245    let rumor = &opened.rumor;
246    let at_ms = opened.at_ms;
247
248    let entry = match rumor.kind.as_u16() {
249        kind::JOIN_LEAVE => match rumor.content.as_str() {
250            VERB_JOIN => {
251                let invited_by = rumor.tags.iter().find_map(|t| {
252                    let s = t.as_slice();
253                    (s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
254                });
255                GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
256            }
257            VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
258            _ => return Err(GuestbookError::BadVerb),
259        },
260        kind::KICK => {
261            // The target must come from a UNIQUE p tag: a second one makes
262            // "who was kicked" pick-your-favorite — reject, never choose.
263            let mut target: Option<PublicKey> = None;
264            for t in rumor.tags.iter() {
265                let s = t.as_slice();
266                if s.len() >= 2 && s[0] == TAG_TARGET {
267                    if target.is_some() {
268                        return Err(GuestbookError::DuplicateTag(TAG_TARGET));
269                    }
270                    target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
271                }
272            }
273            let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
274            GuestbookEntry::Kick {
275                actor: opened.author,
276                target,
277                citation: AuthorityCitation::from_tags(&rumor.tags),
278                at_ms,
279            }
280        }
281        kind::SNAPSHOT => {
282            let mut snap: Option<(String, String, String)> = None;
283            for t in rumor.tags.iter() {
284                let s = t.as_slice();
285                if s.len() >= 2 && s[0] == TAG_SNAP {
286                    if snap.is_some() {
287                        return Err(GuestbookError::DuplicateTag(TAG_SNAP));
288                    }
289                    if s.len() < 4 {
290                        return Err(GuestbookError::BadTag(TAG_SNAP));
291                    }
292                    snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
293                }
294            }
295            // The snap tag is load-bearing (the one-id-one-time consistency
296            // rule keys on it), so any malformation rejects the whole event.
297            let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
298            if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
299                return Err(GuestbookError::BadTag(TAG_SNAP));
300            }
301            let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
302            let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
303            let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
304            if i < 1 || i > n {
305                return Err(GuestbookError::BadTag(TAG_SNAP));
306            }
307            let raw: Vec<serde_json::Value> =
308                serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
309            // Malformed member entries drop INDIVIDUALLY: a snapshot is
310            // secondhand seeding and absence just means "no seed" (§5), so one
311            // bad entry shouldn't cost the other 399 theirs — the gap heals by
312            // observation or the victim's own fresh Join.
313            let members = raw
314                .iter()
315                .filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
316                .collect();
317            GuestbookEntry::Snapshot {
318                refounder: opened.author,
319                members,
320                snapshot_id,
321                chunk: (i, n),
322                at_ms,
323            }
324        }
325        k => return Err(GuestbookError::NotGuestbook(k)),
326    };
327
328    Ok(GuestbookEvent { rumor_id: opened.rumor_id.to_bytes(), entry })
329}
330
331// ── Coalesce fold (CORD-02 §5) ───────────────────────────────────────────────
332
333/// An npub's final coalesced state.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum Verdict {
336    Joined,
337    Left,
338    Kicked,
339}
340
341/// Whether the winning entry was the member's own word or a refounder's
342/// secondhand snapshot seed.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum Source {
345    Firsthand,
346    Snapshot,
347}
348
349/// One npub's folded guestbook state — the winning entry, flat.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct MemberState {
352    pub verdict: Verdict,
353    /// Millisecond time of the winning entry.
354    pub at_ms: u64,
355    pub source: Source,
356    /// Invite attribution (firsthand Joins only): `(creator hex, label)`.
357    pub invited_by: Option<(String, String)>,
358    /// The winning entry's inner rumor id — the tie-break identity.
359    pub rumor_id: [u8; 32],
360}
361
362/// Does `next` beat `prev`? Later ms wins; at a tie a firsthand entry beats a
363/// snapshot seed (a member's own word over the refounder's attestation), then
364/// the lower rumor id. The id tie-break is author-grindable — an accepted
365/// residual: the coalesce is per-npub, so an author only ever grinds ties
366/// against their own entries (CORD-02 §5).
367fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
368    if next.at_ms != prev.at_ms {
369        return next.at_ms > prev.at_ms;
370    }
371    if next.source != prev.source {
372        return next.source == Source::Firsthand;
373    }
374    next.rumor_id < prev.rumor_id
375}
376
377fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
378    match fold.get(&member) {
379        Some(prev) if !supersedes(prev, &next) => {}
380        _ => {
381            fold.insert(member, next);
382        }
383    }
384}
385
386/// Coalesce parsed guestbook events flat: one final [`MemberState`] per npub
387/// (CORD-02 §5), order-independent over `events`.
388///
389///   - entries dated more than [`MAX_FUTURE_MS`] ahead of `now_ms` drop
390///     outright (the forged-future "latest" squat);
391///   - a Kick is honored only when `can_kick(actor, target)` — the caller
392///     closes that over its folded roster (KICK bit + strict outrank);
393///   - a Snapshot is honored ONLY from `snapshot_authority`, the npub whose
394///     Refounding minted the epoch (`None` — unknown or genesis — honors no
395///     snapshots; there is deliberately NO owner fallback, an owner who didn't
396///     mint the epoch has no snapshot authority over it);
397///   - all chunks of one snapshot id must share one `(created_at, ms)`: the
398///     first-seen chunk pins it, disagreeing chunks drop. Pinning `at_ms` pins
399///     the pair — it's a bijection of `(created_at, ms)` under the strict
400///     `0..=999` ms rule [`stream::open_wrap`] already enforced;
401///   - a snapshot merely SEEDS `Joined` at its timestamp: any firsthand entry
402///     (or authorized Kick) newer than it — or tying it — supersedes.
403pub fn coalesce(
404    events: &[GuestbookEvent],
405    now_ms: u64,
406    snapshot_authority: Option<&PublicKey>,
407    can_kick: &dyn Fn(&PublicKey, &PublicKey) -> bool,
408) -> BTreeMap<PublicKey, MemberState> {
409    let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
410    let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
411
412    for ev in events {
413        if ev.entry.at_ms() > horizon {
414            continue;
415        }
416        match &ev.entry {
417            GuestbookEntry::Join { member, invited_by, at_ms } => apply(
418                &mut fold,
419                *member,
420                MemberState {
421                    verdict: Verdict::Joined,
422                    at_ms: *at_ms,
423                    source: Source::Firsthand,
424                    invited_by: invited_by.clone(),
425                    rumor_id: ev.rumor_id,
426                },
427            ),
428            GuestbookEntry::Leave { member, at_ms } => apply(
429                &mut fold,
430                *member,
431                MemberState {
432                    verdict: Verdict::Left,
433                    at_ms: *at_ms,
434                    source: Source::Firsthand,
435                    invited_by: None,
436                    rumor_id: ev.rumor_id,
437                },
438            ),
439            GuestbookEntry::Kick { actor, target, at_ms, .. } => {
440                if !can_kick(actor, target) {
441                    continue;
442                }
443                apply(
444                    &mut fold,
445                    *target,
446                    MemberState {
447                        verdict: Verdict::Kicked,
448                        at_ms: *at_ms,
449                        source: Source::Firsthand,
450                        invited_by: None,
451                        rumor_id: ev.rumor_id,
452                    },
453                );
454            }
455            GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
456                if snapshot_authority != Some(refounder) {
457                    continue;
458                }
459                // Each authorized chunk seeds its own members at its own at_ms,
460                // with NO cross-chunk consistency gate. CORD-02 §5: "chunks are
461                // independently useful ... there is no torn state to defend
462                // against." A first-seen timestamp pin would make a maliciously
463                // torn snapshot resolve differently by relay delivery order,
464                // breaking the deterministic-when-synced guarantee; the per-npub
465                // fold below (latest-ms wins, firsthand beats snapshot, lower
466                // rumor id ties) is commutative, so seeding every chunk converges.
467                for m in members {
468                    apply(
469                        &mut fold,
470                        *m,
471                        MemberState {
472                            verdict: Verdict::Joined,
473                            at_ms: *at_ms,
474                            source: Source::Snapshot,
475                            invited_by: None,
476                            rumor_id: ev.rumor_id,
477                        },
478                    );
479                }
480            }
481        }
482    }
483
484    fold
485}
486
487// ── Complete Memberlist (CORD-02 §5) ─────────────────────────────────────────
488
489/// The Complete Memberlist: coalesced `Joined` members ∪ observed authors,
490/// minus the Banlist. `observed` maps author → the newest ms they were seen
491/// publishing anywhere in the Community (an author seen publishing is
492/// *observably present*, included even if their Join never arrived).
493/// Observation counts FORWARD only: it re-enters an author whose activity is
494/// strictly newer than their latest departure — a departed member's old
495/// history can never resurrect them.
496pub fn complete_memberlist(
497    coalesced: &BTreeMap<PublicKey, MemberState>,
498    observed: &BTreeMap<PublicKey, u64>,
499    banlist: &BTreeSet<PublicKey>,
500) -> BTreeSet<PublicKey> {
501    let mut out = BTreeSet::new();
502    for (pk, st) in coalesced {
503        if st.verdict == Verdict::Joined && !banlist.contains(pk) {
504            out.insert(*pk);
505        }
506    }
507    for (pk, seen_ms) in observed {
508        if banlist.contains(pk) {
509            continue;
510        }
511        match coalesced.get(pk) {
512            Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
513            _ => {
514                out.insert(*pk);
515            }
516        }
517    }
518    out
519}
520
521#[cfg(test)]
522mod tests {
523    use super::super::super::{CommunityId, Epoch};
524    use super::super::derive::guestbook_group_key;
525    use super::*;
526
527    /// A stable "receiver clock" for the fold tests, far above every event time.
528    const NOW: u64 = 1_722_500_000_000;
529
530    fn cid() -> CommunityId {
531        CommunityId([0x33; 32])
532    }
533
534    fn group() -> GroupKey {
535        guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
536    }
537
538    fn pk() -> PublicKey {
539        Keys::generate().public_key()
540    }
541
542    fn always(_: &PublicKey, _: &PublicKey) -> bool {
543        true
544    }
545
546    /// Full wire path: seal → wrap → open → parse.
547    fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
548        let g = group();
549        let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
550        parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
551    }
552
553    fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
554        let g = group();
555        let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
556        parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
557    }
558
559    // Direct coalesce-input constructors: `id` fills the rumor id, so tie
560    // ordering is choosable per test.
561    fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
562        GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
563    }
564
565    fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
566        GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
567    }
568
569    fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
570        GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
571    }
572
573    fn snap_ev(
574        refounder: PublicKey,
575        members: Vec<PublicKey>,
576        snap: u8,
577        chunk: (u32, u32),
578        at_ms: u64,
579        id: u8,
580    ) -> GuestbookEvent {
581        GuestbookEvent {
582            rumor_id: [id; 32],
583            entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
584        }
585    }
586
587    #[test]
588    fn join_and_leave_round_trip_through_the_stream() {
589        let member = Keys::generate();
590        let creator = pk().to_hex();
591
592        let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
593        let ev = through(&join, &member);
594        assert_eq!(
595            ev.entry,
596            GuestbookEntry::Join {
597                member: member.public_key(),
598                invited_by: Some((creator, "Reddit".into())),
599                at_ms: 1_722_400_000_128,
600            }
601        );
602
603        // Attribution is optional on a Join, and a Leave never carries any.
604        let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
605        assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
606        let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
607        assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
608    }
609
610    #[test]
611    fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
612        let admin = Keys::generate();
613        let target = pk();
614        let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
615        let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
616
617        let g = group();
618        let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
619        let opened = stream::open_wrap(&wrap, &g).unwrap();
620        let ev = parse_guestbook_event(&opened).unwrap();
621
622        assert_eq!(
623            ev.entry,
624            GuestbookEntry::Kick {
625                actor: admin.public_key(),
626                target,
627                citation: Some(cite),
628                at_ms: 1_722_410_000_301,
629            }
630        );
631        // The tie-break identity is the INNER rumor id, never the wrap's
632        // (which differs per re-wrap and would fork clients on ties).
633        assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
634        assert_ne!(ev.rumor_id, wrap.id.to_bytes());
635
636        // Uncited kick: the owner acting supreme — citation is simply None.
637        let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
638        assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
639    }
640
641    #[test]
642    fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
643        let refounder = Keys::generate();
644        let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
645        let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
646        assert_eq!(rumors.len(), 2);
647
648        let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
649        let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
650             GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
651            (&parsed[0].entry, &parsed[1].entry)
652        else {
653            panic!("expected two snapshot entries");
654        };
655        assert_eq!((m1.len(), m2.len()), (400, 1));
656        assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
657        assert_eq!(id1, &[0x5a; 32]);
658        assert_eq!(id1, id2);
659        assert_eq!(t1, t2, "all chunks share one timestamp");
660        assert_eq!(*r1, refounder.public_key());
661        let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
662        assert_eq!(all, members, "membership survives the chunking intact");
663
664        // No survivors still publishes one observable (empty) chunk.
665        let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
666        assert_eq!(empty.len(), 1);
667        let ev = through(&empty[0], &refounder);
668        assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
669    }
670
671    #[test]
672    fn plaintext_sealed_guestbook_events_are_rejected() {
673        // An encrypted seal is what keeps a membership record from being a
674        // liftable public artifact — the plaintext form must never be honored.
675        let member = Keys::generate();
676        let g = group();
677        let rumor = build_join_rumor(member.public_key(), None, 1_000);
678        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
679        let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
680        let opened = stream::open_wrap(&wrap, &g).unwrap();
681        assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
682    }
683
684    #[test]
685    fn future_dated_entries_are_dropped_outright() {
686        let m = pk();
687        // A +2h forged date squatting "latest": dropped, so the honest +59min
688        // leave (inside the skew allowance) holds the head.
689        let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
690        let ok = leave_ev(m, NOW + 59 * 60_000, 2);
691        let fold = coalesce(&[squat, ok], NOW, None, &always);
692        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
693
694        // Exactly +1h is still allowed — only strictly-greater drops.
695        let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
696        let fold = coalesce(&[edge], NOW, None, &always);
697        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
698    }
699
700    #[test]
701    fn latest_wins_per_npub_join_leave_rejoin() {
702        let m = pk();
703        let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
704
705        let fold = coalesce(&evs, NOW, None, &always);
706        let st = fold.get(&m).unwrap();
707        assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
708        assert_eq!(st.at_ms, 3_000);
709
710        // Order-independent: a shuffled delivery folds identically.
711        let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
712        assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
713
714        // Without the rejoin, leave-after-join is Left.
715        let fold = coalesce(&evs[..2], NOW, None, &always);
716        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
717    }
718
719    #[test]
720    fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
721        let m = pk();
722        let auth = pk();
723
724        // Firsthand vs snapshot at one instant: the member's own word wins even
725        // though the snapshot holds the lower (would-otherwise-win) rumor id.
726        let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
727        let leave = leave_ev(m, 5_000, 0xff);
728        for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
729            let fold = coalesce(&evs, NOW, Some(&auth), &always);
730            let st = fold.get(&m).unwrap();
731            assert_eq!(st.verdict, Verdict::Left);
732            assert_eq!(st.source, Source::Firsthand);
733        }
734
735        // Two firsthand entries at one instant: the lower rumor id wins.
736        let join = join_ev(m, 6_000, 0x01);
737        let leave = leave_ev(m, 6_000, 0x02);
738        for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
739            let fold = coalesce(&evs, NOW, None, &always);
740            assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
741        }
742        // And symmetrically when the leave holds the lower id.
743        let join = join_ev(m, 7_000, 0x02);
744        let leave = leave_ev(m, 7_000, 0x01);
745        let fold = coalesce(&[join, leave], NOW, None, &always);
746        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
747    }
748
749    #[test]
750    fn kick_needs_the_callers_authority_verdict() {
751        let admin = pk();
752        let m = pk();
753        let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
754
755        let denied = coalesce(&evs, NOW, None, &|_, _| false);
756        assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
757
758        let granted = coalesce(&evs, NOW, None, &|actor, target| *actor == admin && *target == m);
759        let st = granted.get(&m).unwrap();
760        assert_eq!(st.verdict, Verdict::Kicked);
761        assert_eq!(st.at_ms, 2_000);
762    }
763
764    #[test]
765    fn snapshots_are_honored_only_from_the_epochs_refounder() {
766        let refounder = pk();
767        let impostor = pk();
768        let m = pk();
769
770        // Wrong npub: ignored entirely — no owner fallback, no partial honor.
771        let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
772        assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
773
774        // Unknown authority: NO snapshots honored.
775        let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
776        assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
777
778        // The minting refounder seeds Joined at the snapshot's time.
779        let fold = coalesce(&[real], NOW, Some(&refounder), &always);
780        let st = fold.get(&m).unwrap();
781        assert_eq!(st.verdict, Verdict::Joined);
782        assert_eq!(st.source, Source::Snapshot);
783        assert_eq!(st.at_ms, 5_000);
784    }
785
786    #[test]
787    fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
788        let refounder = pk();
789        let m = pk();
790        let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
791
792        // A newer firsthand Leave supersedes the secondhand seed.
793        let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
794        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
795
796        // An OLDER firsthand Join does not override the newer seed.
797        let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
798        let st = fold.get(&m).unwrap();
799        assert_eq!(st.source, Source::Snapshot);
800        assert_eq!(st.at_ms, 5_000);
801    }
802
803    #[test]
804    fn a_torn_snapshot_coalesces_order_independently() {
805        // A maliciously (or buggily) torn snapshot — two chunks of one snap id
806        // carrying DIFFERENT timestamps — must fold to the SAME member set
807        // regardless of relay delivery order. (A first-seen timestamp pin made
808        // the dropped chunk order-dependent, violating determinism; per CORD-02
809        // §5 there is no torn state to defend against — every authorized chunk
810        // seeds its members.)
811        let refounder = pk();
812        let (a, b, c) = (pk(), pk(), pk());
813        let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
814        let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
815        let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
816
817        let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
818        let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
819        // Both orders seed all three members, identically.
820        for m in [&a, &b, &c] {
821            assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
822        }
823        assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
824    }
825
826    #[test]
827    fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
828        let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
829        let coalesced = coalesce(
830            &[
831                join_ev(joined, 1_000, 1),
832                join_ev(left, 1_000, 2),
833                leave_ev(left, 5_000, 3),
834                join_ev(banned_joined, 1_000, 4),
835            ],
836            NOW,
837            None,
838            &always,
839        );
840        let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
841
842        // Old activity (pre-Leave) never resurrects; silent observed authors
843        // ARE members; the banlist subtracts unconditionally.
844        let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
845        let list = complete_memberlist(&coalesced, &observed, &banlist);
846        assert!(list.contains(&joined));
847        assert!(list.contains(&silent), "an observed author with no guestbook state is present");
848        assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
849        assert!(!list.contains(&banned_joined));
850        assert!(!list.contains(&banned_observed));
851
852        // Activity strictly newer than the departure re-enters them.
853        let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
854        let list = complete_memberlist(&coalesced, &observed, &banlist);
855        assert!(list.contains(&left));
856        // Equal-to-departure is not "newer" — still out.
857        let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
858        assert!(!complete_memberlist(&coalesced, &observed, &banlist).contains(&left));
859    }
860
861    #[test]
862    fn malformed_verbs_and_foreign_kinds_are_rejected() {
863        let k = Keys::generate();
864        // The verb is exact: case variants and paddings are malformed, never
865        // normalized (a lenient reader would fold state a strict one dropped).
866        for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
867            let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
868            assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
869        }
870        let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
871        assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
872    }
873
874    #[test]
875    fn kick_target_must_be_a_unique_valid_p_tag() {
876        let k = Keys::generate();
877
878        let dup = stream::build_rumor_ms(
879            kind::KICK,
880            k.public_key(),
881            "",
882            vec![Tag::public_key(pk()), Tag::public_key(pk())],
883            1_000,
884        );
885        assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
886
887        let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
888        assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
889
890        let bad = stream::build_rumor_ms(
891            kind::KICK,
892            k.public_key(),
893            "",
894            vec![Tag::custom(TagKind::Custom("p".into()), ["not-hex".to_string()])],
895            1_000,
896        );
897        assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
898    }
899
900    #[test]
901    fn snapshot_snap_tag_and_content_are_strict() {
902        let k = Keys::generate();
903        let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
904        let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
905            stream::build_rumor_ms(
906                kind::SNAPSHOT,
907                k.public_key(),
908                content,
909                vec![Tag::custom(
910                    TagKind::Custom("snap".into()),
911                    [id.to_string(), i.to_string(), n.to_string()],
912                )],
913                1_000,
914            )
915        };
916
917        // 0-based, out-of-range, or unparseable indices reject the event.
918        for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
919            let r = snap_rumor("[]", &id_hex, i, n);
920            assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
921        }
922        // A bad snapshot id rejects; a missing snap tag rejects; non-array content rejects.
923        let bad_id = snap_rumor("[]", "zz", "1", "1");
924        assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
925        let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
926        assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
927        let not_array = snap_rumor("{}", &id_hex, "1", "1");
928        assert!(matches!(through_err(&not_array, &k), GuestbookError::BadSnapshotContent));
929    }
930
931    #[test]
932    fn snapshot_bad_member_entries_drop_individually() {
933        let k = Keys::generate();
934        let good = pk();
935        // One valid member among garbage hex and a non-string: the good seed
936        // survives — secondhand seeding never fails wholesale on one entry.
937        let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
938        let rumor = stream::build_rumor_ms(
939            kind::SNAPSHOT,
940            k.public_key(),
941            &content,
942            vec![Tag::custom(
943                TagKind::Custom("snap".into()),
944                [crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
945            )],
946            1_000,
947        );
948        let ev = through(&rumor, &k);
949        assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
950    }
951}