1use 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
15pub const MAX_CONTACTS: usize = 10_000;
17pub 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
34pub struct UserMetadataView {
35 pub rumor: RumorEvent,
37 pub name: Option<String>,
39 pub display_name: Option<String>,
41 pub about: Option<String>,
43 pub picture: Option<String>,
45 pub website: Option<String>,
47 pub banner: Option<String>,
49 pub bot: Option<bool>,
51 pub unknown_json: String,
53}
54
55#[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 pub public_key: String,
62 pub relay_hint: String,
64 pub local_name: String,
66}
67
68#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
70#[serde(rename_all = "camelCase")]
71#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
72pub struct FollowListView {
73 pub rumor: RumorEvent,
75 pub contacts: Vec<Contact>,
77 pub contained_duplicates: bool,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "camelCase")]
84#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
85pub struct ApplicationDataView {
86 pub event: SignedEvent,
88 pub identifier: String,
90 pub content: String,
92}
93
94#[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 ReadState,
101 AppSettings,
103}
104
105impl AppDataContext {
106 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "camelCase")]
127#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
128pub struct AppDataSyncView {
129 pub event: SignedEvent,
131 pub context: AppDataContext,
133 pub json: String,
135}
136
137impl LocalIdentity {
138 #[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 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 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 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 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#[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#[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#[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#[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#[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}