1#![allow(
4 unreachable_pub,
5 reason = "UniFFI handle methods stay inside this private core module"
6)]
7
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9#[cfg(feature = "native-bindings")]
10use std::fmt;
11#[cfg(feature = "native-bindings")]
12use std::sync::{Mutex, MutexGuard};
13
14use crate::event::SignedEvent;
15use crate::{
16 BatchAcknowledgement, BatchEventState, ClientRelayFrame, MAX_RELAY_BATCH_EVENTS, RelayFilter,
17 RelayResponseFrame, SignedNostrEvent, SoftchatError, parse_relay_response_frame,
18};
19use serde::{Deserialize, Serialize};
20
21pub const MAX_OUTBOUND_RELAY_FRAMES: usize = 1_024;
23pub const MAX_RELAY_SUBSCRIPTIONS: usize = 128;
25pub const MAX_INFLIGHT_INGESTION: usize = 1_024;
27pub const MAX_PENDING_PUBLISHES: usize = 1_024;
29const MAX_TERMINAL_DELIVERY_RECORDS: usize = 1_024;
30
31#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
35pub enum RelayConnectionState {
36 Disconnected,
38 Connecting,
40 Authenticating,
42 Ready,
44 Backoff,
46 Cancelled,
48}
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "camelCase")]
53#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
54pub enum DeliveryState {
55 Pending,
57 InFlight,
59 Accepted,
61 Rejected,
63 Retryable,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "camelCase")]
70#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
71pub struct DeliveryDecision {
72 pub event_id: String,
74 pub state: DeliveryState,
76 pub category: String,
78 pub relay_message: String,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct OutboundRelayFrame {
92 pub frame: String,
94 pub delivery_event_ids: Vec<String>,
96}
97
98const EVENT_FRAME_OVERHEAD_BYTES: usize = "[\"EVENT\",]".len();
99pub(crate) const MAX_ACCOUNT_EVENT_JSON_BYTES: usize =
100 crate::MAX_RELAY_FRAME_BYTES - EVENT_FRAME_OVERHEAD_BYTES;
101
102pub(crate) fn validate_event_frame_json(event_json: &str) -> Result<(), SoftchatError> {
104 if event_json.len() > MAX_ACCOUNT_EVENT_JSON_BYTES {
105 Err(SoftchatError::InvalidRelayFrame)
106 } else {
107 Ok(())
108 }
109}
110
111pub(crate) fn batch_event_frames(
115 events: impl IntoIterator<Item = (String, String)>,
116) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
117 let mut frames = Vec::new();
118 let mut ids = Vec::new();
119 let mut jsons = Vec::new();
120 let mut event_bytes = 0_usize;
121 for (event_id, event_json) in events {
122 validate_event_frame_json(&event_json)?;
125 let next_bytes = event_bytes
126 .saturating_add(event_json.len())
127 .saturating_add(ids.len())
128 .saturating_add(EVENT_FRAME_OVERHEAD_BYTES + 1);
129 if !ids.is_empty()
130 && (ids.len() == MAX_RELAY_BATCH_EVENTS || next_bytes > crate::MAX_RELAY_FRAME_BYTES)
131 {
132 frames.push(finish_event_frame(
133 std::mem::take(&mut ids),
134 std::mem::take(&mut jsons),
135 ));
136 event_bytes = 0;
137 }
138 event_bytes += event_json.len();
139 ids.push(event_id);
140 jsons.push(event_json);
141 }
142 if !ids.is_empty() {
143 frames.push(finish_event_frame(ids, jsons));
144 }
145 Ok(frames)
146}
147
148fn finish_event_frame(ids: Vec<String>, jsons: Vec<String>) -> OutboundRelayFrame {
149 let prefix = if ids.len() == 1 {
150 "[\"EVENT\","
151 } else {
152 "[\"EVENTS\","
153 };
154 OutboundRelayFrame {
155 frame: format!("{prefix}{}]", jsons.join(",")),
156 delivery_event_ids: ids,
157 }
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
162#[serde(rename_all = "camelCase")]
163#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
164pub enum RelaySessionActionKind {
165 OpenTransport,
167 SendFrame,
169 Authenticate,
171 PersistEvent,
173 SubscriptionReady,
175 SubscriptionClosed,
177 DeliveryChanged,
179 CloseTransport,
181 ScheduleReconnect,
183}
184
185#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
187#[serde(rename_all = "camelCase")]
188#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
189pub struct RelaySessionAction {
190 pub kind: RelaySessionActionKind,
192 pub frame: String,
194 pub value: String,
196 pub reason: String,
200 pub event: Option<SignedEvent>,
202 pub delay_ms: u64,
204 pub delivery_decision: Option<DeliveryDecision>,
206}
207
208#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
210#[serde(rename_all = "camelCase")]
211#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
212pub struct RelaySessionSnapshot {
213 pub state: RelayConnectionState,
215 pub subscriptions: Vec<String>,
217 pub ready_subscriptions: Vec<String>,
219 pub delivery_event_ids: Vec<String>,
221 pub delivery_states: Vec<DeliveryState>,
223 pub pending_ingestion_ids: Vec<String>,
225 pub reconnect_attempt: u32,
227}
228
229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
231#[serde(rename_all = "camelCase")]
232#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
233pub struct IngestionBatch {
234 pub account_id: String,
236 pub events: Vec<SignedEvent>,
238}
239
240#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
242#[serde(rename_all = "camelCase")]
243#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
244pub struct IngestionResult {
245 pub inserted_event_ids: Vec<String>,
247 pub duplicate_event_ids: Vec<String>,
249 pub quarantined_event_ids: Vec<String>,
251}
252
253#[derive(Debug)]
255pub struct RelaySession {
256 state: RelayConnectionState,
257 subscriptions: BTreeMap<String, Vec<RelayFilter>>,
258 ready_subscriptions: BTreeSet<String>,
259 delivery: BTreeMap<String, DeliveryState>,
260 terminal_delivery_order: VecDeque<String>,
261 pending_events: BTreeMap<String, SignedNostrEvent>,
262 pending_authentication_event_id: Option<String>,
263 pending_ingestion: BTreeSet<String>,
264 outbound: VecDeque<OutboundRelayFrame>,
265 reconnect_attempt: u32,
266}
267
268#[cfg(feature = "native-bindings")]
270#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
271pub struct RelaySessionHandle {
272 session: Mutex<RelaySession>,
273}
274
275#[cfg(feature = "native-bindings")]
276impl fmt::Debug for RelaySessionHandle {
277 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278 formatter
279 .debug_struct("RelaySessionHandle")
280 .field("snapshot", &lock(&self.session).snapshot())
281 .finish()
282 }
283}
284
285#[cfg(feature = "native-bindings")]
286impl Default for RelaySessionHandle {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292#[cfg(feature = "native-bindings")]
293#[cfg_attr(feature = "native-bindings", uniffi::export)]
294impl RelaySessionHandle {
295 #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
297 #[must_use]
298 pub fn new() -> Self {
299 Self {
300 session: Mutex::new(RelaySession::new()),
301 }
302 }
303
304 pub fn connect(&self) -> Result<RelaySessionAction, SoftchatError> {
310 lock(&self.session).connect()
311 }
312
313 pub fn transport_connected(&self) -> Result<(), SoftchatError> {
319 lock(&self.session).transport_connected()
320 }
321
322 pub fn authenticated(&self) -> Result<(), SoftchatError> {
328 lock(&self.session).authenticated()
329 }
330
331 pub fn authenticate(&self, event: SignedEvent) -> Result<(), SoftchatError> {
337 lock(&self.session).authenticate(SignedNostrEvent::try_from(event)?)
338 }
339
340 pub fn subscribe(
346 &self,
347 subscription_id: String,
348 filter_json: Vec<String>,
349 ) -> Result<(), SoftchatError> {
350 let filters = filter_json
351 .iter()
352 .map(|json| RelayFilter::from_json(json))
353 .collect::<Result<Vec<_>, _>>()?;
354 lock(&self.session).subscribe(subscription_id, filters)
355 }
356
357 pub fn close_subscription(&self, subscription_id: String) -> Result<(), SoftchatError> {
363 lock(&self.session).close_subscription(&subscription_id)
364 }
365
366 pub fn publish(&self, events: Vec<SignedEvent>) -> Result<(), SoftchatError> {
372 let events = events
373 .into_iter()
374 .map(SignedNostrEvent::try_from)
375 .collect::<Result<Vec<_>, _>>()?;
376 lock(&self.session).publish(events)
377 }
378
379 pub fn confirm_sent(&self, event_ids: Vec<String>) -> Result<(), SoftchatError> {
390 lock(&self.session).confirm_sent(&event_ids)
391 }
392
393 pub fn receive(&self, frame_json: String) -> Result<Vec<RelaySessionAction>, SoftchatError> {
399 lock(&self.session).receive(&frame_json)
400 }
401
402 pub fn confirm_ingested(&self, event_id: String) -> Result<(), SoftchatError> {
408 lock(&self.session).confirm_ingested(&event_id)
409 }
410
411 pub fn transport_lost(&self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
417 lock(&self.session).transport_lost()
418 }
419
420 pub fn drain_outbound(&self, limit: u32) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
426 let limit = usize::try_from(limit).map_err(|_| SoftchatError::InvalidRelaySession)?;
427 lock(&self.session).drain_outbound(limit)
428 }
429
430 #[must_use]
432 pub fn snapshot(&self) -> RelaySessionSnapshot {
433 lock(&self.session).snapshot()
434 }
435
436 #[must_use]
438 pub fn cancel(&self) -> RelaySessionAction {
439 lock(&self.session).cancel()
440 }
441}
442
443impl Default for RelaySession {
444 fn default() -> Self {
445 Self::new()
446 }
447}
448
449impl RelaySession {
450 #[must_use]
452 pub const fn new() -> Self {
453 Self {
454 state: RelayConnectionState::Disconnected,
455 subscriptions: BTreeMap::new(),
456 ready_subscriptions: BTreeSet::new(),
457 delivery: BTreeMap::new(),
458 terminal_delivery_order: VecDeque::new(),
459 pending_events: BTreeMap::new(),
460 pending_authentication_event_id: None,
461 pending_ingestion: BTreeSet::new(),
462 outbound: VecDeque::new(),
463 reconnect_attempt: 0,
464 }
465 }
466
467 pub fn connect(&mut self) -> Result<RelaySessionAction, SoftchatError> {
474 if !matches!(
475 self.state,
476 RelayConnectionState::Disconnected | RelayConnectionState::Backoff
477 ) {
478 return Err(SoftchatError::InvalidRelaySession);
479 }
480 self.state = RelayConnectionState::Connecting;
481 Ok(action(RelaySessionActionKind::OpenTransport))
482 }
483
484 pub fn transport_connected(&mut self) -> Result<(), SoftchatError> {
493 if self.state != RelayConnectionState::Connecting {
494 return Err(SoftchatError::InvalidRelaySession);
495 }
496 self.state = RelayConnectionState::Authenticating;
497 self.ready_subscriptions.clear();
498 Ok(())
499 }
500
501 pub fn authenticated(&mut self) -> Result<(), SoftchatError> {
507 if self.state != RelayConnectionState::Authenticating {
508 return Err(SoftchatError::InvalidRelaySession);
509 }
510 self.pending_authentication_event_id = None;
511 let mut replay = self
512 .subscriptions
513 .iter()
514 .map(|(subscription_id, filters)| {
515 Ok(OutboundRelayFrame {
516 frame: ClientRelayFrame::Req {
517 subscription_id: subscription_id.clone(),
518 filters: filters.clone(),
519 }
520 .to_json()?,
521 delivery_event_ids: Vec::new(),
522 })
523 })
524 .collect::<Result<Vec<_>, SoftchatError>>()?;
525 let pending = self
526 .pending_events
527 .iter()
528 .filter(|(event_id, _)| {
529 matches!(
530 self.delivery.get(*event_id),
531 Some(DeliveryState::Pending | DeliveryState::Retryable)
532 )
533 })
534 .map(|(_, event)| event.clone())
535 .collect::<Vec<_>>();
536 replay.extend(batch_event_frames(
537 pending
538 .iter()
539 .map(|event| Ok((event.id().to_hex(), event.to_json()?)))
540 .collect::<Result<Vec<_>, SoftchatError>>()?,
541 )?);
542 self.ensure_outbound_capacity(replay.len())?;
543 self.state = RelayConnectionState::Ready;
544 self.reconnect_attempt = 0;
545 self.ready_subscriptions.clear();
546 self.outbound.extend(replay);
547 Ok(())
548 }
549
550 pub fn authenticate(&mut self, event: SignedNostrEvent) -> Result<(), SoftchatError> {
560 if self.state != RelayConnectionState::Authenticating {
561 return Err(SoftchatError::InvalidRelaySession);
562 }
563 let event_id = event.id().to_hex();
564 let frame = ClientRelayFrame::Auth(event).to_json()?;
565 self.ensure_outbound_capacity(1)?;
566 self.enqueue(frame, Vec::new())?;
567 self.pending_authentication_event_id = Some(event_id);
568 Ok(())
569 }
570
571 pub fn subscribe(
580 &mut self,
581 subscription_id: String,
582 filters: Vec<RelayFilter>,
583 ) -> Result<(), SoftchatError> {
584 self.subscribe_many(vec![(subscription_id, filters)])
585 }
586
587 pub(crate) fn subscribe_many(
594 &mut self,
595 subscriptions: Vec<(String, Vec<RelayFilter>)>,
596 ) -> Result<(), SoftchatError> {
597 if self.state == RelayConnectionState::Cancelled || subscriptions.is_empty() {
598 return Err(SoftchatError::InvalidRelaySession);
599 }
600 let unique_ids = subscriptions
601 .iter()
602 .map(|(subscription_id, _)| subscription_id)
603 .collect::<BTreeSet<_>>();
604 if unique_ids.len() != subscriptions.len() {
605 return Err(SoftchatError::InvalidRelaySession);
606 }
607 let additional_subscriptions = unique_ids
608 .iter()
609 .filter(|subscription_id| !self.subscriptions.contains_key(subscription_id.as_str()))
610 .count();
611 if self
612 .subscriptions
613 .len()
614 .saturating_add(additional_subscriptions)
615 > MAX_RELAY_SUBSCRIPTIONS
616 {
617 return Err(SoftchatError::InvalidRelaySession);
618 }
619 let frames = subscriptions
620 .iter()
621 .map(|(subscription_id, filters)| {
622 ClientRelayFrame::Req {
623 subscription_id: subscription_id.clone(),
624 filters: filters.clone(),
625 }
626 .to_json()
627 })
628 .collect::<Result<Vec<_>, _>>()?;
629 if self.state == RelayConnectionState::Ready {
630 self.ensure_outbound_capacity(frames.len())?;
631 }
632 for (subscription_id, filters) in subscriptions {
633 self.subscriptions.insert(subscription_id.clone(), filters);
634 self.ready_subscriptions.remove(&subscription_id);
635 }
636 if self.state == RelayConnectionState::Ready {
637 self.outbound
638 .extend(frames.into_iter().map(|frame| OutboundRelayFrame {
639 frame,
640 delivery_event_ids: Vec::new(),
641 }));
642 }
643 Ok(())
644 }
645
646 pub fn close_subscription(&mut self, subscription_id: &str) -> Result<(), SoftchatError> {
652 if !self.subscriptions.contains_key(subscription_id) {
653 return Err(SoftchatError::InvalidRelaySession);
654 }
655 let frame = if self.state == RelayConnectionState::Ready {
656 self.ensure_outbound_capacity(1)?;
657 Some(
658 ClientRelayFrame::Close {
659 subscription_id: subscription_id.to_owned(),
660 }
661 .to_json()?,
662 )
663 } else {
664 None
665 };
666 self.subscriptions.remove(subscription_id);
667 self.ready_subscriptions.remove(subscription_id);
668 if let Some(frame) = frame {
669 self.enqueue(frame, Vec::new())?;
670 }
671 Ok(())
672 }
673
674 pub fn publish(&mut self, events: Vec<SignedNostrEvent>) -> Result<(), SoftchatError> {
680 if self.state == RelayConnectionState::Cancelled
681 || events.is_empty()
682 || events.len() > MAX_RELAY_BATCH_EVENTS
683 || self.pending_events.len().saturating_add(events.len()) > MAX_PENDING_PUBLISHES
684 {
685 return Err(SoftchatError::InvalidRelaySession);
686 }
687 let event_ids = events
688 .iter()
689 .map(|event| event.id().to_hex())
690 .collect::<Vec<_>>();
691 if event_ids.iter().collect::<BTreeSet<_>>().len() != event_ids.len() {
692 return Err(SoftchatError::InvalidDeliveryState);
693 }
694 for event_id in &event_ids {
695 if self.delivery.contains_key(event_id) {
696 return Err(SoftchatError::InvalidDeliveryState);
697 }
698 }
699 let frames = batch_event_frames(
700 events
701 .iter()
702 .map(|event| Ok((event.id().to_hex(), event.to_json()?)))
703 .collect::<Result<Vec<_>, SoftchatError>>()?,
704 )?;
705 if self.state == RelayConnectionState::Ready {
706 self.ensure_outbound_capacity(frames.len())?;
707 }
708 for (event, event_id) in events.iter().zip(&event_ids) {
709 self.delivery
710 .insert(event_id.clone(), DeliveryState::Pending);
711 self.pending_events.insert(event_id.clone(), event.clone());
712 }
713 if self.state == RelayConnectionState::Ready {
714 self.outbound.extend(frames);
715 }
716 Ok(())
717 }
718
719 pub fn confirm_sent(&mut self, event_ids: &[String]) -> Result<(), SoftchatError> {
732 if event_ids.is_empty() {
733 return Ok(());
734 }
735 if self.state != RelayConnectionState::Ready || event_ids.len() > MAX_RELAY_BATCH_EVENTS {
736 return Err(SoftchatError::InvalidDeliveryState);
737 }
738 let unique = event_ids.iter().collect::<BTreeSet<_>>();
739 if unique.len() != event_ids.len()
740 || event_ids.iter().any(|event_id| {
741 !matches!(
742 self.delivery.get(event_id),
743 Some(
744 DeliveryState::Pending
745 | DeliveryState::Retryable
746 | DeliveryState::Accepted
747 | DeliveryState::Rejected
748 )
749 )
750 })
751 {
752 return Err(SoftchatError::InvalidDeliveryState);
753 }
754 for event_id in event_ids {
755 if matches!(
756 self.delivery.get(event_id),
757 Some(DeliveryState::Pending | DeliveryState::Retryable)
758 ) {
759 self.delivery
760 .insert(event_id.clone(), DeliveryState::InFlight);
761 }
762 }
763 Ok(())
764 }
765
766 pub fn receive(&mut self, frame_json: &str) -> Result<Vec<RelaySessionAction>, SoftchatError> {
772 if !matches!(
773 self.state,
774 RelayConnectionState::Authenticating | RelayConnectionState::Ready
775 ) {
776 return Err(SoftchatError::InvalidRelaySession);
777 }
778 let frame = parse_relay_response_frame(frame_json)?;
779 if self.state == RelayConnectionState::Authenticating
780 && matches!(
781 &frame,
782 RelayResponseFrame::Event { .. }
783 | RelayResponseFrame::Eose { .. }
784 | RelayResponseFrame::Closed { .. }
785 )
786 {
787 return Ok(Vec::new());
788 }
789 match frame {
790 RelayResponseFrame::Auth(challenge) => {
791 self.state = RelayConnectionState::Authenticating;
792 self.outbound.clear();
793 self.pending_authentication_event_id = None;
794 self.ready_subscriptions.clear();
795 for state in self.delivery.values_mut() {
796 if *state == DeliveryState::InFlight {
797 *state = DeliveryState::Retryable;
798 }
799 }
800 Ok(vec![RelaySessionAction {
801 kind: RelaySessionActionKind::Authenticate,
802 value: challenge,
803 ..action(RelaySessionActionKind::Authenticate)
804 }])
805 }
806 RelayResponseFrame::Event {
807 subscription_id,
808 event,
809 } => {
810 if !self.subscriptions.contains_key(&subscription_id) {
811 return Err(SoftchatError::InvalidRelaySession);
812 }
813 let event_id = event.id().to_hex();
814 if self.pending_ingestion.contains(&event_id) {
815 return Ok(Vec::new());
816 }
817 if self.pending_ingestion.len() >= MAX_INFLIGHT_INGESTION {
818 return Err(SoftchatError::RelaySessionQueueFull);
819 }
820 self.pending_ingestion.insert(event_id.clone());
821 Ok(vec![RelaySessionAction {
822 kind: RelaySessionActionKind::PersistEvent,
823 value: subscription_id,
824 event: Some(SignedEvent::from(&event)),
825 ..action(RelaySessionActionKind::PersistEvent)
826 }])
827 }
828 RelayResponseFrame::Eose { subscription_id } => {
829 if !self.subscriptions.contains_key(&subscription_id) {
830 return Err(SoftchatError::InvalidRelaySession);
831 }
832 self.ready_subscriptions.insert(subscription_id.clone());
833 Ok(vec![RelaySessionAction {
834 kind: RelaySessionActionKind::SubscriptionReady,
835 value: subscription_id,
836 ..action(RelaySessionActionKind::SubscriptionReady)
837 }])
838 }
839 RelayResponseFrame::Ok(acknowledgement) => {
840 if self.pending_authentication_event_id.as_deref()
841 == Some(acknowledgement.event_id.as_str())
842 {
843 if !acknowledgement.accepted {
844 self.pending_authentication_event_id = None;
845 return Err(SoftchatError::InvalidRelayAuthentication);
846 }
847 self.pending_authentication_event_id = None;
848 self.authenticated()?;
849 return Ok(Vec::new());
850 }
851 let decision = classify_delivery_acknowledgement(acknowledgement)?;
852 let current = self
853 .delivery
854 .get(&decision.event_id)
855 .copied()
856 .ok_or(SoftchatError::InvalidDeliveryState)?;
857 let is_idempotent_terminal =
858 matches!(current, DeliveryState::Accepted | DeliveryState::Rejected)
859 && current == decision.state;
860 let is_ack_before_write =
861 matches!(current, DeliveryState::Pending | DeliveryState::Retryable);
862 if current != DeliveryState::InFlight
863 && !is_ack_before_write
864 && !is_idempotent_terminal
865 {
866 return Err(SoftchatError::InvalidDeliveryState);
867 }
868 self.delivery
869 .insert(decision.event_id.clone(), decision.state);
870 if matches!(
871 decision.state,
872 DeliveryState::Accepted | DeliveryState::Rejected
873 ) {
874 self.pending_events.remove(&decision.event_id);
875 if !is_idempotent_terminal {
876 self.remember_terminal_delivery(decision.event_id.clone());
877 }
878 }
879 Ok(vec![RelaySessionAction {
880 kind: RelaySessionActionKind::DeliveryChanged,
881 value: decision.event_id.clone(),
882 delivery_decision: Some(decision),
883 ..action(RelaySessionActionKind::DeliveryChanged)
884 }])
885 }
886 RelayResponseFrame::Closed {
887 subscription_id,
888 message,
889 } => {
890 if !self.subscriptions.contains_key(&subscription_id) {
891 return Ok(Vec::new());
892 }
893 self.subscriptions.remove(&subscription_id);
894 self.ready_subscriptions.remove(&subscription_id);
895 Ok(vec![RelaySessionAction {
896 kind: RelaySessionActionKind::SubscriptionClosed,
897 value: subscription_id,
898 reason: message,
899 ..action(RelaySessionActionKind::SubscriptionClosed)
900 }])
901 }
902 RelayResponseFrame::Notice(_) | RelayResponseFrame::Count { .. } => Ok(Vec::new()),
903 }
904 }
905
906 pub fn confirm_ingested(&mut self, event_id: &str) -> Result<(), SoftchatError> {
912 if self.pending_ingestion.remove(event_id) {
913 Ok(())
914 } else {
915 Err(SoftchatError::InvalidPersistenceResult)
916 }
917 }
918
919 #[cfg(feature = "sqlite-storage")]
921 pub(crate) fn discard_ingestion(&mut self, event_id: &str) -> Result<(), SoftchatError> {
922 if self.pending_ingestion.remove(event_id) {
923 Ok(())
924 } else {
925 Err(SoftchatError::InvalidPersistenceResult)
926 }
927 }
928
929 pub fn transport_lost(&mut self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
935 if !matches!(
936 self.state,
937 RelayConnectionState::Connecting
938 | RelayConnectionState::Authenticating
939 | RelayConnectionState::Ready
940 ) {
941 return Err(SoftchatError::InvalidRelaySession);
942 }
943 self.outbound.clear();
944 self.pending_authentication_event_id = None;
945 self.ready_subscriptions.clear();
946 for state in self.delivery.values_mut() {
947 if *state == DeliveryState::InFlight {
948 *state = DeliveryState::Retryable;
949 }
950 }
951 self.reconnect_attempt = self.reconnect_attempt.saturating_add(1);
952 self.state = RelayConnectionState::Backoff;
953 let exponent = self.reconnect_attempt.saturating_sub(1).min(4);
954 let delay_ms = 500_u64.saturating_mul(1_u64 << exponent);
955 Ok(vec![RelaySessionAction {
956 kind: RelaySessionActionKind::ScheduleReconnect,
957 delay_ms,
958 ..action(RelaySessionActionKind::ScheduleReconnect)
959 }])
960 }
961
962 #[must_use]
964 pub fn cancel(&mut self) -> RelaySessionAction {
965 self.state = RelayConnectionState::Cancelled;
966 self.subscriptions.clear();
967 self.ready_subscriptions.clear();
968 self.delivery.clear();
969 self.terminal_delivery_order.clear();
970 self.outbound.clear();
971 self.pending_events.clear();
972 self.pending_authentication_event_id = None;
973 self.pending_ingestion.clear();
974 action(RelaySessionActionKind::CloseTransport)
975 }
976
977 pub fn drain_outbound(
983 &mut self,
984 limit: usize,
985 ) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
986 if limit == 0 {
987 return Err(SoftchatError::InvalidRelaySession);
988 }
989 let count = limit.min(self.outbound.len());
990 Ok(self.outbound.drain(..count).collect())
991 }
992
993 #[must_use]
995 pub fn snapshot(&self) -> RelaySessionSnapshot {
996 RelaySessionSnapshot {
997 state: self.state,
998 subscriptions: self.subscriptions.keys().cloned().collect(),
999 ready_subscriptions: self.ready_subscriptions.iter().cloned().collect(),
1000 delivery_event_ids: self.delivery.keys().cloned().collect(),
1001 delivery_states: self.delivery.values().copied().collect(),
1002 pending_ingestion_ids: self.pending_ingestion.iter().cloned().collect(),
1003 reconnect_attempt: self.reconnect_attempt,
1004 }
1005 }
1006
1007 fn enqueue(
1008 &mut self,
1009 frame: String,
1010 delivery_event_ids: Vec<String>,
1011 ) -> Result<(), SoftchatError> {
1012 self.ensure_outbound_capacity(1)?;
1013 self.outbound.push_back(OutboundRelayFrame {
1014 frame,
1015 delivery_event_ids,
1016 });
1017 Ok(())
1018 }
1019
1020 fn ensure_outbound_capacity(&self, additional: usize) -> Result<(), SoftchatError> {
1021 if self.outbound.len().saturating_add(additional) > MAX_OUTBOUND_RELAY_FRAMES {
1022 Err(SoftchatError::RelaySessionQueueFull)
1023 } else {
1024 Ok(())
1025 }
1026 }
1027
1028 fn remember_terminal_delivery(&mut self, event_id: String) {
1029 self.terminal_delivery_order.push_back(event_id);
1030 while self.terminal_delivery_order.len() > MAX_TERMINAL_DELIVERY_RECORDS {
1031 if let Some(expired) = self.terminal_delivery_order.pop_front()
1032 && matches!(
1033 self.delivery.get(&expired),
1034 Some(DeliveryState::Accepted | DeliveryState::Rejected)
1035 )
1036 {
1037 self.delivery.remove(&expired);
1038 }
1039 }
1040 }
1041}
1042
1043#[cfg_attr(feature = "native-bindings", uniffi::export)]
1049pub fn classify_delivery_acknowledgement(
1050 acknowledgement: BatchAcknowledgement,
1051) -> Result<DeliveryDecision, SoftchatError> {
1052 let parsed = crate::track_batch_acknowledgements(
1053 vec![acknowledgement.event_id.clone()],
1054 vec![acknowledgement.clone()],
1055 )
1056 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1057 let state = match parsed.states.first() {
1058 Some(BatchEventState::Accepted) => DeliveryState::Accepted,
1059 Some(BatchEventState::Rejected) => {
1060 if is_retryable_message(&acknowledgement.message) {
1061 DeliveryState::Retryable
1062 } else {
1063 DeliveryState::Rejected
1064 }
1065 }
1066 _ => return Err(SoftchatError::InvalidDeliveryState),
1067 };
1068 let category = if acknowledgement.accepted {
1069 if acknowledgement.message.starts_with("duplicate:") {
1070 "duplicate"
1071 } else {
1072 "accepted"
1073 }
1074 } else if is_retryable_message(&acknowledgement.message) {
1075 "retryable"
1076 } else if acknowledgement.message.starts_with("auth-required:") {
1077 "authentication"
1078 } else if acknowledgement.message.starts_with("blocked:") {
1079 "blocked"
1080 } else if acknowledgement.message.starts_with("invalid:") {
1081 "invalid"
1082 } else {
1083 "rejected"
1084 };
1085 Ok(DeliveryDecision {
1086 event_id: acknowledgement.event_id,
1087 state,
1088 category: category.to_owned(),
1089 relay_message: acknowledgement.message,
1090 })
1091}
1092
1093#[cfg_attr(feature = "native-bindings", uniffi::export)]
1100pub fn validate_ingestion_result(
1101 batch: &IngestionBatch,
1102 result: &IngestionResult,
1103) -> Result<(), SoftchatError> {
1104 if batch.account_id.is_empty() || batch.events.is_empty() {
1105 return Err(SoftchatError::InvalidPersistenceResult);
1106 }
1107 let requested = batch
1108 .events
1109 .iter()
1110 .map(|event| event.id.clone())
1111 .collect::<BTreeSet<_>>();
1112 if requested.len() != batch.events.len() {
1113 return Err(SoftchatError::InvalidPersistenceResult);
1114 }
1115 let classified = result
1116 .inserted_event_ids
1117 .iter()
1118 .chain(&result.duplicate_event_ids)
1119 .chain(&result.quarantined_event_ids)
1120 .cloned()
1121 .collect::<Vec<_>>();
1122 let unique = classified.iter().cloned().collect::<BTreeSet<_>>();
1123 if unique.len() != classified.len() || unique != requested {
1124 return Err(SoftchatError::InvalidPersistenceResult);
1125 }
1126 Ok(())
1127}
1128
1129fn is_retryable_message(message: &str) -> bool {
1130 ["rate-limited:", "error:", "unavailable:", "timeout:"]
1131 .iter()
1132 .any(|prefix| message.starts_with(prefix))
1133}
1134
1135fn action(kind: RelaySessionActionKind) -> RelaySessionAction {
1136 RelaySessionAction {
1137 kind,
1138 frame: String::new(),
1139 value: String::new(),
1140 reason: String::new(),
1141 event: None,
1142 delay_ms: 0,
1143 delivery_decision: None,
1144 }
1145}
1146
1147#[cfg(feature = "native-bindings")]
1148fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1149 match mutex.lock() {
1150 Ok(guard) => guard,
1151 Err(poisoned) => poisoned.into_inner(),
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use crate::{LocalIdentity, NostrEventDraft, NostrEventKind};
1158
1159 use super::*;
1160
1161 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1162
1163 fn event(created_at: u64) -> Result<SignedNostrEvent, SoftchatError> {
1164 LocalIdentity::from_secret_hex(ALICE_SECRET)?.sign_event(NostrEventDraft::new(
1165 created_at,
1166 NostrEventKind::SHORT_TEXT_NOTE,
1167 Vec::new(),
1168 "test",
1169 )?)
1170 }
1171
1172 #[test]
1173 fn session_publish_and_authentication_replay_split_large_frames() -> Result<(), SoftchatError> {
1174 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1175 let events = (0..3)
1176 .map(|timestamp| {
1177 identity.sign_event(NostrEventDraft::new(
1178 timestamp,
1179 NostrEventKind::SHORT_TEXT_NOTE,
1180 Vec::new(),
1181 "large 🦀".repeat(28_000),
1182 )?)
1183 })
1184 .collect::<Result<Vec<_>, SoftchatError>>()?;
1185 let expected = events
1186 .iter()
1187 .map(|event| event.id().to_hex())
1188 .collect::<Vec<_>>();
1189 for authenticated in [false, true] {
1190 let mut session = RelaySession::new();
1191 session.connect()?;
1192 session.transport_connected()?;
1193 if authenticated {
1194 session.authenticated()?;
1195 }
1196 session.publish(events.clone())?;
1197 if !authenticated {
1198 session.authenticated()?;
1199 }
1200 let frames = session.drain_outbound(8)?;
1201 assert_eq!(frames.len(), 3);
1202 let actual = frames
1203 .iter()
1204 .flat_map(|frame| frame.delivery_event_ids.clone())
1205 .collect::<Vec<_>>();
1206 let expected_order = if authenticated {
1207 expected.clone()
1208 } else {
1209 expected
1210 .iter()
1211 .cloned()
1212 .collect::<BTreeSet<_>>()
1213 .into_iter()
1214 .collect()
1215 };
1216 assert_eq!(actual, expected_order);
1217 for frame in &frames {
1218 let event = events
1219 .iter()
1220 .find(|event| frame.delivery_event_ids.contains(&event.id().to_hex()))
1221 .ok_or(SoftchatError::InvalidRelaySession)?;
1222 assert!(frame.frame.len() <= crate::MAX_RELAY_FRAME_BYTES);
1223 assert_eq!(
1224 crate::parse_client_relay_frame(&frame.frame)?,
1225 ClientRelayFrame::Event(event.clone())
1226 );
1227 session.confirm_sent(&frame.delivery_event_ids)?;
1228 }
1229 assert!(
1230 session
1231 .snapshot()
1232 .delivery_states
1233 .iter()
1234 .all(|state| *state == DeliveryState::InFlight)
1235 );
1236 }
1237 Ok(())
1238 }
1239
1240 #[test]
1241 fn session_authentication_replay_preserves_the_event_count_limit() -> Result<(), SoftchatError>
1242 {
1243 let events = (0..=MAX_RELAY_BATCH_EVENTS)
1244 .map(|timestamp| event(timestamp as u64))
1245 .collect::<Result<Vec<_>, _>>()?;
1246 let mut session = RelaySession::new();
1247 for chunk in events.chunks(MAX_RELAY_BATCH_EVENTS) {
1248 session.publish(chunk.to_vec())?;
1249 }
1250 session.connect()?;
1251 session.transport_connected()?;
1252 session.authenticated()?;
1253 let frames = session.drain_outbound(8)?;
1254 assert_eq!(frames.len(), 2);
1255 assert_eq!(frames[0].delivery_event_ids.len(), MAX_RELAY_BATCH_EVENTS);
1256 assert_eq!(frames[1].delivery_event_ids.len(), 1);
1257 assert_eq!(
1258 frames
1259 .iter()
1260 .flat_map(|frame| frame.delivery_event_ids.clone())
1261 .collect::<Vec<_>>(),
1262 events
1263 .iter()
1264 .map(|event| event.id().to_hex())
1265 .collect::<BTreeSet<_>>()
1266 .into_iter()
1267 .collect::<Vec<_>>()
1268 );
1269 Ok(())
1270 }
1271
1272 #[test]
1273 fn session_replays_subscriptions_and_tracks_eose_ingestion_and_delivery()
1274 -> Result<(), SoftchatError> {
1275 let mut session = RelaySession::new();
1276 session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1277 assert_eq!(
1278 session.connect()?.kind,
1279 RelaySessionActionKind::OpenTransport
1280 );
1281 session.transport_connected()?;
1282 session.authenticated()?;
1283 assert_eq!(session.drain_outbound(8)?.len(), 1);
1284
1285 let authored = event(1)?;
1286 session.publish(vec![authored.clone()])?;
1287 let outbound = session.drain_outbound(8)?;
1288 assert_eq!(outbound.len(), 1);
1289 assert_eq!(outbound[0].delivery_event_ids, vec![authored.id().to_hex()]);
1290 session.confirm_sent(&outbound[0].delivery_event_ids)?;
1291 let ok = RelayResponseFrame::Ok(BatchAcknowledgement {
1292 event_id: authored.id().to_hex(),
1293 accepted: true,
1294 message: String::new(),
1295 })
1296 .to_json()?;
1297 assert_eq!(
1298 session.receive(&ok)?[0]
1299 .delivery_decision
1300 .as_ref()
1301 .map(|decision| decision.state),
1302 Some(DeliveryState::Accepted),
1303 );
1304
1305 let received = event(2)?;
1306 let frame = RelayResponseFrame::Event {
1307 subscription_id: "messages".to_owned(),
1308 event: received.clone(),
1309 }
1310 .to_json()?;
1311 let action = session.receive(&frame)?;
1312 assert_eq!(action[0].kind, RelaySessionActionKind::PersistEvent);
1313 session.confirm_ingested(&received.id().to_hex())?;
1314 assert!(session.snapshot().pending_ingestion_ids.is_empty());
1315
1316 let eose = RelayResponseFrame::Eose {
1317 subscription_id: "messages".to_owned(),
1318 }
1319 .to_json()?;
1320 assert_eq!(
1321 session.receive(&eose)?[0].kind,
1322 RelaySessionActionKind::SubscriptionReady
1323 );
1324 Ok(())
1325 }
1326
1327 #[test]
1328 fn publish_rejects_duplicates_in_one_call_without_mutating_state() -> Result<(), SoftchatError>
1329 {
1330 let authored = event(1)?;
1331 for stage in 0..4 {
1332 let mut session = RelaySession::new();
1333 if stage >= 1 {
1334 session.connect()?;
1335 }
1336 if stage >= 2 {
1337 session.transport_connected()?;
1338 }
1339 if stage >= 3 {
1340 session.authenticated()?;
1341 }
1342 assert_eq!(
1343 session.publish(vec![authored.clone(), authored.clone()]),
1344 Err(SoftchatError::InvalidDeliveryState),
1345 );
1346 assert!(session.snapshot().delivery_event_ids.is_empty());
1347 assert!(session.drain_outbound(8)?.is_empty());
1348 }
1349 Ok(())
1350 }
1351
1352 #[test]
1353 fn duplicate_inflight_event_emits_one_persistence_action() -> Result<(), SoftchatError> {
1354 let mut session = RelaySession::new();
1355 session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1356 session.connect()?;
1357 session.transport_connected()?;
1358 session.authenticated()?;
1359 session.drain_outbound(8)?;
1360 let received = event(2)?;
1361 let frame = RelayResponseFrame::Event {
1362 subscription_id: "messages".to_owned(),
1363 event: received,
1364 }
1365 .to_json()?;
1366
1367 assert_eq!(session.receive(&frame)?.len(), 1);
1368 assert!(session.receive(&frame)?.is_empty());
1369 assert_eq!(session.snapshot().pending_ingestion_ids.len(), 1);
1370 Ok(())
1371 }
1372
1373 #[test]
1374 fn closed_ends_subscription_and_reports_bounded_reason() -> Result<(), SoftchatError> {
1375 let mut session = RelaySession::new();
1376 session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1377 session.connect()?;
1378 session.transport_connected()?;
1379 session.authenticated()?;
1380 session.drain_outbound(8)?;
1381 let frame = RelayResponseFrame::Closed {
1382 subscription_id: "messages".to_owned(),
1383 message: "rate-limited".to_owned(),
1384 }
1385 .to_json()?;
1386
1387 let actions = session.receive(&frame)?;
1388 assert_eq!(actions.len(), 1);
1389 assert_eq!(actions[0].kind, RelaySessionActionKind::SubscriptionClosed);
1390 assert_eq!(actions[0].value, "messages");
1391 assert_eq!(actions[0].reason, "rate-limited");
1392 assert!(session.snapshot().subscriptions.is_empty());
1393 assert!(session.receive(&frame)?.is_empty());
1394 let unknown = RelayResponseFrame::Closed {
1395 subscription_id: "unknown".to_owned(),
1396 message: "not ours".to_owned(),
1397 }
1398 .to_json()?;
1399 assert!(session.receive(&unknown)?.is_empty());
1400
1401 let mut authenticating = RelaySession::new();
1402 authenticating.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1403 authenticating.connect()?;
1404 authenticating.transport_connected()?;
1405 let stale = RelayResponseFrame::Closed {
1406 subscription_id: "retained".to_owned(),
1407 message: "authentication required".to_owned(),
1408 }
1409 .to_json()?;
1410 assert!(authenticating.receive(&stale)?.is_empty());
1411 assert_eq!(
1412 authenticating.snapshot().subscriptions,
1413 vec!["retained".to_owned()],
1414 );
1415 Ok(())
1416 }
1417
1418 #[test]
1419 fn disconnect_preserves_terminal_state_and_retries_only_inflight() -> Result<(), SoftchatError>
1420 {
1421 let mut session = RelaySession::new();
1422 session.connect()?;
1423 session.transport_connected()?;
1424 session.authenticated()?;
1425 let first = event(1)?;
1426 let second = event(2)?;
1427 session.publish(vec![first.clone(), second.clone()])?;
1428 let outbound = session.drain_outbound(8)?;
1429 session.confirm_sent(&outbound[0].delivery_event_ids)?;
1430 let accepted = RelayResponseFrame::Ok(BatchAcknowledgement {
1431 event_id: first.id().to_hex(),
1432 accepted: true,
1433 message: String::new(),
1434 })
1435 .to_json()?;
1436 session.receive(&accepted)?;
1437 let reconnect = session.transport_lost()?;
1438 assert_eq!(reconnect[0].kind, RelaySessionActionKind::ScheduleReconnect);
1439 let snapshot = session.snapshot();
1440 let states = snapshot
1441 .delivery_event_ids
1442 .iter()
1443 .cloned()
1444 .zip(snapshot.delivery_states)
1445 .collect::<BTreeMap<_, _>>();
1446 assert_eq!(
1447 states.get(&first.id().to_hex()),
1448 Some(&DeliveryState::Accepted)
1449 );
1450 assert_eq!(
1451 states.get(&second.id().to_hex()),
1452 Some(&DeliveryState::Retryable)
1453 );
1454 Ok(())
1455 }
1456
1457 #[test]
1458 fn persisted_publish_intent_queues_after_authentication() -> Result<(), SoftchatError> {
1459 let mut session = RelaySession::new();
1460 let authored = event(1)?;
1461 session.publish(vec![authored.clone()])?;
1462 assert!(session.drain_outbound(8)?.is_empty());
1463 session.connect()?;
1464 session.transport_connected()?;
1465 session.authenticated()?;
1466 let outbound = session.drain_outbound(8)?;
1467 assert_eq!(outbound.len(), 1);
1468 assert!(outbound[0].frame.starts_with("[\"EVENT\","));
1469 let snapshot = session.snapshot();
1470 let state = snapshot
1471 .delivery_event_ids
1472 .iter()
1473 .position(|event_id| event_id == &authored.id().to_hex())
1474 .and_then(|index| snapshot.delivery_states.get(index).copied());
1475 assert_eq!(state, Some(DeliveryState::Pending));
1476 session.confirm_sent(&outbound[0].delivery_event_ids)?;
1477 let snapshot = session.snapshot();
1478 let state = snapshot
1479 .delivery_event_ids
1480 .iter()
1481 .position(|event_id| event_id == &authored.id().to_hex())
1482 .and_then(|index| snapshot.delivery_states.get(index).copied());
1483 assert_eq!(state, Some(DeliveryState::InFlight));
1484 Ok(())
1485 }
1486
1487 #[test]
1488 fn acknowledgement_may_race_socket_handoff_and_confirmation_is_idempotent()
1489 -> Result<(), SoftchatError> {
1490 let mut session = RelaySession::new();
1491 session.connect()?;
1492 session.transport_connected()?;
1493 session.authenticated()?;
1494 let authored = event(1)?;
1495 session.publish(vec![authored.clone()])?;
1496 let outbound = session.drain_outbound(8)?;
1497 assert_eq!(
1498 session.snapshot().delivery_states,
1499 vec![DeliveryState::Pending]
1500 );
1501 let acknowledgement = RelayResponseFrame::Ok(BatchAcknowledgement {
1502 event_id: authored.id().to_hex(),
1503 accepted: true,
1504 message: String::new(),
1505 })
1506 .to_json()?;
1507 let decisions = session.receive(&acknowledgement)?;
1508 assert_eq!(
1509 decisions[0]
1510 .delivery_decision
1511 .as_ref()
1512 .map(|decision| decision.state),
1513 Some(DeliveryState::Accepted)
1514 );
1515 session.confirm_sent(&outbound[0].delivery_event_ids)?;
1516 assert_eq!(
1517 session.snapshot().delivery_states,
1518 vec![DeliveryState::Accepted]
1519 );
1520 let duplicate = session.receive(&acknowledgement)?;
1521 assert_eq!(
1522 duplicate[0]
1523 .delivery_decision
1524 .as_ref()
1525 .map(|decision| decision.state),
1526 Some(DeliveryState::Accepted)
1527 );
1528 Ok(())
1529 }
1530
1531 #[test]
1532 fn queue_backpressure_does_not_partially_mutate_intent() -> Result<(), SoftchatError> {
1533 let mut session = RelaySession::new();
1534 session.connect()?;
1535 session.transport_connected()?;
1536 session.authenticated()?;
1537 for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1538 session.enqueue("[]".to_owned(), Vec::new())?;
1539 }
1540
1541 let authored = event(1)?;
1542 assert_eq!(
1543 session.publish(vec![authored.clone()]),
1544 Err(SoftchatError::RelaySessionQueueFull),
1545 );
1546 assert!(
1547 !session
1548 .snapshot()
1549 .delivery_event_ids
1550 .contains(&authored.id().to_hex())
1551 );
1552 assert_eq!(
1553 session.subscribe("blocked".to_owned(), vec![RelayFilter::default()]),
1554 Err(SoftchatError::RelaySessionQueueFull),
1555 );
1556 assert!(
1557 !session
1558 .snapshot()
1559 .subscriptions
1560 .contains(&"blocked".to_owned())
1561 );
1562
1563 let mut closing = RelaySession::new();
1564 closing.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1565 closing.connect()?;
1566 closing.transport_connected()?;
1567 closing.authenticated()?;
1568 closing.drain_outbound(MAX_OUTBOUND_RELAY_FRAMES)?;
1569 for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1570 closing.enqueue("[]".to_owned(), Vec::new())?;
1571 }
1572 assert_eq!(
1573 closing.close_subscription("retained"),
1574 Err(SoftchatError::RelaySessionQueueFull),
1575 );
1576 assert_eq!(
1577 closing.snapshot().subscriptions,
1578 vec!["retained".to_owned()]
1579 );
1580
1581 let mut replay = RelaySession::new();
1582 replay.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1583 replay.publish(vec![event(2)?])?;
1584 replay.connect()?;
1585 replay.transport_connected()?;
1586 for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1587 replay.enqueue("[]".to_owned(), Vec::new())?;
1588 }
1589 assert_eq!(
1590 replay.authenticated(),
1591 Err(SoftchatError::RelaySessionQueueFull),
1592 );
1593 assert_eq!(
1594 replay.snapshot().state,
1595 RelayConnectionState::Authenticating,
1596 );
1597 assert_eq!(replay.outbound.len(), MAX_OUTBOUND_RELAY_FRAMES);
1598 Ok(())
1599 }
1600
1601 #[test]
1602 fn terminal_delivery_history_is_bounded_and_does_not_consume_pending_capacity()
1603 -> Result<(), SoftchatError> {
1604 let mut session = RelaySession::new();
1605 for index in 0..=MAX_TERMINAL_DELIVERY_RECORDS {
1606 let event_id = format!("{index:064x}");
1607 session
1608 .delivery
1609 .insert(event_id.clone(), DeliveryState::Accepted);
1610 session.remember_terminal_delivery(event_id);
1611 }
1612 assert_eq!(session.delivery.len(), MAX_TERMINAL_DELIVERY_RECORDS);
1613 assert_eq!(
1614 session.terminal_delivery_order.len(),
1615 MAX_TERMINAL_DELIVERY_RECORDS
1616 );
1617 assert!(!session.delivery.contains_key(&format!("{:064x}", 0)));
1618 session.publish(vec![event(1)?])?;
1619 assert_eq!(session.pending_events.len(), 1);
1620 Ok(())
1621 }
1622
1623 #[test]
1624 fn authentication_challenge_discards_stale_frames_and_replays_pending_intent()
1625 -> Result<(), SoftchatError> {
1626 let mut session = RelaySession::new();
1627 session.connect()?;
1628 session.transport_connected()?;
1629 session.authenticated()?;
1630 let authored = event(1)?;
1631 session.publish(vec![authored.clone()])?;
1632 assert_eq!(session.outbound.len(), 1);
1633
1634 let actions =
1635 session.receive(&RelayResponseFrame::Auth("challenge".to_owned()).to_json()?)?;
1636 assert_eq!(actions[0].kind, RelaySessionActionKind::Authenticate);
1637 assert!(session.outbound.is_empty());
1638 let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1639 let authentication =
1640 crate::plan_nip42_authentication(&identity, "challenge", "wss://relay.example", 1_000)?;
1641 let authentication_id = authentication.id().to_hex();
1642 session.authenticate(authentication)?;
1643 let auth_frame = session.drain_outbound(1)?;
1644 assert!(auth_frame[0].delivery_event_ids.is_empty());
1645 assert!(matches!(
1646 crate::parse_client_relay_frame(&auth_frame[0].frame)?,
1647 ClientRelayFrame::Auth(_)
1648 ));
1649 session.receive(
1650 &RelayResponseFrame::Ok(BatchAcknowledgement {
1651 event_id: authentication_id,
1652 accepted: true,
1653 message: String::new(),
1654 })
1655 .to_json()?,
1656 )?;
1657 let replay = session.drain_outbound(1)?;
1658 assert_eq!(replay[0].delivery_event_ids, vec![authored.id().to_hex()]);
1659 Ok(())
1660 }
1661
1662 #[test]
1663 fn persistence_result_is_total_unique_and_account_scoped() -> Result<(), SoftchatError> {
1664 let first = SignedEvent::from(&event(1)?);
1665 let second = SignedEvent::from(&event(2)?);
1666 let batch = IngestionBatch {
1667 account_id: "account-1".to_owned(),
1668 events: vec![first.clone(), second.clone()],
1669 };
1670 validate_ingestion_result(
1671 &batch,
1672 &IngestionResult {
1673 inserted_event_ids: vec![first.id],
1674 duplicate_event_ids: vec![second.id],
1675 quarantined_event_ids: Vec::new(),
1676 },
1677 )?;
1678 assert!(
1679 validate_ingestion_result(
1680 &batch,
1681 &IngestionResult {
1682 inserted_event_ids: Vec::new(),
1683 duplicate_event_ids: Vec::new(),
1684 quarantined_event_ids: Vec::new(),
1685 },
1686 )
1687 .is_err()
1688 );
1689 Ok(())
1690 }
1691
1692 #[test]
1693 fn delivery_classifier_separates_retryable_and_permanent_rejection() -> Result<(), SoftchatError>
1694 {
1695 let event_id = event(1)?.id().to_hex();
1696 assert_eq!(
1697 classify_delivery_acknowledgement(BatchAcknowledgement {
1698 event_id: event_id.clone(),
1699 accepted: false,
1700 message: "rate-limited: slow down".to_owned(),
1701 })?
1702 .state,
1703 DeliveryState::Retryable
1704 );
1705 assert_eq!(
1706 classify_delivery_acknowledgement(BatchAcknowledgement {
1707 event_id,
1708 accepted: false,
1709 message: "blocked: policy".to_owned(),
1710 })?
1711 .state,
1712 DeliveryState::Rejected
1713 );
1714 Ok(())
1715 }
1716}