Skip to main content

vector_core/community/v2/
chat.rs

1//! CORD-03 Chat Plane — a Channel's messages over the v2 stream envelope.
2//!
3//! Every chat action is an unsigned rumor in an **encrypted seal** (CORD-02 §5
4//! makes 20013 mandatory here — chat content must never be liftable as a
5//! standalone public event) inside a wrap at the Channel's stream address.
6//! The rumor kinds reuse standard Nostr shapes wherever one fits:
7//!   - kind 9 message (NIP-C7: content = text, replies via a `q` tag naming
8//!     the parent RUMOR id — never the outer wrap's, which differs per re-wrap)
9//!   - kind 7 reaction (NIP-25: `e`/`p`/`k` name the target)
10//!   - kind 5 delete (NIP-09: `e` = the author's own rumor id, `k` its kind)
11//!   - kind 3302 edit (`e` = own message rumor id, content = replacement text)
12//!   - kind 3310 WebXDC peer signal (payload opaque to the protocol)
13//!   - kind 23311 typing (ephemeral tier — rides the 21059 wrap)
14//!
15//! Two Vector inner-tag conventions carry over from v1: NIP-30
16//! `["emoji", shortcode, url]` tags and verbatim extra tags (NIP-92 `imeta`
17//! attachments) ride inside the signed rumor, so they are author-committed.
18//!
19//! Every rumor MUST commit `["channel", id]` + `["epoch", n]`, checked
20//! strict-equal against the coordinate whose key decrypted the wrap (CORD-03
21//! §3) — the rumor's own claim is never trusted, so a keyholder of two planes
22//! cannot re-seal a rumor across Channels or replay it across epochs.
23//!
24//! The wrap kind (1059/21059) is a transport tier, not a content authority:
25//! the open side admits any allowlisted rumor kind on either wrap and lets the
26//! rumor kind govern meaning. Publishers still MUST put typing on 21059
27//! (relays MUST NOT store it) — that is a send-side duty, not a read gate.
28
29use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent,
30};
31
32use super::super::{ChannelId, Epoch};
33use super::derive::{channel_group_key, GroupKey};
34use super::kind;
35use super::stream::{self, OpenedStream, SealForm, StreamError};
36
37const TAG_QUOTE: &str = "q";
38const TAG_TARGET: &str = "e";
39const TAG_TARGET_AUTHOR: &str = "p";
40const TAG_TARGET_KIND: &str = "k";
41const TAG_EMOJI: &str = "emoji";
42
43/// Errors from the chat plane layer (envelope errors ride inside).
44#[derive(Debug)]
45pub enum ChatError {
46    Stream(StreamError),
47    /// A chat rumor arrived in a plaintext seal — CORD-02 §5 requires the
48    /// encrypted form on this plane, a strict reader drops the violation.
49    NotEncryptedSealed,
50    /// The rumor kind isn't in the chat-plane registry (retired numbers stay
51    /// burned — a 3300 is v1 traffic, never a v2 message).
52    UnknownKind(u16),
53    MissingTag(&'static str),
54    /// A target-bearing tag appears more than once — ambiguous, rejected
55    /// (same discipline as the stream module's binding tags).
56    DuplicateTag(&'static str),
57    /// A tag value failed its shape check (64-hex id, pubkey, or integer kind).
58    BadTag(&'static str),
59    /// The wrap's author matches no held `(epoch, key)` — not ours to open.
60    NoHeldEpoch,
61}
62
63impl std::fmt::Display for ChatError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            ChatError::Stream(e) => write!(f, "stream: {e}"),
67            ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
68            ChatError::UnknownKind(k) => write!(f, "rumor kind {k} is not a chat-plane kind"),
69            ChatError::MissingTag(t) => write!(f, "missing chat tag: {t}"),
70            ChatError::DuplicateTag(t) => write!(f, "duplicate chat tag: {t}"),
71            ChatError::BadTag(t) => write!(f, "malformed chat tag: {t}"),
72            ChatError::NoHeldEpoch => write!(f, "wrap author matches no held epoch key"),
73        }
74    }
75}
76
77impl std::error::Error for ChatError {}
78
79impl From<StreamError> for ChatError {
80    fn from(e: StreamError) -> Self {
81        ChatError::Stream(e)
82    }
83}
84
85// ── Keying (CORD-03 §1) ──────────────────────────────────────────────────────
86
87/// A Channel's Chat Plane group key. `secret` is whatever feeds the Channel at
88/// this epoch: the `community_root` for a Public Channel (at the root epoch),
89/// or the Channel's independent key for a Private one (at its own channel
90/// epoch). The channel id inside the derivation gives every Channel a distinct
91/// address regardless of which secret feeds it.
92pub fn chat_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
93    channel_group_key(secret, channel_id, epoch)
94}
95
96// ── Rumor builders ───────────────────────────────────────────────────────────
97//
98// All builders take the author's pubkey (not keys) so bunker accounts build
99// identical rumors — signing happens at the seal, not here. `at_ms` is the
100// full epoch-ms send time (CORD-02 §4); the binding tags are always attached.
101
102/// Build a kind-9 message rumor. `reply_to` is the parent's
103/// `(rumor_id_hex, author_hex)` — the NIP-C7 `q` tag; `emoji` the NIP-30
104/// `(shortcode, url)` pairs for any `:shortcode:` in the content; `extra_tags`
105/// ride verbatim (NIP-92 `imeta` attachments), author-committed by the seal.
106#[allow(clippy::too_many_arguments)]
107pub fn build_message_rumor(
108    author: PublicKey,
109    channel_id: &ChannelId,
110    epoch: Epoch,
111    content: &str,
112    reply_to: Option<(&str, &str)>,
113    emoji: &[(&str, &str)],
114    extra_tags: Vec<Tag>,
115    at_ms: u64,
116) -> UnsignedEvent {
117    let mut tags = stream::channel_binding_tags(channel_id, epoch);
118    if let Some((parent_id, parent_author)) = reply_to {
119        tags.push(Tag::custom(
120            "q",
121            [parent_id.to_string(), String::new(), parent_author.to_string()],
122        ));
123    }
124    for (shortcode, url) in emoji {
125        tags.push(emoji_tag(shortcode, url));
126    }
127    tags.extend(extra_tags);
128    stream::build_rumor_ms(kind::MESSAGE, author, content, tags, at_ms)
129}
130
131/// Parse the NIP-40 `["expiration", <unix secs>]` tag off a chat rumor
132/// (Self-Destruct Timer). The inner twin of the wrap tag relays act on: this is
133/// what drives a receiver's local countdown + purge, and the only copy that
134/// survives a restart (the wrap is a discarded transport artifact).
135pub(crate) fn message_expiration(rumor: &UnsignedEvent) -> Option<u64> {
136    rumor.tags.iter().find_map(|tag| {
137        let s = tag.as_slice();
138        if s.len() >= 2 && s[0] == "expiration" {
139            s[1].parse::<u64>().ok()
140        } else {
141            None
142        }
143    })
144}
145
146/// Build a kind-1111 threaded-reply rumor (NIP-22, CORD-03 §3). Uppercase
147/// `K`/`E`/`P` pin the immutable thread ROOT, lowercase `k`/`e`/`p` the
148/// immediate PARENT — all rumor ids. `parent_root` names the parent's own root
149/// when the parent is itself a reply (inherited verbatim, so the root stays
150/// stable at any depth — the exact shape Armada builds); `None` means the
151/// parent IS the root.
152#[allow(clippy::too_many_arguments)]
153pub fn build_comment_rumor(
154    author: PublicKey,
155    channel_id: &ChannelId,
156    epoch: Epoch,
157    content: &str,
158    parent_id_hex: &str,
159    parent_kind: u16,
160    parent_author_hex: &str,
161    parent_root: Option<(&str, u16, &str)>,
162    emoji: &[(&str, &str)],
163    at_ms: u64,
164) -> UnsignedEvent {
165    let mut tags = stream::channel_binding_tags(channel_id, epoch);
166    let (root_id, root_kind, root_author) = parent_root.unwrap_or((parent_id_hex, parent_kind, parent_author_hex));
167    tags.push(Tag::custom("K", [root_kind.to_string()]));
168    tags.push(Tag::custom(
169        "E",
170        [root_id.to_string(), String::new(), root_author.to_string()],
171    ));
172    tags.push(Tag::custom("P", [root_author.to_string()]));
173    tags.push(Tag::custom("k", [parent_kind.to_string()]));
174    tags.push(Tag::custom(
175        "e",
176        [parent_id_hex.to_string(), String::new(), parent_author_hex.to_string()],
177    ));
178    tags.push(Tag::custom("p", [parent_author_hex.to_string()]));
179    for (shortcode, url) in emoji {
180        tags.push(emoji_tag(shortcode, url));
181    }
182    stream::build_rumor_ms(kind::COMMENT, author, content, tags, at_ms)
183}
184
185/// Build a kind-7 reaction rumor (NIP-25): `e` = the target rumor id, `p` its
186/// author, `k` = the target's kind (`9` for a message, `1111` for a threaded
187/// reply). `emoji_content` is the reaction itself (`"+"`, an emoji, or a
188/// `:shortcode:`); `emoji` carries the NIP-30 pair when the content is a
189/// custom-emoji shortcode.
190#[allow(clippy::too_many_arguments)]
191pub fn build_reaction_rumor(
192    author: PublicKey,
193    channel_id: &ChannelId,
194    epoch: Epoch,
195    target_rumor_id_hex: &str,
196    target_author_hex: &str,
197    target_kind: u16,
198    emoji_content: &str,
199    emoji: Option<(&str, &str)>,
200    at_ms: u64,
201) -> UnsignedEvent {
202    let mut tags = stream::channel_binding_tags(channel_id, epoch);
203    tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
204    tags.push(Tag::custom("p", [target_author_hex.to_string()]));
205    tags.push(Tag::custom(
206        "k",
207        [target_kind.to_string()],
208    ));
209    if let Some((shortcode, url)) = emoji {
210        tags.push(emoji_tag(shortcode, url));
211    }
212    stream::build_rumor_ms(kind::REACTION, author, emoji_content, tags, at_ms)
213}
214
215/// Build a kind-5 delete rumor (NIP-09): `e` = the target rumor id, `k` = its
216/// kind. Semantic within the plane only — members stop rendering; the wrap
217/// ciphertext on relays needs a separate NIP-09 scrub by its `p` tag.
218///
219/// `citation` is the moderator's `vac` (CORD-04 §5) when this deletes SOMEONE
220/// ELSE's message: peers resolve the removal against that exact Grant version.
221/// A self-delete carries none, and neither does the owner (supreme, no grant to
222/// cite) — so an absent `vac` never widens what a delete may reach.
223pub fn build_delete_rumor(
224    author: PublicKey,
225    channel_id: &ChannelId,
226    epoch: Epoch,
227    target_rumor_id_hex: &str,
228    target_kind: u16,
229    at_ms: u64,
230    citation: Option<&crate::community::edition::AuthorityCitation>,
231) -> UnsignedEvent {
232    let mut tags = stream::channel_binding_tags(channel_id, epoch);
233    tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
234    tags.push(Tag::custom(
235        "k",
236        [target_kind.to_string()],
237    ));
238    if let Some(c) = citation {
239        tags.push(c.to_tag());
240    }
241    stream::build_rumor_ms(kind::DELETE, author, "", tags, at_ms)
242}
243
244/// Build a kind-3302 edit rumor: `e` = the author's own message rumor id,
245/// content = the replacement text (fields unpinned upstream; this shape
246/// matches the CORD examples). `emoji` carries the NIP-30 pairs for any
247/// `:shortcode:` in the replacement — the edit is what peers render from, so
248/// it must be as self-describing as the message it replaces.
249pub fn build_edit_rumor(
250    author: PublicKey,
251    channel_id: &ChannelId,
252    epoch: Epoch,
253    target_rumor_id_hex: &str,
254    new_content: &str,
255    emoji: &[(&str, &str)],
256    at_ms: u64,
257) -> UnsignedEvent {
258    let mut tags = stream::channel_binding_tags(channel_id, epoch);
259    tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
260    for (shortcode, url) in emoji {
261        tags.push(emoji_tag(shortcode, url));
262    }
263    stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
264}
265
266/// Build a kind-3310 WebXDC peer-signal rumor: `content` and `extra_tags` are
267/// the app payload, opaque to the protocol, carried verbatim.
268pub fn build_webxdc_rumor(
269    author: PublicKey,
270    channel_id: &ChannelId,
271    epoch: Epoch,
272    content: &str,
273    extra_tags: Vec<Tag>,
274    at_ms: u64,
275) -> UnsignedEvent {
276    let mut tags = stream::channel_binding_tags(channel_id, epoch);
277    tags.extend(extra_tags);
278    stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
279}
280
281/// Build a kind-23311 typing rumor — presence of the event is the signal, it
282/// carries nothing. Seal it with `ephemeral: true` so relays never store it.
283pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
284    let tags = stream::channel_binding_tags(channel_id, epoch);
285    stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
286}
287
288// ── Seal / open over the stream ──────────────────────────────────────────────
289
290/// Seal a chat rumor into its wrap: encrypted seal (mandatory on this plane),
291/// then the durable 1059 wrap — or the ephemeral 21059 when `ephemeral` (the
292/// typing tier). Refuses non-chat rumor kinds so a control edition can never
293/// be published onto a Channel's stream by mistake. Returns the wrap plus the
294/// ephemeral `p` keypair (retain it to best-effort NIP-09-scrub the wrap
295/// later). Local-keys convenience; bunker accounts use [`stream::seal_content`]
296/// + their remote signer + [`stream::wrap_seal`] for identical wire output.
297pub fn seal_chat_rumor(
298    rumor: &UnsignedEvent,
299    group: &GroupKey,
300    author_keys: &Keys,
301    wrap_at: Timestamp,
302    ephemeral: bool,
303) -> Result<(Event, Keys), ChatError> {
304    let k = rumor.kind.as_u16();
305    if !is_chat_kind(k) {
306        return Err(ChatError::UnknownKind(k));
307    }
308    let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
309    let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
310    // Mirror a NIP-40 expiration (Self-Destruct Timer) from the inner rumor onto
311    // the outer wrap so relays drop the stored event on schedule; the inner copy
312    // drives each client's local purge. Only chat messages ever carry one.
313    let wrap_extra: Vec<Tag> = rumor
314        .tags
315        .iter()
316        .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
317        .cloned()
318        .collect();
319    Ok(stream::wrap_seal_with_tags(&seal, group, wrap_kind, wrap_at, &wrap_extra)?)
320}
321
322/// Signer-driven twin of [`seal_chat_rumor`] for bunker / NIP-55 accounts: the
323/// author seal signs through a [`VectorSigner`]; the group-key wrap is unchanged.
324/// `author` is the identity the signer signs as (must equal `my_public_key()`).
325pub async fn seal_chat_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
326    signer: &S,
327    author: nostr_sdk::prelude::PublicKey,
328    rumor: &UnsignedEvent,
329    group: &GroupKey,
330    wrap_at: Timestamp,
331    ephemeral: bool,
332) -> Result<(Event, Keys), ChatError> {
333    let k = rumor.kind.as_u16();
334    if !is_chat_kind(k) {
335        return Err(ChatError::UnknownKind(k));
336    }
337    let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
338    let wrap_extra: Vec<Tag> = rumor
339        .tags
340        .iter()
341        .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
342        .cloned()
343        .collect();
344    Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, wrap_kind, wrap_at, &wrap_extra).await?)
345}
346
347/// A parsed reply reference — the NIP-C7 `q` tag's parent rumor id and (when
348/// carried, a SHOULD upstream) its author.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct ReplyRef {
351    pub id: [u8; 32],
352    pub author: Option<PublicKey>,
353}
354
355/// A fully verified, typed chat event. Every variant keeps its
356/// [`OpenedStream`] — the proven author, rumor id, and ms time live there.
357#[derive(Debug, Clone)]
358pub enum ChatEvent {
359    /// Kind 9 (message; `reply_to` = its `q` inline quote) or kind 1111
360    /// (threaded reply; `reply_to` = its immediate parent, the lowercase `e`).
361    /// Text lives in `opened.rumor.content`; `emoji` is the NIP-30
362    /// `(shortcode, url)` pairs (attachments ride the rumor's `imeta` tags).
363    /// `opened.rumor.kind` distinguishes the two when a caller needs to.
364    Message {
365        opened: OpenedStream,
366        reply_to: Option<ReplyRef>,
367        emoji: Vec<(String, String)>,
368    },
369    /// Kind 7 — `emoji` is the reaction content; `emoji_url` the NIP-30 image
370    /// when the content is a custom `:shortcode:`.
371    Reaction {
372        opened: OpenedStream,
373        target: [u8; 32],
374        target_author: PublicKey,
375        emoji: String,
376        emoji_url: Option<String>,
377    },
378    /// Kind 5 — authorization (self, or a moderator) is the fold's job, not
379    /// the envelope's.
380    Delete {
381        opened: OpenedStream,
382        target: [u8; 32],
383        target_kind: Option<u16>,
384    },
385    /// Kind 3302 — author-only validity is likewise judged at fold time.
386    /// `emoji` is the NIP-30 pairs for the REPLACEMENT text; the fold adopts
387    /// them wholesale, so an edit that drops a shortcode retires its image.
388    Edit {
389        opened: OpenedStream,
390        target: [u8; 32],
391        new_content: String,
392        emoji: Vec<(String, String)>,
393    },
394    /// Kind 3310 — payload opaque, read `opened.rumor` directly.
395    Webxdc { opened: OpenedStream },
396    /// Kind 23311 — the event's presence is the whole signal.
397    Typing { opened: OpenedStream },
398}
399
400impl ChatEvent {
401    /// The verified envelope facts (author, ms, rumor id) behind any variant.
402    pub fn opened(&self) -> &OpenedStream {
403        match self {
404            ChatEvent::Message { opened, .. }
405            | ChatEvent::Reaction { opened, .. }
406            | ChatEvent::Delete { opened, .. }
407            | ChatEvent::Edit { opened, .. }
408            | ChatEvent::Webxdc { opened }
409            | ChatEvent::Typing { opened } => opened,
410        }
411    }
412}
413
414/// Open and fully verify one chat wrap against the `(channel, epoch)` whose
415/// key is being tried: envelope verification ([`stream::open_wrap`]), the
416/// encrypted-seal gate, the strict channel/epoch binding, the kind allowlist,
417/// then the typed parse. Malformed targets are errors, never panics.
418pub fn open_chat_event(
419    wrap: &Event,
420    group: &GroupKey,
421    channel_id: &ChannelId,
422    epoch: Epoch,
423) -> Result<ChatEvent, ChatError> {
424    let opened = stream::open_wrap(wrap, group)?;
425    if opened.seal_form != SealForm::Encrypted {
426        return Err(ChatError::NotEncryptedSealed);
427    }
428    stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
429    parse_chat_rumor(opened)
430}
431
432/// Open a chat wrap against every `(epoch, secret)` a client holds for one
433/// Channel — the CORD-03 §3 read path, where history spanning a rekey is
434/// queried across all held epoch pubkeys. Selection is by the wrap's author
435/// (each epoch's derived address), NEVER trial decryption, and the binding is
436/// then enforced against the epoch that actually matched — so an epoch-N rumor
437/// re-sealed under epoch M's key still dies as a splice.
438///
439/// `secret` per entry is whatever feeds the Channel at that epoch (CORD-03 §1:
440/// `community_root` for Public epochs, the channel key for Private ones).
441/// Derivation costs an HKDF + keypair per entry per call — batch readers
442/// should derive their [`GroupKey`]s once and match `wrap.pubkey` themselves.
443pub fn open_chat_event_multi(
444    wrap: &Event,
445    held: &[(Epoch, [u8; 32])],
446    channel_id: &ChannelId,
447) -> Result<(ChatEvent, Epoch), ChatError> {
448    for (epoch, secret) in held {
449        let group = channel_group_key(secret, channel_id, *epoch);
450        if wrap.pubkey == group.pk() {
451            return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
452        }
453    }
454    Err(ChatError::NoHeldEpoch)
455}
456
457// ── Parse (rumor → typed event) ──────────────────────────────────────────────
458
459fn is_chat_kind(k: u16) -> bool {
460    matches!(
461        k,
462        kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
463    )
464}
465
466/// Type an ALREADY-VERIFIED rumor (one produced by [`stream::open_wrap`] and
467/// binding-checked). Target ids come from UNIQUE tags — a duplicated `e`/`p`/
468/// `q` is ambiguous (which target did the author sign off on?) and rejected.
469fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
470    match opened.rumor.kind.as_u16() {
471        kind::MESSAGE => {
472            let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
473                None => None,
474                Some(s) => {
475                    let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
476                    // NIP-C7 q tag: [q, id, relay-hint, pubkey] — the author
477                    // slot is a SHOULD; absent or empty parses as unknown.
478                    let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
479                        Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
480                        None => None,
481                    };
482                    Some(ReplyRef { id, author })
483                }
484            };
485            let emoji = collect_emoji(&opened.rumor);
486            Ok(ChatEvent::Message { opened, reply_to, emoji })
487        }
488        kind::COMMENT => {
489            // A threaded reply (NIP-22, CORD-03 §3). Vector's timeline renders it
490            // INLINE: the immediate parent (the lowercase `e`) becomes the reply
491            // context, exactly like a kind-9 quote — never dropped. The uppercase
492            // root tags stay on the rumor for a future thread view.
493            let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
494                None => None, // a parentless comment still renders as plain text.
495                Some(s) => {
496                    let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
497                    // NIP-22 e tag: [e, id, relay-hint, pubkey] — author optional.
498                    let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
499                        Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
500                        None => None,
501                    };
502                    Some(ReplyRef { id, author })
503                }
504            };
505            let emoji = collect_emoji(&opened.rumor);
506            Ok(ChatEvent::Message { opened, reply_to, emoji })
507        }
508        kind::REACTION => {
509            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
510            let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
511                .map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
512            let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
513            let emoji = opened.rumor.content.clone();
514            Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
515        }
516        kind::DELETE => {
517            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
518            let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
519                None => None,
520                Some(s) => Some(
521                    value_of(s, TAG_TARGET_KIND)?
522                        .parse::<u16>()
523                        .map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
524                ),
525            };
526            Ok(ChatEvent::Delete { opened, target, target_kind })
527        }
528        kind::EDIT => {
529            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
530            let new_content = opened.rumor.content.clone();
531            let emoji = collect_emoji(&opened.rumor);
532            Ok(ChatEvent::Edit { opened, target, new_content, emoji })
533        }
534        kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
535        kind::TYPING => Ok(ChatEvent::Typing { opened }),
536        k => Err(ChatError::UnknownKind(k)),
537    }
538}
539
540// ── Tag helpers ──────────────────────────────────────────────────────────────
541
542fn emoji_tag(shortcode: &str, url: &str) -> Tag {
543    Tag::custom(TAG_EMOJI, [shortcode.to_string(), url.to_string()])
544}
545
546/// The unique tag named `name`, or None. More than one match = ambiguous,
547/// rejected (any keyholder can craft a rumor; first-match is nondeterministic).
548fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
549    let mut found: Option<&[String]> = None;
550    for t in rumor.tags.iter() {
551        let s = t.as_slice();
552        if s.first().map(|n| n == name).unwrap_or(false) {
553            if found.is_some() {
554                return Err(ChatError::DuplicateTag(name));
555            }
556            found = Some(s);
557        }
558    }
559    Ok(found)
560}
561
562/// The unique tag's value, requiring the tag to be present.
563fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
564    let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
565    value_of(s, name)
566}
567
568fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
569    slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
570}
571
572fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
573    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
574        return Err(ChatError::BadTag(field));
575    }
576    Ok(crate::simd::hex::hex_to_bytes_32(hex))
577}
578
579/// All well-formed NIP-30 `(shortcode, url)` pairs; malformed emoji tags are
580/// skipped, not fatal (worst case the raw `:shortcode:` text renders).
581fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
582    rumor
583        .tags
584        .iter()
585        .filter_map(|t| {
586            let s = t.as_slice();
587            (s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
588        })
589        .collect()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    const AT: u64 = 1_686_840_217_417;
597    const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
598
599    fn chan() -> ChannelId {
600        ChannelId([0xab; 32])
601    }
602
603    fn secret() -> [u8; 32] {
604        [7u8; 32]
605    }
606
607    fn group() -> GroupKey {
608        chat_group_key(&secret(), &chan(), Epoch(0))
609    }
610
611    fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
612        open_chat_event(wrap, &group(), &chan(), Epoch(0))
613    }
614
615    fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
616        seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
617    }
618
619    #[test]
620    fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
621        let author = Keys::generate();
622        let parent = Keys::generate();
623        let parent_id = "aa".repeat(32);
624        let imeta = Tag::custom(
625            "imeta",
626            ["url https://x/f.png".to_string(), "m image/png".to_string()],
627        );
628        let rumor = build_message_rumor(
629            author.public_key(),
630            &chan(),
631            Epoch(0),
632            "welcome :catJAM:",
633            Some((&parent_id, &parent.public_key().to_hex())),
634            &[("catJAM", "https://x/cat.gif")],
635            vec![imeta.clone()],
636            AT,
637        );
638        let wrap = seal(&rumor, &author);
639
640        let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
641            panic!("expected a Message");
642        };
643        assert_eq!(opened.author, author.public_key());
644        assert_eq!(opened.rumor.content, "welcome :catJAM:");
645        assert_eq!(opened.at_ms, AT);
646        let reply = reply_to.expect("reply parses back");
647        assert_eq!(reply.id, [0xaa; 32]);
648        assert_eq!(reply.author, Some(parent.public_key()));
649        assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
650        // The imeta tag rides the signed rumor byte-verbatim.
651        assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
652    }
653
654    #[test]
655    fn self_destruct_expiration_mirrors_onto_the_wrap_and_round_trips_into_the_message() {
656        // NIP-40 Self-Destruct Timer: the expiry rides the inner rumor (drives
657        // every client's local purge + survives restart) AND is mirrored onto the
658        // outer wrap (so relays drop the stored event). Without a timer, neither
659        // layer carries a tag.
660        const EXP: u64 = 1_700_000_500;
661        let author = Keys::generate();
662        let me = author.public_key();
663
664        // Sender stamps the expiry as an extra tag (exactly how the send command does).
665        let rumor = build_message_rumor(
666            me,
667            &chan(),
668            Epoch(0),
669            "poof",
670            None,
671            &[],
672            vec![Tag::expiration(Timestamp::from_secs(EXP))],
673            AT,
674        );
675        assert_eq!(message_expiration(&rumor), Some(EXP), "inner rumor carries the expiry");
676
677        let wrap = seal(&rumor, &author);
678        // Relay-drop half: the outer wrap mirrors the expiration tag.
679        let mirrored = wrap.tags.iter().any(|t| {
680            let s = t.as_slice();
681            s.len() >= 2 && s[0] == "expiration" && s[1] == EXP.to_string()
682        });
683        assert!(mirrored, "the outer wrap mirrors the NIP-40 expiration for relays");
684
685        // Local-purge half: the receiver's opened Message carries the expiry.
686        let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
687            panic!("expected a Message");
688        };
689        let msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
690        assert_eq!(msg.expiration, Some(EXP), "the receiver's Message carries the expiry");
691
692        // Control: no timer → clean rumor, clean wrap, no Message expiry.
693        let plain = build_message_rumor(me, &chan(), Epoch(0), "forever", None, &[], vec![], AT);
694        assert_eq!(message_expiration(&plain), None);
695        let plain_wrap = seal(&plain, &author);
696        assert!(!plain_wrap
697            .tags
698            .iter()
699            .any(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false)));
700        let ChatEvent::Message { opened, reply_to, emoji } = open(&plain_wrap).unwrap() else {
701            panic!("expected a Message");
702        };
703        let plain_msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
704        assert_eq!(plain_msg.expiration, None);
705    }
706
707    #[test]
708    fn message_without_q_has_no_reply() {
709        let author = Keys::generate();
710        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
711        let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
712            panic!("expected a Message");
713        };
714        assert_eq!(reply_to, None);
715        assert!(emoji.is_empty());
716    }
717
718    #[test]
719    fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
720        // CORD-03 §3 kind-1111: Vector renders a thread reply inline — the
721        // immediate parent (lowercase e) is the reply context, never dropped.
722        let author = Keys::generate();
723        let root_author = Keys::generate();
724        let root_id = "cd".repeat(32);
725        let rumor = build_comment_rumor(
726            author.public_key(),
727            &chan(),
728            Epoch(0),
729            "replying in the thread!",
730            &root_id,
731            kind::MESSAGE,
732            &root_author.public_key().to_hex(),
733            None, // the parent IS the root
734            &[],
735            AT,
736        );
737        // The wire shape matches the spec example: K/E/P root + k/e/p parent.
738        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
739        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
740        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
741
742        let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
743            panic!("a threaded reply parses as a Message");
744        };
745        assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
746        let reply = reply_to.expect("the immediate parent is the reply context");
747        assert_eq!(reply.id, [0xcd; 32]);
748        assert_eq!(reply.author, Some(root_author.public_key()));
749    }
750
751    #[test]
752    fn an_armada_shaped_threaded_reply_parses_verbatim() {
753        // The EXACT tag shape from the spec example (examples.md §2.2) built by
754        // hand — proving we parse the cross-client wire form, not just our own
755        // builder's output.
756        let author = Keys::generate();
757        let root_author = Keys::generate();
758        let parent_author = Keys::generate();
759        let root_id = "ef".repeat(32);
760        let parent_id = "12".repeat(32);
761        let tags = vec![
762            Tag::custom("channel", [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
763            Tag::custom("epoch", ["0".to_string()]),
764            Tag::custom("K", ["9".to_string()]),
765            Tag::custom(
766                "E",
767                [root_id.clone(), String::new(), root_author.public_key().to_hex()],
768            ),
769            Tag::custom("P", [root_author.public_key().to_hex()]),
770            Tag::custom("k", ["1111".to_string()]),
771            Tag::custom("e", [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
772            Tag::custom("p", [parent_author.public_key().to_hex()]),
773        ];
774        let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
775        let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
776            panic!("expected a Message");
777        };
778        // A NESTED reply: the immediate parent (lowercase e), not the root, is
779        // the inline context.
780        let reply = reply_to.expect("parent parses");
781        assert_eq!(reply.id, [0x12; 32]);
782        assert_eq!(reply.author, Some(parent_author.public_key()));
783    }
784
785    #[test]
786    fn a_nested_comment_inherits_its_root_tags_verbatim() {
787        let author = Keys::generate();
788        let root_id = "ab".repeat(32);
789        let root_author_hex = Keys::generate().public_key().to_hex();
790        let parent_id = "cd".repeat(32);
791        let parent_author_hex = Keys::generate().public_key().to_hex();
792        let rumor = build_comment_rumor(
793            author.public_key(),
794            &chan(),
795            Epoch(0),
796            "deep",
797            &parent_id,
798            kind::COMMENT, // the parent is itself a reply
799            &parent_author_hex,
800            Some((&root_id, kind::MESSAGE, &root_author_hex)),
801            &[],
802            AT,
803        );
804        // Root pinned to the ORIGINAL root (stable at any depth), parent to the
805        // immediate reply.
806        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
807        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
808        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
809        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
810    }
811
812    #[test]
813    fn a_comment_with_duplicate_parent_tags_is_rejected() {
814        // Two lowercase `e` tags = ambiguous parent (which did the author sign
815        // off on?) — same discipline as every target-bearing tag.
816        let author = Keys::generate();
817        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
818        for id in ["ab", "ff"] {
819            tags.push(Tag::custom("e", [
820                id.repeat(32),
821                String::new(),
822                Keys::generate().public_key().to_hex(),
823            ]));
824        }
825        let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
826        let got = open(&seal(&rumor, &author));
827        assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
828    }
829
830    #[test]
831    fn a_reaction_to_a_threaded_reply_carries_k_1111() {
832        let author = Keys::generate();
833        let rumor = build_reaction_rumor(
834            author.public_key(),
835            &chan(),
836            Epoch(0),
837            &"bc".repeat(32),
838            &Keys::generate().public_key().to_hex(),
839            kind::COMMENT,
840            "🔥",
841            None,
842            AT,
843        );
844        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
845        assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
846    }
847
848    #[test]
849    fn reaction_round_trip_and_nip25_shape() {
850        let author = Keys::generate();
851        let target_author = Keys::generate();
852        let target_id = "bc".repeat(32);
853        let rumor = build_reaction_rumor(
854            author.public_key(),
855            &chan(),
856            Epoch(0),
857            &target_id,
858            &target_author.public_key().to_hex(),
859            kind::MESSAGE,
860            "🔥",
861            None,
862            AT,
863        );
864        // NIP-25 shape: the k tag names the reacted-to kind.
865        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
866
867        let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
868            open(&seal(&rumor, &author)).unwrap()
869        else {
870            panic!("expected a Reaction");
871        };
872        assert_eq!(opened.author, author.public_key());
873        assert_eq!(target, [0xbc; 32]);
874        assert_eq!(ta, target_author.public_key());
875        assert_eq!(emoji, "🔥");
876        assert_eq!(emoji_url, None);
877        assert_eq!(opened.at_ms, AT);
878    }
879
880    #[test]
881    fn reaction_custom_emoji_carries_the_nip30_url() {
882        let author = Keys::generate();
883        let rumor = build_reaction_rumor(
884            author.public_key(),
885            &chan(),
886            Epoch(0),
887            &"bc".repeat(32),
888            &Keys::generate().public_key().to_hex(),
889            kind::MESSAGE,
890            ":catJAM:",
891            Some(("catJAM", "https://x/cat.gif")),
892            AT,
893        );
894        let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
895            panic!("expected a Reaction");
896        };
897        assert_eq!(emoji, ":catJAM:");
898        assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
899    }
900
901    #[test]
902    fn delete_round_trip_and_optional_target_kind() {
903        let author = Keys::generate();
904        let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT, None);
905        let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
906            panic!("expected a Delete");
907        };
908        assert_eq!(target, [0xcd; 32]);
909        assert_eq!(target_kind, Some(kind::MESSAGE));
910
911        // A k-less delete (the tag is optional in NIP-09) parses with None.
912        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
913        tags.push(Tag::custom("e", ["cd".repeat(32)]));
914        let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
915        let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
916            panic!("expected a Delete");
917        };
918        assert_eq!(target_kind, None);
919    }
920
921    #[test]
922    fn edit_round_trip_replaces_content() {
923        let author = Keys::generate();
924        let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", &[], AT);
925        let ChatEvent::Edit { opened, target, new_content, .. } = open(&seal(&rumor, &author)).unwrap() else {
926            panic!("expected an Edit");
927        };
928        assert_eq!(opened.author, author.public_key());
929        assert_eq!(target, [0xde; 32]);
930        assert_eq!(new_content, "fixed the typo");
931    }
932
933    #[test]
934    fn webxdc_round_trip_is_opaque() {
935        let author = Keys::generate();
936        let app_tag = Tag::custom("xdc", ["state-update".to_string()]);
937        let rumor = build_webxdc_rumor(
938            author.public_key(),
939            &chan(),
940            Epoch(0),
941            "{\"move\":\"e4\"}",
942            vec![app_tag.clone()],
943            AT,
944        );
945        let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
946            panic!("expected a Webxdc");
947        };
948        assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
949        assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
950    }
951
952    #[test]
953    fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
954        let author = Keys::generate();
955        let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
956        let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
957        assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
958        let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
959            panic!("expected a Typing");
960        };
961        assert_eq!(opened.author, author.public_key());
962        assert_eq!(opened.rumor.content, "");
963
964        // A kind-9 on a 21059 wrap still opens: the wrap kind is a transport
965        // tier (storage policy), the rumor-kind allowlist governs content.
966        let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
967        let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
968        assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
969    }
970
971    #[test]
972    fn wrong_channel_and_wrong_epoch_are_rejected() {
973        let author = Keys::generate();
974        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
975        let wrap = seal(&rumor, &author);
976        // Same key, wrong claimed coordinate: the strict-equal binding gates it.
977        assert!(matches!(
978            open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
979            Err(ChatError::Stream(StreamError::ChannelMismatch))
980        ));
981        // A rumor bound to epoch 1 sealed under epoch 0's key (replay shape).
982        let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
983        let wrap = seal(&stale, &author);
984        assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
985    }
986
987    #[test]
988    fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
989        // Cross-channel splice: a keyholder of both channels re-seals a rumor
990        // bound to A under B's key — the binding must be judged against the
991        // key that decrypted, never the rumor's own claim.
992        let author = Keys::generate();
993        let chan_a = ChannelId([0xaa; 32]);
994        let chan_b = ChannelId([0xbb; 32]);
995        let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
996        let rumor = build_reaction_rumor(
997            author.public_key(),
998            &chan_a,
999            Epoch(0),
1000            &"bc".repeat(32),
1001            &Keys::generate().public_key().to_hex(),
1002            kind::MESSAGE,
1003            "🔥",
1004            None,
1005            AT,
1006        );
1007        let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
1008        assert!(matches!(
1009            open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
1010            Err(ChatError::Stream(StreamError::ChannelMismatch))
1011        ));
1012    }
1013
1014    #[test]
1015    fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
1016        let author = Keys::generate();
1017        let key0 = [1u8; 32];
1018        let key1 = [2u8; 32];
1019        let held = [(Epoch(0), key0), (Epoch(1), key1)];
1020
1021        let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
1022        let g0 = chat_group_key(&key0, &chan(), Epoch(0));
1023        let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
1024
1025        let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
1026        let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1027        let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
1028
1029        let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
1030        assert_eq!(e0, Epoch(0));
1031        assert_eq!(ev0.opened().rumor.content, "before the rekey");
1032        let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
1033        assert_eq!(e1, Epoch(1));
1034        assert_eq!(ev1.opened().rumor.content, "after the rekey");
1035    }
1036
1037    #[test]
1038    fn multi_epoch_unheld_wrap_is_not_ours() {
1039        let author = Keys::generate();
1040        let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
1041        let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
1042        let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
1043        let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
1044        assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
1045    }
1046
1047    #[test]
1048    fn multi_epoch_cross_epoch_splice_is_rejected() {
1049        // A rumor bound to epoch 0 re-sealed under epoch 1's key: selection by
1050        // wrap author picks epoch 1, and the binding must then fail — held-key
1051        // selection can never launder a stale-epoch rumor.
1052        let author = Keys::generate();
1053        let key0 = [1u8; 32];
1054        let key1 = [2u8; 32];
1055        let held = [(Epoch(0), key0), (Epoch(1), key1)];
1056        let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
1057        let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1058        let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
1059        assert!(matches!(
1060            open_chat_event_multi(&wrap, &held, &chan()),
1061            Err(ChatError::Stream(StreamError::EpochMismatch))
1062        ));
1063    }
1064
1065    #[test]
1066    fn duplicate_e_tag_on_a_reaction_is_rejected() {
1067        let author = Keys::generate();
1068        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1069        tags.push(Tag::custom("e", ["aa".repeat(32)]));
1070        tags.push(Tag::custom("e", ["bb".repeat(32)]));
1071        tags.push(Tag::custom("p", [Keys::generate().public_key().to_hex()]));
1072        let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1073        assert!(matches!(
1074            open(&seal(&rumor, &author)),
1075            Err(ChatError::DuplicateTag(TAG_TARGET))
1076        ));
1077    }
1078
1079    #[test]
1080    fn unknown_rumor_kind_is_rejected_on_both_sides() {
1081        let author = Keys::generate();
1082        // 3300 is a RETIRED v1 number — burned forever, never a v2 chat kind.
1083        let tags = stream::channel_binding_tags(&chan(), Epoch(0));
1084        let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
1085        assert!(matches!(
1086            seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
1087            Err(ChatError::UnknownKind(3300))
1088        ));
1089        // And a wrap hand-built around it (bypassing the send gate) dies on open.
1090        let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
1091        let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1092        assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
1093    }
1094
1095    #[test]
1096    fn plaintext_sealed_chat_event_is_rejected() {
1097        let author = Keys::generate();
1098        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
1099        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
1100        let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1101        assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
1102    }
1103
1104    #[test]
1105    fn malformed_targets_are_errors_not_panics() {
1106        let author = Keys::generate();
1107        // Reaction: 64 chars but not hex.
1108        let rumor = build_reaction_rumor(
1109            author.public_key(),
1110            &chan(),
1111            Epoch(0),
1112            &"zz".repeat(32),
1113            &Keys::generate().public_key().to_hex(),
1114            kind::MESSAGE,
1115            "+",
1116            None,
1117            AT,
1118        );
1119        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
1120        // Message: truncated q id.
1121        let rumor = build_message_rumor(
1122            author.public_key(),
1123            &chan(),
1124            Epoch(0),
1125            "x",
1126            Some(("abcd", &Keys::generate().public_key().to_hex())),
1127            &[],
1128            vec![],
1129            AT,
1130        );
1131        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
1132        // Delete: a k tag that isn't an integer kind.
1133        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1134        tags.push(Tag::custom("e", ["cd".repeat(32)]));
1135        tags.push(Tag::custom(
1136            "k",
1137            ["nine".to_string()],
1138        ));
1139        let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
1140        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
1141        // Reaction missing its p target author entirely.
1142        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1143        tags.push(Tag::custom("e", ["cd".repeat(32)]));
1144        let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1145        assert!(matches!(
1146            open(&seal(&rumor, &author)),
1147            Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
1148        ));
1149    }
1150}