Skip to main content

softchat/
account.rs

1//! Private user metadata, contact backups, and addressable application data.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use url::Url;
8
9use crate::event::{RumorEvent, SignedEvent};
10use crate::{
11    LocalIdentity, Nip44EncryptedMessage, Nip44Payload, NostrEventDraft, NostrEventKind,
12    NostrPublicKey, NostrRumor, NostrTag, SignedNostrEvent, SoftchatError,
13};
14
15/// Maximum contacts in one private backup.
16pub const MAX_CONTACTS: usize = 10_000;
17/// Maximum decrypted application-data JSON accepted by the shared layer.
18pub const MAX_APP_DATA_JSON_BYTES: usize = 256 * 1024;
19
20const METADATA_KEYS: [&str; 7] = [
21    "name",
22    "display_name",
23    "about",
24    "picture",
25    "website",
26    "banner",
27    "bot",
28];
29
30/// Typed private user metadata with retained unknown JSON.
31#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
34pub struct UserMetadataView {
35    /// Complete exact source rumor.
36    pub rumor: RumorEvent,
37    /// Optional short name.
38    pub name: Option<String>,
39    /// Optional display name.
40    pub display_name: Option<String>,
41    /// Optional profile description.
42    pub about: Option<String>,
43    /// Optional absolute HTTPS picture URL.
44    pub picture: Option<String>,
45    /// Optional absolute HTTPS website URL.
46    pub website: Option<String>,
47    /// Optional absolute HTTPS banner URL.
48    pub banner: Option<String>,
49    /// Optional bot marker.
50    pub bot: Option<bool>,
51    /// Compact JSON object containing every unknown field.
52    pub unknown_json: String,
53}
54
55/// One canonical contact-backup entry.
56#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
59pub struct Contact {
60    /// Canonical contact public key.
61    pub public_key: String,
62    /// Optional relay hint represented as an empty string.
63    pub relay_hint: String,
64    /// Optional local name represented as an empty string.
65    pub local_name: String,
66}
67
68/// Typed private kind-3 contact backup.
69#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
70#[serde(rename_all = "camelCase")]
71#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
72pub struct FollowListView {
73    /// Complete exact source rumor.
74    pub rumor: RumorEvent,
75    /// First occurrence of each public key, sorted by public key.
76    pub contacts: Vec<Contact>,
77    /// Whether duplicate received contacts were ignored for the typed view.
78    pub contained_duplicates: bool,
79}
80
81/// Generic direct NIP-78 application data.
82#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "camelCase")]
84#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
85pub struct ApplicationDataView {
86    /// Complete verified signed event.
87    pub event: SignedEvent,
88    /// Addressable `d` identity.
89    pub identifier: String,
90    /// Opaque application-owned content.
91    pub content: String,
92}
93
94/// Accepted Softchat kind-30079 contexts.
95#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
96#[serde(rename_all = "camelCase")]
97#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
98pub enum AppDataContext {
99    /// Cross-device message read state.
100    ReadState,
101    /// Cross-device application settings.
102    AppSettings,
103}
104
105impl AppDataContext {
106    /// Return the exact `d`-tag value.
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::ReadState => "read-state",
111            Self::AppSettings => "app-settings",
112        }
113    }
114
115    fn parse(value: &str) -> Result<Self, SoftchatError> {
116        match value {
117            "read-state" => Ok(Self::ReadState),
118            "app-settings" => Ok(Self::AppSettings),
119            _ => Err(SoftchatError::InvalidApplicationData),
120        }
121    }
122}
123
124/// Authenticated and self-decrypted Softchat kind-30079 data.
125#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "camelCase")]
127#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
128pub struct AppDataSyncView {
129    /// Complete verified direct event.
130    pub event: SignedEvent,
131    /// Accepted application context.
132    pub context: AppDataContext,
133    /// Complete validated JSON, including unknown fields.
134    pub json: String,
135}
136
137impl LocalIdentity {
138    /// Create canonical private kind-0 user metadata.
139    ///
140    /// Unknown fields are supplied as a JSON object and retained unless they
141    /// collide with the seven known fields.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`SoftchatError::InvalidUserMetadata`] for malformed values.
146    #[allow(clippy::too_many_arguments)]
147    pub fn create_user_metadata(
148        &self,
149        created_at: u64,
150        name: Option<String>,
151        display_name: Option<String>,
152        about: Option<String>,
153        picture: Option<String>,
154        website: Option<String>,
155        banner: Option<String>,
156        bot: Option<bool>,
157        unknown_json: &str,
158    ) -> Result<UserMetadataView, SoftchatError> {
159        for value in [&picture, &website, &banner].into_iter().flatten() {
160            validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
161        }
162        let mut object = parse_unknown_object(unknown_json)?;
163        for key in METADATA_KEYS {
164            if object.contains_key(key) {
165                return Err(SoftchatError::InvalidUserMetadata);
166            }
167        }
168        insert_optional(&mut object, "name", name);
169        insert_optional(&mut object, "display_name", display_name);
170        insert_optional(&mut object, "about", about);
171        insert_optional(&mut object, "picture", picture);
172        insert_optional(&mut object, "website", website);
173        insert_optional(&mut object, "banner", banner);
174        if let Some(value) = bot {
175            object.insert("bot".to_owned(), Value::Bool(value));
176        }
177        let content =
178            serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
179        if content.len() > MAX_APP_DATA_JSON_BYTES {
180            return Err(SoftchatError::InvalidUserMetadata);
181        }
182        let rumor = self
183            .create_rumor(
184                NostrEventDraft::new(
185                    created_at,
186                    NostrEventKind::USER_METADATA,
187                    Vec::new(),
188                    content,
189                )
190                .map_err(|_| SoftchatError::InvalidUserMetadata)?,
191            )
192            .map_err(|_| SoftchatError::InvalidUserMetadata)?;
193        parse_user_metadata(&rumor)
194    }
195
196    /// Create a canonical private author-copy kind-3 contact backup.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`SoftchatError::InvalidContactList`] for duplicate contacts,
201    /// malformed relay hints, or excessive list size.
202    pub fn create_follow_list(
203        &self,
204        created_at: u64,
205        contacts: Vec<Contact>,
206    ) -> Result<FollowListView, SoftchatError> {
207        if contacts.len() > MAX_CONTACTS {
208            return Err(SoftchatError::InvalidContactList);
209        }
210        let mut canonical = contacts
211            .into_iter()
212            .map(validate_contact)
213            .collect::<Result<Vec<_>, _>>()?;
214        canonical.sort();
215        let original_len = canonical.len();
216        canonical.dedup_by(|left, right| left.public_key == right.public_key);
217        if canonical.len() != original_len {
218            return Err(SoftchatError::InvalidContactList);
219        }
220        let tags = canonical
221            .iter()
222            .map(|contact| {
223                NostrTag::new(vec![
224                    "p".to_owned(),
225                    contact.public_key.clone(),
226                    contact.relay_hint.clone(),
227                    contact.local_name.clone(),
228                ])
229                .map_err(|_| SoftchatError::InvalidContactList)
230            })
231            .collect::<Result<Vec<_>, _>>()?;
232        let rumor = self
233            .create_rumor(
234                NostrEventDraft::new(created_at, NostrEventKind::FOLLOW_LIST, tags, "")
235                    .map_err(|_| SoftchatError::InvalidContactList)?,
236            )
237            .map_err(|_| SoftchatError::InvalidContactList)?;
238        parse_follow_list(&rumor)
239    }
240
241    /// Create a direct signed generic NIP-78 event.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`SoftchatError::InvalidApplicationData`] for an empty
246    /// identifier or an oversized event.
247    pub fn create_application_data(
248        &self,
249        created_at: u64,
250        identifier: String,
251        content: String,
252    ) -> Result<ApplicationDataView, SoftchatError> {
253        validate_identifier(&identifier)?;
254        let event = self
255            .sign_event(
256                NostrEventDraft::new(
257                    created_at,
258                    NostrEventKind::APPLICATION_DATA,
259                    vec![NostrTag::new(vec!["d".to_owned(), identifier])?],
260                    content,
261                )
262                .map_err(|_| SoftchatError::InvalidApplicationData)?,
263            )
264            .map_err(|_| SoftchatError::InvalidApplicationData)?;
265        parse_application_data(&event)
266    }
267
268    /// Create direct signed, self-encrypted Softchat application data.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`SoftchatError::InvalidApplicationData`] for invalid JSON,
273    /// bounds, encryption, or signing failure.
274    pub fn create_app_data_sync(
275        &self,
276        created_at: u64,
277        context: AppDataContext,
278        json: &str,
279    ) -> Result<AppDataSyncView, SoftchatError> {
280        let json = validate_json_object(json)?;
281        let encrypted = self
282            .encrypt_utf8(&self.public_key(), &json)
283            .map_err(|_| SoftchatError::InvalidApplicationData)?;
284        let event = self
285            .sign_event(
286                NostrEventDraft::new(
287                    created_at,
288                    NostrEventKind::APPLICATION_DATA_SYNC,
289                    vec![NostrTag::new(vec!["d", context.as_str()])?],
290                    encrypted.payload().as_str(),
291                )
292                .map_err(|_| SoftchatError::InvalidApplicationData)?,
293            )
294            .map_err(|_| SoftchatError::InvalidApplicationData)?;
295        self.decrypt_app_data_sync(&event)
296    }
297
298    /// Validate and self-decrypt one kind-30079 event.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`SoftchatError::InvalidApplicationData`] unless the event is
303    /// authored by this identity and all encrypted JSON authenticates.
304    pub fn decrypt_app_data_sync(
305        &self,
306        event: &SignedNostrEvent,
307    ) -> Result<AppDataSyncView, SoftchatError> {
308        if event.kind() != NostrEventKind::APPLICATION_DATA_SYNC
309            || event.public_key() != self.public_key()
310        {
311            return Err(SoftchatError::InvalidApplicationData);
312        }
313        let identifier = exactly_one_d(event.tags())?;
314        let context = AppDataContext::parse(&identifier)?;
315        let encrypted = Nip44EncryptedMessage::new(
316            self.public_key(),
317            Nip44Payload::from_encoded(event.content())
318                .map_err(|_| SoftchatError::InvalidApplicationData)?,
319        );
320        let json = self
321            .decrypt_utf8(&encrypted)
322            .map_err(|_| SoftchatError::InvalidApplicationData)?;
323        let json = validate_json_object(&json)?;
324        Ok(AppDataSyncView {
325            event: SignedEvent::from(event),
326            context,
327            json,
328        })
329    }
330}
331
332/// Validate a binding-owned private user-metadata rumor.
333#[cfg_attr(feature = "native-bindings", uniffi::export)]
334pub fn classify_user_metadata(rumor: RumorEvent) -> Result<UserMetadataView, SoftchatError> {
335    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidUserMetadata)?;
336    parse_user_metadata(&rumor)
337}
338
339/// Select the deterministic latest private metadata rumor.
340///
341/// # Errors
342///
343/// All candidates must have the same author and valid kind-0 profile.
344#[cfg_attr(feature = "native-bindings", uniffi::export)]
345pub fn latest_user_metadata(
346    candidates: Vec<RumorEvent>,
347) -> Result<UserMetadataView, SoftchatError> {
348    let candidates = candidates
349        .into_iter()
350        .map(|candidate| {
351            let rumor =
352                NostrRumor::try_from(candidate).map_err(|_| SoftchatError::InvalidUserMetadata)?;
353            let view = parse_user_metadata(&rumor)?;
354            Ok((rumor.public_key(), rumor.created_at(), rumor.id(), view))
355        })
356        .collect::<Result<Vec<_>, SoftchatError>>()?;
357    let author = candidates
358        .first()
359        .map(|(author, _, _, _)| author.clone())
360        .ok_or(SoftchatError::InvalidUserMetadata)?;
361    if candidates
362        .iter()
363        .any(|(candidate, _, _, _)| *candidate != author)
364    {
365        return Err(SoftchatError::InvalidUserMetadata);
366    }
367    candidates
368        .into_iter()
369        .max_by(|left, right| crate::replacement::compare(&left.1, &left.2, &right.1, &right.2))
370        .map(|(_, _, _, view)| view)
371        .ok_or(SoftchatError::InvalidUserMetadata)
372}
373
374/// Validate a binding-owned private contact-backup rumor.
375#[cfg_attr(feature = "native-bindings", uniffi::export)]
376pub fn classify_follow_list(rumor: RumorEvent) -> Result<FollowListView, SoftchatError> {
377    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidContactList)?;
378    parse_follow_list(&rumor)
379}
380
381/// Validate a direct generic NIP-78 event.
382#[cfg_attr(feature = "native-bindings", uniffi::export)]
383pub fn classify_application_data(event: SignedEvent) -> Result<ApplicationDataView, SoftchatError> {
384    let event =
385        SignedNostrEvent::try_from(event).map_err(|_| SoftchatError::InvalidApplicationData)?;
386    parse_application_data(&event)
387}
388
389/// Select the deterministic latest addressable application-data event.
390///
391/// # Errors
392///
393/// All events must share `(author, kind, d)`.
394#[cfg_attr(feature = "native-bindings", uniffi::export)]
395pub fn latest_application_data(
396    candidates: Vec<SignedEvent>,
397) -> Result<ApplicationDataView, SoftchatError> {
398    let candidates = candidates
399        .into_iter()
400        .map(|candidate| {
401            let event = SignedNostrEvent::try_from(candidate)
402                .map_err(|_| SoftchatError::InvalidApplicationData)?;
403            let view = parse_application_data(&event)?;
404            Ok((
405                event.public_key(),
406                event.kind(),
407                view.identifier.clone(),
408                event.created_at(),
409                event.id(),
410                view,
411            ))
412        })
413        .collect::<Result<Vec<_>, SoftchatError>>()?;
414    let identity = candidates
415        .first()
416        .map(|(author, kind, identifier, _, _, _)| (author.clone(), *kind, identifier.clone()))
417        .ok_or(SoftchatError::InvalidApplicationData)?;
418    if candidates
419        .iter()
420        .any(|candidate| (candidate.0.clone(), candidate.1, candidate.2.clone()) != identity)
421    {
422        return Err(SoftchatError::InvalidApplicationData);
423    }
424    candidates
425        .into_iter()
426        .max_by(|left, right| crate::replacement::compare(&left.3, &left.4, &right.3, &right.4))
427        .map(|(_, _, _, _, _, view)| view)
428        .ok_or(SoftchatError::InvalidApplicationData)
429}
430
431fn parse_user_metadata(rumor: &NostrRumor) -> Result<UserMetadataView, SoftchatError> {
432    if rumor.kind() != NostrEventKind::USER_METADATA || rumor.tags().len() != 0 {
433        return Err(SoftchatError::InvalidUserMetadata);
434    }
435    if rumor.content().len() > MAX_APP_DATA_JSON_BYTES {
436        return Err(SoftchatError::InvalidUserMetadata);
437    }
438    crate::json::reject_duplicate_keys(rumor.content())
439        .map_err(|_| SoftchatError::InvalidUserMetadata)?;
440    let mut object: Map<String, Value> =
441        serde_json::from_str(rumor.content()).map_err(|_| SoftchatError::InvalidUserMetadata)?;
442    let name = take_string(&mut object, "name")?;
443    let display_name = take_string(&mut object, "display_name")?;
444    let about = take_string(&mut object, "about")?;
445    let picture = take_string(&mut object, "picture")?;
446    let website = take_string(&mut object, "website")?;
447    let banner = take_string(&mut object, "banner")?;
448    for value in [&picture, &website, &banner].into_iter().flatten() {
449        validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
450    }
451    let bot = match object.remove("bot") {
452        Some(Value::Bool(value)) => Some(value),
453        Some(_) => return Err(SoftchatError::InvalidUserMetadata),
454        None => None,
455    };
456    let unknown_json =
457        serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
458    Ok(UserMetadataView {
459        rumor: RumorEvent::from(rumor),
460        name,
461        display_name,
462        about,
463        picture,
464        website,
465        banner,
466        bot,
467        unknown_json,
468    })
469}
470
471fn parse_follow_list(rumor: &NostrRumor) -> Result<FollowListView, SoftchatError> {
472    if rumor.kind() != NostrEventKind::FOLLOW_LIST
473        || !rumor.content().is_empty()
474        || rumor.tags().len() > MAX_CONTACTS
475    {
476        return Err(SoftchatError::InvalidContactList);
477    }
478    let mut contacts = BTreeMap::new();
479    let mut seen = BTreeSet::new();
480    let mut contained_duplicates = false;
481    for tag in rumor.tags() {
482        if tag.first().map(String::as_str) != Some("p") {
483            continue;
484        }
485        let contact = validate_contact(Contact {
486            public_key: tag
487                .get(1)
488                .cloned()
489                .ok_or(SoftchatError::InvalidContactList)?,
490            relay_hint: tag.get(2).cloned().unwrap_or_default(),
491            local_name: tag.get(3).cloned().unwrap_or_default(),
492        })?;
493        if !seen.insert(contact.public_key.clone()) {
494            contained_duplicates = true;
495            continue;
496        }
497        contacts.insert(contact.public_key.clone(), contact);
498    }
499    Ok(FollowListView {
500        rumor: RumorEvent::from(rumor),
501        contacts: contacts.into_values().collect(),
502        contained_duplicates,
503    })
504}
505
506fn parse_application_data(event: &SignedNostrEvent) -> Result<ApplicationDataView, SoftchatError> {
507    if event.kind() != NostrEventKind::APPLICATION_DATA {
508        return Err(SoftchatError::InvalidApplicationData);
509    }
510    let identifier = exactly_one_d(event.tags())?;
511    validate_identifier(&identifier)?;
512    Ok(ApplicationDataView {
513        event: SignedEvent::from(event),
514        identifier,
515        content: event.content().to_owned(),
516    })
517}
518
519fn exactly_one_d<'a>(tags: impl Iterator<Item = &'a [String]>) -> Result<String, SoftchatError> {
520    let identifiers = tags
521        .filter(|tag| tag.first().map(String::as_str) == Some("d"))
522        .map(|tag| {
523            tag.get(1)
524                .cloned()
525                .ok_or(SoftchatError::InvalidApplicationData)
526        })
527        .collect::<Result<Vec<_>, _>>()?;
528    match identifiers.as_slice() {
529        [identifier] => Ok(identifier.clone()),
530        _ => Err(SoftchatError::InvalidApplicationData),
531    }
532}
533
534fn parse_unknown_object(json: &str) -> Result<Map<String, Value>, SoftchatError> {
535    if json.len() > MAX_APP_DATA_JSON_BYTES {
536        return Err(SoftchatError::InvalidUserMetadata);
537    }
538    serde_json::from_str(json).map_err(|_| SoftchatError::InvalidUserMetadata)
539}
540
541fn validate_json_object(json: &str) -> Result<String, SoftchatError> {
542    if json.len() > MAX_APP_DATA_JSON_BYTES {
543        return Err(SoftchatError::InvalidApplicationData);
544    }
545    let object: Map<String, Value> =
546        serde_json::from_str(json).map_err(|_| SoftchatError::InvalidApplicationData)?;
547    serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidApplicationData)
548}
549
550fn take_string(
551    object: &mut Map<String, Value>,
552    key: &str,
553) -> Result<Option<String>, SoftchatError> {
554    match object.remove(key) {
555        Some(Value::String(value)) => Ok(Some(value)),
556        Some(_) => Err(SoftchatError::InvalidUserMetadata),
557        None => Ok(None),
558    }
559}
560
561fn insert_optional(object: &mut Map<String, Value>, key: &str, value: Option<String>) {
562    if let Some(value) = value {
563        object.insert(key.to_owned(), Value::String(value));
564    }
565}
566
567fn validate_contact(mut contact: Contact) -> Result<Contact, SoftchatError> {
568    contact.public_key = NostrPublicKey::from_hex(&contact.public_key)
569        .map_err(|_| SoftchatError::InvalidContactList)?
570        .to_hex();
571    if !contact.relay_hint.is_empty() {
572        let url = Url::parse(&contact.relay_hint).map_err(|_| SoftchatError::InvalidContactList)?;
573        if !matches!(url.scheme(), "ws" | "wss")
574            || url.host_str().is_none()
575            || url.fragment().is_some()
576        {
577            return Err(SoftchatError::InvalidContactList);
578        }
579    }
580    if contact.local_name.chars().count() > 256 {
581        return Err(SoftchatError::InvalidContactList);
582    }
583    Ok(contact)
584}
585
586fn validate_identifier(identifier: &str) -> Result<(), SoftchatError> {
587    if identifier.is_empty() || identifier.chars().count() > 256 {
588        return Err(SoftchatError::InvalidApplicationData);
589    }
590    Ok(())
591}
592
593fn validate_https(value: &str) -> Result<(), SoftchatError> {
594    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
595    if url.scheme() != "https" || url.host_str().is_none() || url.fragment().is_some() {
596        return Err(SoftchatError::InvalidUserMetadata);
597    }
598    Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
606    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
607
608    #[test]
609    fn metadata_retains_unknown_fields_and_orders_replacements() -> Result<(), SoftchatError> {
610        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
611        let first = alice.create_user_metadata(
612            1,
613            Some("Alice".to_owned()),
614            None,
615            None,
616            Some("https://cdn.example/a.webp".to_owned()),
617            None,
618            None,
619            Some(false),
620            r#"{"future":{"retained":true}}"#,
621        )?;
622        assert_eq!(first.name.as_deref(), Some("Alice"));
623        assert_eq!(first.unknown_json, r#"{"future":{"retained":true}}"#);
624        let second =
625            alice.create_user_metadata(2, None, None, None, None, None, None, None, "{}")?;
626        assert_eq!(
627            latest_user_metadata(vec![first.rumor, second.rumor.clone()])?
628                .rumor
629                .id,
630            second.rumor.id
631        );
632
633        let tied_first = alice.create_user_metadata(
634            3,
635            Some("Tied first".to_owned()),
636            None,
637            None,
638            None,
639            None,
640            None,
641            None,
642            "{}",
643        )?;
644        let tied_second = alice.create_user_metadata(
645            3,
646            Some("Tied second".to_owned()),
647            None,
648            None,
649            None,
650            None,
651            None,
652            None,
653            "{}",
654        )?;
655        let expected = if tied_first.rumor.id < tied_second.rumor.id {
656            tied_first.rumor.clone()
657        } else {
658            tied_second.rumor.clone()
659        };
660        for order in [
661            vec![tied_first.rumor.clone(), tied_second.rumor.clone()],
662            vec![tied_second.rumor.clone(), tied_first.rumor.clone()],
663        ] {
664            assert_eq!(latest_user_metadata(order)?.rumor.id, expected.id);
665        }
666        Ok(())
667    }
668
669    #[test]
670    fn contact_writer_is_four_field_sorted_and_reader_keeps_first_duplicate()
671    -> Result<(), SoftchatError> {
672        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
673        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
674        let bob_hex = bob.public_key().to_hex();
675        let contact = Contact {
676            public_key: bob_hex.clone(),
677            relay_hint: String::new(),
678            local_name: "Bob".to_owned(),
679        };
680        let view = alice.create_follow_list(1, vec![contact.clone()])?;
681        assert_eq!(view.rumor.tags[0].len(), 4);
682        assert_eq!(view.contacts, vec![contact]);
683
684        let duplicate = alice.create_rumor(NostrEventDraft::new(
685            2,
686            NostrEventKind::FOLLOW_LIST,
687            vec![
688                NostrTag::new(vec!["p", &bob_hex, "", "First"])?,
689                NostrTag::new(vec!["p", &bob_hex, "", "Second"])?,
690            ],
691            "",
692        )?)?;
693        let read = parse_follow_list(&duplicate)?;
694        assert!(read.contained_duplicates);
695        assert_eq!(read.contacts[0].local_name, "First");
696        Ok(())
697    }
698
699    #[test]
700    fn app_data_sync_is_direct_self_encrypted_and_deterministically_replaceable()
701    -> Result<(), SoftchatError> {
702        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
703        let first = alice.create_app_data_sync(
704            1,
705            AppDataContext::ReadState,
706            r#"{"version":1,"read":1}"#,
707        )?;
708        let second = alice.create_app_data_sync(
709            2,
710            AppDataContext::ReadState,
711            r#"{"version":1,"read":2}"#,
712        )?;
713        assert_ne!(first.event.content, first.json);
714        assert_eq!(
715            alice
716                .decrypt_app_data_sync(&SignedNostrEvent::try_from(second.event.clone())?)?
717                .json,
718            r#"{"read":2,"version":1}"#
719        );
720
721        let generic_first =
722            alice.create_application_data(1, "drafts".to_owned(), "one".to_owned())?;
723        let generic_second =
724            alice.create_application_data(2, "drafts".to_owned(), "two".to_owned())?;
725        assert_eq!(
726            latest_application_data(vec![generic_first.event, generic_second.event.clone()])?
727                .content,
728            "two"
729        );
730
731        let tied_first =
732            alice.create_application_data(3, "drafts".to_owned(), "alpha".to_owned())?;
733        let tied_second =
734            alice.create_application_data(3, "drafts".to_owned(), "beta".to_owned())?;
735        let expected_id = std::cmp::min(tied_first.event.id.clone(), tied_second.event.id.clone());
736        for order in [
737            vec![tied_first.event.clone(), tied_second.event.clone()],
738            vec![tied_second.event, tied_first.event],
739        ] {
740            assert_eq!(latest_application_data(order)?.event.id, expected_id);
741        }
742        Ok(())
743    }
744}