Skip to main content

offline_protocol/protocol/
nostr_publication.rs

1//! Nostr key-package publication slots and peer key-package resolution.
2//!
3//! Cold first contact over Nostr (reaching a peer known only by address, with
4//! no prior key-package exchange over some other transport) needs the peer's
5//! key package to be *fetchable* rather than pushed. This module owns both
6//! halves of that: it keeps a small set of single-use MLS key packages
7//! standing in addressable relay records, replaces each as it is consumed,
8//! asks the relays for a peer's records when a session with that peer is
9//! wanted and nothing else can produce one, and feeds the answers back into
10//! the ordinary key-package handler.
11//!
12//! # Why slots, rather than one record
13//!
14//! An MLS key package's init key is consumed by the first peer who uses it, so
15//! a single replaceable record would mean the second stranger to fetch it
16//! builds a Welcome that can never be processed. Each slot therefore holds its
17//! own package under its own `d` tag, and
18//! [`MlsManager::key_package_by_id`](offline_protocol_mls::MlsManager::key_package_by_id)
19//! reporting a package missing is the consumption signal that refills it.
20//!
21//! Consumption is *local*: an init key leaves OpenMLS provider storage only
22//! when this node processes a Welcome built against it. A stranger can drive it
23//! only by actually establishing sessions with us, and each one they burn is
24//! refilled on the next tick — so the exposure is a narrow window in which cold
25//! contact degrades, not a way to disable it.
26
27use super::{internal_prefixes, storage_keys, OfflineProtocol};
28use crate::events::SecurityWarningCode;
29use crate::{Error, Result};
30use offline_protocol_core::time::{Duration, Instant};
31use offline_protocol_core::{Message, MessagePriority};
32use offline_protocol_transport::constants::NOSTR_KEY_PACKAGE_SLOTS;
33use offline_protocol_transport::TransportType;
34use serde::{Deserialize, Serialize};
35use std::collections::HashSet;
36use tracing::{debug, warn};
37
38/// How often the publication slots are re-scanned. See
39/// [`OfflineProtocol::nostr_slot_refresh_due`] for what the interval trades.
40const NOSTR_SLOT_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
41
42/// Ceiling on the republish backoff after consecutive publication failures,
43/// and the quiet period after which a slot's failure streak resets.
44///
45/// Republication is otherwise unconditional on every refresh, so a relay that
46/// rejects the kind outright — or rate-limits a fresh pubkey bursting the whole
47/// slot set at once — would be retried once per slot per refresh forever, never
48/// converging and never backing off. Every other retry ladder in this engine is
49/// bounded; this one was not.
50const NOSTR_PUBLICATION_MAX_BACKOFF: Duration = Duration::from_secs(1800);
51
52/// Minimum gap between [`SecurityWarningCode::NostrKeyPackageSlotExhausted`]
53/// emissions.
54///
55/// The condition it reports (an MLS or storage error refilling a slot) is
56/// exactly the kind that persists, and without suppression it would emit once
57/// per failing slot per refresh — five events a minute, indefinitely, for a
58/// single stuck cause. Matches the suppression the unauthorized-membership
59/// report uses for the same reason.
60const NOSTR_SLOT_WARNING_SUPPRESS_INTERVAL: Duration = Duration::from_secs(300);
61
62/// One publication slot's persisted state.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub(crate) struct NostrPublicationSlot {
65    /// The addressable event's `d` tag: stable and random, so republishing
66    /// replaces this slot's record rather than adding another, and so the set
67    /// of tags reveals no ordering or count convention.
68    pub(crate) slot_id: String,
69    /// The MLS key package currently standing in this slot.
70    pub(crate) package_id: String,
71}
72
73/// A slot's publication-failure state, driving the republish backoff.
74///
75/// Memory-only: it exists to stop hammering a relay within one process, and a
76/// launch that starts over costs one prompt retry per slot.
77#[derive(Debug, Clone)]
78pub(crate) struct PublicationBackoff {
79    /// Consecutive failures, which set the delay.
80    pub(crate) failures: u32,
81    /// When the most recent failure was reported.
82    pub(crate) last_failure: Instant,
83    /// Earliest this slot may be republished.
84    pub(crate) retry_at: Instant,
85}
86
87impl OfflineProtocol {
88    /// Brings the published key-package slots back up to strength.
89    ///
90    /// Runs on the process tick. Three things can put a slot out of date, and
91    /// all three are handled the same way — mint a fresh package, publish it
92    /// under the slot's existing `d` tag:
93    ///
94    /// 1. the slot has never existed (first run, or the persisted map was lost);
95    /// 2. its package was consumed by a Welcome we processed;
96    /// 3. its package expired.
97    ///
98    /// Every slot is also republished once per process, because an addressable
99    /// record lives on the relays rather than here: a relay that dropped it, or
100    /// one added to the configuration since, would otherwise hold nothing.
101    /// Republication under the same slot id is idempotent — it replaces.
102    pub(crate) fn refresh_nostr_key_package_slots(&mut self) {
103        if !self.transport_manager.nostr_cold_contact_active() {
104            return;
105        }
106        if self.mls_manager.is_none() {
107            return;
108        }
109        if !self.nostr_slot_refresh_due() {
110            return;
111        }
112
113        let now = Instant::now();
114
115        // A slot is marked published when its record is *queued*, which is the
116        // only point this layer hears about — so a record that then failed to
117        // reach a relay would leave the slot looking healthy forever while the
118        // relays hold nothing. The transport reports those back here. Drained
119        // after the throttle check on purpose: draining is destructive, and a
120        // throttled tick that dropped the reports would lose them outright.
121        for slot_id in self.transport_manager.take_failed_nostr_publications() {
122            self.nostr_published_slots.remove(&slot_id);
123            self.note_publication_failure(&slot_id, now);
124        }
125
126        let mut changed = false;
127        let mut failed_slots = 0usize;
128        for index in 0..NOSTR_KEY_PACKAGE_SLOTS {
129            match self.refresh_one_slot(index, now) {
130                Ok(true) => changed = true,
131                Ok(false) => {}
132                Err(e) => {
133                    warn!(
134                        slot_index = index,
135                        error = %e,
136                        "Failed to refresh a Nostr key-package publication slot"
137                    );
138                    failed_slots += 1;
139                }
140            }
141        }
142
143        // The one failure the publication design must not absorb quietly: a
144        // slot left standing with a consumed package makes every stranger who
145        // fetches it build an unprocessable Welcome, and nothing else in the
146        // system would ever say so. Reported once for the pass rather than once
147        // per slot, and suppressed between passes — the causes here (MLS or
148        // storage errors) persist, and five events a minute forever would bury
149        // the signal in its own repetition.
150        if failed_slots > 0 {
151            self.report_nostr_slot_refresh_failure(failed_slots, now);
152        }
153
154        self.prune_nostr_publication_backoff();
155
156        if changed {
157            self.persist_nostr_publication_slots();
158        }
159    }
160
161    /// Emits the slot-exhaustion warning, at most once per suppression window.
162    fn report_nostr_slot_refresh_failure(&mut self, failed_slots: usize, now: Instant) {
163        if let Some(last) = self.last_nostr_slot_warning {
164            if now.duration_since(last) < NOSTR_SLOT_WARNING_SUPPRESS_INTERVAL {
165                return;
166            }
167        }
168        self.last_nostr_slot_warning = Some(now);
169        self.emit_security_warning(
170            &self.local_id.clone(),
171            SecurityWarningCode::NostrKeyPackageSlotExhausted,
172            format!(
173                "could not refill {} Nostr key-package publication slot(s); \
174                 cold contact over Nostr is degraded until it succeeds",
175                failed_slots
176            ),
177        );
178    }
179
180    /// Records a failed publication and pushes the slot's next attempt out.
181    ///
182    /// The delay doubles from [`NOSTR_SLOT_REFRESH_INTERVAL`] with each
183    /// consecutive failure and stops at [`NOSTR_PUBLICATION_MAX_BACKOFF`], so a
184    /// relay that rejects the kind is retried on a schedule that converges
185    /// instead of once per slot per minute forever. A slot quiet for longer
186    /// than the ceiling starts its streak over, so a genuine one-off failure
187    /// long after a bad patch still retries promptly.
188    fn note_publication_failure(&mut self, slot_id: &str, now: Instant) {
189        let entry = self
190            .nostr_publication_backoff
191            .entry(slot_id.to_string())
192            .or_insert(PublicationBackoff {
193                failures: 0,
194                last_failure: now,
195                retry_at: now,
196            });
197
198        if now.duration_since(entry.last_failure) > NOSTR_PUBLICATION_MAX_BACKOFF {
199            entry.failures = 1;
200        } else {
201            entry.failures = entry.failures.saturating_add(1);
202        }
203        entry.last_failure = now;
204
205        // The *first* failure retries on the next refresh: a relay hiccup or a
206        // socket dropped mid-flight should not cost a window. Only a slot that
207        // keeps failing climbs the ladder. Shift capped well below `u32`'s
208        // width; the ceiling clamps the result long before it matters.
209        let delay = if entry.failures <= 1 {
210            Duration::ZERO
211        } else {
212            let shift = (entry.failures - 2).min(16);
213            NOSTR_SLOT_REFRESH_INTERVAL
214                .saturating_mul(1u32 << shift)
215                .min(NOSTR_PUBLICATION_MAX_BACKOFF)
216        };
217        entry.retry_at = now + delay;
218    }
219
220    /// Whether `slot_id` is still waiting out a publication backoff.
221    fn publication_backoff_active(&self, slot_id: &str, now: Instant) -> bool {
222        self.nostr_publication_backoff
223            .get(slot_id)
224            .is_some_and(|backoff| now < backoff.retry_at)
225    }
226
227    /// Drops backoff state for slots that no longer exist.
228    ///
229    /// The keys are our own slot ids, of which there are at most
230    /// `NOSTR_KEY_PACKAGE_SLOTS` — but a lost slot map mints fresh ones, and
231    /// the abandoned entries would otherwise linger for the life of the
232    /// process.
233    fn prune_nostr_publication_backoff(&mut self) {
234        if self.nostr_publication_backoff.is_empty() {
235            return;
236        }
237        let live: HashSet<String> = self
238            .nostr_publication_slots
239            .iter()
240            .map(|slot| slot.slot_id.clone())
241            .collect();
242        self.nostr_publication_backoff
243            .retain(|slot_id, _| live.contains(slot_id));
244    }
245
246    /// Whether enough time has passed to re-scan the slots.
247    ///
248    /// Throttled for the same reason session reconciliation is: checking a slot
249    /// means asking the MLS layer whether its key package is still there, and
250    /// that is a storage read plus a TLS deserialize, validate, and provider
251    /// lookup — five of them per pass. On the raw tick that is a steady trickle
252    /// of Keychain/Keystore traffic to answer a question whose answer changes
253    /// only when a Welcome arrives.
254    ///
255    /// The interval bounds how long a consumed slot can stand stale, which is
256    /// the only thing being traded: a stranger who fetches the stale record in
257    /// that window builds a Welcome we cannot process, and retries once their
258    /// own send ladder brings them back around.
259    fn nostr_slot_refresh_due(&mut self) -> bool {
260        let now = Instant::now();
261        if let Some(last) = self.last_nostr_slot_refresh {
262            if now.duration_since(last) < NOSTR_SLOT_REFRESH_INTERVAL {
263                return false;
264            }
265        }
266        self.last_nostr_slot_refresh = Some(now);
267        true
268    }
269
270    /// Refreshes slot `index`, returning whether the persisted map changed.
271    fn refresh_one_slot(&mut self, index: usize, now: Instant) -> Result<bool> {
272        // The map is dense: position `i` always holds slot `i`. An earlier slot
273        // that failed this pass leaves a gap, and filling it out of order would
274        // put this slot's record at the wrong position — harmless today, since
275        // slots are identified by `slot_id` and never by index, but it makes the
276        // index mean two different things. Leave the gap for the next pass.
277        if index > self.nostr_publication_slots.len() {
278            return Ok(false);
279        }
280
281        let existing = self.nostr_publication_slots.get(index).cloned();
282
283        // Checked before the MLS read and before any mint: a slot whose
284        // publication keeps failing should cost nothing at all while it waits.
285        if let Some(slot) = existing.as_ref() {
286            if self.publication_backoff_active(&slot.slot_id, now) {
287                return Ok(false);
288            }
289        }
290
291        let live_package = match existing.as_ref() {
292            Some(slot) => self.load_publication_package(&slot.package_id)?,
293            None => None,
294        };
295
296        // A live package that has already been published this process is
297        // nothing to do — the common case, every tick, for every slot.
298        if let (Some(slot), Some(_)) = (existing.as_ref(), live_package.as_ref()) {
299            if self.nostr_published_slots.contains(&slot.slot_id) {
300                return Ok(false);
301            }
302        }
303
304        let slot_id = match existing.as_ref() {
305            Some(slot) => slot.slot_id.clone(),
306            None => new_slot_id(),
307        };
308
309        // `live_package` is `Some` only when `existing` is, so a live package
310        // never adds a slot to the map — it is the republish-this-process case.
311        let (bundle, map_changed) = match live_package {
312            Some(bundle) => (bundle, false),
313            None => {
314                let bundle = {
315                    let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
316                    let manager = mls
317                        .read()
318                        .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
319                    manager.generate_publication_key_package()?
320                };
321                (bundle, true)
322            }
323        };
324
325        let queued = match self.build_published_key_package_message(&bundle) {
326            Ok(message) => self
327                .transport_manager
328                .publish_nostr_key_package(&slot_id, &message),
329            Err(e) => Err(e),
330        };
331
332        if let Err(e) = queued {
333            // A package minted for a record that was never built or queued is
334            // reserved, so the push path will never hand it out, and no slot
335            // references it — nothing would reclaim it before its lifetime runs
336            // out. Left alone, a persistently failing build would strand fresh
337            // provider key material on every refresh.
338            if map_changed {
339                self.delete_publication_package(&bundle.package_id);
340            }
341            return Err(e);
342        }
343
344        self.nostr_published_slots.insert(slot_id.clone());
345
346        if map_changed {
347            let record = NostrPublicationSlot {
348                slot_id,
349                package_id: bundle.package_id,
350            };
351            if index < self.nostr_publication_slots.len() {
352                self.nostr_publication_slots[index] = record;
353            } else {
354                self.nostr_publication_slots.push(record);
355            }
356        }
357
358        Ok(map_changed)
359    }
360
361    /// Loads the key package standing in a slot, or `None` if it is gone.
362    ///
363    /// `None` covers consumed *and* expired: the MLS loader prunes both, and
364    /// the slot's response to either is identical.
365    fn load_publication_package(
366        &self,
367        package_id: &str,
368    ) -> Result<Option<offline_protocol_mls::KeyPackageBundle>> {
369        let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
370        let manager = mls
371            .read()
372            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
373        Ok(manager.key_package_by_id(package_id)?)
374    }
375
376    /// Drops a publication package that never made it into a queued record.
377    fn delete_publication_package(&self, package_id: &str) {
378        let Some(mls) = self.mls_manager.as_ref() else {
379            return;
380        };
381        let Ok(manager) = mls.read() else {
382            return;
383        };
384        if let Err(e) = manager.delete_key_package(package_id) {
385            warn!(
386                package_id = %package_id,
387                error = %e,
388                "Failed to reclaim an unpublished Nostr key package"
389            );
390        }
391    }
392
393    /// Builds the signed protocol message a published record carries.
394    ///
395    /// Self-addressed: a published record has no recipient in advance, which is
396    /// the point of it. The address is inside the Ed25519 signature, so it is
397    /// also not something a fetcher may rewrite — which is why the resolution
398    /// path feeds the message to the internal handler directly instead of
399    /// through the receive loop, whose relay decision keys on the recipient
400    /// being us. See [`Self::handle_resolved_key_package`].
401    fn build_published_key_package_message(
402        &mut self,
403        bundle: &offline_protocol_mls::KeyPackageBundle,
404    ) -> Result<Message> {
405        let payload = self.build_key_package_payload(bundle, false);
406        let serialized =
407            serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
408        let content = format!("{}{}", internal_prefixes::KEY_PACKAGE, serialized);
409
410        let self_id = self.local_id.clone();
411        let mut message =
412            self.create_message(&self_id, content, Some(MessagePriority::Low), None)?;
413        // Nothing ACKs a published record: it is not delivered to anyone, it is
414        // left somewhere to be found. An outbox entry and a retry ladder behind
415        // it would retransmit against a peer that does not exist.
416        message.requires_ack = false;
417        // Signed under the older payload, on purpose, and this is the one
418        // place in the engine that says so.
419        //
420        // A published record has no known verifier: it is left on a relay for
421        // any stranger to fetch, so there is no peer whose advertised
422        // capability could pick the payload. Signing the freshness-bound one
423        // would make the record unverifiable to every install that has not
424        // upgraded, which for cold contact means the record does not work at
425        // all — its whole purpose is being readable by someone this node has
426        // never met.
427        //
428        // It is not the hole it looks like. The record carries
429        // `session_reset: false` (see the call above), so it holds no
430        // directive worth replaying, and re-delivering it can do nothing but
431        // re-offer a key package that is still within its own validity. What
432        // bounds *that* is the key package's lifetime, which is issue 396's
433        // subject rather than this one's.
434        let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
435        let manager = mls
436            .read()
437            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
438        Self::sign_control_message_with(&mut message, &manager, false)?;
439        drop(manager);
440        Ok(message)
441    }
442
443    /// Asks the relays for `peer_id`'s published key packages when a session
444    /// with them is wanted and only the relays can produce one. Returns
445    /// whether a query was queued.
446    ///
447    /// Invariant: a session wanted with a peer this device holds no key
448    /// package for is enough to ask every carrier that can produce one. For a
449    /// peer known only by address and reachable only through a relay, the
450    /// relays are that carrier, and nothing else ever asked them: a send with
451    /// no session only queues, so no frame is built for the transport to
452    /// attach a lookup to; discovery's first push never names a peer that has
453    /// sent nothing; and the tick's mesh push stands down whenever Nostr
454    /// claims to reach the peer. The records were published and never
455    /// fetched, so cold first contact never started on any binding (issue
456    /// #633).
457    ///
458    /// The gates run cheapest first, and the storage read comes last:
459    ///
460    /// - **Blocked peers are never asked about.** A query names the peer's
461    ///   routing tag to every relay, which is interest in them.
462    /// - **Cold contact must be on.** Off, nothing is published to fetch.
463    /// - **A live mesh link wins.** The link carries the key-package exchange
464    ///   within a tick, and a query would only tell the relays which address
465    ///   this device wants.
466    /// - **The transport's rate limit decides how often.** One query per peer
467    ///   per interval, read before the session check so that a peer with a
468    ///   message waiting costs one storage read per interval, not per tick.
469    /// - **A session already formed needs nothing.**
470    ///
471    /// Callers reach this only where they have found no key package for the
472    /// peer, so it does not look for one itself.
473    pub(crate) fn request_nostr_key_package_for(&mut self, peer_id: &str) -> bool {
474        if self.mls_manager.is_none()
475            || !self.config.encryption.enabled
476            || peer_id == self.local_id
477            || self.is_user_blocked(peer_id)
478            || !self.transport_manager.nostr_cold_contact_active()
479            || self.transport_manager.mesh_can_address(peer_id)
480            || !self.transport_manager.nostr_resolution_due(peer_id)
481        {
482            return false;
483        }
484        if !matches!(self.has_mls_session(peer_id), Ok(false)) {
485            return false;
486        }
487        let queued = self.transport_manager.request_nostr_key_packages(peer_id);
488        if queued {
489            debug!(peer_id = %peer_id, "No key package for a wanted session, asking the Nostr relays");
490        }
491        queued
492    }
493
494    /// Handles a key-package record returned by a resolution query.
495    ///
496    /// Deliberately narrow: this channel answers exactly one question, so
497    /// anything that is not a key package is dropped rather than dispatched.
498    /// The records come from a public routing tag that anyone may publish to,
499    /// and a query returns whatever the relay holds there — without this gate,
500    /// a squatter could deliver any internal control frame through a path that
501    /// never went past the receive loop's dedup and block checks.
502    ///
503    /// What survives the gate is *not* trusted here either. It goes through
504    /// `process_internal_message_via`, so the Ed25519 signature and the
505    /// sender-address derivation decide whose key package it is: a record
506    /// planted at the queried peer's tag by somebody else is signed by *that*
507    /// party's key, which derives to *that* party's address, so it registers
508    /// under their identity — never under the peer we asked about. A record
509    /// signed by nobody is refused outright.
510    pub fn handle_resolved_key_package(&mut self, data: &[u8]) -> Result<()> {
511        let transport = self
512            .transport_manager
513            .get_transport(TransportType::Nostr)
514            .ok_or_else(|| Error::Other("Nostr transport not installed".to_string()))?;
515        let message = transport
516            .deserialize_message(data)
517            .map_err(|e| Error::Serialization(e.to_string()))?;
518
519        if !message.content.starts_with(internal_prefixes::KEY_PACKAGE) {
520            debug!(
521                sender = %message.sender,
522                "Discarding a non-key-package record returned by a Nostr resolution query"
523            );
524            return Ok(());
525        }
526
527        let sender = message.sender.as_str().to_string();
528        if self.is_user_blocked(&sender) {
529            debug!(sender = %sender, "Discarding a published key package from a blocked user");
530            return Ok(());
531        }
532
533        self.process_internal_message_via(&message, Some(TransportType::Nostr));
534        Ok(())
535    }
536}
537
538#[cfg(test)]
539impl OfflineProtocol {
540    /// The current publication slot map.
541    pub(crate) fn nostr_publication_slots_for_test(&self) -> Vec<NostrPublicationSlot> {
542        self.nostr_publication_slots.clone()
543    }
544
545    /// Drives the slot-exhaustion report directly, standing in for a refresh
546    /// pass whose slots failed — which needs a broken MLS or storage layer to
547    /// reach naturally.
548    pub(crate) fn report_nostr_slot_refresh_failure_for_test(&mut self, failed_slots: usize) {
549        self.report_nostr_slot_refresh_failure(failed_slots, Instant::now());
550    }
551
552    /// Lets the next tick actually re-scan.
553    ///
554    /// Every test that ticks twice must call this, or the throttle makes the
555    /// second pass a no-op — which for an idempotency test looks exactly like
556    /// the property under test holding.
557    pub(crate) fn reset_nostr_slot_throttle_for_test(&mut self) {
558        self.last_nostr_slot_refresh = None;
559    }
560
561    /// Points slot `index` at `package_id` and marks it unpublished, standing
562    /// in for "a Welcome consumed this slot's key package" without needing a
563    /// real session handshake. Returns the slot's stable id.
564    pub(crate) fn force_nostr_slot_package_for_test(
565        &mut self,
566        index: usize,
567        package_id: &str,
568    ) -> String {
569        let slot_id = self.nostr_publication_slots[index].slot_id.clone();
570        self.nostr_publication_slots[index].package_id = package_id.to_string();
571        slot_id
572    }
573}
574
575/// Mints a stable, random addressable-slot id.
576///
577/// Random rather than an index, following the same reasoning Marmot gives for
578/// its publication-slot ids: a `d` tag of `0`..`4` would advertise both the
579/// convention and the slot count to anyone who fetches one record, and would
580/// make two installs' records trivially alignable.
581fn new_slot_id() -> String {
582    uuid::Uuid::new_v4().simple().to_string()
583}
584
585impl OfflineProtocol {
586    /// Persists the publication slot map.
587    pub(crate) fn persist_nostr_publication_slots(&mut self) {
588        let Some(storage) = self.protocol_state_storage.clone() else {
589            return;
590        };
591        let bytes = match serde_json::to_vec(&self.nostr_publication_slots) {
592            Ok(bytes) => bytes,
593            Err(e) => {
594                warn!(error = %e, "Failed to serialize Nostr publication slots");
595                return;
596            }
597        };
598        if let Err(e) = self.write_state_record(
599            storage.as_ref(),
600            storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
601            storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
602            &bytes,
603        ) {
604            warn!(error = %e, "Failed to persist Nostr publication slots");
605        }
606    }
607
608    /// Restores the publication slot map.
609    ///
610    /// Every failure mode lands on the same benign outcome — an empty map, so
611    /// the next tick mints fresh slots under fresh ids. The records left at the
612    /// abandoned slot ids are not orphans that need collecting: they expire
613    /// with the key packages inside them, and until then they resolve to
614    /// packages this install still holds.
615    pub(crate) fn restore_nostr_publication_slots(&mut self) {
616        let Some(storage) = self.protocol_state_storage.clone() else {
617            return;
618        };
619
620        let data = match self.read_state_record(
621            storage.as_ref(),
622            storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
623            storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
624        ) {
625            Ok(Some(data)) => data,
626            Ok(None) => return,
627            Err(e) => {
628                warn!(error = %e, "Failed to read Nostr publication slots; starting fresh");
629                return;
630            }
631        };
632
633        match serde_json::from_slice::<Vec<NostrPublicationSlot>>(&data) {
634            Ok(slots) => {
635                // Truncate rather than reject if the configured slot count has
636                // shrunk: the surplus records expire on their own.
637                self.nostr_publication_slots = slots;
638                self.nostr_publication_slots
639                    .truncate(NOSTR_KEY_PACKAGE_SLOTS);
640                debug!(
641                    slots = self.nostr_publication_slots.len(),
642                    "Restored Nostr publication slots"
643                );
644            }
645            Err(e) => {
646                warn!(error = %e, "Corrupted Nostr publication slots; starting fresh");
647            }
648        }
649    }
650}