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}