1use std::collections::BTreeSet;
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::event::{RumorEvent, SignedEvent};
13use crate::{
14 AppDataContext, LocalIdentity, Nip59EnvelopeKind, NostrEventKind, NostrPublicKey, NostrRumor,
15 SignedNostrEvent, SoftchatError, classify_application_data, classify_chat_message,
16 classify_deletion, classify_edit, classify_follow_list, classify_generic_repost,
17 classify_reaction, classify_subject, classify_typing, classify_user_metadata,
18 parse_relay_endpoint,
19};
20
21pub const MAX_ACCOUNT_INGESTION_EVENTS: usize = 150;
23pub const MAX_ACCOUNT_INGESTION_BYTES: usize = 512 * 1_024;
25pub const MAX_OPERATION_RELAYS: usize = 64;
27pub const MAX_OPERATION_EVENT_COPIES: usize = 512;
29pub const MAX_OPERATION_DELIVERY_INTENTS: usize = 4_096;
31pub const MAX_DELIVERY_CLAIM_INTENTS: usize = 100;
33pub const MAX_DELIVERY_CLAIM_BYTES: usize = 512 * 1_024;
35
36const MAX_ACCOUNT_ID_BYTES: usize = 128;
37const MAX_OPERATION_ID_BYTES: usize = 128;
38const MAX_LEASE_ID_BYTES: usize = 128;
39const MAX_DELIVERY_CATEGORY_BYTES: usize = 64;
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
45pub enum ProjectionKind {
46 ChatMessage,
48 Subject,
50 Reaction,
52 Deletion,
54 Edit,
56 Typing,
58 UserMetadata,
60 FollowList,
62 ApplicationData,
64 AppDataSync,
66 GenericRepost,
68 Unknown,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74#[serde(rename_all = "camelCase")]
75#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
76pub struct ProjectionMutation {
77 pub kind: ProjectionKind,
79 pub logical_event_id: String,
81 pub author_public_key: String,
83 pub created_at: i64,
85 pub conversation_id: String,
87 pub participants: Vec<String>,
89 pub target_event_ids: Vec<String>,
91 pub content: String,
93 pub ephemeral: bool,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
101pub struct PreparedIncomingEvent {
102 pub outer_event: SignedEvent,
104 pub rumor: Option<RumorEvent>,
106 pub projection: ProjectionMutation,
108}
109
110#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
112#[serde(rename_all = "camelCase")]
113#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
114pub struct PreparedIncomingBatch {
115 pub account_id: String,
117 pub canonical_bytes: u64,
119 pub events: Vec<PreparedIncomingEvent>,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "camelCase", deny_unknown_fields)]
126#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
127pub struct IngestionReceipt {
128 pub account_id: String,
130 pub inserted_event_ids: Vec<String>,
132 pub duplicate_event_ids: Vec<String>,
134 pub quarantined_event_ids: Vec<String>,
136}
137
138#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "camelCase")]
141#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
142pub struct PreparedEventCopy {
143 pub event_copy_id: String,
145 pub recipient_public_key: String,
147 pub event: SignedEvent,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "camelCase")]
154#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
155pub struct PreparedRelayIntent {
156 pub intent_id: String,
158 pub operation_id: String,
160 pub event_copy_id: String,
162 pub relay_url: String,
164 pub payload_bytes: u64,
166}
167
168#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
170#[serde(rename_all = "camelCase")]
171#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
172pub struct PreparedOutgoingOperation {
173 pub account_id: String,
175 pub operation_id: String,
177 pub event_copies: Vec<PreparedEventCopy>,
179 pub relay_intents: Vec<PreparedRelayIntent>,
181}
182
183#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(rename_all = "camelCase")]
186#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
187pub enum DeliveryIntentState {
188 Pending,
190 Claimed,
192 SocketWritten,
194 Accepted,
196 Rejected,
198 Retryable,
200 Cancelled,
202}
203
204#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
208pub struct DeliveryIntentSnapshot {
209 pub intent_id: String,
211 pub operation_id: String,
213 pub event_copy_id: String,
215 pub relay_url: String,
217 pub payload_bytes: u64,
219 pub state: DeliveryIntentState,
221 pub lease_id: String,
223 pub attempt_count: u32,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
229#[serde(rename_all = "camelCase")]
230#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
231pub struct DeliveryStateMutation {
232 pub intent_id: String,
234 pub state: DeliveryIntentState,
236 pub lease_id: String,
238 pub category: String,
240 pub attempt_count: u32,
242}
243
244#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
246#[serde(rename_all = "camelCase")]
247#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
248pub struct DeliveryClaim {
249 pub lease_id: String,
251 pub intents: Vec<DeliveryIntentSnapshot>,
253 pub payload_bytes: u64,
255 pub mutations: Vec<DeliveryStateMutation>,
257}
258
259#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
261#[serde(rename_all = "camelCase")]
262#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
263pub enum RelayDeliveryResultKind {
264 Accepted,
266 Rejected,
268 Retryable,
270}
271
272#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct RelayDeliveryResult {
277 pub intent_id: String,
279 pub kind: RelayDeliveryResultKind,
281 pub category: String,
283}
284
285#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "camelCase")]
288#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
289pub enum OperationState {
290 Preparing,
292 Uploading,
294 Queued,
296 Sending,
298 PartiallySent,
300 Sent,
302 Failed,
304 Cancelled,
306}
307
308#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
310#[serde(rename_all = "camelCase")]
311#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
312pub struct OperationSnapshot {
313 pub operation_id: String,
315 pub state: OperationState,
317 pub total_intents: u32,
319 pub accepted_intents: u32,
321 pub rejected_intents: u32,
323 pub queued_intents: u32,
325 pub active_intents: u32,
327}
328
329#[derive(Clone, Copy, Debug, Default)]
331pub struct AccountEngine;
332
333impl AccountEngine {
334 pub fn validate_account_id(account_id: &str) -> Result<(), SoftchatError> {
341 validate_identifier(account_id, MAX_ACCOUNT_ID_BYTES)
342 }
343
344 pub fn prepare_incoming(
351 identity: &LocalIdentity,
352 account_id: String,
353 events: Vec<SignedEvent>,
354 ) -> Result<PreparedIncomingBatch, SoftchatError> {
355 Self::validate_account_id(&account_id)?;
356 if events.is_empty() || events.len() > MAX_ACCOUNT_INGESTION_EVENTS {
357 return Err(SoftchatError::InvalidAccountOperation);
358 }
359
360 let mut canonical_bytes = 0_u64;
361 let mut unique_ids = BTreeSet::new();
362 let mut prepared = Vec::with_capacity(events.len());
363 for event in events {
364 let outer = SignedNostrEvent::try_from(event)?;
365 let outer_json = outer.to_json()?;
366 crate::session::validate_event_frame_json(&outer_json)?;
367 canonical_bytes = canonical_bytes
368 .checked_add(
369 u64::try_from(outer_json.len())
370 .map_err(|_| SoftchatError::InvalidAccountOperation)?,
371 )
372 .ok_or(SoftchatError::InvalidAccountOperation)?;
373 if canonical_bytes > MAX_ACCOUNT_INGESTION_BYTES as u64
374 || !unique_ids.insert(outer.id().to_hex())
375 {
376 return Err(SoftchatError::InvalidAccountOperation);
377 }
378
379 let (rumor, projection) = if matches!(
380 outer.kind(),
381 NostrEventKind::GIFT_WRAP | NostrEventKind::EPHEMERAL_GIFT_WRAP
382 ) {
383 let unwrapped = identity.unwrap_gift_wrap(&outer)?;
384 let rumor = unwrapped.into_rumor();
385 let projection = projection_for_rumor(
386 &rumor,
387 &identity.public_key(),
388 outer.kind() == NostrEventKind::EPHEMERAL_GIFT_WRAP,
389 )?;
390 (Some(RumorEvent::from(&rumor)), projection)
391 } else {
392 (None, projection_for_signed(identity, &outer)?)
393 };
394 prepared.push(PreparedIncomingEvent {
395 outer_event: SignedEvent::from(&outer),
396 rumor,
397 projection,
398 });
399 }
400
401 Ok(PreparedIncomingBatch {
402 account_id,
403 canonical_bytes,
404 events: prepared,
405 })
406 }
407
408 pub fn validate_ingestion_receipt(
415 batch: &PreparedIncomingBatch,
416 receipt: &IngestionReceipt,
417 ) -> Result<(), SoftchatError> {
418 if batch.account_id != receipt.account_id {
419 return Err(SoftchatError::InvalidPersistenceResult);
420 }
421 let expected = batch
422 .events
423 .iter()
424 .map(|event| event.outer_event.id.clone())
425 .collect::<BTreeSet<_>>();
426 let mut actual = BTreeSet::new();
427 for event_id in receipt
428 .inserted_event_ids
429 .iter()
430 .chain(&receipt.duplicate_event_ids)
431 .chain(&receipt.quarantined_event_ids)
432 {
433 if !actual.insert(event_id.clone()) {
434 return Err(SoftchatError::InvalidPersistenceResult);
435 }
436 }
437 if expected == actual {
438 Ok(())
439 } else {
440 Err(SoftchatError::InvalidPersistenceResult)
441 }
442 }
443
444 #[allow(clippy::too_many_arguments)]
452 pub fn prepare_rumor_operation(
453 identity: &LocalIdentity,
454 account_id: String,
455 operation_id: String,
456 rumor: RumorEvent,
457 recipients: Vec<String>,
458 notification_recipients: Vec<String>,
459 relay_urls: Vec<String>,
460 kind: Nip59EnvelopeKind,
461 now: i64,
462 ) -> Result<PreparedOutgoingOperation, SoftchatError> {
463 Self::validate_account_id(&account_id)?;
464 validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
465 let now = u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
466 let rumor =
467 NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidAccountOperation)?;
468 if rumor.public_key() != identity.public_key() {
469 return Err(SoftchatError::InvalidAccountOperation);
470 }
471
472 let mut recipients = recipients
473 .iter()
474 .map(|value| {
475 NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidAccountOperation)
476 })
477 .collect::<Result<Vec<_>, _>>()?;
478 let recipient_ids = recipients
479 .iter()
480 .map(NostrPublicKey::to_hex)
481 .collect::<BTreeSet<_>>();
482 let notification_recipients = notification_recipients
483 .into_iter()
484 .map(|value| {
485 NostrPublicKey::from_hex(&value)
486 .map(|key| key.to_hex())
487 .map_err(|_| SoftchatError::InvalidAccountOperation)
488 })
489 .collect::<Result<BTreeSet<_>, _>>()?;
490 if !notification_recipients.is_subset(&recipient_ids) {
491 return Err(SoftchatError::InvalidAccountOperation);
492 }
493 crate::envelope::validate_nip59_wire_size(
494 &rumor,
495 kind,
496 now,
497 !notification_recipients.is_empty(),
498 )?;
499 recipients.push(identity.public_key());
500 recipients.sort_by_key(NostrPublicKey::to_hex);
501 recipients.dedup();
502 if recipients.is_empty() || recipients.len() > MAX_OPERATION_EVENT_COPIES {
503 return Err(SoftchatError::InvalidAccountOperation);
504 }
505
506 let relays = canonical_relay_urls(relay_urls)?;
507 let mut event_copies = Vec::with_capacity(recipients.len());
508 for recipient in recipients {
509 let notify_recipient = notification_recipients.contains(&recipient.to_hex());
510 let event = identity.gift_wrap_for_operation_prevalidated(
511 &recipient,
512 &rumor,
513 kind,
514 now,
515 notify_recipient,
516 )?;
517 event_copies.push(PreparedEventCopy {
518 event_copy_id: event.id().to_hex(),
519 recipient_public_key: recipient.to_hex(),
520 event: SignedEvent::from(&event),
521 });
522 }
523 prepare_operation(account_id, operation_id, event_copies, relays)
524 }
525
526 pub fn prepare_signed_operation(
532 account_id: String,
533 operation_id: String,
534 events: Vec<SignedEvent>,
535 relay_urls: Vec<String>,
536 ) -> Result<PreparedOutgoingOperation, SoftchatError> {
537 Self::validate_account_id(&account_id)?;
538 validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
539 if events.is_empty() || events.len() > MAX_OPERATION_EVENT_COPIES {
540 return Err(SoftchatError::InvalidAccountOperation);
541 }
542 let mut unique = BTreeSet::new();
543 let mut event_copies = Vec::with_capacity(events.len());
544 for event in events {
545 let event = SignedNostrEvent::try_from(event)?;
546 let event_copy_id = event.id().to_hex();
547 if !unique.insert(event_copy_id.clone()) {
548 return Err(SoftchatError::InvalidAccountOperation);
549 }
550 event_copies.push(PreparedEventCopy {
551 event_copy_id,
552 recipient_public_key: String::new(),
553 event: SignedEvent::from(&event),
554 });
555 }
556 prepare_operation(
557 account_id,
558 operation_id,
559 event_copies,
560 canonical_relay_urls(relay_urls)?,
561 )
562 }
563}
564
565#[derive(Clone, Copy, Debug, Default)]
567pub struct DeliveryReducer;
568
569impl DeliveryReducer {
570 pub fn claim(
577 intents: Vec<DeliveryIntentSnapshot>,
578 lease_id: String,
579 ) -> Result<DeliveryClaim, SoftchatError> {
580 validate_identifier(&lease_id, MAX_LEASE_ID_BYTES)
581 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
582 let mut claimed = Vec::new();
583 let mut mutations = Vec::new();
584 let mut payload_bytes = 0_u64;
585 let mut unique = BTreeSet::new();
586 for mut intent in intents {
587 validate_intent_snapshot(&intent)?;
588 if !unique.insert(intent.intent_id.clone()) {
589 return Err(SoftchatError::InvalidDeliveryState);
590 }
591 if !matches!(
592 intent.state,
593 DeliveryIntentState::Pending | DeliveryIntentState::Retryable
594 ) {
595 continue;
596 }
597 if claimed.len() >= MAX_DELIVERY_CLAIM_INTENTS {
598 break;
599 }
600 let next_bytes = payload_bytes
601 .checked_add(intent.payload_bytes)
602 .ok_or(SoftchatError::InvalidDeliveryState)?;
603 if next_bytes > MAX_DELIVERY_CLAIM_BYTES as u64 {
604 if claimed.is_empty() {
605 return Err(SoftchatError::InvalidDeliveryState);
606 }
607 break;
608 }
609 payload_bytes = next_bytes;
610 let attempt_count = intent.attempt_count.saturating_add(1);
611 mutations.push(DeliveryStateMutation {
612 intent_id: intent.intent_id.clone(),
613 state: DeliveryIntentState::Claimed,
614 lease_id: lease_id.clone(),
615 category: "claimed".to_owned(),
616 attempt_count,
617 });
618 intent.state = DeliveryIntentState::Claimed;
619 intent.lease_id.clone_from(&lease_id);
620 intent.attempt_count = attempt_count;
621 claimed.push(intent);
622 }
623 if claimed.is_empty() {
624 return Err(SoftchatError::InvalidDeliveryState);
625 }
626 Ok(DeliveryClaim {
627 lease_id,
628 intents: claimed,
629 payload_bytes,
630 mutations,
631 })
632 }
633
634 pub fn mark_socket_written(
641 claim: &DeliveryClaim,
642 intent_ids: Vec<String>,
643 ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
644 transition_claim_subset(
645 claim,
646 intent_ids,
647 DeliveryIntentState::Claimed,
648 DeliveryIntentState::SocketWritten,
649 "socket_written",
650 true,
651 )
652 }
653
654 pub fn apply_relay_results(
661 claim: &DeliveryClaim,
662 results: Vec<RelayDeliveryResult>,
663 ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
664 if results.is_empty() {
665 return Err(SoftchatError::InvalidDeliveryState);
666 }
667 let claimed = claim
668 .intents
669 .iter()
670 .map(|intent| (intent.intent_id.as_str(), intent))
671 .collect::<std::collections::BTreeMap<_, _>>();
672 let mut seen = BTreeSet::new();
673 let mut mutations = Vec::with_capacity(results.len());
674 for result in results {
675 if !seen.insert(result.intent_id.clone())
676 || result.category.is_empty()
677 || result.category.len() > MAX_DELIVERY_CATEGORY_BYTES
678 || !result
679 .category
680 .bytes()
681 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
682 {
683 return Err(SoftchatError::InvalidDeliveryState);
684 }
685 let intent = claimed
686 .get(result.intent_id.as_str())
687 .ok_or(SoftchatError::InvalidDeliveryState)?;
688 if intent.state != DeliveryIntentState::SocketWritten
689 && intent.state != DeliveryIntentState::Claimed
690 {
691 return Err(SoftchatError::InvalidDeliveryState);
692 }
693 let state = match result.kind {
694 RelayDeliveryResultKind::Accepted => DeliveryIntentState::Accepted,
695 RelayDeliveryResultKind::Rejected => DeliveryIntentState::Rejected,
696 RelayDeliveryResultKind::Retryable => DeliveryIntentState::Retryable,
697 };
698 mutations.push(DeliveryStateMutation {
699 intent_id: result.intent_id,
700 state,
701 lease_id: String::new(),
702 category: result.category,
703 attempt_count: intent.attempt_count,
704 });
705 }
706 Ok(mutations)
707 }
708
709 pub fn release_claim(
715 claim: &DeliveryClaim,
716 ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
717 if claim.intents.is_empty() {
718 return Err(SoftchatError::InvalidDeliveryState);
719 }
720 claim
721 .intents
722 .iter()
723 .map(|intent| {
724 validate_intent_snapshot(intent)?;
725 if !matches!(
726 intent.state,
727 DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
728 ) {
729 return Err(SoftchatError::InvalidDeliveryState);
730 }
731 Ok(DeliveryStateMutation {
732 intent_id: intent.intent_id.clone(),
733 state: DeliveryIntentState::Retryable,
734 lease_id: String::new(),
735 category: "lease_released".to_owned(),
736 attempt_count: intent.attempt_count,
737 })
738 })
739 .collect()
740 }
741
742 pub fn operation_snapshot(
748 intents: Vec<DeliveryIntentSnapshot>,
749 ) -> Result<OperationSnapshot, SoftchatError> {
750 let operation_id = intents
751 .first()
752 .map(|intent| intent.operation_id.clone())
753 .ok_or(SoftchatError::InvalidAccountOperation)?;
754 validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
755 let mut accepted = 0_u32;
756 let mut rejected = 0_u32;
757 let mut queued = 0_u32;
758 let mut active = 0_u32;
759 let mut cancelled = 0_u32;
760 for intent in &intents {
761 validate_intent_snapshot(intent)?;
762 if intent.operation_id != operation_id {
763 return Err(SoftchatError::InvalidAccountOperation);
764 }
765 match intent.state {
766 DeliveryIntentState::Accepted => accepted = accepted.saturating_add(1),
767 DeliveryIntentState::Rejected => rejected = rejected.saturating_add(1),
768 DeliveryIntentState::Pending | DeliveryIntentState::Retryable => {
769 queued = queued.saturating_add(1);
770 }
771 DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten => {
772 active = active.saturating_add(1);
773 }
774 DeliveryIntentState::Cancelled => cancelled = cancelled.saturating_add(1),
775 }
776 }
777 let total =
778 u32::try_from(intents.len()).map_err(|_| SoftchatError::InvalidAccountOperation)?;
779 let state = if accepted == total {
780 OperationState::Sent
781 } else if cancelled == total {
782 OperationState::Cancelled
783 } else if accepted > 0 {
784 OperationState::PartiallySent
785 } else if rejected > 0 {
786 OperationState::Failed
787 } else if active > 0 {
788 OperationState::Sending
789 } else {
790 OperationState::Queued
791 };
792 Ok(OperationSnapshot {
793 operation_id,
794 state,
795 total_intents: total,
796 accepted_intents: accepted,
797 rejected_intents: rejected,
798 queued_intents: queued,
799 active_intents: active,
800 })
801 }
802}
803
804pub fn conversation_id(public_keys: Vec<String>) -> Result<String, SoftchatError> {
811 if public_keys.len() < 2 || public_keys.len() > crate::MAX_CHAT_PARTICIPANTS + 1 {
812 return Err(SoftchatError::InvalidAccountOperation);
813 }
814 let mut keys = public_keys
815 .iter()
816 .map(|value| {
817 NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidAccountOperation)
818 })
819 .collect::<Result<Vec<_>, _>>()?;
820 keys.sort_by_key(NostrPublicKey::to_hex);
821 let original_len = keys.len();
822 keys.dedup();
823 if keys.len() != original_len {
824 return Err(SoftchatError::InvalidAccountOperation);
825 }
826 let mut digest = Sha256::new();
827 for key in keys {
828 digest.update(key.as_inner().as_bytes());
829 }
830 Ok(hex::encode(digest.finalize()))
831}
832
833fn prepare_operation(
834 account_id: String,
835 operation_id: String,
836 event_copies: Vec<PreparedEventCopy>,
837 relay_urls: Vec<String>,
838) -> Result<PreparedOutgoingOperation, SoftchatError> {
839 let intent_count = event_copies
840 .len()
841 .checked_mul(relay_urls.len())
842 .ok_or(SoftchatError::InvalidAccountOperation)?;
843 if intent_count == 0 || intent_count > MAX_OPERATION_DELIVERY_INTENTS {
844 return Err(SoftchatError::InvalidAccountOperation);
845 }
846 let mut relay_intents = Vec::with_capacity(intent_count);
847 for event_copy in &event_copies {
848 let event = SignedNostrEvent::try_from(event_copy.event.clone())?;
849 let event_json = event.to_json()?;
850 crate::session::validate_event_frame_json(&event_json)?;
851 let payload_bytes =
852 u64::try_from(event_json.len()).map_err(|_| SoftchatError::InvalidAccountOperation)?;
853 for relay_url in &relay_urls {
854 relay_intents.push(PreparedRelayIntent {
855 intent_id: stable_intent_id(&operation_id, &event_copy.event_copy_id, relay_url),
856 operation_id: operation_id.clone(),
857 event_copy_id: event_copy.event_copy_id.clone(),
858 relay_url: relay_url.clone(),
859 payload_bytes,
860 });
861 }
862 }
863 Ok(PreparedOutgoingOperation {
864 account_id,
865 operation_id,
866 event_copies,
867 relay_intents,
868 })
869}
870
871fn projection_for_rumor(
872 rumor: &NostrRumor,
873 authenticated_recipient: &NostrPublicKey,
874 ephemeral: bool,
875) -> Result<ProjectionMutation, SoftchatError> {
876 let record = RumorEvent::from(rumor);
877 let (kind, target_event_ids, content) = match rumor.kind() {
878 NostrEventKind::PRIVATE_DIRECT_MESSAGE => {
879 if let Ok(view) = classify_chat_message(record.clone()) {
880 let targets = (!view.related_event_id.is_empty())
881 .then_some(view.related_event_id)
882 .into_iter()
883 .collect();
884 (ProjectionKind::ChatMessage, targets, view.rumor.content)
885 } else {
886 let view = classify_subject(record.clone())?;
887 (ProjectionKind::Subject, Vec::new(), view.subject)
888 }
889 }
890 NostrEventKind::REACTION => {
891 let view = classify_reaction(record.clone())?;
892 (ProjectionKind::Reaction, vec![view.event_id], view.reaction)
893 }
894 NostrEventKind::EVENT_DELETION_REQUEST => {
895 let view = classify_deletion(record.clone())?;
896 (ProjectionKind::Deletion, view.event_ids, view.reason)
897 }
898 NostrEventKind::UPDATED_CONTENT => {
899 let view = classify_edit(record.clone())?;
900 (
901 ProjectionKind::Edit,
902 vec![view.original_event_id],
903 view.content,
904 )
905 }
906 NostrEventKind::TYPING => {
907 classify_typing(record.clone())?;
908 (ProjectionKind::Typing, Vec::new(), String::new())
909 }
910 NostrEventKind::USER_METADATA => {
911 let view = classify_user_metadata(record.clone())?;
912 (ProjectionKind::UserMetadata, Vec::new(), view.rumor.content)
913 }
914 NostrEventKind::FOLLOW_LIST => {
915 classify_follow_list(record.clone())?;
916 (ProjectionKind::FollowList, Vec::new(), String::new())
917 }
918 _ => (
919 ProjectionKind::Unknown,
920 Vec::new(),
921 rumor.content().to_owned(),
922 ),
923 };
924 let (conversation_id, participants) = if matches!(
928 kind,
929 ProjectionKind::ChatMessage
930 | ProjectionKind::Subject
931 | ProjectionKind::Reaction
932 | ProjectionKind::Deletion
933 | ProjectionKind::Edit
934 | ProjectionKind::Typing
935 ) {
936 conversation_for_rumor(rumor, authenticated_recipient)?
937 } else {
938 (String::new(), Vec::new())
939 };
940 Ok(ProjectionMutation {
941 kind,
942 logical_event_id: rumor.id().to_hex(),
943 author_public_key: rumor.public_key().to_hex(),
944 created_at: i64::try_from(rumor.created_at())
945 .map_err(|_| SoftchatError::InvalidAccountOperation)?,
946 conversation_id,
947 participants,
948 target_event_ids,
949 content,
950 ephemeral,
951 })
952}
953
954fn projection_for_signed(
955 identity: &LocalIdentity,
956 event: &SignedNostrEvent,
957) -> Result<ProjectionMutation, SoftchatError> {
958 let record = SignedEvent::from(event);
959 let (kind, content) = match event.kind() {
960 NostrEventKind::APPLICATION_DATA => {
961 let view = classify_application_data(record.clone())?;
962 (ProjectionKind::ApplicationData, view.content)
963 }
964 NostrEventKind::APPLICATION_DATA_SYNC => {
965 let view = identity.decrypt_app_data_sync(event)?;
966 let context = match view.context {
967 AppDataContext::ReadState => "read-state",
968 AppDataContext::AppSettings => "app-settings",
969 };
970 (
971 ProjectionKind::AppDataSync,
972 format!("{context}:{}", view.json),
973 )
974 }
975 NostrEventKind::GENERIC_REPOST => {
976 classify_generic_repost(record)?;
977 (ProjectionKind::GenericRepost, event.content().to_owned())
978 }
979 _ => (ProjectionKind::Unknown, event.content().to_owned()),
980 };
981 Ok(ProjectionMutation {
982 kind,
983 logical_event_id: event.id().to_hex(),
984 author_public_key: event.public_key().to_hex(),
985 created_at: i64::try_from(event.created_at())
986 .map_err(|_| SoftchatError::InvalidAccountOperation)?,
987 conversation_id: String::new(),
988 participants: Vec::new(),
989 target_event_ids: Vec::new(),
990 content,
991 ephemeral: false,
992 })
993}
994
995fn conversation_for_rumor(
996 rumor: &NostrRumor,
997 authenticated_recipient: &NostrPublicKey,
998) -> Result<(String, Vec<String>), SoftchatError> {
999 let participant_count = rumor
1000 .tags()
1001 .filter(|tag| tag.first().map(String::as_str) == Some("p"))
1002 .count();
1003 if participant_count == 0 || participant_count > crate::MAX_CHAT_PARTICIPANTS {
1004 return Err(SoftchatError::InvalidSoftchatEvent);
1005 }
1006
1007 let author = rumor.public_key();
1008 let mut participants = Vec::with_capacity(participant_count.saturating_add(1));
1009 for tag in rumor
1010 .tags()
1011 .filter(|tag| tag.first().map(String::as_str) == Some("p"))
1012 {
1013 let value = tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?;
1014 let participant =
1015 NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
1016 if participant == author {
1017 return Err(SoftchatError::InvalidSoftchatEvent);
1018 }
1019 participants.push(participant);
1020 }
1021 participants.sort_by_key(NostrPublicKey::to_hex);
1022 let original_len = participants.len();
1023 participants.dedup();
1024 if participants.len() != original_len {
1025 return Err(SoftchatError::InvalidSoftchatEvent);
1026 }
1027 participants.push(author);
1028 participants.sort_by_key(NostrPublicKey::to_hex);
1029 if !participants.contains(authenticated_recipient) || participants.len() < 2 {
1030 return Err(SoftchatError::InvalidSoftchatEvent);
1031 }
1032 let participants = participants
1033 .into_iter()
1034 .map(|key| key.to_hex())
1035 .collect::<Vec<_>>();
1036 Ok((conversation_id(participants.clone())?, participants))
1037}
1038
1039fn canonical_relay_urls(values: Vec<String>) -> Result<Vec<String>, SoftchatError> {
1040 if values.is_empty() || values.len() > MAX_OPERATION_RELAYS {
1041 return Err(SoftchatError::InvalidAccountOperation);
1042 }
1043 let mut relays = values
1044 .iter()
1045 .map(|value| {
1046 parse_relay_endpoint(value)
1047 .map(|endpoint| endpoint.configured_url)
1048 .map_err(|_| SoftchatError::InvalidAccountOperation)
1049 })
1050 .collect::<Result<Vec<_>, _>>()?;
1051 relays.sort();
1052 let original_len = relays.len();
1053 relays.dedup();
1054 if relays.len() != original_len {
1055 return Err(SoftchatError::InvalidAccountOperation);
1056 }
1057 Ok(relays)
1058}
1059
1060pub(crate) fn stable_intent_id(operation_id: &str, event_copy_id: &str, relay_url: &str) -> String {
1061 let mut digest = Sha256::new();
1062 digest.update(operation_id.as_bytes());
1063 digest.update([0]);
1064 digest.update(event_copy_id.as_bytes());
1065 digest.update([0]);
1066 digest.update(relay_url.as_bytes());
1067 hex::encode(digest.finalize())
1068}
1069
1070fn validate_identifier(value: &str, max_bytes: usize) -> Result<(), SoftchatError> {
1071 if value.is_empty()
1072 || value.len() > max_bytes
1073 || !value
1074 .bytes()
1075 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1076 {
1077 Err(SoftchatError::InvalidAccountOperation)
1078 } else {
1079 Ok(())
1080 }
1081}
1082
1083fn validate_intent_snapshot(intent: &DeliveryIntentSnapshot) -> Result<(), SoftchatError> {
1084 validate_identifier(&intent.intent_id, MAX_OPERATION_ID_BYTES)
1085 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1086 validate_identifier(&intent.operation_id, MAX_OPERATION_ID_BYTES)
1087 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1088 if intent.event_copy_id.len() != 64
1089 || hex::decode(&intent.event_copy_id)
1090 .ok()
1091 .is_none_or(|bytes| bytes.len() != 32)
1092 || intent.payload_bytes == 0
1093 || intent.payload_bytes > crate::MAX_NOSTR_EVENT_JSON_BYTES as u64
1094 {
1095 return Err(SoftchatError::InvalidDeliveryState);
1096 }
1097 canonical_relay_urls(vec![intent.relay_url.clone()])
1098 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1099 if matches!(
1100 intent.state,
1101 DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
1102 ) {
1103 validate_identifier(&intent.lease_id, MAX_LEASE_ID_BYTES)
1104 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1105 } else if !intent.lease_id.is_empty() {
1106 return Err(SoftchatError::InvalidDeliveryState);
1107 }
1108 Ok(())
1109}
1110
1111fn transition_claim_subset(
1112 claim: &DeliveryClaim,
1113 intent_ids: Vec<String>,
1114 expected: DeliveryIntentState,
1115 next: DeliveryIntentState,
1116 category: &str,
1117 retain_lease: bool,
1118) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
1119 if intent_ids.is_empty() {
1120 return Err(SoftchatError::InvalidDeliveryState);
1121 }
1122 validate_identifier(&claim.lease_id, MAX_LEASE_ID_BYTES)
1123 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1124 let claimed = claim
1125 .intents
1126 .iter()
1127 .map(|intent| (intent.intent_id.as_str(), intent))
1128 .collect::<std::collections::BTreeMap<_, _>>();
1129 let mut seen = BTreeSet::new();
1130 intent_ids
1131 .into_iter()
1132 .map(|intent_id| {
1133 if !seen.insert(intent_id.clone()) {
1134 return Err(SoftchatError::InvalidDeliveryState);
1135 }
1136 let intent = claimed
1137 .get(intent_id.as_str())
1138 .ok_or(SoftchatError::InvalidDeliveryState)?;
1139 if intent.state != expected
1140 || (!intent.lease_id.is_empty() && intent.lease_id != claim.lease_id)
1141 {
1142 return Err(SoftchatError::InvalidDeliveryState);
1143 }
1144 Ok(DeliveryStateMutation {
1145 intent_id,
1146 state: next,
1147 lease_id: if retain_lease {
1148 claim.lease_id.clone()
1149 } else {
1150 String::new()
1151 },
1152 category: category.to_owned(),
1153 attempt_count: intent.attempt_count,
1154 })
1155 })
1156 .collect()
1157}
1158
1159#[cfg(test)]
1160#[allow(clippy::unwrap_used)]
1161mod tests {
1162 use super::*;
1163 use crate::{ChatMessageDraft, ChatRelation, NostrEventDraft};
1164
1165 const ALICE: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1166 const BOB: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
1167
1168 fn intent(id: u8, state: DeliveryIntentState, bytes: u64) -> DeliveryIntentSnapshot {
1169 DeliveryIntentSnapshot {
1170 intent_id: format!("intent-{id}"),
1171 operation_id: "operation-1".to_owned(),
1172 event_copy_id: hex::encode([id; 32]),
1173 relay_url: "wss://relay.example/".to_owned(),
1174 payload_bytes: bytes,
1175 state,
1176 lease_id: if matches!(
1177 state,
1178 DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
1179 ) {
1180 "lease-1".to_owned()
1181 } else {
1182 String::new()
1183 },
1184 attempt_count: 0,
1185 }
1186 }
1187
1188 #[test]
1189 fn account_events_reserve_their_publish_frame_before_acceptance() -> Result<(), SoftchatError> {
1190 let identity = LocalIdentity::from_secret_hex(ALICE)?;
1191 let sign = |content: String| {
1192 identity.sign_event(NostrEventDraft::new(
1193 1_700_000_000,
1194 NostrEventKind::SHORT_TEXT_NOTE,
1195 Vec::new(),
1196 content,
1197 )?)
1198 };
1199 let base_bytes = sign(String::new())?.to_json()?.len();
1200 let envelope_bytes = "[\"EVENT\",]".len();
1201 for event_bytes in [
1202 crate::MAX_RELAY_FRAME_BYTES - envelope_bytes,
1203 crate::MAX_RELAY_FRAME_BYTES - envelope_bytes + 1,
1204 ] {
1205 let event = sign("x".repeat(event_bytes - base_bytes))?;
1206 let json = event.to_json()?;
1207 assert_eq!(json.len(), event_bytes);
1208 assert!(SignedNostrEvent::from_json(&json).is_ok());
1209 let incoming = AccountEngine::prepare_incoming(
1210 &identity,
1211 "frame-account".to_owned(),
1212 vec![SignedEvent::from(&event)],
1213 );
1214 let outgoing = AccountEngine::prepare_signed_operation(
1215 "frame-account".to_owned(),
1216 "frame-command".to_owned(),
1217 vec![SignedEvent::from(&event)],
1218 vec!["wss://relay.example".to_owned()],
1219 );
1220 if event_bytes + envelope_bytes <= crate::MAX_RELAY_FRAME_BYTES {
1221 assert!(incoming.is_ok());
1222 assert!(outgoing.is_ok());
1223 assert_eq!(
1224 crate::ClientRelayFrame::Event(event).to_json()?.len(),
1225 crate::MAX_RELAY_FRAME_BYTES
1226 );
1227 } else {
1228 assert!(matches!(incoming, Err(SoftchatError::InvalidRelayFrame)));
1229 assert!(matches!(outgoing, Err(SoftchatError::InvalidRelayFrame)));
1230 }
1231 }
1232 Ok(())
1233 }
1234
1235 #[test]
1236 fn prepares_and_authenticates_account_batches_and_operations() {
1237 let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1238 let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1239 let message = alice
1240 .create_chat_message(ChatMessageDraft {
1241 created_at: 1_700_000_000,
1242 participants: vec![bob.public_key()],
1243 content: "hello".to_owned(),
1244 relation: ChatRelation::None,
1245 attachments: Vec::new(),
1246 emoji_tags: Vec::new(),
1247 extension_tags: Vec::new(),
1248 })
1249 .unwrap();
1250 let operation = AccountEngine::prepare_rumor_operation(
1251 &alice,
1252 "account-1".to_owned(),
1253 "operation-1".to_owned(),
1254 message.rumor.clone(),
1255 vec![bob.public_key().to_hex()],
1256 vec![bob.public_key().to_hex()],
1257 vec!["wss://relay.example".to_owned()],
1258 Nip59EnvelopeKind::Durable,
1259 1_700_172_800,
1260 )
1261 .unwrap();
1262 assert_eq!(operation.event_copies.len(), 2);
1263 assert_eq!(operation.relay_intents.len(), 2);
1264 for copy in &operation.event_copies {
1265 let event = SignedNostrEvent::try_from(copy.event.clone()).unwrap();
1266 let has_alert = event
1267 .tags()
1268 .any(|tag| tag.first().is_some_and(|value| value == "alert"));
1269 assert_eq!(
1270 has_alert,
1271 copy.recipient_public_key == bob.public_key().to_hex()
1272 );
1273 }
1274 assert_ne!(
1275 operation.event_copies[0].event.public_key,
1276 operation.event_copies[1].event.public_key
1277 );
1278
1279 let bob_copy = operation
1280 .event_copies
1281 .iter()
1282 .find(|copy| copy.recipient_public_key == bob.public_key().to_hex())
1283 .unwrap();
1284 let batch = AccountEngine::prepare_incoming(
1285 &bob,
1286 "account-2".to_owned(),
1287 vec![bob_copy.event.clone()],
1288 )
1289 .unwrap();
1290 assert_eq!(batch.events[0].projection.kind, ProjectionKind::ChatMessage);
1291 assert_eq!(batch.events[0].projection.content, "hello");
1292 assert_eq!(batch.events[0].projection.participants.len(), 2);
1293
1294 let receipt = IngestionReceipt {
1295 account_id: "account-2".to_owned(),
1296 inserted_event_ids: vec![bob_copy.event.id.clone()],
1297 duplicate_event_ids: Vec::new(),
1298 quarantined_event_ids: Vec::new(),
1299 };
1300 AccountEngine::validate_ingestion_receipt(&batch, &receipt).unwrap();
1301 }
1302
1303 #[test]
1304 fn account_data_p_tags_do_not_create_chat_conversations() {
1305 let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1306 let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1307 let follow_list = alice
1308 .create_follow_list(
1309 1_700_000_000,
1310 vec![crate::Contact {
1311 public_key: bob.public_key().to_hex(),
1312 relay_hint: String::new(),
1313 local_name: "Bob".to_owned(),
1314 }],
1315 )
1316 .unwrap();
1317 let rumor = NostrRumor::try_from(follow_list.rumor).unwrap();
1318
1319 let projection = projection_for_rumor(&rumor, &alice.public_key(), false).unwrap();
1320
1321 assert_eq!(projection.kind, ProjectionKind::FollowList);
1322 assert!(projection.conversation_id.is_empty());
1323 assert!(projection.participants.is_empty());
1324 }
1325
1326 #[test]
1327 fn conversation_id_matches_the_released_android_hash() {
1328 let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1329 let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1330 let result =
1331 conversation_id(vec![bob.public_key().to_hex(), alice.public_key().to_hex()]).unwrap();
1332 let mut digest = Sha256::new();
1333 let mut keys = [alice.public_key(), bob.public_key()];
1334 keys.sort_by_key(NostrPublicKey::to_hex);
1335 for key in keys {
1336 digest.update(key.as_inner().as_bytes());
1337 }
1338 assert_eq!(result, hex::encode(digest.finalize()));
1339 }
1340
1341 #[test]
1342 fn delivery_claim_and_results_never_treat_socket_write_as_acceptance() {
1343 let claim = DeliveryReducer::claim(
1344 vec![
1345 intent(1, DeliveryIntentState::Pending, 100),
1346 intent(2, DeliveryIntentState::Retryable, 200),
1347 ],
1348 "lease-1".to_owned(),
1349 )
1350 .unwrap();
1351 assert_eq!(claim.payload_bytes, 300);
1352 assert!(
1353 claim
1354 .mutations
1355 .iter()
1356 .all(|mutation| mutation.state == DeliveryIntentState::Claimed)
1357 );
1358
1359 let mut persisted_claim = claim.clone();
1360 for intent in &mut persisted_claim.intents {
1361 intent.state = DeliveryIntentState::Claimed;
1362 intent.lease_id = "lease-1".to_owned();
1363 }
1364 let written = DeliveryReducer::mark_socket_written(
1365 &persisted_claim,
1366 vec!["intent-1".to_owned(), "intent-2".to_owned()],
1367 )
1368 .unwrap();
1369 assert!(
1370 written
1371 .iter()
1372 .all(|mutation| mutation.state == DeliveryIntentState::SocketWritten)
1373 );
1374
1375 for intent in &mut persisted_claim.intents {
1376 intent.state = DeliveryIntentState::SocketWritten;
1377 }
1378 let outcomes = DeliveryReducer::apply_relay_results(
1379 &persisted_claim,
1380 vec![
1381 RelayDeliveryResult {
1382 intent_id: "intent-1".to_owned(),
1383 kind: RelayDeliveryResultKind::Accepted,
1384 category: "accepted".to_owned(),
1385 },
1386 RelayDeliveryResult {
1387 intent_id: "intent-2".to_owned(),
1388 kind: RelayDeliveryResultKind::Retryable,
1389 category: "rate_limited".to_owned(),
1390 },
1391 ],
1392 )
1393 .unwrap();
1394 assert_eq!(outcomes[0].state, DeliveryIntentState::Accepted);
1395 assert_eq!(outcomes[1].state, DeliveryIntentState::Retryable);
1396 }
1397
1398 #[test]
1399 fn operation_state_requires_every_relay_intent_to_accept() {
1400 let queued = DeliveryReducer::operation_snapshot(vec![
1401 intent(1, DeliveryIntentState::Pending, 1),
1402 intent(2, DeliveryIntentState::Retryable, 1),
1403 ])
1404 .unwrap();
1405 assert_eq!(queued.state, OperationState::Queued);
1406
1407 let partial = DeliveryReducer::operation_snapshot(vec![
1408 intent(1, DeliveryIntentState::Accepted, 1),
1409 intent(2, DeliveryIntentState::Retryable, 1),
1410 ])
1411 .unwrap();
1412 assert_eq!(partial.state, OperationState::PartiallySent);
1413
1414 let sent = DeliveryReducer::operation_snapshot(vec![
1415 intent(1, DeliveryIntentState::Accepted, 1),
1416 intent(2, DeliveryIntentState::Accepted, 1),
1417 ])
1418 .unwrap();
1419 assert_eq!(sent.state, OperationState::Sent);
1420 }
1421
1422 #[test]
1423 fn incoming_rejects_duplicates_and_receipt_gaps() {
1424 let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1425 let event = alice
1426 .sign_event(
1427 NostrEventDraft::new(
1428 1,
1429 NostrEventKind::APPLICATION_DATA,
1430 vec![crate::NostrTag::new(vec!["d".to_owned(), "fixture".to_owned()]).unwrap()],
1431 "content",
1432 )
1433 .unwrap(),
1434 )
1435 .unwrap();
1436 let value = SignedEvent::from(&event);
1437 assert!(matches!(
1438 AccountEngine::prepare_incoming(
1439 &alice,
1440 "account-1".to_owned(),
1441 vec![value.clone(), value]
1442 ),
1443 Err(SoftchatError::InvalidAccountOperation)
1444 ));
1445 }
1446
1447 #[test]
1448 fn incoming_chat_binds_outer_recipient_to_inner_participants() {
1449 let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1450 let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1451 let carol = LocalIdentity::from_secret_hex(
1452 "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
1453 )
1454 .unwrap();
1455
1456 let prepare = |recipient: &LocalIdentity, tags: Vec<crate::NostrTag>| {
1457 let rumor = alice
1458 .create_rumor(
1459 NostrEventDraft::new(
1460 1_700_000_000,
1461 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
1462 tags,
1463 "bounded participant fixture",
1464 )
1465 .unwrap(),
1466 )
1467 .unwrap();
1468 let outer = alice
1469 .gift_wrap(
1470 &recipient.public_key(),
1471 &rumor,
1472 Nip59EnvelopeKind::Durable,
1473 1_700_172_800,
1474 )
1475 .unwrap();
1476 AccountEngine::prepare_incoming(
1477 recipient,
1478 "recipient-account".to_owned(),
1479 vec![SignedEvent::from(&outer)],
1480 )
1481 };
1482 let participant_tag = |identity: &LocalIdentity| {
1483 crate::NostrTag::new(vec!["p".to_owned(), identity.public_key().to_hex()]).unwrap()
1484 };
1485
1486 assert!(prepare(&bob, vec![participant_tag(&bob)]).is_ok());
1487 assert!(prepare(&alice, vec![participant_tag(&bob)]).is_ok());
1490 assert!(matches!(
1491 prepare(&bob, vec![participant_tag(&carol)]),
1492 Err(SoftchatError::InvalidSoftchatEvent)
1493 ));
1494 assert!(matches!(
1495 prepare(&bob, vec![participant_tag(&bob), participant_tag(&bob)]),
1496 Err(SoftchatError::InvalidSoftchatEvent)
1497 ));
1498 assert!(matches!(
1499 prepare(&alice, Vec::new()),
1500 Err(SoftchatError::InvalidSoftchatEvent)
1501 ));
1502
1503 let mut oversized = vec![participant_tag(&bob)];
1504 for value in 1_u16..=crate::MAX_CHAT_PARTICIPANTS as u16 {
1505 let identity = LocalIdentity::from_secret_hex(&format!("{value:064x}")).unwrap();
1506 oversized.push(participant_tag(&identity));
1507 }
1508 assert!(oversized.len() > crate::MAX_CHAT_PARTICIPANTS);
1509 assert!(matches!(
1510 prepare(&bob, oversized),
1511 Err(SoftchatError::InvalidSoftchatEvent)
1512 ));
1513 }
1514}