Skip to main content

softchat/
account_transport.rs

1//! Correlated account transport actions over the Rust-owned relay state.
2//!
3//! At this low-level boundary the host executes WebSocket effects only. This
4//! module retains protocol, ingestion, delivery, authentication, retry, and
5//! relay-selection decisions.
6
7use std::cell::Cell;
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::account_diagnostics::{
14    AccountLogCategory, AccountLogCounterKind as Counter, AccountLogDirection, AccountLogLevel,
15    Diagnostic,
16};
17use crate::message::LocalIdentityHandle;
18use crate::runtime_facade::{
19    SyncTransportFrame, parse_sync_message_frame, plan_sync_transport, resolve_sync_action,
20    resolve_sync_actions,
21};
22use crate::{
23    AccountDatabase, ClaimedDelivery, DeliveryClaim, DeliveryDecision, DeliveryState,
24    ProjectionKind, RelayConnectionState, RelayDeliveryResult, RelayDeliveryResultKind,
25    RelayEndpointPlan, RelayFailureKind, RelayResponseFrame, RelayRetryAction, RelaySession,
26    RelaySessionAction, RelaySessionActionKind, SignedNostrEvent, SoftchatError, SyncAction,
27    SyncActionKind, SyncEngine, SyncEngineSnapshot, SyncPhase, parse_relay_endpoint,
28    parse_relay_response_frame, plan_nip42_authentication, plan_relay_retry,
29};
30
31const MAX_TRANSPORT_ID_BYTES: usize = 128;
32pub(crate) const MAX_TRANSPORT_ACTIONS: usize = 128;
33const MAX_TYPING_INDICATORS: usize = 1_024;
34const DELIVERY_LEASE_SECONDS: u32 = 30;
35const SYNC_ID: &str = "account-sync";
36const SYNC_OVERLAP_SECONDS: i64 = 86_400;
37pub(crate) const SYNC_FRAME_SIZE_LIMIT: u64 = 60_000;
38const MAX_SYNC_RESEND_EVENTS: usize = crate::SYNC_MAX_PAGE_LIMIT as usize;
39const TYPING_LIFETIME_SECONDS: i64 = 5;
40const MAX_RETIRED_TRANSPORT_ACTIONS: usize = 1_024;
41
42/// One system effect Android executes for the active account transport.
43#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "camelCase")]
45#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
46pub enum AccountTransportActionKind {
47    /// Open the selected WebSocket, optionally after the bounded delay.
48    Connect,
49    /// Send one complete ordered text frame.
50    SendText,
51    /// Wait for one complete incoming text frame or the bounded deadline.
52    ReceiveText,
53    /// Close the current WebSocket.
54    Close,
55}
56
57/// One correlated, bounded platform action.
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
61pub struct AccountTransportAction {
62    /// Caller-supplied run identifier.
63    pub run_id: String,
64    /// Unique idempotency key for this action.
65    pub action_id: String,
66    /// Connection generation; increases after transport loss.
67    pub generation: u64,
68    /// System effect to execute.
69    pub kind: AccountTransportActionKind,
70    /// Canonical persisted endpoint for `Connect`, otherwise empty.
71    pub configured_url: String,
72    /// Ordinary WSS URL for `Connect`, otherwise empty.
73    pub network_url: String,
74    /// Pinned Noise responder key for `Connect`, otherwise empty.
75    pub noise_remote_static_key: Vec<u8>,
76    /// Complete relay frame for `SendText`, otherwise empty.
77    pub text_frame: String,
78    /// Delay before `Connect` or receive deadline for `ReceiveText`.
79    pub delay_ms: u64,
80}
81
82/// Typed outcome of exactly one transport action.
83#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
84#[serde(rename_all = "camelCase")]
85#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
86pub enum AccountTransportResultKind {
87    /// The requested WebSocket is open.
88    Connected,
89    /// The complete text frame was written in order.
90    FrameWritten,
91    /// One complete text frame was received.
92    FrameReceived,
93    /// A receive deadline elapsed without a frame.
94    TimedOut,
95    /// The socket disconnected or failed temporarily.
96    Disconnected,
97    /// Android reports no usable network.
98    NetworkUnavailable,
99    /// Relay authentication failed permanently.
100    AuthenticationFailed,
101    /// Relay or network requested backoff.
102    RateLimited,
103    /// The selected relay violated the protocol.
104    ProtocolFailed,
105    /// Android cancelled the run.
106    Cancelled,
107}
108
109/// Correlated result returned by the Android system executor.
110#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
113pub struct AccountTransportResult {
114    /// Run copied from the action.
115    pub run_id: String,
116    /// Action copied from the action.
117    pub action_id: String,
118    /// Generation copied from the action.
119    pub generation: u64,
120    /// Typed outcome.
121    pub kind: AccountTransportResultKind,
122    /// Complete received frame for `FrameReceived`, otherwise empty.
123    pub text_frame: String,
124}
125
126/// One authenticated non-durable typing indicator retained until expiry.
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
130pub struct AccountTypingIndicator {
131    /// Stable conversation identifier.
132    pub conversation_id: String,
133    /// Authenticated remote author.
134    pub author_public_key: String,
135    /// Portable wall-clock expiry selected by Rust.
136    pub expires_at: i64,
137}
138
139/// Complete result of one correlated state-machine transition.
140#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "camelCase")]
142#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
143pub struct AccountTransportBatch {
144    /// Newly committed system actions in execution order.
145    pub actions: Vec<AccountTransportAction>,
146    /// Rust-owned connection state after the transition.
147    pub connection_state: RelayConnectionState,
148    /// Whether no delivery is currently leased or claimable.
149    pub idle: bool,
150    /// Durable account revision observed after the transition.
151    pub revision: i64,
152    /// Current authenticated non-durable typing indicators.
153    pub typing_indicators: Vec<AccountTypingIndicator>,
154    /// Rust-owned synchronization state, absent before start or after reset.
155    pub sync: Option<SyncEngineSnapshot>,
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub(crate) struct StoredAccountTransportOutcome {
161    pub actions: Vec<AccountTransportAction>,
162    pub connection_state: RelayConnectionState,
163    pub idle: bool,
164    pub revision: i64,
165    #[serde(default)]
166    pub typing_indicators: Vec<AccountTypingIndicator>,
167    #[serde(default)]
168    pub sync: Option<SyncEngineSnapshot>,
169}
170
171impl StoredAccountTransportOutcome {
172    pub(crate) fn into_batch(self) -> AccountTransportBatch {
173        AccountTransportBatch {
174            actions: self.actions,
175            connection_state: self.connection_state,
176            idle: self.idle,
177            revision: self.revision,
178            typing_indicators: self.typing_indicators,
179            sync: self.sync,
180        }
181    }
182}
183
184impl From<&AccountTransportBatch> for StoredAccountTransportOutcome {
185    fn from(value: &AccountTransportBatch) -> Self {
186        Self {
187            actions: value.actions.clone(),
188            connection_state: value.connection_state,
189            idle: value.idle,
190            revision: value.revision,
191            typing_indicators: value.typing_indicators.clone(),
192            sync: value.sync.clone(),
193        }
194    }
195}
196
197#[derive(Debug)]
198struct PendingAction {
199    frame_class: &'static str,
200    kind: AccountTransportActionKind,
201    delivery_event_ids: Vec<String>,
202}
203
204#[derive(Debug)]
205struct ActiveDelivery {
206    claim: DeliveryClaim,
207    event_to_intent: BTreeMap<String, String>,
208    remaining_event_ids: BTreeSet<String>,
209    socket_written_event_ids: BTreeSet<String>,
210    expires_at: i64,
211}
212
213#[derive(Debug)]
214struct ActiveSync {
215    engine: SyncEngine,
216    neg_subscription_id: String,
217    fetch_subscription_base_id: Option<String>,
218    fetch_subscription_ids: BTreeSet<String>,
219}
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222enum SyncFrameDisposition {
223    NotSync,
224    Handled,
225    Retry,
226}
227
228/// One process-local serialized transport run.
229#[derive(Debug)]
230pub(crate) struct AccountTransportRun {
231    run_id: String,
232    generation: u64,
233    next_action_sequence: u64,
234    relay_url: String,
235    endpoint: RelayEndpointPlan,
236    inbox_filters: Vec<String>,
237    session: RelaySession,
238    pending: BTreeMap<String, PendingAction>,
239    retired_pending: BTreeMap<String, PendingAction>,
240    active_delivery: Option<ActiveDelivery>,
241    raw_outbound: VecDeque<String>,
242    sync_resend_backlog: VecDeque<String>,
243    sync_resend_event_ids: BTreeSet<String>,
244    sync: Option<ActiveSync>,
245    authentication_fence: bool,
246    authentication_event_id: Option<String>,
247    typing_indicators: BTreeMap<(String, String), i64>,
248    retry_attempt: u32,
249    cancelled: bool,
250    idle: bool,
251    diagnostic_state: Cell<RelayConnectionState>,
252}
253
254impl AccountTransportRun {
255    pub(crate) fn start(
256        run_id: String,
257        inbox_filters: Vec<String>,
258        database: &AccountDatabase,
259    ) -> Result<(Self, AccountTransportBatch), SoftchatError> {
260        validate_transport_id(&run_id)?;
261        let relay_url = database.active_relay_url()?;
262        let endpoint = parse_relay_endpoint(&relay_url)?;
263        let revision = database.info()?.revision;
264        let mut run = Self {
265            run_id,
266            generation: 1,
267            next_action_sequence: 0,
268            relay_url,
269            endpoint,
270            inbox_filters,
271            session: RelaySession::new(),
272            pending: BTreeMap::new(),
273            retired_pending: BTreeMap::new(),
274            active_delivery: None,
275            raw_outbound: VecDeque::new(),
276            sync_resend_backlog: VecDeque::new(),
277            sync_resend_event_ids: BTreeSet::new(),
278            sync: None,
279            authentication_fence: false,
280            authentication_event_id: None,
281            typing_indicators: BTreeMap::new(),
282            retry_attempt: 0,
283            cancelled: false,
284            idle: false,
285            diagnostic_state: Cell::new(RelayConnectionState::Connecting),
286        };
287        run.session.connect()?;
288        let action = run.connect_action(0);
289        database.record_diagnostic(crate::account_diagnostics::connection(
290            RelayConnectionState::Connecting,
291        ));
292        Ok((
293            run,
294            AccountTransportBatch {
295                actions: vec![action],
296                connection_state: RelayConnectionState::Connecting,
297                idle: false,
298                revision,
299                typing_indicators: Vec::new(),
300                sync: None,
301            },
302        ))
303    }
304
305    pub(crate) fn validate_result_preflight(
306        &self,
307        result: &AccountTransportResult,
308    ) -> Result<(), SoftchatError> {
309        if result.run_id != self.run_id {
310            return Err(SoftchatError::InvalidRelaySession);
311        }
312        if result.generation < self.generation {
313            return Ok(());
314        }
315        if result.generation != self.generation || self.cancelled {
316            return Err(SoftchatError::InvalidRelaySession);
317        }
318        let pending = self
319            .retired_pending
320            .get(&result.action_id)
321            .or_else(|| self.pending.get(&result.action_id))
322            .ok_or(SoftchatError::UnsupportedSystemAction)?;
323        validate_result_for_action(result, pending.kind)
324    }
325
326    pub(crate) fn handle_result(
327        &mut self,
328        result: &AccountTransportResult,
329        database: &mut AccountDatabase,
330        identity: &LocalIdentityHandle,
331        now: i64,
332    ) -> Result<AccountTransportBatch, SoftchatError> {
333        self.prune_typing(now);
334        if result.run_id != self.run_id {
335            return Err(SoftchatError::InvalidRelaySession);
336        }
337        if result.generation < self.generation {
338            return self.batch(Vec::new(), database, false);
339        }
340        if result.generation != self.generation || self.cancelled {
341            return Err(SoftchatError::InvalidRelaySession);
342        }
343        if let Some(retired) = self.retired_pending.remove(&result.action_id) {
344            validate_result_for_action(result, retired.kind)?;
345            return self.batch(Vec::new(), database, false);
346        }
347        let pending = self
348            .pending
349            .remove(&result.action_id)
350            .ok_or(SoftchatError::UnsupportedSystemAction)?;
351        validate_result_for_action(result, pending.kind)?;
352
353        let mut actions = Vec::new();
354        match result.kind {
355            AccountTransportResultKind::Connected => {
356                self.session.transport_connected()?;
357                for (index, filter) in self.inbox_filters.iter().enumerate() {
358                    self.session.subscribe(
359                        format!("softchat-live-{index}"),
360                        vec![crate::RelayFilter::from_json(filter)?],
361                    )?;
362                }
363                self.session.authenticated()?;
364                self.authentication_fence = false;
365                self.authentication_event_id = None;
366                self.retry_attempt = 0;
367                self.start_sync(database, identity, now)?;
368            }
369            AccountTransportResultKind::FrameWritten => {
370                database.record_diagnostic(crate::account_diagnostics::frame(
371                    pending.frame_class,
372                    AccountLogDirection::Tx,
373                ));
374                let intent_ids =
375                    self.unresolved_intent_ids_for_events(&pending.delivery_event_ids)?;
376                self.session.confirm_sent(&pending.delivery_event_ids)?;
377                if !intent_ids.is_empty() {
378                    let claim = self
379                        .active_delivery
380                        .as_ref()
381                        .ok_or(SoftchatError::InvalidDeliveryState)?
382                        .claim
383                        .clone();
384                    database.mark_socket_written(&claim, intent_ids.clone())?;
385                    let written_intents = intent_ids.into_iter().collect::<BTreeSet<_>>();
386                    if let Some(active) = self.active_delivery.as_mut() {
387                        for event_id in &pending.delivery_event_ids {
388                            if active
389                                .event_to_intent
390                                .get(event_id)
391                                .is_some_and(|intent_id| written_intents.contains(intent_id))
392                            {
393                                active.socket_written_event_ids.insert(event_id.clone());
394                            }
395                        }
396                    }
397                }
398            }
399            AccountTransportResultKind::FrameReceived => {
400                match self.process_inbound_frame(&result.text_frame, database, identity, now) {
401                    Ok(None) => {}
402                    Ok(Some(failure)) => {
403                        return self.recover_transport(database, failure, now);
404                    }
405                    Err(error) => {
406                        database.record_diagnostic(
407                            Diagnostic::new(
408                                AccountLogCategory::Relay,
409                                "relay.input.rejected",
410                                "Relay input rejected",
411                            )
412                            .failure(error),
413                        );
414                        match inbound_failure_disposition(error) {
415                            Some(InboundFailureDisposition::Ignore) => {}
416                            Some(InboundFailureDisposition::Recover(failure)) => {
417                                return self.recover_transport(database, failure, now);
418                            }
419                            None => return Err(error),
420                        }
421                    }
422                }
423            }
424            AccountTransportResultKind::TimedOut => {}
425            AccountTransportResultKind::Disconnected => {
426                return self.recover_transport(database, RelayFailureKind::Temporary, now);
427            }
428            AccountTransportResultKind::NetworkUnavailable => {
429                return self.recover_transport(database, RelayFailureKind::NetworkUnavailable, now);
430            }
431            AccountTransportResultKind::AuthenticationFailed => {
432                return self.recover_transport(database, RelayFailureKind::Authentication, now);
433            }
434            AccountTransportResultKind::RateLimited => {
435                return self.recover_transport(database, RelayFailureKind::RateLimited, now);
436            }
437            AccountTransportResultKind::ProtocolFailed => {
438                return self.recover_transport(database, RelayFailureKind::ProtocolPermanent, now);
439            }
440            AccountTransportResultKind::Cancelled => {
441                self.release_active(database)?;
442                let _ = self.session.cancel();
443                self.pending.clear();
444                self.retired_pending.clear();
445                self.raw_outbound.clear();
446                self.sync_resend_backlog.clear();
447                self.sync_resend_event_ids.clear();
448                self.typing_indicators.clear();
449                if let Some(active) = self.sync.as_mut() {
450                    let _ = active.engine.cancel();
451                }
452                self.sync = None;
453                self.authentication_fence = false;
454                self.authentication_event_id = None;
455                self.cancelled = true;
456                self.idle = true;
457                return self.batch(Vec::new(), database, true);
458            }
459        }
460        actions.append(&mut self.pump(database, now)?);
461        self.batch(actions, database, false)
462    }
463
464    pub(crate) fn cancel(
465        &mut self,
466        database: &mut AccountDatabase,
467    ) -> Result<AccountTransportBatch, SoftchatError> {
468        if !self.cancelled {
469            self.release_active(database)?;
470            let _ = self.session.cancel();
471            self.pending.clear();
472            self.retired_pending.clear();
473            self.raw_outbound.clear();
474            self.sync_resend_backlog.clear();
475            self.sync_resend_event_ids.clear();
476            self.typing_indicators.clear();
477            if let Some(active) = self.sync.as_mut() {
478                let _ = active.engine.cancel();
479            }
480            self.sync = None;
481            self.authentication_fence = false;
482            self.authentication_event_id = None;
483            self.cancelled = true;
484            self.idle = true;
485        }
486        self.batch(Vec::new(), database, true)
487    }
488
489    pub(crate) fn wake(
490        &mut self,
491        database: &mut AccountDatabase,
492        now: i64,
493    ) -> Result<AccountTransportBatch, SoftchatError> {
494        self.prune_typing(now);
495        if database.active_relay_url()? != self.relay_url {
496            return self.reconnect_selected(database);
497        }
498        let actions = self.pump(database, now)?;
499        self.batch(actions, database, false)
500    }
501
502    pub(crate) fn restart_sync(
503        &mut self,
504        database: &mut AccountDatabase,
505        _identity: &LocalIdentityHandle,
506        _now: i64,
507    ) -> Result<AccountTransportBatch, SoftchatError> {
508        self.reconnect_selected(database)
509    }
510
511    fn reconnect_selected(
512        &mut self,
513        database: &mut AccountDatabase,
514    ) -> Result<AccountTransportBatch, SoftchatError> {
515        if let Some(mut active) = self.sync.take() {
516            for subscription_id in std::mem::take(&mut active.fetch_subscription_ids) {
517                let _ = self.session.close_subscription(&subscription_id);
518            }
519            let _ = active.engine.cancel();
520        }
521        self.raw_outbound.clear();
522        self.sync_resend_backlog.clear();
523        self.sync_resend_event_ids.clear();
524        self.typing_indicators.clear();
525        self.release_active(database)?;
526        self.pending.clear();
527        self.retired_pending.clear();
528        self.authentication_fence = false;
529        self.authentication_event_id = None;
530        self.session.transport_lost()?;
531        // Durable delivery intent is released above and becomes the source of
532        // truth for the replacement connection. Do not retain the same event
533        // in RelaySession as retryable as well, or authentication replay and
534        // the next durable claim would enqueue it twice.
535        self.session = RelaySession::new();
536        self.relay_url = database.active_relay_url()?;
537        self.endpoint = parse_relay_endpoint(&self.relay_url)?;
538        self.generation = self
539            .generation
540            .checked_add(1)
541            .ok_or(SoftchatError::InvalidRelaySession)?;
542        self.session.connect()?;
543        let action = self.connect_action(0);
544        self.batch(vec![action], database, false)
545    }
546
547    fn apply_relay_actions(
548        &mut self,
549        actions: Vec<RelaySessionAction>,
550        database: &mut AccountDatabase,
551        identity: &LocalIdentityHandle,
552        now: i64,
553    ) -> Result<(), SoftchatError> {
554        for action in actions {
555            match action.kind {
556                RelaySessionActionKind::Authenticate => {
557                    self.enter_authentication_fence()?;
558                    let created_at = u64::try_from(now)
559                        .map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
560                    let event = identity.with_identity(|authority| {
561                        plan_nip42_authentication(
562                            authority,
563                            &action.value,
564                            &self.relay_url,
565                            created_at,
566                        )
567                    })?;
568                    self.authentication_event_id = Some(event.id().to_hex());
569                    self.session.authenticate(event)?;
570                }
571                RelaySessionActionKind::PersistEvent => {
572                    let event = action.event.ok_or(SoftchatError::InvalidRelaySession)?;
573                    let event_id = event.id.clone();
574                    let is_sync_fetch = self.sync.as_ref().is_some_and(|active| {
575                        active.fetch_subscription_ids.contains(&action.value)
576                    });
577                    if is_sync_fetch {
578                        let active = self.sync.as_ref().ok_or(SoftchatError::InvalidSyncState)?;
579                        let base_id = active
580                            .fetch_subscription_base_id
581                            .as_deref()
582                            .ok_or(SoftchatError::InvalidSyncState)?;
583                        active
584                            .engine
585                            .validate_fetched_events(base_id, std::slice::from_ref(&event_id))?;
586                    }
587                    let stored = match identity.with_identity(|authority| {
588                        database.ingest_from_relay(authority, vec![event], now, is_sync_fetch)
589                    }) {
590                        Ok(stored) => stored,
591                        Err(error) if invalid_incoming_event(error) => {
592                            database.record_diagnostic(
593                                Diagnostic::new(
594                                    AccountLogCategory::Relay,
595                                    "relay.input.rejected",
596                                    "Relay input rejected",
597                                )
598                                .failure(error),
599                            );
600                            self.session.discard_ingestion(&event_id)?;
601                            if is_sync_fetch {
602                                // An uncommittable requested ID cannot advance the
603                                // checkpoint. Recover against another relay from
604                                // durable history instead of retiring the account.
605                                return Err(SoftchatError::InvalidSyncState);
606                            }
607                            continue;
608                        }
609                        Err(error) => return Err(error),
610                    };
611                    if is_sync_fetch {
612                        let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
613                        let base_id = active
614                            .fetch_subscription_base_id
615                            .as_deref()
616                            .ok_or(SoftchatError::InvalidSyncState)?;
617                        active
618                            .engine
619                            .record_fetched_event(base_id.to_owned(), event_id.clone())?;
620                        active.engine.confirm_committed(vec![event_id.clone()])?;
621                    }
622                    let own_public_key =
623                        identity.with_identity(|authority| Ok(authority.public_key().to_hex()))?;
624                    for projection in stored.ephemeral_projections {
625                        if projection.kind == ProjectionKind::Typing
626                            && !projection.conversation_id.is_empty()
627                            && projection.author_public_key != own_public_key
628                        {
629                            self.record_typing_indicator(
630                                projection.conversation_id,
631                                projection.author_public_key,
632                                now.saturating_add(TYPING_LIFETIME_SECONDS),
633                            );
634                        }
635                    }
636                    self.session.confirm_ingested(&event_id)?;
637                }
638                RelaySessionActionKind::DeliveryChanged => {
639                    let decision = action
640                        .delivery_decision
641                        .ok_or(SoftchatError::InvalidDeliveryState)?;
642                    self.commit_delivery_decision(database, decision)?;
643                }
644                RelaySessionActionKind::SubscriptionReady => {
645                    let sync_actions = {
646                        let Some(active) = self.sync.as_mut() else {
647                            continue;
648                        };
649                        if !active.fetch_subscription_ids.remove(&action.value) {
650                            continue;
651                        }
652                        self.session.close_subscription(&action.value)?;
653                        if !active.fetch_subscription_ids.is_empty() {
654                            continue;
655                        }
656                        let base_id = active
657                            .fetch_subscription_base_id
658                            .take()
659                            .ok_or(SoftchatError::InvalidSyncState)?;
660                        let actions = active.engine.finish_fetch(base_id)?;
661                        resolve_sync_actions(
662                            database,
663                            &mut active.engine,
664                            actions,
665                            SYNC_FRAME_SIZE_LIMIT,
666                        )?
667                    };
668                    self.apply_sync_actions(sync_actions, database, identity)?;
669                }
670                RelaySessionActionKind::SubscriptionClosed => {}
671                RelaySessionActionKind::OpenTransport
672                | RelaySessionActionKind::SendFrame
673                | RelaySessionActionKind::CloseTransport
674                | RelaySessionActionKind::ScheduleReconnect => {
675                    return Err(SoftchatError::UnsupportedSystemAction);
676                }
677            }
678        }
679        Ok(())
680    }
681
682    fn enter_authentication_fence(&mut self) -> Result<(), SoftchatError> {
683        if self
684            .retired_pending
685            .len()
686            .saturating_add(self.pending.len())
687            > MAX_RETIRED_TRANSPORT_ACTIONS
688        {
689            return Err(SoftchatError::RelaySessionQueueFull);
690        }
691        self.retired_pending.append(&mut self.pending);
692        if let Some(mut active) = self.sync.take() {
693            for subscription_id in std::mem::take(&mut active.fetch_subscription_ids) {
694                self.session.close_subscription(&subscription_id)?;
695            }
696            let _ = active.engine.cancel();
697        }
698        self.raw_outbound.clear();
699        self.sync_resend_backlog.clear();
700        self.sync_resend_event_ids.clear();
701        self.authentication_fence = true;
702        self.authentication_event_id = None;
703        self.idle = false;
704        Ok(())
705    }
706
707    fn should_ignore_during_authentication(&self, frame_json: &str) -> Result<bool, SoftchatError> {
708        if !self.authentication_fence {
709            return Ok(false);
710        }
711        let frame = parse_relay_response_frame(frame_json)?;
712        Ok(match frame {
713            RelayResponseFrame::Auth(_) => false,
714            RelayResponseFrame::Ok(acknowledgement) => {
715                self.authentication_event_id.as_deref() != Some(acknowledgement.event_id.as_str())
716            }
717            RelayResponseFrame::Event { .. }
718            | RelayResponseFrame::Eose { .. }
719            | RelayResponseFrame::Closed { .. }
720            | RelayResponseFrame::Notice(_)
721            | RelayResponseFrame::Count { .. } => true,
722        })
723    }
724
725    fn start_sync(
726        &mut self,
727        database: &mut AccountDatabase,
728        identity: &LocalIdentityHandle,
729        now: i64,
730    ) -> Result<(), SoftchatError> {
731        if self.sync.is_some() {
732            return Ok(());
733        }
734        let since_timestamp = database
735            .sync_checkpoint(SYNC_ID)?
736            .map(|checkpoint| checkpoint.saturating_sub(SYNC_OVERLAP_SECONDS));
737        let suffix = format!("{}-{}", self.generation, self.next_action_sequence);
738        let neg_subscription_id = format!("softchat-neg-{suffix}");
739        let mut engine = SyncEngine::new(
740            SYNC_ID.to_owned(),
741            neg_subscription_id.clone(),
742            format!("softchat-events-{suffix}"),
743            since_timestamp,
744            now,
745        )?;
746        let action = engine.begin()?;
747        let action = resolve_sync_action(database, &mut engine, action, SYNC_FRAME_SIZE_LIMIT)?;
748        self.sync = Some(ActiveSync {
749            engine,
750            neg_subscription_id,
751            fetch_subscription_base_id: None,
752            fetch_subscription_ids: BTreeSet::new(),
753        });
754        self.apply_sync_actions(vec![action], database, identity)?;
755        database.record_diagnostic(Diagnostic::new(
756            AccountLogCategory::Synchronization,
757            "sync.started",
758            "Synchronization started",
759        ));
760        Ok(())
761    }
762
763    fn handle_sync_frame(
764        &mut self,
765        frame_json: &str,
766        database: &mut AccountDatabase,
767        identity: &LocalIdentityHandle,
768        _now: i64,
769    ) -> Result<SyncFrameDisposition, SoftchatError> {
770        let parsed = parse_relay_response_frame(frame_json);
771        if let Ok(frame) = &parsed {
772            database.record_diagnostic(crate::account_diagnostics::frame(
773                crate::account_diagnostics::received_frame_class(frame),
774                AccountLogDirection::Rx,
775            ));
776        }
777        if let Ok(RelayResponseFrame::Ok(acknowledgement)) = parsed
778            && self.sync_resend_event_ids.remove(&acknowledgement.event_id)
779        {
780            if acknowledgement.accepted {
781                return Ok(SyncFrameDisposition::Handled);
782            }
783            return Ok(SyncFrameDisposition::Retry);
784        }
785        let Some(frame) = parse_sync_message_frame(frame_json)? else {
786            return Ok(SyncFrameDisposition::NotSync);
787        };
788        let frame_class = match &frame {
789            SyncTransportFrame::Message { .. } => "NEG_MSG",
790            SyncTransportFrame::Error { .. } => "NEG_ERR",
791            SyncTransportFrame::Control { .. } => "NEG_CONTROL",
792        };
793        database.record_diagnostic(crate::account_diagnostics::frame(
794            frame_class,
795            AccountLogDirection::Rx,
796        ));
797        if self.authentication_fence {
798            return Ok(SyncFrameDisposition::Handled);
799        }
800        let (subscription_id, message) = match frame {
801            SyncTransportFrame::Message {
802                subscription_id,
803                message,
804            } => (subscription_id, message),
805            SyncTransportFrame::Error {
806                subscription_id, ..
807            } => {
808                return Ok(
809                    if self
810                        .sync
811                        .as_ref()
812                        .is_some_and(|active| active.neg_subscription_id == subscription_id)
813                    {
814                        SyncFrameDisposition::Retry
815                    } else {
816                        SyncFrameDisposition::Handled
817                    },
818                );
819            }
820            SyncTransportFrame::Control { subscription_id } => {
821                return Ok(
822                    if self
823                        .sync
824                        .as_ref()
825                        .is_some_and(|active| active.neg_subscription_id == subscription_id)
826                    {
827                        SyncFrameDisposition::Retry
828                    } else {
829                        SyncFrameDisposition::Handled
830                    },
831                );
832            }
833        };
834        if self
835            .sync
836            .as_ref()
837            .is_some_and(|active| active.neg_subscription_id != subscription_id)
838        {
839            return Ok(SyncFrameDisposition::Handled);
840        }
841        let actions = {
842            let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
843            let actions = active.engine.reconcile(subscription_id, message)?;
844            resolve_sync_actions(database, &mut active.engine, actions, SYNC_FRAME_SIZE_LIMIT)?
845        };
846        self.apply_sync_actions(actions, database, identity)?;
847        Ok(SyncFrameDisposition::Handled)
848    }
849
850    fn process_inbound_frame(
851        &mut self,
852        frame_json: &str,
853        database: &mut AccountDatabase,
854        identity: &LocalIdentityHandle,
855        now: i64,
856    ) -> Result<Option<RelayFailureKind>, SoftchatError> {
857        match self.handle_sync_frame(frame_json, database, identity, now)? {
858            SyncFrameDisposition::Handled => return Ok(None),
859            SyncFrameDisposition::Retry => return Ok(Some(RelayFailureKind::Temporary)),
860            SyncFrameDisposition::NotSync => {}
861        }
862        if self.should_ignore_during_authentication(frame_json)? {
863            return Ok(None);
864        }
865        let state_before = self.session.snapshot().state;
866        let relay_actions = self.session.receive(frame_json)?;
867        let subscription_closed = relay_actions
868            .iter()
869            .any(|action| action.kind == RelaySessionActionKind::SubscriptionClosed);
870        let retry_failure = relay_actions
871            .iter()
872            .filter_map(|action| action.delivery_decision.as_ref())
873            .find(|decision| decision.state == DeliveryState::Retryable)
874            .map(|decision| {
875                if decision.relay_message.starts_with("rate-limited:") {
876                    RelayFailureKind::RateLimited
877                } else {
878                    RelayFailureKind::Temporary
879                }
880            });
881        self.apply_relay_actions(relay_actions, database, identity, now)?;
882        // Commit the retryable outcome before releasing the remaining claim.
883        // A fresh session must reclaim it from SQLite, not publish the same ID
884        // into a session that still remembers its previous delivery state.
885        if retry_failure.is_some() {
886            return Ok(retry_failure);
887        }
888        if subscription_closed {
889            return Ok(Some(RelayFailureKind::Temporary));
890        }
891        if state_before == RelayConnectionState::Authenticating
892            && self.session.snapshot().state == RelayConnectionState::Ready
893        {
894            self.authentication_fence = false;
895            self.authentication_event_id = None;
896            self.start_sync(database, identity, now)?;
897        }
898        Ok(None)
899    }
900
901    fn apply_sync_actions(
902        &mut self,
903        actions: Vec<SyncAction>,
904        database: &mut AccountDatabase,
905        identity: &LocalIdentityHandle,
906    ) -> Result<(), SoftchatError> {
907        let recipient_public_key =
908            identity.with_identity(|authority| Ok(authority.public_key().to_hex()))?;
909        let mut request_filters = BTreeMap::<String, Vec<String>>::new();
910        for action in actions {
911            let action = plan_sync_transport(action, &recipient_public_key)?;
912            match action.kind {
913                SyncActionKind::SendNegOpen
914                | SyncActionKind::SendNegMessage
915                | SyncActionKind::SendNegClose => {
916                    if action.frame_json.is_empty() {
917                        return Err(SoftchatError::InvalidSyncState);
918                    }
919                    self.raw_outbound.push_back(action.frame_json);
920                }
921                SyncActionKind::RequestEvents => {
922                    if action.filters_json.is_empty() {
923                        return Err(SoftchatError::InvalidSyncState);
924                    }
925                    request_filters
926                        .entry(action.subscription_id)
927                        .or_default()
928                        .extend(action.filters_json);
929                }
930                SyncActionKind::ResendEvents => {
931                    if self
932                        .sync_resend_backlog
933                        .len()
934                        .saturating_add(action.event_ids.len())
935                        > MAX_SYNC_RESEND_EVENTS
936                    {
937                        return Err(SoftchatError::RelaySessionQueueFull);
938                    }
939                    self.sync_resend_backlog.extend(action.event_ids);
940                }
941                SyncActionKind::Complete => {
942                    if let Some(active) = &self.sync {
943                        let snapshot = active.engine.snapshot();
944                        database.record_diagnostic(
945                            Diagnostic::new(
946                                AccountLogCategory::Synchronization,
947                                "sync.completed",
948                                "Synchronization checkpoint committed",
949                            )
950                            .count(
951                                Counter::RequestedEvents,
952                                u64::from(snapshot.requested_count),
953                            )
954                            .count(
955                                Counter::CommittedEvents,
956                                u64::from(snapshot.committed_count),
957                            ),
958                        );
959                    }
960                }
961                SyncActionKind::LoadSnapshot | SyncActionKind::SaveCheckpoint => {
962                    return Err(SoftchatError::UnsupportedSystemAction);
963                }
964            }
965        }
966        if request_filters.len() > 1 {
967            return Err(SoftchatError::InvalidSyncState);
968        }
969        for (subscription_id, filters) in request_filters {
970            let parsed = filters
971                .iter()
972                .map(|filter| crate::RelayFilter::from_json(filter))
973                .collect::<Result<Vec<_>, _>>()?;
974            let subscriptions = shard_sync_event_request(&subscription_id, parsed)?;
975            let active = self.sync.as_ref().ok_or(SoftchatError::InvalidSyncState)?;
976            if active.fetch_subscription_base_id.is_some()
977                || !active.fetch_subscription_ids.is_empty()
978            {
979                return Err(SoftchatError::InvalidSyncState);
980            }
981            self.session.subscribe_many(subscriptions.clone())?;
982            let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
983            active.fetch_subscription_base_id = Some(subscription_id);
984            active.fetch_subscription_ids = subscriptions
985                .into_iter()
986                .map(|(subscription_id, _)| subscription_id)
987                .collect();
988        }
989        if self.raw_outbound.len() > MAX_TRANSPORT_ACTIONS {
990            return Err(SoftchatError::RelaySessionQueueFull);
991        }
992        Ok(())
993    }
994
995    fn commit_delivery_decision(
996        &mut self,
997        database: &mut AccountDatabase,
998        decision: DeliveryDecision,
999    ) -> Result<(), SoftchatError> {
1000        let Some(active) = self.active_delivery.as_mut() else {
1001            return Ok(());
1002        };
1003        if !active.remaining_event_ids.contains(&decision.event_id) {
1004            return Ok(());
1005        }
1006        let intent_id = active
1007            .event_to_intent
1008            .get(&decision.event_id)
1009            .cloned()
1010            .ok_or(SoftchatError::InvalidDeliveryState)?;
1011        let kind = match decision.state {
1012            DeliveryState::Accepted => RelayDeliveryResultKind::Accepted,
1013            DeliveryState::Rejected => RelayDeliveryResultKind::Rejected,
1014            DeliveryState::Retryable => RelayDeliveryResultKind::Retryable,
1015            DeliveryState::Pending | DeliveryState::InFlight => {
1016                return Err(SoftchatError::InvalidDeliveryState);
1017            }
1018        };
1019        database.apply_relay_results(
1020            &active.claim,
1021            vec![RelayDeliveryResult {
1022                intent_id,
1023                kind,
1024                category: decision.category,
1025            }],
1026        )?;
1027        active.remaining_event_ids.remove(&decision.event_id);
1028        if active.remaining_event_ids.is_empty() {
1029            self.active_delivery = None;
1030        }
1031        Ok(())
1032    }
1033
1034    fn prune_typing(&mut self, now: i64) {
1035        self.typing_indicators
1036            .retain(|_, expires_at| *expires_at > now);
1037    }
1038
1039    fn record_typing_indicator(
1040        &mut self,
1041        conversation_id: String,
1042        author_public_key: String,
1043        expires_at: i64,
1044    ) {
1045        let key = (conversation_id, author_public_key);
1046        if !self.typing_indicators.contains_key(&key)
1047            && self.typing_indicators.len() == MAX_TYPING_INDICATORS
1048            && let Some(oldest) = self
1049                .typing_indicators
1050                .iter()
1051                .min_by(|(left_key, left_expiry), (right_key, right_expiry)| {
1052                    left_expiry
1053                        .cmp(right_expiry)
1054                        .then_with(|| left_key.cmp(right_key))
1055                })
1056                .map(|(entry, _)| entry.clone())
1057        {
1058            self.typing_indicators.remove(&oldest);
1059        }
1060        self.typing_indicators.insert(key, expires_at);
1061    }
1062
1063    fn pump(
1064        &mut self,
1065        database: &mut AccountDatabase,
1066        now: i64,
1067    ) -> Result<Vec<AccountTransportAction>, SoftchatError> {
1068        // Receive activity must not extend the durable delivery deadline.
1069        // The current result is applied before pumping, so a terminal ACK
1070        // arriving at the deadline still wins over timeout recovery.
1071        if self
1072            .active_delivery
1073            .as_ref()
1074            .is_some_and(|delivery| now >= delivery.expires_at)
1075        {
1076            return self
1077                .recover_transport(database, RelayFailureKind::Temporary, now)
1078                .map(|batch| batch.actions);
1079        }
1080        let mut actions = Vec::new();
1081        if self.session.snapshot().state == RelayConnectionState::Ready
1082            && self.active_delivery.is_none()
1083        {
1084            let lease_id = self.next_lease_id();
1085            if let Some(claimed) = database.claim_deliveries(
1086                lease_id,
1087                self.relay_url.clone(),
1088                now,
1089                DELIVERY_LEASE_SECONDS,
1090            )? {
1091                self.publish_claim(claimed, now)?;
1092                self.idle = false;
1093            } else {
1094                self.idle = true;
1095            }
1096        }
1097        let send_limit = MAX_TRANSPORT_ACTIONS.saturating_sub(1);
1098        for frame in self.session.drain_outbound(send_limit)? {
1099            actions.push(self.new_action(
1100                AccountTransportActionKind::SendText,
1101                String::new(),
1102                frame.frame,
1103                0,
1104                frame.delivery_event_ids,
1105            ));
1106        }
1107        while actions.len() < send_limit {
1108            let Some(frame) = self.raw_outbound.pop_front() else {
1109                break;
1110            };
1111            actions.push(self.new_action(
1112                AccountTransportActionKind::SendText,
1113                String::new(),
1114                frame,
1115                0,
1116                Vec::new(),
1117            ));
1118        }
1119        while actions.len() < send_limit {
1120            if self.sync_resend_backlog.is_empty() {
1121                break;
1122            }
1123            let count = self
1124                .sync_resend_backlog
1125                .len()
1126                .min(crate::MAX_RELAY_BATCH_EVENTS);
1127            let event_ids = self.sync_resend_backlog.drain(..count).collect();
1128            let events = database.event_jsons(event_ids)?;
1129            let mut frames = crate::session::batch_event_frames(
1130                events
1131                    .into_iter()
1132                    // Old account versions admitted standalone Nostr events
1133                    // that cannot fit even one EVENT frame. Preserve that
1134                    // history; it is also excluded from sync fingerprints.
1135                    .filter(|event| {
1136                        crate::session::validate_event_frame_json(&event.event_json).is_ok()
1137                    })
1138                    .map(|event| (event.event_id, event.event_json)),
1139            )?
1140            .into_iter();
1141            while actions.len() < send_limit {
1142                let Some(frame) = frames.next() else {
1143                    break;
1144                };
1145                self.sync_resend_event_ids.extend(frame.delivery_event_ids);
1146                actions.push(self.new_action(
1147                    AccountTransportActionKind::SendText,
1148                    String::new(),
1149                    frame.frame,
1150                    0,
1151                    Vec::new(),
1152                ));
1153            }
1154            // Byte splitting can fill this action batch before all loaded IDs
1155            // fit. Keep the rest in original order for the next pump.
1156            for event_id in frames.flat_map(|frame| frame.delivery_event_ids).rev() {
1157                self.sync_resend_backlog.push_front(event_id);
1158            }
1159        }
1160        let receive_pending = self
1161            .pending
1162            .values()
1163            .any(|pending| pending.kind == AccountTransportActionKind::ReceiveText);
1164        if matches!(
1165            self.session.snapshot().state,
1166            RelayConnectionState::Authenticating | RelayConnectionState::Ready
1167        ) && !receive_pending
1168        {
1169            let typing_delay_ms = self
1170                .typing_indicators
1171                .values()
1172                .min()
1173                .map(|expires_at| {
1174                    u64::try_from(expires_at.saturating_sub(now))
1175                        .unwrap_or_default()
1176                        .saturating_mul(1_000)
1177                        .clamp(1, 30_000)
1178                })
1179                .unwrap_or(30_000);
1180            actions.push(self.new_action(
1181                AccountTransportActionKind::ReceiveText,
1182                String::new(),
1183                String::new(),
1184                typing_delay_ms,
1185                Vec::new(),
1186            ));
1187        }
1188        if actions.len() > MAX_TRANSPORT_ACTIONS {
1189            return Err(SoftchatError::RelaySessionQueueFull);
1190        }
1191        let sync_complete = self
1192            .sync
1193            .as_ref()
1194            .is_none_or(|active| active.engine.snapshot().phase == SyncPhase::Complete);
1195        self.idle = self.idle
1196            && sync_complete
1197            && self.raw_outbound.is_empty()
1198            && self.sync_resend_backlog.is_empty()
1199            && self.sync_resend_event_ids.is_empty();
1200        Ok(actions)
1201    }
1202
1203    fn publish_claim(&mut self, claimed: ClaimedDelivery, now: i64) -> Result<(), SoftchatError> {
1204        let mut events = Vec::with_capacity(claimed.payloads.len());
1205        let mut event_to_intent = BTreeMap::new();
1206        let mut remaining_event_ids = BTreeSet::new();
1207        for payload in &claimed.payloads {
1208            let event = SignedNostrEvent::from_json(&payload.event_json)?;
1209            let event_id = event.id().to_hex();
1210            if event_to_intent
1211                .insert(event_id.clone(), payload.intent_id.clone())
1212                .is_some()
1213            {
1214                return Err(SoftchatError::InvalidDeliveryState);
1215            }
1216            remaining_event_ids.insert(event_id);
1217            events.push(event);
1218        }
1219        self.session.publish(events)?;
1220        self.active_delivery = Some(ActiveDelivery {
1221            claim: claimed.claim,
1222            event_to_intent,
1223            remaining_event_ids,
1224            socket_written_event_ids: BTreeSet::new(),
1225            expires_at: now
1226                .checked_add(i64::from(DELIVERY_LEASE_SECONDS))
1227                .ok_or(SoftchatError::InvalidDeliveryState)?,
1228        });
1229        self.idle = false;
1230        Ok(())
1231    }
1232
1233    fn recover_transport(
1234        &mut self,
1235        database: &mut AccountDatabase,
1236        failure: RelayFailureKind,
1237        now: i64,
1238    ) -> Result<AccountTransportBatch, SoftchatError> {
1239        self.release_active(database)?;
1240        let mut diagnostic = Diagnostic::new(
1241            AccountLogCategory::Relay,
1242            "relay.retry_scheduled",
1243            "Relay reconnect scheduled",
1244        )
1245        .count(Counter::Attempts, u64::from(self.retry_attempt) + 1);
1246        diagnostic.level = AccountLogLevel::Warn;
1247        diagnostic.summarize = true;
1248        diagnostic.reason = crate::account_diagnostics::relay_failure_reason(failure);
1249        database.record_diagnostic(diagnostic);
1250        if self
1251            .sync
1252            .as_ref()
1253            .is_some_and(|active| active.engine.snapshot().phase != SyncPhase::Complete)
1254        {
1255            database.record_diagnostic(Diagnostic::new(
1256                AccountLogCategory::Synchronization,
1257                "sync.cancelled",
1258                "Synchronization cancelled before completion",
1259            ));
1260        }
1261        self.pending.clear();
1262        self.retired_pending.clear();
1263        self.raw_outbound.clear();
1264        self.sync_resend_backlog.clear();
1265        self.sync_resend_event_ids.clear();
1266        self.typing_indicators.clear();
1267        if let Some(active) = self.sync.as_mut() {
1268            let _ = active.engine.cancel();
1269        }
1270        self.sync = None;
1271        self.authentication_fence = false;
1272        self.authentication_event_id = None;
1273        self.session.transport_lost()?;
1274        // The active claim was released to durable retryable state. A fresh
1275        // relay session must recover it from SQLite exactly once instead of
1276        // also replaying the process-local pending event.
1277        self.session = RelaySession::new();
1278        let retry = plan_relay_retry(self.retry_attempt, failure, false);
1279        self.retry_attempt = self.retry_attempt.saturating_add(1);
1280        if retry.action == RelayRetryAction::RotateRelay {
1281            let mutation = database.rotate_failed_relay(self.relay_url.clone(), now)?;
1282            self.relay_url = mutation.active_relay_url;
1283            self.endpoint = parse_relay_endpoint(&self.relay_url)?;
1284        }
1285        self.generation = self
1286            .generation
1287            .checked_add(1)
1288            .ok_or(SoftchatError::InvalidRelaySession)?;
1289        self.session.connect()?;
1290        let action = self.connect_action(retry.delay_ms);
1291        self.batch(vec![action], database, false)
1292    }
1293
1294    fn release_active(&mut self, database: &mut AccountDatabase) -> Result<(), SoftchatError> {
1295        let Some(active) = self.active_delivery.take() else {
1296            return Ok(());
1297        };
1298        if active.remaining_event_ids.is_empty() {
1299            return Ok(());
1300        }
1301        let remaining_intents = active
1302            .remaining_event_ids
1303            .iter()
1304            .filter_map(|event_id| active.event_to_intent.get(event_id))
1305            .cloned()
1306            .collect::<BTreeSet<_>>();
1307        let mut claim = active.claim;
1308        claim
1309            .intents
1310            .retain(|intent| remaining_intents.contains(&intent.intent_id));
1311        claim
1312            .mutations
1313            .retain(|mutation| remaining_intents.contains(&mutation.intent_id));
1314        if !claim.intents.is_empty() {
1315            database.release_claim(&claim)?;
1316        }
1317        Ok(())
1318    }
1319
1320    fn unresolved_intent_ids_for_events(
1321        &self,
1322        event_ids: &[String],
1323    ) -> Result<Vec<String>, SoftchatError> {
1324        if event_ids.is_empty() {
1325            return Ok(Vec::new());
1326        }
1327        let snapshot = self.session.snapshot();
1328        let states = snapshot
1329            .delivery_event_ids
1330            .into_iter()
1331            .zip(snapshot.delivery_states)
1332            .collect::<BTreeMap<_, _>>();
1333        let mut intent_ids = Vec::new();
1334        for event_id in event_ids {
1335            match states.get(event_id) {
1336                Some(DeliveryState::Pending) => {
1337                    let active = self
1338                        .active_delivery
1339                        .as_ref()
1340                        .ok_or(SoftchatError::InvalidDeliveryState)?;
1341                    if !active.remaining_event_ids.contains(event_id)
1342                        || active.socket_written_event_ids.contains(event_id)
1343                    {
1344                        return Err(SoftchatError::InvalidDeliveryState);
1345                    }
1346                    intent_ids.push(
1347                        active
1348                            .event_to_intent
1349                            .get(event_id)
1350                            .cloned()
1351                            .ok_or(SoftchatError::InvalidDeliveryState)?,
1352                    );
1353                }
1354                Some(DeliveryState::Retryable) => {
1355                    let active = self
1356                        .active_delivery
1357                        .as_ref()
1358                        .ok_or(SoftchatError::InvalidDeliveryState)?;
1359                    if !active.remaining_event_ids.contains(event_id)
1360                        || !active.socket_written_event_ids.contains(event_id)
1361                    {
1362                        return Err(SoftchatError::InvalidDeliveryState);
1363                    }
1364                }
1365                Some(DeliveryState::Accepted | DeliveryState::Rejected) => {}
1366                Some(DeliveryState::InFlight) | None => {
1367                    return Err(SoftchatError::InvalidDeliveryState);
1368                }
1369            }
1370        }
1371        Ok(intent_ids)
1372    }
1373
1374    fn connect_action(&mut self, delay_ms: u64) -> AccountTransportAction {
1375        self.new_action(
1376            AccountTransportActionKind::Connect,
1377            self.endpoint.network_url.clone(),
1378            String::new(),
1379            delay_ms,
1380            Vec::new(),
1381        )
1382    }
1383
1384    fn new_action(
1385        &mut self,
1386        kind: AccountTransportActionKind,
1387        network_url: String,
1388        text_frame: String,
1389        delay_ms: u64,
1390        delivery_event_ids: Vec<String>,
1391    ) -> AccountTransportAction {
1392        self.next_action_sequence = self.next_action_sequence.saturating_add(1);
1393        let action_id = stable_action_id(
1394            &self.run_id,
1395            self.generation,
1396            self.next_action_sequence,
1397            kind,
1398        );
1399        self.pending.insert(
1400            action_id.clone(),
1401            PendingAction {
1402                frame_class: crate::account_diagnostics::planned_frame_class(&text_frame),
1403                kind,
1404                delivery_event_ids,
1405            },
1406        );
1407        AccountTransportAction {
1408            run_id: self.run_id.clone(),
1409            action_id,
1410            generation: self.generation,
1411            kind,
1412            configured_url: if kind == AccountTransportActionKind::Connect {
1413                self.endpoint.configured_url.clone()
1414            } else {
1415                String::new()
1416            },
1417            network_url,
1418            noise_remote_static_key: if kind == AccountTransportActionKind::Connect {
1419                self.endpoint.noise_remote_static_key.clone()
1420            } else {
1421                Vec::new()
1422            },
1423            text_frame,
1424            delay_ms,
1425        }
1426    }
1427
1428    fn next_lease_id(&mut self) -> String {
1429        self.next_action_sequence = self.next_action_sequence.saturating_add(1);
1430        format!(
1431            "transport-{}-{}",
1432            self.generation, self.next_action_sequence
1433        )
1434    }
1435
1436    fn batch(
1437        &self,
1438        actions: Vec<AccountTransportAction>,
1439        database: &AccountDatabase,
1440        idle: bool,
1441    ) -> Result<AccountTransportBatch, SoftchatError> {
1442        let state = self.session.snapshot().state;
1443        if self.diagnostic_state.replace(state) != state {
1444            database.record_diagnostic(crate::account_diagnostics::connection(state));
1445        }
1446        let sync = self.sync.as_ref().map(|active| active.engine.snapshot());
1447        let sync_complete = sync
1448            .as_ref()
1449            .is_none_or(|snapshot| snapshot.phase == SyncPhase::Complete);
1450        Ok(AccountTransportBatch {
1451            actions,
1452            connection_state: state,
1453            idle: (idle || self.idle) && sync_complete,
1454            revision: database.info()?.revision,
1455            typing_indicators: self
1456                .typing_indicators
1457                .iter()
1458                .map(
1459                    |((conversation_id, author_public_key), expires_at)| AccountTypingIndicator {
1460                        conversation_id: conversation_id.clone(),
1461                        author_public_key: author_public_key.clone(),
1462                        expires_at: *expires_at,
1463                    },
1464                )
1465                .collect(),
1466            sync,
1467        })
1468    }
1469}
1470
1471fn shard_sync_event_request(
1472    base_subscription_id: &str,
1473    filters: Vec<crate::RelayFilter>,
1474) -> Result<Vec<(String, Vec<crate::RelayFilter>)>, SoftchatError> {
1475    if filters.is_empty() {
1476        return Err(SoftchatError::InvalidSyncState);
1477    }
1478    let mut subscriptions = Vec::new();
1479    let mut current = Vec::new();
1480    for filter in filters {
1481        current.push(filter);
1482        let subscription_id =
1483            sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1484        let fits = current.len() <= crate::MAX_RELAY_FILTERS
1485            && crate::ClientRelayFrame::Req {
1486                subscription_id: subscription_id.clone(),
1487                filters: current.clone(),
1488            }
1489            .to_json()
1490            .is_ok();
1491        if fits {
1492            continue;
1493        }
1494        let last = current.pop().ok_or(SoftchatError::InvalidSyncState)?;
1495        if current.is_empty() {
1496            return Err(SoftchatError::InvalidRelayFrame);
1497        }
1498        subscriptions.push((subscription_id, std::mem::take(&mut current)));
1499        current.push(last);
1500        let next_id = sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1501        crate::ClientRelayFrame::Req {
1502            subscription_id: next_id,
1503            filters: current.clone(),
1504        }
1505        .to_json()?;
1506    }
1507    if !current.is_empty() {
1508        let subscription_id =
1509            sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1510        subscriptions.push((subscription_id, current));
1511    }
1512    if subscriptions.len() > crate::MAX_RELAY_SUBSCRIPTIONS {
1513        return Err(SoftchatError::RelaySessionQueueFull);
1514    }
1515    Ok(subscriptions)
1516}
1517
1518#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1519enum InboundFailureDisposition {
1520    Ignore,
1521    Recover(RelayFailureKind),
1522}
1523
1524/// Untrusted event validation failures are distinct from storage, erased
1525/// identity, and account-lifecycle failures, which must remain fail-closed.
1526const fn invalid_incoming_event(error: SoftchatError) -> bool {
1527    matches!(
1528        error,
1529        SoftchatError::InvalidPublicKey
1530            | SoftchatError::InvalidRelayFrame
1531            | SoftchatError::InvalidEventId
1532            | SoftchatError::InvalidEventSignature
1533            | SoftchatError::InvalidEventKind
1534            | SoftchatError::InvalidEventTimestamp
1535            | SoftchatError::InvalidEventTag
1536            | SoftchatError::InvalidEventJson
1537            | SoftchatError::EventTooLarge
1538            | SoftchatError::InvalidNip59Envelope
1539            | SoftchatError::InvalidNip17Message
1540            | SoftchatError::InvalidAttachmentMetadata
1541            | SoftchatError::InvalidSoftchatEvent
1542            | SoftchatError::InvalidSoftchatProjection
1543            | SoftchatError::InvalidUserMetadata
1544            | SoftchatError::InvalidContactList
1545            | SoftchatError::InvalidApplicationData
1546            | SoftchatError::InvalidAccountOperation
1547            | SoftchatError::InvalidAccountSettings
1548            | SoftchatError::DecryptionFailed
1549    )
1550}
1551
1552const fn inbound_failure_disposition(error: SoftchatError) -> Option<InboundFailureDisposition> {
1553    match error {
1554        SoftchatError::InvalidRelayFrame
1555        | SoftchatError::InvalidRelayBatch
1556        | SoftchatError::InvalidRelaySession
1557        | SoftchatError::InvalidDeliveryState => Some(InboundFailureDisposition::Ignore),
1558        SoftchatError::InvalidRelayAuthentication => Some(InboundFailureDisposition::Recover(
1559            RelayFailureKind::Authentication,
1560        )),
1561        SoftchatError::RelaySessionQueueFull => Some(InboundFailureDisposition::Recover(
1562            RelayFailureKind::Temporary,
1563        )),
1564        SoftchatError::InvalidSyncState | SoftchatError::InvalidNegentropy => Some(
1565            InboundFailureDisposition::Recover(RelayFailureKind::ProtocolPermanent),
1566        ),
1567        _ => None,
1568    }
1569}
1570
1571fn sync_fetch_subscription_id(
1572    base_subscription_id: &str,
1573    shard_index: usize,
1574) -> Result<String, SoftchatError> {
1575    let subscription_id = format!("{base_subscription_id}-{shard_index}");
1576    if subscription_id.chars().count() > crate::MAX_SUBSCRIPTION_ID_CHARS {
1577        return Err(SoftchatError::InvalidSyncState);
1578    }
1579    Ok(subscription_id)
1580}
1581
1582pub(crate) fn result_hash(result: &AccountTransportResult) -> Result<String, SoftchatError> {
1583    validate_transport_id(&result.run_id)?;
1584    validate_transport_id(&result.action_id)?;
1585    if result.text_frame.len() > crate::MAX_RELAY_FRAME_BYTES {
1586        return Err(SoftchatError::InvalidRelayFrame);
1587    }
1588    let encoded = serde_json::to_vec(result).map_err(|_| SoftchatError::InternalFailure)?;
1589    Ok(hex::encode(Sha256::digest(encoded)))
1590}
1591
1592fn validate_result_for_action(
1593    result: &AccountTransportResult,
1594    action: AccountTransportActionKind,
1595) -> Result<(), SoftchatError> {
1596    let valid = match action {
1597        AccountTransportActionKind::Connect => matches!(
1598            result.kind,
1599            AccountTransportResultKind::Connected
1600                | AccountTransportResultKind::Disconnected
1601                | AccountTransportResultKind::NetworkUnavailable
1602                | AccountTransportResultKind::AuthenticationFailed
1603                | AccountTransportResultKind::RateLimited
1604                | AccountTransportResultKind::ProtocolFailed
1605                | AccountTransportResultKind::Cancelled
1606        ),
1607        AccountTransportActionKind::SendText => matches!(
1608            result.kind,
1609            AccountTransportResultKind::FrameWritten
1610                | AccountTransportResultKind::Disconnected
1611                | AccountTransportResultKind::NetworkUnavailable
1612                | AccountTransportResultKind::RateLimited
1613                | AccountTransportResultKind::ProtocolFailed
1614                | AccountTransportResultKind::Cancelled
1615        ),
1616        AccountTransportActionKind::ReceiveText => matches!(
1617            result.kind,
1618            AccountTransportResultKind::FrameReceived
1619                | AccountTransportResultKind::TimedOut
1620                | AccountTransportResultKind::Disconnected
1621                | AccountTransportResultKind::NetworkUnavailable
1622                | AccountTransportResultKind::AuthenticationFailed
1623                | AccountTransportResultKind::RateLimited
1624                | AccountTransportResultKind::ProtocolFailed
1625                | AccountTransportResultKind::Cancelled
1626        ),
1627        AccountTransportActionKind::Close => matches!(
1628            result.kind,
1629            AccountTransportResultKind::Disconnected | AccountTransportResultKind::Cancelled
1630        ),
1631    };
1632    if !valid
1633        || (result.kind == AccountTransportResultKind::FrameReceived
1634            && result.text_frame.is_empty())
1635        || (result.kind != AccountTransportResultKind::FrameReceived
1636            && !result.text_frame.is_empty())
1637    {
1638        return Err(SoftchatError::UnsupportedSystemAction);
1639    }
1640    Ok(())
1641}
1642
1643fn stable_action_id(
1644    run_id: &str,
1645    generation: u64,
1646    sequence: u64,
1647    kind: AccountTransportActionKind,
1648) -> String {
1649    let mut digest = Sha256::new();
1650    digest.update(run_id.as_bytes());
1651    digest.update(generation.to_be_bytes());
1652    digest.update(sequence.to_be_bytes());
1653    digest.update([kind as u8]);
1654    hex::encode(digest.finalize())
1655}
1656
1657fn validate_transport_id(value: &str) -> Result<(), SoftchatError> {
1658    if value.is_empty()
1659        || value.len() > MAX_TRANSPORT_ID_BYTES
1660        || !value
1661            .bytes()
1662            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1663    {
1664        return Err(SoftchatError::InvalidRelaySession);
1665    }
1666    Ok(())
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use super::*;
1672
1673    const NOW: i64 = 1_700_000_000;
1674
1675    fn authority() -> Result<(crate::LocalIdentity, LocalIdentityHandle), SoftchatError> {
1676        let mut secret = vec![0; 32];
1677        secret[31] = 1;
1678        Ok((
1679            crate::LocalIdentity::from_secret_bytes(&secret)?,
1680            LocalIdentityHandle::new(secret)?,
1681        ))
1682    }
1683
1684    fn result(
1685        action: &AccountTransportAction,
1686        kind: AccountTransportResultKind,
1687        text_frame: String,
1688    ) -> AccountTransportResult {
1689        AccountTransportResult {
1690            run_id: action.run_id.clone(),
1691            action_id: action.action_id.clone(),
1692            generation: action.generation,
1693            kind,
1694            text_frame,
1695        }
1696    }
1697
1698    fn action(
1699        batch: &AccountTransportBatch,
1700        kind: AccountTransportActionKind,
1701    ) -> Result<AccountTransportAction, SoftchatError> {
1702        batch
1703            .actions
1704            .iter()
1705            .find(|action| action.kind == kind)
1706            .cloned()
1707            .ok_or(SoftchatError::UnsupportedSystemAction)
1708    }
1709
1710    fn connected_run(
1711        database: &mut AccountDatabase,
1712        identity: &LocalIdentityHandle,
1713    ) -> Result<(AccountTransportRun, AccountTransportBatch), SoftchatError> {
1714        let (mut run, started) = AccountTransportRun::start(
1715            "transport-regression".to_owned(),
1716            vec!["{}".to_owned()],
1717            database,
1718        )?;
1719        let batch = run.handle_result(
1720            &result(
1721                &started.actions[0],
1722                AccountTransportResultKind::Connected,
1723                String::new(),
1724            ),
1725            database,
1726            identity,
1727            NOW,
1728        )?;
1729        Ok((run, batch))
1730    }
1731
1732    fn signed_note(
1733        identity: &crate::LocalIdentity,
1734        timestamp: u64,
1735        text: String,
1736    ) -> Result<SignedNostrEvent, SoftchatError> {
1737        identity.sign_event(crate::NostrEventDraft::new(
1738            timestamp,
1739            crate::NostrEventKind::SHORT_TEXT_NOTE,
1740            Vec::new(),
1741            text,
1742        )?)
1743    }
1744
1745    fn invalid_wrapper(identity: &crate::LocalIdentity) -> Result<SignedNostrEvent, SoftchatError> {
1746        identity.sign_event(crate::NostrEventDraft::new(
1747            NOW as u64,
1748            crate::NostrEventKind::GIFT_WRAP,
1749            vec![crate::NostrTag::new(vec![
1750                "p".to_owned(),
1751                identity.public_key().to_hex(),
1752            ])?],
1753            "invalid ciphertext",
1754        )?)
1755    }
1756
1757    fn reconcile_remote(
1758        run: &mut AccountTransportRun,
1759        connected: &AccountTransportBatch,
1760        database: &mut AccountDatabase,
1761        identity: &LocalIdentityHandle,
1762        events: &[SignedNostrEvent],
1763    ) -> Result<AccountTransportBatch, Box<dyn std::error::Error>> {
1764        let open = connected
1765            .actions
1766            .iter()
1767            .find_map(|action| {
1768                let values =
1769                    serde_json::from_str::<Vec<serde_json::Value>>(&action.text_frame).ok()?;
1770                (values.first()?.as_str()? == "NEG-OPEN").then_some(values)
1771            })
1772            .ok_or("missing NEG-OPEN")?;
1773        let subscription = open[1].as_str().ok_or("missing subscription")?;
1774        let initial = hex::decode(
1775            open.last()
1776                .and_then(serde_json::Value::as_str)
1777                .ok_or("missing message")?,
1778        )?;
1779        let mut storage = softrelay_negentropy::NegentropyStorageVector::new();
1780        for event in events {
1781            storage.insert(
1782                event.created_at(),
1783                softrelay_negentropy::Id::from_byte_array(*event.id().as_inner().as_bytes()),
1784            )?;
1785        }
1786        storage.seal()?;
1787        let mut relay = softrelay_negentropy::Negentropy::owned(storage, SYNC_FRAME_SIZE_LIMIT)?;
1788        let response = relay.reconcile(&initial)?;
1789        Ok(run.handle_result(
1790            &result(
1791                &action(connected, AccountTransportActionKind::ReceiveText)?,
1792                AccountTransportResultKind::FrameReceived,
1793                serde_json::json!(["NEG-MSG", subscription, hex::encode(response)]).to_string(),
1794            ),
1795            database,
1796            identity,
1797            NOW + 1,
1798        )?)
1799    }
1800
1801    #[test]
1802    fn transport_invalid_inner_event_does_not_poison_live_ingestion()
1803    -> Result<(), Box<dyn std::error::Error>> {
1804        let (identity, handle) = authority()?;
1805        let mut database = AccountDatabase::open(":memory:", "live-invalid".to_owned())?;
1806        let (mut run, connected) = connected_run(&mut database, &handle)?;
1807        let malformed_rumor = identity.create_rumor(crate::NostrEventDraft::new(
1808            NOW as u64,
1809            crate::NostrEventKind::REACTION,
1810            Vec::new(),
1811            "+",
1812        )?)?;
1813        let bad_events = [
1814            invalid_wrapper(&identity)?,
1815            identity.gift_wrap(
1816                &identity.public_key(),
1817                &malformed_rumor,
1818                crate::Nip59EnvelopeKind::Durable,
1819                NOW as u64,
1820            )?,
1821        ];
1822        let before = database.info()?.revision;
1823        let mut after = connected;
1824        for bad in bad_events {
1825            after = run.handle_result(
1826                &result(
1827                    &action(&after, AccountTransportActionKind::ReceiveText)?,
1828                    AccountTransportResultKind::FrameReceived,
1829                    RelayResponseFrame::Event {
1830                        subscription_id: "softchat-live-0".to_owned(),
1831                        event: bad.clone(),
1832                    }
1833                    .to_json()?,
1834                ),
1835                &mut database,
1836                &handle,
1837                NOW + 1,
1838            )?;
1839            assert_eq!(after.connection_state, RelayConnectionState::Ready);
1840            assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1841            assert_eq!(database.info()?.revision, before);
1842            assert!(database.event_json(&bad.id().to_hex()).is_err());
1843        }
1844        let valid = signed_note(&identity, NOW as u64, "valid next event".to_owned())?;
1845        run.handle_result(
1846            &result(
1847                &action(&after, AccountTransportActionKind::ReceiveText)?,
1848                AccountTransportResultKind::FrameReceived,
1849                RelayResponseFrame::Event {
1850                    subscription_id: "softchat-live-0".to_owned(),
1851                    event: valid.clone(),
1852                }
1853                .to_json()?,
1854            ),
1855            &mut database,
1856            &handle,
1857            NOW + 2,
1858        )?;
1859        assert_eq!(database.event_json(&valid.id().to_hex())?, valid.to_json()?);
1860        assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1861        // Authority/lifecycle errors still stop the transition; only rejected
1862        // input is safe to discard without committing an event.
1863        handle.erase();
1864        assert_eq!(
1865            run.process_inbound_frame(
1866                &RelayResponseFrame::Event {
1867                    subscription_id: "softchat-live-0".to_owned(),
1868                    event: valid,
1869                }
1870                .to_json()?,
1871                &mut database,
1872                &handle,
1873                NOW + 3
1874            ),
1875            Err(SoftchatError::IdentityErased)
1876        );
1877        Ok(())
1878    }
1879
1880    #[test]
1881    fn transport_invalid_sync_event_recovers_without_advancing_checkpoint()
1882    -> Result<(), Box<dyn std::error::Error>> {
1883        let (identity, handle) = authority()?;
1884        let mut database = AccountDatabase::open(":memory:", "sync-invalid".to_owned())?;
1885        let (mut run, connected) = connected_run(&mut database, &handle)?;
1886        let bad = invalid_wrapper(&identity)?;
1887        let fetching = reconcile_remote(
1888            &mut run,
1889            &connected,
1890            &mut database,
1891            &handle,
1892            std::slice::from_ref(&bad),
1893        )?;
1894        let subscription = run
1895            .sync
1896            .as_ref()
1897            .and_then(|sync| sync.fetch_subscription_ids.first())
1898            .cloned()
1899            .ok_or("missing fetch")?;
1900        let recovered = run.handle_result(
1901            &result(
1902                &action(&fetching, AccountTransportActionKind::ReceiveText)?,
1903                AccountTransportResultKind::FrameReceived,
1904                RelayResponseFrame::Event {
1905                    subscription_id: subscription,
1906                    event: bad.clone(),
1907                }
1908                .to_json()?,
1909            ),
1910            &mut database,
1911            &handle,
1912            NOW + 2,
1913        )?;
1914        assert_eq!(recovered.connection_state, RelayConnectionState::Connecting);
1915        assert!(
1916            action(&recovered, AccountTransportActionKind::Connect)?.generation
1917                > connected.actions[0].generation
1918        );
1919        assert_eq!(database.sync_checkpoint(SYNC_ID)?, None);
1920        assert!(database.event_json(&bad.id().to_hex()).is_err());
1921        assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1922        Ok(())
1923    }
1924
1925    #[test]
1926    fn transport_retryable_ack_preserves_terminal_outcomes_and_reclaims_once()
1927    -> Result<(), Box<dyn std::error::Error>> {
1928        // Cover the final outstanding ACK after a socket write and an ACK
1929        // racing the write while another delivery still needs release.
1930        for (count, written) in [(3, true), (4, false)] {
1931            let (identity, handle) = authority()?;
1932            let mut database = AccountDatabase::open(":memory:", "retryable-ack".to_owned())?;
1933            let events = (0..count)
1934                .map(|offset| signed_note(&identity, NOW as u64 + offset, "outgoing".to_owned()))
1935                .collect::<Result<Vec<_>, _>>()?;
1936            database.enqueue_signed(
1937                &identity,
1938                "retry-operation".to_owned(),
1939                events.iter().map(crate::event::SignedEvent::from).collect(),
1940                vec![database.active_relay_url()?],
1941                NOW,
1942            )?;
1943            let (mut run, connected) = connected_run(&mut database, &handle)?;
1944            let send = connected
1945                .actions
1946                .iter()
1947                .find(|action| {
1948                    run.pending
1949                        .get(&action.action_id)
1950                        .is_some_and(|pending| !pending.delivery_event_ids.is_empty())
1951                })
1952                .cloned()
1953                .ok_or("missing delivery send")?;
1954            if written {
1955                run.handle_result(
1956                    &result(
1957                        &send,
1958                        AccountTransportResultKind::FrameWritten,
1959                        String::new(),
1960                    ),
1961                    &mut database,
1962                    &handle,
1963                    NOW + 1,
1964                )?;
1965            }
1966            let mut receive = action(&connected, AccountTransportActionKind::ReceiveText)?;
1967            for (index, accepted, message) in [
1968                (0, true, ""),
1969                (1, false, "blocked: policy"),
1970                (2, false, "rate-limited: slow down"),
1971            ] {
1972                let batch = run.handle_result(
1973                    &result(
1974                        &receive,
1975                        AccountTransportResultKind::FrameReceived,
1976                        RelayResponseFrame::Ok(crate::BatchAcknowledgement {
1977                            event_id: events[index].id().to_hex(),
1978                            accepted,
1979                            message: message.to_owned(),
1980                        })
1981                        .to_json()?,
1982                    ),
1983                    &mut database,
1984                    &handle,
1985                    NOW + 2 + index as i64,
1986                )?;
1987                if index < 2 {
1988                    receive = action(&batch, AccountTransportActionKind::ReceiveText)?;
1989                } else {
1990                    let reconnect = action(&batch, AccountTransportActionKind::Connect)?;
1991                    assert!(
1992                        reconnect.delay_ms > 0
1993                            && reconnect.delay_ms <= crate::MAX_RELAY_RETRY_DELAY_MS
1994                    );
1995                    let snapshot = database.operation("retry-operation")?;
1996                    assert_eq!(
1997                        (
1998                            snapshot.accepted_intents,
1999                            snapshot.rejected_intents,
2000                            snapshot.queued_intents
2001                        ),
2002                        (1, 1, u32::try_from(count - 2)?)
2003                    );
2004                    let ready = run.handle_result(
2005                        &result(
2006                            &reconnect,
2007                            AccountTransportResultKind::Connected,
2008                            String::new(),
2009                        ),
2010                        &mut database,
2011                        &handle,
2012                        NOW + 5,
2013                    )?;
2014                    let resent = ready
2015                        .actions
2016                        .iter()
2017                        .filter_map(|action| run.pending.get(&action.action_id))
2018                        .flat_map(|pending| pending.delivery_event_ids.clone())
2019                        .collect::<Vec<_>>();
2020                    assert_eq!(resent.len(), usize::try_from(count - 2)?);
2021                    assert_eq!(
2022                        resent.into_iter().collect::<BTreeSet<_>>(),
2023                        events[2..]
2024                            .iter()
2025                            .map(|event| event.id().to_hex())
2026                            .collect()
2027                    );
2028                    run.handle_result(
2029                        &result(
2030                            &send,
2031                            AccountTransportResultKind::FrameWritten,
2032                            String::new(),
2033                        ),
2034                        &mut database,
2035                        &handle,
2036                        NOW + 6,
2037                    )?;
2038                }
2039            }
2040        }
2041        Ok(())
2042    }
2043
2044    #[test]
2045    fn transport_delivery_expiry_is_not_postponed_by_inbound_traffic()
2046    -> Result<(), Box<dyn std::error::Error>> {
2047        enum Progress {
2048            Notice,
2049            Wake,
2050            Accepted,
2051        }
2052        for progress in [Progress::Notice, Progress::Wake, Progress::Accepted] {
2053            let (identity, handle) = authority()?;
2054            let mut database = AccountDatabase::open(":memory:", "busy-expiry".to_owned())?;
2055            let event = signed_note(&identity, NOW as u64, "awaiting acknowledgement".to_owned())?;
2056            database.enqueue_signed(
2057                &identity,
2058                "busy-operation".to_owned(),
2059                vec![crate::event::SignedEvent::from(&event)],
2060                vec![database.active_relay_url()?],
2061                NOW,
2062            )?;
2063            let (mut run, connected) = connected_run(&mut database, &handle)?;
2064            let send = connected
2065                .actions
2066                .iter()
2067                .find(|action| {
2068                    run.pending
2069                        .get(&action.action_id)
2070                        .is_some_and(|pending| !pending.delivery_event_ids.is_empty())
2071                })
2072                .ok_or("missing send")?;
2073            run.handle_result(
2074                &result(
2075                    send,
2076                    AccountTransportResultKind::FrameWritten,
2077                    String::new(),
2078                ),
2079                &mut database,
2080                &handle,
2081                NOW + 1,
2082            )?;
2083            let busy = run.handle_result(
2084                &result(
2085                    &action(&connected, AccountTransportActionKind::ReceiveText)?,
2086                    AccountTransportResultKind::FrameReceived,
2087                    RelayResponseFrame::Notice("still connected".to_owned()).to_json()?,
2088                ),
2089                &mut database,
2090                &handle,
2091                NOW + 20,
2092            )?;
2093            assert_eq!(busy.connection_state, RelayConnectionState::Ready);
2094            let expired = match progress {
2095                Progress::Wake => run.wake(&mut database, NOW + 31)?,
2096                Progress::Notice | Progress::Accepted => {
2097                    let frame = if matches!(progress, Progress::Accepted) {
2098                        RelayResponseFrame::Ok(crate::BatchAcknowledgement {
2099                            event_id: event.id().to_hex(),
2100                            accepted: true,
2101                            message: String::new(),
2102                        })
2103                    } else {
2104                        RelayResponseFrame::Notice("still busy".to_owned())
2105                    };
2106                    run.handle_result(
2107                        &result(
2108                            &action(&busy, AccountTransportActionKind::ReceiveText)?,
2109                            AccountTransportResultKind::FrameReceived,
2110                            frame.to_json()?,
2111                        ),
2112                        &mut database,
2113                        &handle,
2114                        NOW + 31,
2115                    )?
2116                }
2117            };
2118            let operation = database.operation("busy-operation")?;
2119            if matches!(progress, Progress::Accepted) {
2120                assert_eq!(operation.state, crate::OperationState::Sent);
2121                assert_eq!(expired.connection_state, RelayConnectionState::Ready);
2122            } else {
2123                let reconnect = action(&expired, AccountTransportActionKind::Connect)?;
2124                assert!(reconnect.generation > connected.actions[0].generation);
2125                assert!(reconnect.delay_ms > 0);
2126                assert_eq!((operation.queued_intents, operation.active_intents), (1, 0));
2127            }
2128        }
2129        Ok(())
2130    }
2131
2132    #[test]
2133    fn transport_sync_accepts_a_maximum_cohort_independent_of_chunk_boundaries()
2134    -> Result<(), Box<dyn std::error::Error>> {
2135        let (_, handle) = authority()?;
2136        let mut database = AccountDatabase::open(":memory:", "sync-max".to_owned())?;
2137        let (mut run, _) = connected_run(&mut database, &handle)?;
2138        let ids = (0..crate::SYNC_MAX_PAGE_LIMIT)
2139            .map(|index| format!("{index:064x}"))
2140            .collect::<Vec<_>>();
2141        let resend = |ids: Vec<String>| SyncAction {
2142            kind: SyncActionKind::ResendEvents,
2143            event_ids: ids,
2144            subscription_id: String::new(),
2145            since_timestamp: -1,
2146            until_timestamp: -1,
2147            limit: 0,
2148            message: Vec::new(),
2149            frame_json: String::new(),
2150            filters_json: Vec::new(),
2151            checkpoint_timestamp: -1,
2152        };
2153        run.apply_sync_actions(
2154            ids.chunks(crate::SYNC_EVENT_REQUEST_CHUNK)
2155                .map(|chunk| resend(chunk.to_vec()))
2156                .collect(),
2157            &mut database,
2158            &handle,
2159        )?;
2160        assert_eq!(
2161            run.sync_resend_backlog.iter().cloned().collect::<Vec<_>>(),
2162            ids
2163        );
2164        assert_eq!(
2165            run.apply_sync_actions(vec![resend(vec!["ff".repeat(32)])], &mut database, &handle),
2166            Err(SoftchatError::RelaySessionQueueFull)
2167        );
2168        assert_eq!(run.sync_resend_backlog.len(), ids.len());
2169        Ok(())
2170    }
2171
2172    #[test]
2173    fn transport_sync_resends_fit_frame_bytes_and_keep_every_event()
2174    -> Result<(), Box<dyn std::error::Error>> {
2175        let (identity, handle) = authority()?;
2176        let mut database = AccountDatabase::open(":memory:", "sync-large".to_owned())?;
2177        let events = (0..3)
2178            .map(|offset| signed_note(&identity, NOW as u64 - offset, "x".repeat(280 * 1024)))
2179            .collect::<Result<Vec<_>, _>>()?;
2180        for event in &events {
2181            database.ingest(&identity, vec![crate::event::SignedEvent::from(event)], NOW)?;
2182        }
2183        let (mut run, connected) = connected_run(&mut database, &handle)?;
2184        // Leave one send slot after NEG-CLOSE so byte splitting must retain
2185        // the remaining events for a later action batch.
2186        for index in 0..MAX_TRANSPORT_ACTIONS - 3 {
2187            run.raw_outbound.push_back(
2188                crate::ClientRelayFrame::Close {
2189                    subscription_id: format!("finished-{index}"),
2190                }
2191                .to_json()?,
2192            );
2193        }
2194        let resent = reconcile_remote(&mut run, &connected, &mut database, &handle, &[])?;
2195        assert!(!run.sync_resend_backlog.is_empty());
2196        let next = run.wake(&mut database, NOW + 2)?;
2197        assert!(run.sync_resend_backlog.is_empty());
2198        let mut actual = Vec::new();
2199        for action in resent
2200            .actions
2201            .iter()
2202            .chain(&next.actions)
2203            .filter(|action| action.kind == AccountTransportActionKind::SendText)
2204        {
2205            assert!(
2206                action.text_frame.len() <= crate::MAX_RELAY_FRAME_BYTES,
2207                "oversized resend: {} bytes",
2208                action.text_frame.len()
2209            );
2210            let values: Vec<serde_json::Value> = serde_json::from_str(&action.text_frame)?;
2211            if matches!(values[0].as_str(), Some("EVENT" | "EVENTS")) {
2212                actual.extend(
2213                    values[1..]
2214                        .iter()
2215                        .filter_map(|event| event["id"].as_str().map(str::to_owned)),
2216                );
2217            }
2218        }
2219        assert_eq!(actual.len(), events.len());
2220        assert_eq!(
2221            actual.into_iter().collect::<BTreeSet<_>>(),
2222            events.iter().map(|event| event.id().to_hex()).collect()
2223        );
2224        Ok(())
2225    }
2226
2227    #[test]
2228    fn maximum_sync_request_is_sharded_into_valid_relay_frames() -> Result<(), SoftchatError> {
2229        let filters = (0..200_u64)
2230            .map(|chunk| crate::RelayFilter {
2231                ids: (0..crate::SYNC_EVENT_REQUEST_CHUNK)
2232                    .map(|offset| {
2233                        let value = chunk
2234                            .saturating_mul(crate::SYNC_EVENT_REQUEST_CHUNK as u64)
2235                            .saturating_add(offset as u64);
2236                        format!("{value:064x}")
2237                    })
2238                    .collect(),
2239                ..crate::RelayFilter::default()
2240            })
2241            .collect::<Vec<_>>();
2242
2243        let subscriptions = shard_sync_event_request("softchat-events-1", filters)?;
2244        assert!(subscriptions.len() > 1);
2245        assert_eq!(
2246            subscriptions
2247                .iter()
2248                .map(|(_, filters)| filters.len())
2249                .sum::<usize>(),
2250            200,
2251        );
2252        assert_eq!(
2253            subscriptions
2254                .iter()
2255                .map(|(subscription_id, _)| subscription_id)
2256                .collect::<BTreeSet<_>>()
2257                .len(),
2258            subscriptions.len(),
2259        );
2260        for adjacent in subscriptions.windows(2) {
2261            let mut over_boundary = adjacent[0].1.clone();
2262            over_boundary.push(
2263                adjacent[1]
2264                    .1
2265                    .first()
2266                    .cloned()
2267                    .ok_or(SoftchatError::InvalidSyncState)?,
2268            );
2269            let exceeds_count = over_boundary.len() > crate::MAX_RELAY_FILTERS;
2270            let exceeds_bytes = crate::ClientRelayFrame::Req {
2271                subscription_id: adjacent[0].0.clone(),
2272                filters: over_boundary,
2273            }
2274            .to_json()
2275            .is_err();
2276            assert!(exceeds_count || exceeds_bytes);
2277        }
2278        for (subscription_id, filters) in subscriptions {
2279            assert!(filters.len() <= crate::MAX_RELAY_FILTERS);
2280            let frame = crate::ClientRelayFrame::Req {
2281                subscription_id,
2282                filters,
2283            }
2284            .to_json()?;
2285            assert!(frame.len() <= crate::MAX_RELAY_FRAME_BYTES);
2286        }
2287        Ok(())
2288    }
2289}