Skip to main content

offline_protocol_transport/
nostr.rs

1//! Nostr relay transport queue engine.
2//!
3//! Censorship-resistant, decentralized messaging over Nostr relays
4//! (WebSockets). No relay connection is opened here: the platform side
5//! manages the actual WebSocket connections and subscriptions; the Rust
6//! side manages queues, metrics, event signing, and the confirmation loop.
7//!
8//! The bridge contract: the platform reports relay connectivity via
9//! [`NostrTransport::on_status_changed`], drains signed events with
10//! [`NostrTransport::get_next_signed_event`] (woken by the
11//! [`NostrTransport::set_on_messages_available`] callback) and submits them
12//! to the relays, correlates relay `OK` responses back via
13//! [`NostrTransport::confirm_sent`] /
14//! [`NostrTransport::report_send_failure`], and injects inbound event
15//! payloads via [`NostrTransport::on_data_received`].
16//!
17//! Addressing uses public routing tags derived from this device's address
18//! ([`nostr_crypto::routing_tag_for_address`]); event signing uses a
19//! per-install secret key that starts out ephemeral and is upgraded to a
20//! persisted identity via [`NostrTransport::install_signing_secret`].
21
22use crate::constants::{
23    NOSTR_CLOCK_SKEW_MARGIN_SECS, NOSTR_CONNECTION_TIMEOUT_SECS, NOSTR_CREATED_AT_JITTER_SECS,
24    NOSTR_FIRST_RUN_BACKFILL_SECS, NOSTR_FUTURE_DATED_TOLERANCE_SECS, NOSTR_MAX_PAYLOAD_SIZE,
25    NOSTR_MAX_TRACKED_PEER_KEYS, NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS,
26};
27use crate::nostr_crypto::{self, now_unix_secs, NostrKeypair};
28use crate::{
29    Error, Result, SharedCallback, Transport, TransportMetrics, TransportStatus, TransportType,
30};
31use base64::Engine;
32use offline_protocol_core::time::{Duration, Instant};
33use offline_protocol_core::{Address, Message, MutexExt, RwLockExt, Username};
34use std::borrow::Cow;
35use std::collections::{HashMap, HashSet, VecDeque};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Arc, Mutex, RwLock};
38
39use crate::common::recalculate_delivery_ratios;
40
41/// Maximum number of signing attempts before a message is permanently failed.
42const MAX_SIGN_RETRIES: u8 = 3;
43
44/// Marks a synthetic message id belonging to a published key-package record
45/// rather than a protocol message.
46///
47/// Publications ride the ordinary send queue — the bridge treats `event_json`
48/// as opaque, so nothing platform-side needed to change to publish them — but
49/// they have no outbox entry, no ACK, and no retry ladder behind them. The
50/// prefix keeps that visible in logs and confirmation callbacks; the engine's
51/// `on_transport_send_confirmed` already returns early for an id it holds no
52/// welcome lifecycle for, so a publication's confirm is a no-op by
53/// construction rather than by a check that could be forgotten.
54pub const NOSTR_PUBLICATION_ID_PREFIX: &str = "__nostr_kp__:";
55
56/// Marks a synthetic message id belonging to a published username discovery
57/// record.
58///
59/// A second discriminator beside [`NOSTR_PUBLICATION_ID_PREFIX`], and the
60/// reason the classifier returns an enum rather than an `Option`.
61pub const NOSTR_DISCOVERY_ID_PREFIX: &str = "__nostr_disc__:";
62
63/// Marks a synthetic message id belonging to a NIP-09 deletion request.
64///
65/// Deletions are the best-effort half of a retraction and nothing recovers when
66/// one fails, but they still ride the send queue and so still need to be kept
67/// out of the delivery metrics. Giving them a discriminator is what makes that
68/// automatic rather than a special case someone has to remember.
69pub const NOSTR_DELETION_ID_PREFIX: &str = "__nostr_del__:";
70
71/// What a synthetic message id denotes, when it is one.
72///
73/// # Why this is an enum and not two `Option`-returning helpers
74///
75/// This classifier is consulted by every message-path side effect —
76/// [`NostrTransport::confirm_sent`], [`NostrTransport::report_send_failure`],
77/// `drain_expired_pending` and `fail_all_pending` — and getting *one* of them
78/// wrong is a shipped, previously-diagnosed bug: publication outcomes leaking
79/// into [`TransportMetrics`] poison DORS scoring, because DORS scores this
80/// transport on `success_count / (success_count + failure_count)`, those
81/// counters are lifetime totals with no decay, and an idle install publishes
82/// far more than it sends. A relay that rejects the kind drives the ratio
83/// toward zero and makes DORS deprioritise Nostr for traffic that delivers
84/// fine; publications that succeed equally mask real message failures.
85///
86/// A second record type is exactly the change that historically half-wires:
87/// adding another prefix plus another `Option` helper leaves four call sites
88/// that each *silently* keep compiling while consulting only the old one.
89/// Returning a single enum means every site matches, so a third record type
90/// added later fails to compile at each of them instead of quietly scoring
91/// itself as delivery. That is the whole point of the shape.
92///
93/// The metrics rule is kind-independent — any variant stays out of
94/// [`TransportMetrics`] — while failure *routing* differs per variant, which is
95/// why the payload rides along. See
96/// `test_publication_outcomes_stay_out_of_the_delivery_metrics` and
97/// `test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub(crate) enum SyntheticPublication {
100    /// A key-package record. Carries the addressable slot id, which the engine
101    /// needs in order to mark the slot unpublished and refill it.
102    KeyPackage(String),
103    /// A username discovery record. Carries the discovery tag it was published
104    /// at, which is both its `#p` tag and its `d` tag.
105    Discovery(String),
106    /// A NIP-09 deletion request, the best-effort half of a retraction.
107    ///
108    /// Carries no payload because nothing acts on its outcome: the durable half
109    /// of a retraction is the tombstone, which replaces the claim through the
110    /// addressable rule rather than through a relay's cooperation. It is a
111    /// variant rather than an untagged id so the metrics exclusion covers it by
112    /// construction.
113    Deletion,
114}
115
116/// Classifies a message id, returning `None` for an ordinary protocol message.
117///
118/// See [`SyntheticPublication`] for why this exists and what every caller owes
119/// it.
120pub(crate) fn synthetic_publication(message_id: &str) -> Option<SyntheticPublication> {
121    if let Some(slot_id) = message_id.strip_prefix(NOSTR_PUBLICATION_ID_PREFIX) {
122        return Some(SyntheticPublication::KeyPackage(slot_id.to_string()));
123    }
124    if let Some(tag) = message_id.strip_prefix(NOSTR_DISCOVERY_ID_PREFIX) {
125        return Some(SyntheticPublication::Discovery(tag.to_string()));
126    }
127    if message_id.starts_with(NOSTR_DELETION_ID_PREFIX) {
128        return Some(SyntheticPublication::Deletion);
129    }
130    None
131}
132
133/// Maximum peers queued for key-package resolution at once.
134///
135/// Resolution is triggered by a send to a peer whose per-install key we lack,
136/// and the recipient of a send is wire-influenced, so this is bounded like
137/// every other such queue. Overflow drops the *newest* request: the queued
138/// ones are already-attempted sends waiting on an answer, and a peer whose
139/// request is dropped simply takes the bootstrap leg and is retried on the
140/// next send to them.
141const MAX_PENDING_RESOLUTIONS: usize = 64;
142
143/// Minimum interval between resolution attempts for the same peer.
144///
145/// Without this, every frame to a peer who has published nothing would mint
146/// another relay round-trip that cannot succeed. The bootstrap leg carries
147/// those sends meanwhile, so the only cost of waiting is that the metadata
148/// upgrade lands later.
149const RESOLUTION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
150
151/// Maximum events one resolution query will accept, opened or not.
152///
153/// The REQ asks each relay for [`NOSTR_KEY_PACKAGE_SLOTS`] records, but a relay
154/// is free to ignore `limit` and stream indefinitely, and the query is
155/// broadcast so every connected relay answers under the same subscription id.
156/// Each record that opens costs the key-package handler two durable
157/// secure-storage writes, so the ceiling has to be ours rather than the
158/// relay's. Sized for a generous fan-out — well above `slots × relays` for any
159/// real configuration — since exceeding it only costs the metadata upgrade,
160/// which falls back to the bootstrap leg.
161const MAX_QUERY_EVENTS: usize = 64;
162
163/// A resolution query the platform is currently running.
164#[derive(Debug)]
165struct ActiveQuery {
166    /// What this query is resolving, and the key that opens what comes back.
167    subject: QuerySubject,
168    /// Event ids already taken for this query. The query is broadcast, so the
169    /// same record arrives once per relay, and opening it more than once
170    /// re-runs the key-package handler's durable writes for no gain. Bounded
171    /// by `delivered` below, which is checked before anything is inserted.
172    seen_events: HashSet<String>,
173    /// Events delivered for this query so far, whether or not they opened.
174    delivered: usize,
175}
176
177/// What an active query is asking for.
178///
179/// The variant decides three things at once — which kind is accepted, which
180/// derivable key opens the payload, and which engine handler the plaintext goes
181/// to — so carrying them together is what stops a discovery record from being
182/// fed to the key-package handler, or vice versa. A record delivered under the
183/// wrong subject is not merely useless: `handle_resolved_key_package` is a
184/// deliberately narrow gate, and widening the set of things that can reach it
185/// is exactly what that gate exists to prevent.
186#[derive(Debug, Clone)]
187enum QuerySubject {
188    /// A peer's published key packages.
189    ///
190    /// Carried as a parsed [`Address`] so the record-seal derivation this feeds
191    /// cannot be handed a string that was never validated.
192    KeyPackage(Address),
193    /// The devices claiming a username.
194    ///
195    /// Carried as a parsed [`Username`] for the same reason: the
196    /// discovery-seal derivation must not be handed an unnormalized name.
197    Discovery(Username),
198}
199
200/// What a username-resolution request did.
201///
202/// Exists because "no query was queued" is three different promises to the
203/// caller, and only one of them means an answer is still coming. A bare `false`
204/// makes an app that awaits the resolution event indistinguishable from an app
205/// that hangs.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ResolveRequest {
208    /// Newly queued. A resolution event will follow.
209    Queued,
210    /// A lookup for this name is already queued. Its event answers both
211    /// callers, so this is a success from the caller's point of view.
212    AlreadyQueued,
213    /// Username discovery is off. Nothing was sent to a relay, and no event
214    /// will ever arrive for this name.
215    Disabled,
216    /// The resolve queue is at its ceiling. Nothing will arrive for this name;
217    /// the caller may retry once earlier lookups drain.
218    QueueFull,
219}
220
221/// What opening a query event produced.
222#[derive(Debug, Clone)]
223pub enum ResolvedRecord {
224    /// A key-package record: the author's Nostr pubkey and the decrypted
225    /// protocol message bytes.
226    KeyPackage {
227        /// The event's author key.
228        author: String,
229        /// The decrypted protocol message.
230        plaintext: Vec<u8>,
231    },
232    /// A discovery record: the username queried, the author key it must be
233    /// bound to, and the decrypted record body.
234    Discovery {
235        /// The username this query resolved.
236        username: Username,
237        /// The event's author key, which the record's `nostr_author` field must
238        /// equal.
239        author: String,
240        /// The decrypted `DiscoveryRecordV1` or tombstone body.
241        plaintext: Vec<u8>,
242    },
243}
244
245/// A key-package record waiting to be published to the relays.
246#[derive(Debug, Clone)]
247struct PendingPublication {
248    /// The addressable slot (`d` tag) this record occupies. Republishing the
249    /// same slot replaces the record rather than adding to it.
250    slot_id: String,
251    /// Serialized protocol message carrying the signed `KeyPackagePayload`.
252    payload: Vec<u8>,
253}
254
255/// A username discovery record waiting to be published.
256#[derive(Debug, Clone)]
257struct PendingDiscoveryPublication {
258    /// The username this record claims. Held parsed so the tag and seal
259    /// derivations at drain time cannot be handed an unnormalized string.
260    username: Username,
261    /// The serialized `DiscoveryRecordV1`, or a tombstone for a retraction.
262    payload: Vec<u8>,
263    /// Whether this publication is a retraction.
264    ///
265    /// A retraction publishes a tombstone into the addressable slot *and*
266    /// emits a best-effort NIP-09 deletion. Carried as a flag rather than a
267    /// separate queue because the two share the whole drain path and differ
268    /// only in that one extra event.
269    retraction: bool,
270}
271
272/// A relay query the platform should issue on the transport's behalf.
273#[derive(Debug, Clone)]
274pub struct NostrQuery {
275    /// Correlation id; also the NIP-01 subscription id the platform must use,
276    /// so inbound events can be routed back to the right request.
277    pub query_id: String,
278    /// Complete `["REQ", ...]` JSON string for the relay WebSocket.
279    pub req_json: String,
280    /// The username being resolved, when this is a discovery query.
281    ///
282    /// Carried out so the engine can open an accumulator keyed by `query_id`
283    /// at the moment the query is minted. Doing it here rather than on the
284    /// first answer means a query that returns *nothing* still emits an empty
285    /// result rather than silently never completing — "no such name" is an
286    /// answer, and an app waiting on one deserves to receive it.
287    pub discovery_username: Option<Username>,
288}
289
290/// Mints a subscription id for a resolution query.
291///
292/// Random rather than sequential: the id goes on the wire in the REQ, and a
293/// counter would tell every relay how many peers this install has looked up.
294/// A failed RNG degrades to a fixed id, which costs correlation between one
295/// install's queries — never correctness, since the platform routes on
296/// whatever id it was handed.
297fn new_query_id() -> String {
298    use rand_core::{OsRng, RngCore};
299    let mut buf = [0u8; 16];
300    if OsRng.try_fill_bytes(&mut buf).is_err() {
301        tracing::warn!("OS RNG unavailable for Nostr query id; using a fixed one");
302        return "offline-protocol-kp-query".to_string();
303    }
304    hex::encode(buf)
305}
306
307/// A signed Nostr event ready for relay submission, together with the
308/// metadata the platform needs for confirmation tracking.
309#[derive(Debug, Clone)]
310pub struct SignedNostrEvent {
311    /// Protocol message ID (for confirm/fail callbacks).
312    pub message_id: String,
313    /// Nostr event ID (SHA-256 hex). The platform uses this to correlate
314    /// relay `["OK", event_id, ...]` responses back to the message.
315    pub event_id: String,
316    /// Complete `["EVENT", {...}]` JSON string for the relay WebSocket.
317    pub event_json: String,
318}
319
320/// Nostr transport configuration.
321#[derive(Debug, Clone)]
322pub struct NostrConfig {
323    /// Relay URLs to connect to (`wss://`), chosen by the application. The
324    /// SDK names none.
325    /// The platform manages actual WebSocket connections.
326    pub relay_urls: Vec<String>,
327    /// Connection timeout for reaching Nostr relays.
328    pub connection_timeout: Duration,
329    /// Enable automatic reconnection to relays.
330    pub auto_reconnect: bool,
331    /// Reconnection delay.
332    pub reconnect_delay: Duration,
333    /// Maximum reconnection attempts (0 = infinite).
334    pub max_reconnect_attempts: u32,
335}
336
337impl Default for NostrConfig {
338    fn default() -> Self {
339        Self {
340            relay_urls: Vec::new(),
341            connection_timeout: Duration::from_secs(NOSTR_CONNECTION_TIMEOUT_SECS),
342            auto_reconnect: true,
343            reconnect_delay: Duration::from_secs(5),
344            max_reconnect_attempts: 0,
345        }
346    }
347}
348
349/// Nostr relay transport implementation.
350///
351/// Provides connectivity via Nostr relays for censorship-resistant,
352/// decentralized messaging. The platform bridges to Nostr relay
353/// WebSocket connections and handles event signing and subscriptions.
354///
355/// ## Lock ordering
356///
357/// When acquiring more than one lock in a single scope, follow this order:
358///
359/// 1. `status`
360/// 2. `pending_confirmation`
361/// 3. `send_queue`
362/// 4. `metrics`
363/// 5. `receive_queue`
364/// 6. `reconnect_attempts` / `platform_handle`
365///
366/// `keypair`, `receive_watermark`, `peer_nostr_pubkeys`, `sealing_enabled` and
367/// `failed_publications` are leaf locks: they are only ever held in a narrow
368/// scope with no other lock acquisition inside. In particular the sealing path
369/// releases each before calling into the crypto layer, so a slow ECDH never
370/// blocks the send queue, and the publication-failure paths collect their slot
371/// ids under `pending_confirmation` and record them only after releasing it.
372pub struct NostrTransport {
373    device_id: String,
374    /// Per-install signing keypair. Ephemeral (random per process) until the
375    /// engine installs the persisted secret via
376    /// [`Self::install_signing_secret`].
377    keypair: RwLock<NostrKeypair>,
378    /// This device's public routing tag (derived from `device_id`); peers
379    /// address us by putting it in the `#p` tag, we subscribe on it.
380    routing_tag: String,
381    /// Newest `created_at` (unix seconds) of any relay event this install has
382    /// accepted, or `None` before the first one — the high-water mark the
383    /// subscription's `since` is derived from. See
384    /// [`Self::advance_receive_watermark`].
385    ///
386    /// Owned here because [`Self::create_subscription`] needs it, but it is the
387    /// *engine* that gives it durability: `OfflineProtocol` persists it as a
388    /// protocol-state record and re-installs it on launch. Without that this is
389    /// a per-process value and every cold start replays a full backfill window.
390    receive_watermark: Mutex<Option<i64>>,
391    /// The publicly computable keypair for our own address.
392    ///
393    /// Held to unseal bootstrap-leg frames and to seal our published records —
394    /// see [`nostr_crypto::record_seal_keypair_for_address`], which documents
395    /// why this is not a secret and must never authenticate anything.
396    ///
397    /// Its public half used to *be* `routing_tag`. It is now a separate,
398    /// domain-separated derivation, so the two are unequal and neither can be
399    /// mistaken for the other.
400    record_seal_keypair: NostrKeypair,
401    /// Peer user ID → that peer's real per-install Nostr public key, learned
402    /// from the `nostr_pubkey` field of their signed key package. Populated by
403    /// the engine via [`Self::set_peer_nostr_pubkey`]; a peer absent here takes
404    /// the bootstrap path.
405    peer_nostr_pubkeys: RwLock<HashMap<String, String>>,
406    /// Whether outgoing frames are sealed into gift wraps. Mirrors
407    /// `TransportConfig::nostr_sealing_enabled`; the receive path always
408    /// accepts both forms regardless.
409    sealing_enabled: Mutex<bool>,
410    /// Whether this install publishes key-package records and resolves peers'.
411    /// Mirrors `TransportConfig::nostr_cold_contact_enabled`.
412    ///
413    /// Gates both halves together on purpose: resolving records nobody
414    /// publishes is pure round-trips, and publishing into a fleet that never
415    /// resolves is a standing beacon bought for nothing.
416    cold_contact_enabled: Mutex<bool>,
417    /// Key-package records waiting to go out, drained ahead of the message
418    /// queue so a fresh slot replaces a consumed one before the traffic that
419    /// depends on it.
420    publication_queue: Mutex<VecDeque<PendingPublication>>,
421    /// Slot ids whose publication left this queue but never reached a relay,
422    /// waiting for the engine to drain them via
423    /// [`Self::take_failed_publications`].
424    ///
425    /// The engine marks a slot published when it *queues* the record, because
426    /// that is the only point it hears about. Without this channel every
427    /// failure after that point — a build error, a relay rejection, a
428    /// confirmation timeout, a disconnect — would leave the slot marked
429    /// published for the life of the process, so the relays hold nothing (or
430    /// last process's stale record) while the engine believes the slot is
431    /// healthy. That is precisely the silently-stale record the publication
432    /// design exists to prevent.
433    ///
434    /// Bounded by construction: the keys are our own slot ids, of which the
435    /// engine keeps at most `NOSTR_KEY_PACKAGE_SLOTS`.
436    failed_publications: Mutex<HashSet<String>>,
437    /// Whether this install publishes and resolves username discovery records.
438    /// Mirrors `TransportConfig::nostr_username_discovery_enabled`.
439    ///
440    /// Off by default, unlike cold contact. Publishing a discovery record binds
441    /// a human-readable name to an address in a public place, which is
442    /// materially more disclosure than the key-package record's "an install
443    /// with this tag exists", and it is the kind of decision an app should make
444    /// deliberately rather than inherit.
445    discovery_enabled: Mutex<bool>,
446    /// Username discovery records waiting to go out.
447    ///
448    /// Separate from [`Self::publication_queue`] rather than a variant inside
449    /// it, because the two drain in a fixed order (key packages first) and
450    /// share no state: a discovery claim republished while a key-package slot
451    /// is queued must not displace it.
452    discovery_queue: Mutex<VecDeque<PendingDiscoveryPublication>>,
453    /// Discovery tags whose publication left the queue but never reached a
454    /// relay, waiting for the engine to drain them via
455    /// [`Self::take_failed_discovery_publications`].
456    ///
457    /// The same channel [`Self::failed_publications`] provides for slots, and
458    /// for the same reason: the engine marks a claim published when it queues
459    /// it, so without this a failure after that point leaves the claim marked
460    /// healthy while the relays hold nothing.
461    ///
462    /// Bounded by construction: an install claims one username, so this holds
463    /// at most one entry in practice and is keyed by our own derived tag in
464    /// every case.
465    failed_discovery_publications: Mutex<HashSet<String>>,
466    /// Discovery tags whose publication a relay acknowledged, waiting for the
467    /// engine to drain them via
468    /// [`Self::take_confirmed_discovery_publications`].
469    ///
470    /// The mirror of [`Self::failed_discovery_publications`], and it exists for
471    /// the half of the lifecycle that failure reporting alone cannot express: a
472    /// **retraction** is owed until it lands, so the engine must keep the
473    /// retracted name until something says the tombstone reached a relay.
474    /// Absence of a failure is not that signal — a report that never arrives is
475    /// indistinguishable from a publication still in flight — so the engine
476    /// would either forget the name too early (stranding a claim in a public
477    /// directory) or retract forever.
478    ///
479    /// Bounded by the same construction as its failure twin: entries are this
480    /// install's own derived tags, one per name it has claimed.
481    confirmed_discovery_publications: Mutex<HashSet<String>>,
482    /// Ready-built NIP-09 deletion events, as `(event_id, relay_json)`.
483    ///
484    /// Queued by a retraction after its tombstone is built, so the deletion
485    /// follows the tombstone rather than racing it. Nothing retries these.
486    pending_deletions: Mutex<VecDeque<(String, String)>>,
487    /// Peer addresses whose published key packages we want fetched.
488    ///
489    /// Holds parsed [`Address`]es rather than strings: an entry becomes the
490    /// `#p` tag of a relay query, so admitting one that is not an address
491    /// would publish a guessable label. `enqueue_resolution` is the only
492    /// writer and parses there, which is what lets `next_query` derive a tag
493    /// without re-validating or re-deciding what to do when it fails.
494    resolve_queue: Mutex<VecDeque<Address>>,
495    /// Usernames whose discovery records we want fetched.
496    ///
497    /// Holds parsed [`Username`]s for the same reason [`Self::resolve_queue`]
498    /// holds parsed [`Address`]es: an entry becomes the `#p` tag of a relay
499    /// query, and an unnormalized name derives a tag nobody publishes at.
500    discovery_resolve_queue: Mutex<VecDeque<Username>>,
501    /// Query id → the query's state. An inbound event is meaningless without
502    /// this: the id tells us whose derivable key opens it, and carries the
503    /// per-query dedup and delivery ceiling with it.
504    active_queries: Mutex<HashMap<String, ActiveQuery>>,
505    /// Peer user id → last resolution attempt, rate-limiting repeats for a
506    /// peer who has published nothing.
507    resolve_attempts: Mutex<HashMap<String, Instant>>,
508    config: NostrConfig,
509    status: Arc<Mutex<TransportStatus>>,
510    receive_queue: Arc<Mutex<VecDeque<Message>>>,
511    send_queue: Arc<Mutex<VecDeque<Message>>>,
512    /// Messages dequeued by the platform but not yet confirmed as sent.
513    pending_confirmation: Arc<Mutex<HashMap<String, Instant>>>,
514    /// Tracks how many times signing has been attempted for a given message ID.
515    /// Entries are removed on success or after reaching [`MAX_SIGN_RETRIES`].
516    sign_retry_counts: Arc<Mutex<HashMap<String, u8>>>,
517    metrics: Arc<Mutex<TransportMetrics>>,
518    reconnect_attempts: Arc<Mutex<u32>>,
519    platform_handle: Arc<Mutex<Option<usize>>>,
520    on_messages_available: SharedCallback,
521    /// Whether the [`Transport::get_next_message`] refusal has already been
522    /// logged for this transport.
523    ///
524    /// The refusal reports a *misintegration* — a caller polling the wrong
525    /// drain — which is a standing condition, not an event: the caller is on a
526    /// timer or a wake callback and will hit it again on every tick. One
527    /// report identifies the bug; the rest are noise that would bury it.
528    generic_poll_refusal_logged: AtomicBool,
529}
530
531impl NostrTransport {
532    /// Creates a new Nostr transport with default configuration.
533    pub fn new(device_id: impl Into<String>) -> Result<Self> {
534        Self::with_config(device_id, NostrConfig::default())
535    }
536
537    /// Creates a new Nostr transport with custom configuration.
538    ///
539    /// # `device_id` must be this device's derived address
540    ///
541    /// Not a profile, not an app-chosen string: the id given here is the sole
542    /// preimage of the routing tag, which is the label this device subscribes
543    /// on and the one peers publish to. Both ways of getting it wrong are
544    /// silent, and one of them is a disclosure:
545    ///
546    /// - a *guessable* preimage (a username-shaped profile) puts a label on a
547    ///   public relay that anyone can recompute from the name, which is the
548    ///   property the derived address exists to remove;
549    /// - a preimage that is merely *different* from the address peers know
550    ///   addresses this device where nobody writes, so every Nostr-carried
551    ///   frame is simply never seen — no error, no event, nothing arriving.
552    ///
553    /// Neither failure surfaces anywhere at runtime, so the id is refused
554    /// here rather than hashed. See
555    /// [`test_construction_refuses_an_id_that_is_not_an_address`](self#tests),
556    /// and — for the caller that made this necessary —
557    /// `test_nostr_is_absent_until_the_identity_rebuild_installs_it` in the
558    /// bindings crate.
559    ///
560    /// The signing keypair starts out ephemeral (random for this process);
561    /// call [`Self::install_signing_secret`] once persisted storage is
562    /// available to give the install a stable Nostr identity.
563    pub fn with_config(device_id: impl Into<String>, config: NostrConfig) -> Result<Self> {
564        let device_id = device_id.into();
565        // The value is deliberately absent from the error: it is the rejected
566        // id, which in the case this check exists for is the app's profile.
567        let address = device_id.parse::<Address>().map_err(|e| {
568            Error::ConfigurationError(format!(
569                "Nostr transport requires this device's derived address: {e}"
570            ))
571        })?;
572        let routing_tag = nostr_crypto::routing_tag_for_address(&address)?;
573        let record_seal_keypair = nostr_crypto::record_seal_keypair_for_address(&address)?;
574        let keypair = RwLock::new(NostrKeypair::generate_ephemeral()?);
575        Ok(Self {
576            device_id,
577            keypair,
578            routing_tag,
579            receive_watermark: Mutex::new(None),
580            record_seal_keypair,
581            peer_nostr_pubkeys: RwLock::new(HashMap::new()),
582            sealing_enabled: Mutex::new(true),
583            cold_contact_enabled: Mutex::new(true),
584            publication_queue: Mutex::new(VecDeque::new()),
585            failed_publications: Mutex::new(HashSet::new()),
586            discovery_enabled: Mutex::new(false),
587            discovery_queue: Mutex::new(VecDeque::new()),
588            failed_discovery_publications: Mutex::new(HashSet::new()),
589            confirmed_discovery_publications: Mutex::new(HashSet::new()),
590            pending_deletions: Mutex::new(VecDeque::new()),
591            resolve_queue: Mutex::new(VecDeque::new()),
592            discovery_resolve_queue: Mutex::new(VecDeque::new()),
593            active_queries: Mutex::new(HashMap::new()),
594            resolve_attempts: Mutex::new(HashMap::new()),
595            config,
596            status: Arc::new(Mutex::new(TransportStatus::Unavailable)),
597            receive_queue: Arc::new(Mutex::new(VecDeque::new())),
598            send_queue: Arc::new(Mutex::new(VecDeque::new())),
599            pending_confirmation: Arc::new(Mutex::new(HashMap::new())),
600            sign_retry_counts: Arc::new(Mutex::new(HashMap::new())),
601            metrics: Arc::new(Mutex::new(TransportMetrics::default())),
602            reconnect_attempts: Arc::new(Mutex::new(0)),
603            platform_handle: Arc::new(Mutex::new(None)),
604            on_messages_available: Arc::new(Mutex::new(None)),
605            generic_poll_refusal_logged: AtomicBool::new(false),
606        })
607    }
608
609    /// Gets the local device ID.
610    pub fn device_id(&self) -> &str {
611        &self.device_id
612    }
613
614    /// Gets the configuration.
615    pub fn config(&self) -> &NostrConfig {
616        &self.config
617    }
618
619    /// Sets the platform-specific handle.
620    pub fn set_platform_handle(&self, handle: usize) {
621        crate::common::set_platform_handle(&self.platform_handle, handle);
622    }
623
624    /// Gets the platform-specific handle.
625    pub fn platform_handle(&self) -> Option<usize> {
626        crate::common::platform_handle(&self.platform_handle)
627    }
628
629    /// Notifies the platform that messages are ready to send.
630    ///
631    /// The callback Arc is cloned out of the mutex and the guard dropped
632    /// before the call, so a callback that re-enters the transport (e.g.
633    /// another `send()`) cannot self-deadlock on the callback mutex.
634    fn notify_messages_available(&self) {
635        let callback = self.on_messages_available.lock_or_recover().clone();
636        if let Some(cb) = callback {
637            cb();
638        }
639    }
640
641    /// Called when a message is received.
642    pub fn on_message_received(&self, message: Message) {
643        crate::common::on_message_received(&self.receive_queue, message);
644    }
645
646    /// Like [`on_message_received`](Self::on_message_received), but attaches a
647    /// transport-verified `peer_id` to the message.
648    pub fn on_message_received_from(&self, message: Message, peer_id: String) {
649        crate::common::on_message_received_from(&self.receive_queue, message, peer_id);
650    }
651
652    /// Serializes a message to JSON bytes.
653    pub fn serialize_message(&self, message: &Message) -> Result<Vec<u8>> {
654        crate::common::serialize_message_with(message)
655    }
656
657    /// Whether the transport should attempt reconnection.
658    pub fn should_reconnect(&self) -> bool {
659        if !self.config.auto_reconnect {
660            return false;
661        }
662        if self.config.max_reconnect_attempts == 0 {
663            return true;
664        }
665        *self.reconnect_attempts.lock_or_recover() < self.config.max_reconnect_attempts
666    }
667
668    /// Increments the reconnection attempt counter.
669    pub fn increment_reconnect_attempts(&self) {
670        let mut attempts = self.reconnect_attempts.lock_or_recover();
671        *attempts = attempts.saturating_add(1);
672    }
673
674    /// Updates transport metrics, preserving confirmation-loop counts.
675    pub fn update_metrics(&self, incoming: TransportMetrics) {
676        let mut metrics = self.metrics.lock_or_recover();
677        let prev_success = metrics.success_count;
678        let prev_failure = metrics.failure_count;
679        *metrics = incoming;
680        metrics.success_count = prev_success;
681        metrics.failure_count = prev_failure;
682        recalculate_delivery_ratios(&mut metrics);
683    }
684
685    /// Checks if there are messages waiting to be sent.
686    pub fn has_pending_sends(&self) -> bool {
687        !self.send_queue.lock_or_recover().is_empty()
688    }
689
690    /// Returns the number of messages awaiting platform confirmation.
691    pub fn pending_confirmation_count(&self) -> usize {
692        self.pending_confirmation.lock_or_recover().len()
693    }
694
695    // ========================================================================
696    // Nostr crypto methods
697    // ========================================================================
698
699    /// Returns this install's Nostr signing public key as a 64-char hex string.
700    ///
701    /// This is the key outgoing events are signed with (their `pubkey`
702    /// field), used by platforms to filter self-published events. It changes
703    /// when [`Self::install_signing_secret`] swaps the ephemeral key for the
704    /// persisted one, so platforms should read it after protocol
705    /// initialization, not cache it across that boundary.
706    pub fn public_key_hex(&self) -> String {
707        self.keypair.read_or_recover().public_key_hex().to_string()
708    }
709
710    /// Returns this device's public routing tag (the `#p` value peers use to
711    /// address us, and the value our relay subscription filters on).
712    pub fn routing_tag(&self) -> &str {
713        &self.routing_tag
714    }
715
716    /// Replaces the ephemeral signing keypair with one derived from the
717    /// persisted per-install secret.
718    ///
719    /// Idempotent for a given secret: deriving from the same secret yields
720    /// the same keypair. Events signed before this call used the ephemeral
721    /// key, which peers accept because inbound events are never
722    /// authenticated by their Nostr pubkey (sender authenticity comes from
723    /// the protocol-layer MLS signatures).
724    pub fn install_signing_secret(&self, secret: &[u8]) -> Result<()> {
725        let keypair = NostrKeypair::from_install_secret(secret)?;
726        let pubkey = keypair.public_key_hex().to_string();
727        *self.keypair.write_or_recover() = keypair;
728        tracing::debug!(
729            pubkey = %pubkey,
730            "Installed persisted Nostr signing key"
731        );
732        Ok(())
733    }
734
735    /// Enables or disables sealing of outgoing frames.
736    ///
737    /// Only the *send* side is gated. Inbound gift wraps are unsealed
738    /// unconditionally, so turning this off does not make a peer's traffic
739    /// unreadable and turning it back on needs no renegotiation.
740    pub fn set_sealing_enabled(&self, enabled: bool) {
741        *self.sealing_enabled.lock_or_recover() = enabled;
742    }
743
744    /// Whether outgoing frames are sealed.
745    pub fn sealing_enabled(&self) -> bool {
746        *self.sealing_enabled.lock_or_recover()
747    }
748
749    /// Enables or disables key-package publication and peer resolution.
750    pub fn set_cold_contact_enabled(&self, enabled: bool) {
751        *self.cold_contact_enabled.lock_or_recover() = enabled;
752    }
753
754    /// Whether key-package publication and peer resolution are enabled.
755    pub fn cold_contact_enabled(&self) -> bool {
756        *self.cold_contact_enabled.lock_or_recover()
757    }
758
759    /// Queues a key-package record for publication at `slot_id`.
760    ///
761    /// Replacing a slot is the caller's decision, not this queue's: the record
762    /// is addressable, so publishing the same `slot_id` again overwrites it at
763    /// the relay. A queued slot that is queued again before it drains is
764    /// collapsed to the newer payload — the older one names a key package the
765    /// engine has already replaced, and publishing both would briefly stand a
766    /// consumed package back up as the live record.
767    pub fn publish_key_package(&self, slot_id: &str, payload: Vec<u8>) {
768        if !self.cold_contact_enabled() {
769            return;
770        }
771        {
772            let mut queue = self.publication_queue.lock_or_recover();
773            queue.retain(|p| p.slot_id != slot_id);
774            queue.push_back(PendingPublication {
775                slot_id: slot_id.to_string(),
776                payload,
777            });
778        }
779        self.notify_messages_available();
780    }
781
782    /// Whether any key-package record is waiting to be published.
783    pub fn has_pending_publications(&self) -> bool {
784        !self.publication_queue.lock_or_recover().is_empty()
785    }
786
787    /// Records that a slot's publication never reached a relay.
788    ///
789    /// A `warn!` rather than a security warning: with the engine re-publishing
790    /// on its next tick this is transient and self-healing, unlike an engine
791    /// side refill failure (which leaves a genuinely stale record standing and
792    /// does emit `NostrKeyPackageSlotExhausted`). A relay that rejects the kind
793    /// outright makes this repeat once per refresh interval per slot — loud in
794    /// the log and visible in the transport's failure metrics, but never
795    /// escalating and never head-of-line blocking anything.
796    fn mark_publications_failed(&self, slot_ids: Vec<String>) {
797        if slot_ids.is_empty() {
798            return;
799        }
800        let mut failed = self.failed_publications.lock_or_recover();
801        for slot_id in slot_ids {
802            tracing::warn!(
803                slot_id = %slot_id,
804                "Nostr key-package publication did not reach a relay; the slot will be republished"
805            );
806            failed.insert(slot_id);
807        }
808    }
809
810    /// Records discovery publications that never reached a relay.
811    fn mark_discovery_publications_failed(&self, tags: Vec<String>) {
812        if tags.is_empty() {
813            return;
814        }
815        let mut failed = self.failed_discovery_publications.lock_or_recover();
816        for tag in tags {
817            tracing::warn!(
818                "Nostr username discovery publication did not reach a relay; \
819                 the claim will be republished"
820            );
821            failed.insert(tag);
822        }
823    }
824
825    /// Drains the discovery tags whose publication never reached a relay.
826    pub fn take_failed_discovery_publications(&self) -> Vec<String> {
827        self.failed_discovery_publications
828            .lock_or_recover()
829            .drain()
830            .collect()
831    }
832
833    /// Records a discovery publication a relay acknowledged.
834    fn mark_discovery_publication_confirmed(&self, tag: String) {
835        self.confirmed_discovery_publications
836            .lock_or_recover()
837            .insert(tag);
838    }
839
840    /// Drains the discovery tags whose publication a relay acknowledged.
841    ///
842    /// The engine uses this to retire a retraction it owes. A tag appears here
843    /// for a claim and for a tombstone alike, which is not an ambiguity to
844    /// resolve: a name is either claimed or being retracted, and publishing a
845    /// claim for a name cancels any retraction owed for it anyway.
846    pub fn take_confirmed_discovery_publications(&self) -> Vec<String> {
847        self.confirmed_discovery_publications
848            .lock_or_recover()
849            .drain()
850            .collect()
851    }
852
853    /// Enables or disables username discovery publication and resolution.
854    pub fn set_discovery_enabled(&self, enabled: bool) {
855        *self.discovery_enabled.lock_or_recover() = enabled;
856    }
857
858    /// Whether username discovery is active.
859    ///
860    /// **Requires cold contact as well as its own switch.** A discovery record
861    /// points at an address whose key packages are what a resolver fetches
862    /// next; with cold contact off, nothing is published there and the claim
863    /// resolves to a dead end one hop later. Hard-coupled here rather than
864    /// documented, so the dead-end configuration is unreachable rather than
865    /// merely discouraged.
866    pub fn discovery_enabled(&self) -> bool {
867        *self.discovery_enabled.lock_or_recover() && self.cold_contact_enabled()
868    }
869
870    /// Queues a username discovery record for publication.
871    ///
872    /// Replacing a claim is the caller's decision, not this queue's: the record
873    /// is addressable at a deterministic `d`, so publishing the same username
874    /// again overwrites it at the relay. A claim queued twice before it drains
875    /// is collapsed to the newer payload — the older one is by definition
876    /// superseded, and publishing both would briefly stand the stale one back
877    /// up.
878    pub fn publish_discovery_record(&self, username: Username, payload: Vec<u8>) {
879        self.enqueue_discovery_publication(username, payload, false);
880    }
881
882    /// Queues a retraction: a tombstone into the claim's slot, plus a
883    /// best-effort NIP-09 deletion.
884    ///
885    /// Not gated on [`Self::discovery_enabled`], deliberately and unlike
886    /// [`Self::publish_discovery_record`]. Turning the feature *off* is the
887    /// single most likely reason to retract, and a gate here would make the
888    /// switch strand exactly the claim the user just asked to withdraw.
889    pub fn retract_discovery_record(&self, username: Username, payload: Vec<u8>) {
890        self.enqueue_discovery_publication(username, payload, true);
891    }
892
893    fn enqueue_discovery_publication(
894        &self,
895        username: Username,
896        payload: Vec<u8>,
897        retraction: bool,
898    ) {
899        if !retraction && !self.discovery_enabled() {
900            return;
901        }
902        {
903            let mut queue = self.discovery_queue.lock_or_recover();
904            queue.retain(|pending| pending.username != username);
905            queue.push_back(PendingDiscoveryPublication {
906                username,
907                payload,
908                retraction,
909            });
910        }
911        self.notify_messages_available();
912    }
913
914    /// Drains the slot ids whose publication never reached a relay, so the
915    /// engine can clear them from its published set and republish.
916    pub fn take_failed_publications(&self) -> Vec<String> {
917        self.failed_publications.lock_or_recover().drain().collect()
918    }
919
920    /// Requests resolution of `user_id`'s published key packages.
921    ///
922    /// Returns whether a request was newly queued; a `false` means the peer was
923    /// already queued, was attempted within `RESOLUTION_RETRY_INTERVAL`, or
924    /// cold contact is disabled. Callers treat it as advisory — the send that
925    /// triggered it proceeds on the bootstrap leg either way, because waiting
926    /// on a relay round-trip would convert a metadata upgrade into latency.
927    pub fn request_peer_key_packages(&self, user_id: &str) -> bool {
928        let queued = self.enqueue_resolution(user_id);
929        if queued {
930            self.notify_messages_available();
931        }
932        queued
933    }
934
935    /// Whether a request for `user_id`'s published key packages would be
936    /// queued now, without queueing one.
937    ///
938    /// The engine asks this before it reads storage to decide whether it
939    /// wants the packages at all, so that a peer with a message waiting costs
940    /// one storage read per `RESOLUTION_RETRY_INTERVAL` rather than one per
941    /// tick. It is advisory: the answer can change before
942    /// [`Self::request_peer_key_packages`] runs, which re-checks everything.
943    pub fn resolution_due(&self, user_id: &str) -> bool {
944        self.admissible_resolution(user_id).is_some()
945    }
946
947    /// The checks a resolution request must pass, read without changing
948    /// anything: cold contact on, the id an address, the rate limit lapsed,
949    /// and room in the queue for an id not already in it.
950    fn admissible_resolution(&self, user_id: &str) -> Option<Address> {
951        // A queued id becomes the `#p` tag of a relay query, so it is held to
952        // the same address requirement as a send recipient — and held here,
953        // at the queue's only gate, so `next_query` can never pop an id whose
954        // tag derivation fails after the entry was already consumed. Subsumes
955        // the emptiness check this replaces: an empty id is not an address.
956        if !self.cold_contact_enabled() {
957            return None;
958        }
959        let address = user_id.parse::<Address>().ok()?;
960
961        // Read the rate limit without stamping it. Stamping here would burn the
962        // whole interval on a request the queue then refuses, so a peer dropped
963        // at capacity would wait out `RESOLUTION_RETRY_INTERVAL` despite never
964        // having been looked up — the opposite of what the overflow policy
965        // promises.
966        {
967            let attempts = self.resolve_attempts.lock_or_recover();
968            if let Some(last) = attempts.get(user_id) {
969                if last.elapsed() < RESOLUTION_RETRY_INTERVAL {
970                    return None;
971                }
972            }
973        }
974
975        let queue = self.resolve_queue.lock_or_recover();
976        if queue.iter().any(|q| *q == address) || queue.len() >= MAX_PENDING_RESOLUTIONS {
977            return None;
978        }
979        Some(address)
980    }
981
982    /// Forgets every resolution attempt, as if `RESOLUTION_RETRY_INTERVAL`
983    /// had lapsed for every peer. For tests that drive a retry without
984    /// waiting out the interval.
985    #[cfg(any(test, feature = "test-utils"))]
986    pub fn forget_resolution_attempts_for_test(&self) {
987        self.resolve_attempts.lock_or_recover().clear();
988    }
989
990    /// Queues a resolution without waking the platform.
991    ///
992    /// Used from the send path, which is already running inside the platform's
993    /// poll loop: waking it from there would re-enter the transport partway
994    /// through building an event, to tell it something it is about to look for
995    /// anyway.
996    fn enqueue_resolution(&self, user_id: &str) -> bool {
997        let Some(address) = self.admissible_resolution(user_id) else {
998            return false;
999        };
1000
1001        {
1002            // Checked again under the lock that pushes, so two callers racing
1003            // past the read above cannot queue one peer twice or overrun the cap.
1004            let mut queue = self.resolve_queue.lock_or_recover();
1005            if queue.iter().any(|q| *q == address) {
1006                return false;
1007            }
1008            if queue.len() >= MAX_PENDING_RESOLUTIONS {
1009                return false;
1010            }
1011            queue.push_back(address);
1012        }
1013
1014        // Only now is an attempt real. Bounded like the peer-key map, and for
1015        // the same reason: the key is a wire-influenced recipient id. Clearing
1016        // costs at most one premature retry per forgotten peer.
1017        let mut attempts = self.resolve_attempts.lock_or_recover();
1018        if attempts.len() >= NOSTR_MAX_TRACKED_PEER_KEYS {
1019            attempts.clear();
1020        }
1021        attempts.insert(user_id.to_string(), Instant::now());
1022        true
1023    }
1024
1025    /// Pops the next queued resolution and returns the REQ for the platform to
1026    /// issue, registering the query so inbound events can be routed back.
1027    pub fn next_query(&self) -> Result<Option<NostrQuery>> {
1028        // Key-package resolutions drain first: they are triggered by a send
1029        // that is already waiting on the bootstrap leg, whereas a discovery
1030        // query is a user-initiated lookup with no frame behind it.
1031        let subject = {
1032            let mut queue = self.resolve_queue.lock_or_recover();
1033            queue.pop_front().map(QuerySubject::KeyPackage)
1034        };
1035        let subject = match subject {
1036            Some(subject) => subject,
1037            None => {
1038                let mut queue = self.discovery_resolve_queue.lock_or_recover();
1039                match queue.pop_front() {
1040                    Some(username) => QuerySubject::Discovery(username),
1041                    None => return Ok(None),
1042                }
1043            }
1044        };
1045
1046        let query_id = new_query_id();
1047        let req_json = match &subject {
1048            QuerySubject::KeyPackage(address) => {
1049                let routing_tag = nostr_crypto::routing_tag_for_address(address)?;
1050                nostr_crypto::create_key_package_query_message(&routing_tag, &query_id)?
1051            }
1052            QuerySubject::Discovery(username) => {
1053                let discovery_tag = nostr_crypto::discovery_tag_for_username(username)?;
1054                nostr_crypto::create_discovery_query_message(&discovery_tag, &query_id)?
1055            }
1056        };
1057
1058        {
1059            let mut active = self.active_queries.lock_or_recover();
1060            // A query the platform never completes would otherwise leak an
1061            // entry per attempt. The rate limit bounds the rate, this bounds
1062            // the total; the victim is whichever entry the map yields first
1063            // (arbitrary, not oldest — the order carries no meaning here) and
1064            // costs one unresolvable answer.
1065            if active.len() >= MAX_PENDING_RESOLUTIONS {
1066                if let Some(stale) = active.keys().next().cloned() {
1067                    active.remove(&stale);
1068                }
1069            }
1070            active.insert(
1071                query_id.clone(),
1072                ActiveQuery {
1073                    subject: subject.clone(),
1074                    seen_events: HashSet::new(),
1075                    delivered: 0,
1076                },
1077            );
1078        }
1079
1080        let discovery_username = match &subject {
1081            QuerySubject::Discovery(username) => Some(username.clone()),
1082            QuerySubject::KeyPackage(_) => None,
1083        };
1084
1085        Ok(Some(NostrQuery {
1086            query_id,
1087            req_json,
1088            discovery_username,
1089        }))
1090    }
1091
1092    /// Opens an event delivered for `query_id`, returning the author's Nostr
1093    /// pubkey and the decrypted protocol message bytes.
1094    ///
1095    /// `Ok(None)` means the event was not a record this query can open — a
1096    /// wrong kind, a foreign publisher at the same tag, or a payload that does
1097    /// not decrypt. All three are ordinary: the routing tag is public, so
1098    /// anyone may publish to it, and a query returns whatever the relay holds
1099    /// there. The caller drops those and keeps going.
1100    ///
1101    /// What comes back is *not* trusted here. It is fed through the same
1102    /// receive path as any inbound frame, so the Ed25519 control gate and the
1103    /// sender-address derivation decide whose key package it is — and a record
1104    /// placed at this peer's tag by somebody else therefore registers under
1105    /// *that* signer's identity, not under the peer we asked about.
1106    ///
1107    /// # The one asymmetry: discovery events are authenticated here
1108    ///
1109    /// A key-package record needs no event-level check, for the reason above:
1110    /// whatever it carries is authenticated downstream by its own signature. A
1111    /// discovery *tombstone* has nothing downstream — its body is a constant
1112    /// and its meaning is entirely "who published this" — so discovery events
1113    /// are checked against their own BIP-340 signature before they are opened.
1114    /// See `nostr_crypto::event_is_authentic` for what a forged tombstone would
1115    /// otherwise buy.
1116    ///
1117    /// # What squatting a tag does buy
1118    ///
1119    /// Two things, both bounded, neither a loss:
1120    ///
1121    /// 1. **Crowding.** Foreign records displace real ones from the query's
1122    ///    `limit`, costing the metadata upgrade and nothing else: the send
1123    ///    falls back to the bootstrap leg exactly as it did before this
1124    ///    existed.
1125    /// 2. **Replaying a consumed record.** Every published record is openable
1126    ///    by anyone who knows the username — that is the design — so a squatter
1127    ///    can unseal one of the peer's *spent* records, re-seal the untouched
1128    ///    (and genuinely Ed25519-signed) message inside it under their own
1129    ///    author key, and stand it back up with a fresh `created_at`. Nothing
1130    ///    here detects it: the inner signature is real, and no freshness
1131    ///    binding ties a record to the live slot. A resolver then imports a
1132    ///    genuine-but-consumed key package and builds a Welcome the peer can
1133    ///    never process — worse than crowding, because it commits to a dead
1134    ///    session rather than staying on the working bootstrap leg.
1135    ///
1136    ///    It self-heals rather than stranding the pair: importing the package
1137    ///    runs the ordinary key-package handler, which pushes *our* package
1138    ///    back under `auto_key_exchange`, and the peer then establishes from
1139    ///    their side against a package that is actually live. So the cost is
1140    ///    delivery delayed by one exchange — the same bounded class as the
1141    ///    already-accepted `key_package_data` substitution vector, and not
1142    ///    something a squatter can escalate. Closing it outright needs the
1143    ///    record to carry slot-bound freshness (its slot id plus a signed issue
1144    ///    time) so a replay is distinguishable from the current record.
1145    pub fn open_query_event(
1146        &self,
1147        query_id: &str,
1148        event_json: &str,
1149    ) -> Result<Option<ResolvedRecord>> {
1150        if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1151            tracing::warn!(
1152                len = event_json.len(),
1153                "Oversized Nostr resolution record; ignoring"
1154            );
1155            return Ok(None);
1156        }
1157
1158        let subject = {
1159            let mut active = self.active_queries.lock_or_recover();
1160            let Some(query) = active.get_mut(query_id) else {
1161                tracing::debug!(query_id = %query_id, "Nostr query event for an unknown query");
1162                return Ok(None);
1163            };
1164            // The relay decides how many events arrive here, so the ceiling has
1165            // to be ours. See [`MAX_QUERY_EVENTS`].
1166            //
1167            // Counted before the record is parsed or authenticated, and that
1168            // ordering is the deliberate half: the budget is a bound on *work*,
1169            // so a relay streaming junk must spend it. The cost is that such a
1170            // relay can also crowd out honest records that arrive later, which
1171            // is the same crowding the per-relay `limit` accepts, and is why a
1172            // resolution reports what it dropped rather than presenting a
1173            // truncated set as complete. Only the per-query duplicate slot is
1174            // spent after authentication, so a forgery cannot displace the
1175            // genuine record whose id it copied.
1176            if query.delivered >= MAX_QUERY_EVENTS {
1177                tracing::warn!(
1178                    query_id = %query_id,
1179                    cap = MAX_QUERY_EVENTS,
1180                    "Nostr resolution query exceeded its event ceiling; ignoring the rest"
1181                );
1182                return Ok(None);
1183            }
1184            query.delivered += 1;
1185            query.subject.clone()
1186        };
1187
1188        let event: serde_json::Value = match serde_json::from_str(event_json) {
1189            Ok(v) => v,
1190            Err(e) => {
1191                tracing::debug!(error = %e, "Unparseable Nostr resolution record");
1192                return Ok(None);
1193            }
1194        };
1195
1196        // The kind is checked against what *this query asked for*, not against
1197        // the set of kinds this transport understands. A relay answering a
1198        // discovery REQ with a key-package record — or the reverse — must be
1199        // dropped rather than routed to the other handler.
1200        let expected_kind = match &subject {
1201            QuerySubject::KeyPackage(_) => nostr_crypto::NOSTR_KEY_PACKAGE_KIND,
1202            QuerySubject::Discovery(_) => nostr_crypto::NOSTR_DISCOVERY_KIND,
1203        };
1204        if event.get("kind").and_then(|k| k.as_u64()) != Some(expected_kind as u64) {
1205            return Ok(None);
1206        }
1207
1208        let (Some(author), Some(content)) = (
1209            event.get("pubkey").and_then(|p| p.as_str()),
1210            event.get("content").and_then(|c| c.as_str()),
1211        ) else {
1212            return Ok(None);
1213        };
1214
1215        // A discovery event must genuinely be from the key it names, and this
1216        // is the only record kind for which that matters. A tombstone's whole
1217        // meaning is *who published it* — there is nothing inside it to sign —
1218        // and the discovery seal key is public, so without this one hostile
1219        // relay could forge a retraction for an honest claimant and erase them
1220        // from the resolved set. See `nostr_crypto::event_is_authentic`.
1221        //
1222        // Checked before the dedup mark below, so an unauthentic event never
1223        // consumes a genuine record's slot.
1224        if matches!(subject, QuerySubject::Discovery(_))
1225            && !nostr_crypto::event_is_authentic(&event)
1226        {
1227            tracing::debug!("Discovery event failed its own signature check; ignoring");
1228            return Ok(None);
1229        }
1230
1231        // The query is broadcast, so every connected relay answers it and the
1232        // same record arrives once per relay. Take each event id once: behind
1233        // this call the key-package handler performs two durable
1234        // secure-storage writes per record, and re-importing a package we
1235        // already hold buys nothing. Marked before opening rather than after,
1236        // so a record that repeatedly fails to open is absorbed too.
1237        if let Some(event_id) = event.get("id").and_then(|i| i.as_str()) {
1238            let mut active = self.active_queries.lock_or_recover();
1239            let Some(query) = active.get_mut(query_id) else {
1240                return Ok(None);
1241            };
1242            if !query.seen_events.insert(event_id.to_string()) {
1243                tracing::debug!(
1244                    query_id = %query_id,
1245                    "Duplicate Nostr resolution record for this query; already taken"
1246                );
1247                return Ok(None);
1248            }
1249        }
1250
1251        let sealed = match base64::engine::general_purpose::STANDARD.decode(content) {
1252            Ok(bytes) => bytes,
1253            Err(e) => {
1254                tracing::debug!(error = %e, "Nostr resolution record content is not base64");
1255                return Ok(None);
1256            }
1257        };
1258
1259        match subject {
1260            QuerySubject::KeyPackage(address) => {
1261                // The peer's derivable key is computable from their address —
1262                // that is the whole reason a record sealed to it stays
1263                // fetchable by anyone entitled to fetch it, while remaining
1264                // opaque to a relay scraping by kind alone.
1265                let peer_key = nostr_crypto::record_seal_keypair_for_address(&address)?;
1266                match nostr_crypto::open_key_package_publication(&peer_key, author, &sealed) {
1267                    Ok(plaintext) => Ok(Some(ResolvedRecord::KeyPackage {
1268                        author: author.to_string(),
1269                        plaintext,
1270                    })),
1271                    Err(e) => {
1272                        tracing::debug!(error = %e, "Nostr key-package record did not open");
1273                        Ok(None)
1274                    }
1275                }
1276            }
1277            QuerySubject::Discovery(username) => {
1278                // Reconstructed from the queried name, exactly as any resolver
1279                // would. See `discovery_seal_keypair_for_username` for why this
1280                // key is public by construction and must never be load-bearing.
1281                let seal_key = nostr_crypto::discovery_seal_keypair_for_username(&username)?;
1282                match nostr_crypto::open_discovery_record(&seal_key, author, &sealed) {
1283                    Ok(plaintext) => Ok(Some(ResolvedRecord::Discovery {
1284                        username,
1285                        // Lower-cased because the resolver *keys* claims and
1286                        // retractions by this string while verification
1287                        // compares decoded bytes. Two spellings of one key
1288                        // would otherwise be two devices to the accumulator
1289                        // and one to the verifier, so a retraction in one
1290                        // spelling would not suppress a claim in the other.
1291                        author: author.to_ascii_lowercase(),
1292                        plaintext,
1293                    })),
1294                    Err(e) => {
1295                        tracing::debug!(error = %e, "Nostr discovery record did not open");
1296                        Ok(None)
1297                    }
1298                }
1299            }
1300        }
1301    }
1302
1303    /// Releases a query once the platform has seen its end-of-stored-events.
1304    ///
1305    /// Returns the username when the query was a discovery lookup, so the
1306    /// engine knows which accumulated claim set to flush. End-of-stored-events
1307    /// is the *only* natural completion signal a Nostr query has, which is why
1308    /// the engine also sweeps on a timer: a relay that never sends EOSE would
1309    /// otherwise leave a resolution accumulating forever and never answering.
1310    pub fn complete_query(&self, query_id: &str) -> Option<Username> {
1311        let query = self.active_queries.lock_or_recover().remove(query_id)?;
1312        match query.subject {
1313            QuerySubject::Discovery(username) => Some(username),
1314            QuerySubject::KeyPackage(_) => None,
1315        }
1316    }
1317
1318    /// Requests resolution of the devices claiming `username`.
1319    ///
1320    /// Returns *why* it did what it did rather than a bare boolean. The three
1321    /// refusals mean materially different things to a caller: after
1322    /// [`ResolveRequest::AlreadyQueued`] an answer is still coming, while after
1323    /// the other two nothing will ever arrive. Collapsing them into `false`
1324    /// leaves an app that waits on the resolution event unable to tell "wait"
1325    /// from "hang". See `OfflineProtocol::resolve_username`, which is what
1326    /// turns that distinction into an error.
1327    ///
1328    /// Deliberately *not* rate-limited the way key-package resolution is. That
1329    /// limiter exists because a send to an unpublished peer would otherwise
1330    /// mint a round-trip per frame, automatically and invisibly. A discovery
1331    /// lookup is user-initiated — someone typed a name and pressed search — so
1332    /// throttling it would make the second attempt at a mistyped name silently
1333    /// do nothing. The queue bound is what keeps it finite.
1334    pub fn resolve_username(&self, username: Username) -> ResolveRequest {
1335        if !self.discovery_enabled() {
1336            return ResolveRequest::Disabled;
1337        }
1338
1339        {
1340            let mut queue = self.discovery_resolve_queue.lock_or_recover();
1341            if queue.iter().any(|queued| *queued == username) {
1342                return ResolveRequest::AlreadyQueued;
1343            }
1344            if queue.len() >= MAX_PENDING_RESOLUTIONS {
1345                return ResolveRequest::QueueFull;
1346            }
1347            queue.push_back(username);
1348        }
1349
1350        self.notify_messages_available();
1351        ResolveRequest::Queued
1352    }
1353
1354    /// Removes a queued username lookup that has not been minted into a query.
1355    ///
1356    /// Returns whether one was removed. The engine calls this when it gives up
1357    /// on a lookup that never reached a relay — queries are pumped only while
1358    /// the socket is up, so a lookup made offline can sit here indefinitely.
1359    /// Without the removal, [`Self::resolve_username`] would keep refusing that
1360    /// name as "already queued" while nothing was ever going to answer it, so a
1361    /// name that timed out once could not be looked up again.
1362    pub fn cancel_username_resolution(&self, username: &Username) -> bool {
1363        let mut queue = self.discovery_resolve_queue.lock_or_recover();
1364        let before = queue.len();
1365        queue.retain(|queued| queued != username);
1366        before != queue.len()
1367    }
1368
1369    /// Records a peer's real per-install Nostr public key, learned from the
1370    /// `nostr_pubkey` field of their key package.
1371    ///
1372    /// Until this is known, frames to that peer are sealed to their publicly
1373    /// computable key instead (bootstrap leg). Once it is known, every
1374    /// subsequent frame is sealed to a key only that install holds.
1375    ///
1376    /// A wrong value here is not merely a delivery denial: the plaintext is
1377    /// sealed *to* this key, so an attacker who substitutes their own reads the
1378    /// envelope metadata off a public relay. The engine is what keeps that
1379    /// shut — it calls this only for a key package whose Ed25519 signature it
1380    /// verified, never for one that merely arrived (its security gate accepts
1381    /// unsigned control messages from not-yet-pinned peers). Do not add a
1382    /// caller that skips that check.
1383    ///
1384    /// Bounded at [`NOSTR_MAX_TRACKED_PEER_KEYS`]; at capacity the map resets
1385    /// rather than evicting selectively, matching the engine's other
1386    /// wire-keyed maps. The only cost of forgetting a peer is that the next
1387    /// frame to them takes the bootstrap path until their key package is seen
1388    /// again.
1389    pub fn set_peer_nostr_pubkey(&self, user_id: &str, pubkey_hex: &str) {
1390        if user_id.is_empty()
1391            || pubkey_hex.len() != 64
1392            || !pubkey_hex.bytes().all(|b| b.is_ascii_hexdigit())
1393        {
1394            tracing::debug!(
1395                user_id = %user_id,
1396                "Ignoring malformed peer Nostr pubkey"
1397            );
1398            return;
1399        }
1400
1401        let mut map = self.peer_nostr_pubkeys.write_or_recover();
1402        if map.len() >= NOSTR_MAX_TRACKED_PEER_KEYS && !map.contains_key(user_id) {
1403            tracing::warn!(
1404                capacity = NOSTR_MAX_TRACKED_PEER_KEYS,
1405                "Nostr peer key map at capacity; clearing"
1406            );
1407            map.clear();
1408        }
1409        map.insert(user_id.to_string(), pubkey_hex.to_ascii_lowercase());
1410    }
1411
1412    /// Forgets a peer's per-install Nostr public key, reverting frames to them
1413    /// to the bootstrap path.
1414    ///
1415    /// The engine calls this when it declares everything learned about a peer
1416    /// stale (the unblock clean slate). Reverting rather than refusing to send
1417    /// is deliberate: the bootstrap key is derived from the peer's user id, so
1418    /// it is readable by whatever install they are on now — where a key cached
1419    /// from a since-wiped install is readable by nobody.
1420    pub fn forget_peer_nostr_pubkey(&self, user_id: &str) {
1421        self.peer_nostr_pubkeys.write_or_recover().remove(user_id);
1422    }
1423
1424    /// Returns the recipient's known per-install Nostr public key, if any.
1425    fn peer_nostr_pubkey(&self, user_id: &str) -> Option<String> {
1426        self.peer_nostr_pubkeys
1427            .read_or_recover()
1428            .get(user_id)
1429            .cloned()
1430    }
1431
1432    /// Unseals an inbound event payload, or returns it unchanged if it is not
1433    /// sealed.
1434    ///
1435    /// `sender_pubkey_hex` is the wrapper event's `pubkey` — a single-use key
1436    /// with no identity attached. `data` is the base64-decoded event `content`,
1437    /// which is the form the platform bridges produce.
1438    ///
1439    /// Two of our own keys can unseal a frame and both are tried, cheapest and
1440    /// most likely first:
1441    ///
1442    /// 1. the per-install signing key, used by any peer that has seen our key
1443    ///    package — the steady state;
1444    /// 2. the publicly computable key, used on the bootstrap leg before the
1445    ///    sender has learned our real one.
1446    ///
1447    /// A wrong key is rejected by the NIP-44 MAC, so trying both is
1448    /// unambiguous rather than a guess. A frame that unseals with neither is
1449    /// returned to the caller **as-is**: it may still be a legacy unsealed
1450    /// frame, and the caller's decoder is what decides. That ordering matters —
1451    /// deciding "sealed but undecryptable" here and dropping would break every
1452    /// peer that predates sealing.
1453    pub fn unseal_event_payload<'a>(
1454        &self,
1455        sender_pubkey_hex: &str,
1456        data: &'a [u8],
1457    ) -> Cow<'a, [u8]> {
1458        if !nostr_crypto::is_sealed_payload(data) {
1459            return Cow::Borrowed(data);
1460        }
1461        // Anyone can publish to our routing tag, so this is attacker-sized
1462        // input. Unsealing costs an ECDH plus an HMAC and a ChaCha20 pass over
1463        // the whole buffer; do that only for something that could plausibly be
1464        // a message. The receive path rejects it at the same bound anyway, so
1465        // this only moves the rejection ahead of the crypto.
1466        if data.len() > crate::constants::DEFAULT_MAX_MESSAGE_SIZE {
1467            tracing::warn!(
1468                len = data.len(),
1469                "Oversized sealed Nostr frame; not attempting to unseal"
1470            );
1471            return Cow::Borrowed(data);
1472        }
1473
1474        {
1475            let keypair = self.keypair.read_or_recover();
1476            if let Ok(plaintext) = nostr_crypto::unwrap_gift_wrap(&keypair, sender_pubkey_hex, data)
1477            {
1478                return Cow::Owned(plaintext);
1479            }
1480        }
1481
1482        match nostr_crypto::unwrap_gift_wrap(&self.record_seal_keypair, sender_pubkey_hex, data) {
1483            Ok(plaintext) => {
1484                tracing::debug!("Unsealed a bootstrap-leg Nostr frame");
1485                Cow::Owned(plaintext)
1486            }
1487            Err(e) => {
1488                // Not addressed to us, or corrupt. Handing the original bytes
1489                // back lets the caller's decoder produce the single
1490                // "undecodable frame" outcome rather than two divergent ones.
1491                tracing::debug!(error = %e, "Sealed Nostr frame did not unseal with either key");
1492                Cow::Borrowed(data)
1493            }
1494        }
1495    }
1496
1497    /// Builds the event content for `message`: a gift wrap when sealing is on,
1498    /// the legacy base64 envelope otherwise.
1499    ///
1500    /// Returns the whole signed event so the two paths differ in kind, signing
1501    /// key, and timestamp — not just in how `content` is encoded.
1502    fn build_event(&self, message: &Message) -> Result<nostr_crypto::NostrEvent> {
1503        let recipient_device_id = message.recipient.as_str();
1504        // `send` already refused a non-address recipient before queueing, so
1505        // this parse is a restatement rather than a new gate — but it is the
1506        // one that produces the typed value the derivations below require, and
1507        // re-deriving it here means a future queue-filling path cannot skip
1508        // the check by accident.
1509        let recipient_address = recipient_device_id.parse::<Address>().map_err(|_| {
1510            crate::Error::PeerNotReachable(
1511                "Nostr addresses peers by their derived address, and the \
1512                 recipient id is not one"
1513                    .to_string(),
1514            )
1515        })?;
1516        let recipient_tag = nostr_crypto::routing_tag_for_address(&recipient_address)?;
1517        let data = self.serialize_message(message)?;
1518
1519        if !self.sealing_enabled() {
1520            let content_base64 = base64::engine::general_purpose::STANDARD.encode(&data);
1521            let keypair = self.keypair.read_or_recover();
1522            return nostr_crypto::NostrEvent::create_dm(&keypair, &recipient_tag, &content_base64);
1523        }
1524
1525        // With no known per-install key for the recipient, seal to their
1526        // publicly computable one. That is the bootstrap leg: it defeats bulk
1527        // collection by a relay, and nothing more — anyone who guesses the
1528        // recipient's user ID holds the matching private half. It is chosen
1529        // over refusing to send (which would make a stranger unreachable over
1530        // Nostr) and over falling back to a cleartext kind-4 event (which
1531        // would make first contact trivially identifiable with a one-line
1532        // relay filter). On the wire the two cases are indistinguishable.
1533        let encryption_pubkey = match self.peer_nostr_pubkey(recipient_device_id) {
1534            Some(key) => key,
1535            None => {
1536                // Ask the relays for the recipient's published records, so the
1537                // *next* frame seals to a key only they hold. This frame still
1538                // takes the bootstrap leg: blocking a send on a relay
1539                // round-trip would turn a metadata upgrade into latency, and
1540                // the round-trip may have nothing to return.
1541                //
1542                // This is also what bounds the peer-key map's reset-at-capacity
1543                // downgrade for peers who publish — a forgotten key is
1544                // re-resolved on the next send rather than waiting for a fresh
1545                // key-package exchange.
1546                self.enqueue_resolution(recipient_device_id);
1547                // Their record-seal key, reconstructed from their address —
1548                // no longer the routing tag, which since the key split is a
1549                // label with no private half anyone holds.
1550                nostr_crypto::record_seal_keypair_for_address(&recipient_address)?
1551                    .public_key_hex()
1552                    .to_string()
1553            }
1554        };
1555
1556        nostr_crypto::NostrEvent::create_gift_wrap(&recipient_tag, &encryption_pubkey, &data)
1557    }
1558
1559    /// Builds the next queued key-package publication, if any.
1560    ///
1561    /// Publications are drained ahead of messages so a slot refreshed after
1562    /// consumption reaches the relay before the traffic that made it stale.
1563    /// A failure here drops the record rather than re-queueing it — retrying in
1564    /// place would head-of-line block the message queue behind a record that
1565    /// keeps failing — and instead reports the slot through
1566    /// [`Self::mark_publications_failed`], which is what makes the engine
1567    /// republish it on a later tick. Reporting it is not optional: the engine
1568    /// marked the slot published when it queued the record, so a silent drop
1569    /// here strands the slot until the process restarts.
1570    fn next_publication_event(&self) -> Result<Option<SignedNostrEvent>> {
1571        let pending = {
1572            let mut queue = self.publication_queue.lock_or_recover();
1573            match queue.pop_front() {
1574                Some(p) => p,
1575                None => return Ok(None),
1576            }
1577        };
1578
1579        let message_id = format!("{}{}", NOSTR_PUBLICATION_ID_PREFIX, pending.slot_id);
1580
1581        let result = (|| {
1582            let event = {
1583                let keypair = self.keypair.read_or_recover();
1584                nostr_crypto::NostrEvent::create_key_package_publication(
1585                    &keypair,
1586                    &self.routing_tag,
1587                    self.record_seal_keypair.public_key_hex(),
1588                    &pending.slot_id,
1589                    &pending.payload,
1590                )?
1591            };
1592            let event_id = event.id.clone();
1593            let event_json = event.to_relay_message()?;
1594            if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1595                return Err(crate::Error::MessageTooLarge(
1596                    event_json.len(),
1597                    NOSTR_MAX_PAYLOAD_SIZE,
1598                ));
1599            }
1600            Ok((event_id, event_json))
1601        })();
1602
1603        match result {
1604            Ok((event_id, event_json)) => {
1605                self.pending_confirmation
1606                    .lock_or_recover()
1607                    .insert(message_id.clone(), Instant::now());
1608                Ok(Some(SignedNostrEvent {
1609                    message_id,
1610                    event_id,
1611                    event_json,
1612                }))
1613            }
1614            Err(e) => {
1615                tracing::error!(
1616                    slot_id = %pending.slot_id,
1617                    error = %e,
1618                    "Failed to build a Nostr key-package publication; slot left unpublished"
1619                );
1620                self.mark_publications_failed(vec![pending.slot_id]);
1621                Err(e)
1622            }
1623        }
1624    }
1625
1626    /// Builds the next queued discovery publication, if any.
1627    ///
1628    /// Mirrors [`Self::next_publication_event`], including its failure policy:
1629    /// a build error drops the record rather than re-queueing it (retrying in
1630    /// place would head-of-line block the message queue behind something that
1631    /// keeps failing) and reports the tag through
1632    /// [`Self::mark_discovery_publications_failed`], which is what makes the
1633    /// engine republish it on a later tick. Reporting is not optional — the
1634    /// engine marked the claim published when it queued the record, so a silent
1635    /// drop strands it until the process restarts.
1636    ///
1637    /// A retraction additionally emits a NIP-09 deletion. That event is
1638    /// best-effort: it is queued behind the tombstone, and a failure to build
1639    /// it is logged rather than propagated, because the tombstone is the half
1640    /// that actually works through addressable replacement.
1641    fn next_discovery_event(&self) -> Result<Option<SignedNostrEvent>> {
1642        let pending = {
1643            let mut queue = self.discovery_queue.lock_or_recover();
1644            match queue.pop_front() {
1645                Some(p) => p,
1646                None => return Ok(None),
1647            }
1648        };
1649
1650        let tag = match nostr_crypto::discovery_tag_for_username(&pending.username) {
1651            Ok(tag) => tag,
1652            Err(e) => {
1653                tracing::error!(
1654                    error = %e,
1655                    "Failed to derive a Nostr discovery tag; claim left unpublished"
1656                );
1657                return Err(e);
1658            }
1659        };
1660        let message_id = format!("{}{}", NOSTR_DISCOVERY_ID_PREFIX, tag);
1661
1662        let result = (|| {
1663            let seal = nostr_crypto::discovery_seal_keypair_for_username(&pending.username)?;
1664            let (event, deletion) = {
1665                let keypair = self.keypair.read_or_recover();
1666                let event = nostr_crypto::NostrEvent::create_discovery_publication(
1667                    &keypair,
1668                    &tag,
1669                    seal.public_key_hex(),
1670                    &pending.payload,
1671                )?;
1672                // Built under the *same* keypair guard as the tombstone it
1673                // follows. Re-reading the keypair for it would let an identity
1674                // swap in between name a coordinate this install never
1675                // published at, and a NIP-09 deletion is per-author, so that
1676                // request would delete nothing while reporting nothing.
1677                let deletion = pending
1678                    .retraction
1679                    .then(|| nostr_crypto::NostrEvent::create_discovery_deletion(&keypair, &tag));
1680                (event, deletion)
1681            };
1682            let event_id = event.id.clone();
1683            let event_json = event.to_relay_message()?;
1684            if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1685                return Err(crate::Error::MessageTooLarge(
1686                    event_json.len(),
1687                    NOSTR_MAX_PAYLOAD_SIZE,
1688                ));
1689            }
1690            Ok((event_id, event_json, deletion))
1691        })();
1692
1693        match result {
1694            Ok((event_id, event_json, deletion)) => {
1695                if let Some(deletion) = deletion {
1696                    self.queue_discovery_deletion(deletion);
1697                }
1698                self.pending_confirmation
1699                    .lock_or_recover()
1700                    .insert(message_id.clone(), Instant::now());
1701                Ok(Some(SignedNostrEvent {
1702                    message_id,
1703                    event_id,
1704                    event_json,
1705                }))
1706            }
1707            Err(e) => {
1708                tracing::error!(
1709                    error = %e,
1710                    "Failed to build a Nostr discovery publication; claim left unpublished"
1711                );
1712                self.mark_discovery_publications_failed(vec![tag]);
1713                Err(e)
1714            }
1715        }
1716    }
1717
1718    /// Queues the best-effort NIP-09 deletion half of a retraction.
1719    ///
1720    /// Takes an already-built event rather than building one, so it is signed
1721    /// by the same key as the tombstone whose slot it names. See
1722    /// [`Self::next_discovery_event`].
1723    ///
1724    /// Held in its own queue, drained after the discovery queue, so the
1725    /// deletion follows its tombstone rather than racing it. A claim for
1726    /// another name queued in between will go out first; that is harmless,
1727    /// since the deletion names its coordinate explicitly. It carries no
1728    /// synthetic id and no pending-confirmation entry: nothing republishes a
1729    /// deletion and nothing recovers if a relay ignores it, which is what
1730    /// "best effort" means here.
1731    fn queue_discovery_deletion(&self, event: Result<nostr_crypto::NostrEvent>) {
1732        match event {
1733            Ok(event) => {
1734                let event_id = event.id.clone();
1735                match event.to_relay_message() {
1736                    Ok(json) => self
1737                        .pending_deletions
1738                        .lock_or_recover()
1739                        .push_back((event_id, json)),
1740                    Err(e) => tracing::warn!(
1741                        error = %e,
1742                        "Failed to serialize a Nostr discovery deletion; the tombstone still stands"
1743                    ),
1744                }
1745            }
1746            Err(e) => tracing::warn!(
1747                error = %e,
1748                "Failed to build a Nostr discovery deletion; the tombstone still stands"
1749            ),
1750        }
1751    }
1752
1753    /// Pops a ready-built deletion event, if any.
1754    fn next_deletion_event(&self) -> Option<SignedNostrEvent> {
1755        let (event_id, event_json) = self.pending_deletions.lock_or_recover().pop_front()?;
1756        Some(SignedNostrEvent {
1757            message_id: format!("{}{}", NOSTR_DELETION_ID_PREFIX, event_id),
1758            event_id,
1759            event_json,
1760        })
1761    }
1762
1763    /// Pops the next outgoing message, creates a signed Nostr event, and returns
1764    /// `(message_id, recipient_device_id, relay_event_json)`.
1765    ///
1766    /// The `relay_event_json` is a complete `["EVENT", {...}]` string ready to
1767    /// send over a WebSocket connection. The platform no longer needs to do
1768    /// any signing or event creation.
1769    ///
1770    /// Events larger than [`NOSTR_MAX_PAYLOAD_SIZE`] are dropped here rather
1771    /// than handed to the platform, since relays would reject them on arrival.
1772    /// That drop is permanent — unlike a signing failure, an oversized event is
1773    /// oversized on every attempt, so retrying it would only head-of-line-block
1774    /// the queue behind a message no relay will accept.
1775    ///
1776    /// **Sealing shrinks how much fits under that cap**, by more than the
1777    /// base64 layer alone suggests: NIP-44 pads to a power-of-two bucket, so a
1778    /// payload just past a boundary very nearly doubles before the MAC and
1779    /// base64 are applied. The check therefore runs on the final event, after
1780    /// sealing, and a message that fits unsealed may not fit sealed.
1781    pub fn get_next_signed_event(&self) -> Result<Option<SignedNostrEvent>> {
1782        self.drain_expired_pending();
1783
1784        if let Some(publication) = self.next_publication_event()? {
1785            return Ok(Some(publication));
1786        }
1787
1788        // Discovery records drain after key packages and before messages. The
1789        // order matters in one direction only: a claim points at an address
1790        // whose key packages a resolver fetches next, so publishing the claim
1791        // first would advertise a name that momentarily resolves to nothing.
1792        if let Some(publication) = self.next_discovery_event()? {
1793            return Ok(Some(publication));
1794        }
1795
1796        // Deletions last among the synthetic events, so a retraction's
1797        // tombstone is always on the wire before the deletion that accompanies
1798        // it. If a relay honours the deletion and drops the tombstone, the
1799        // claim is gone either way; the reverse order could delete the old
1800        // claim and then have the tombstone fail, leaving nothing to mark the
1801        // slot retracted.
1802        if let Some(deletion) = self.next_deletion_event() {
1803            return Ok(Some(deletion));
1804        }
1805
1806        let message = {
1807            let mut queue = self.send_queue.lock_or_recover();
1808            match queue.pop_front() {
1809                Some(m) => m,
1810                None => return Ok(None),
1811            }
1812        };
1813
1814        let message_id = message.id.to_string();
1815
1816        let result = (|| {
1817            let event = self.build_event(&message)?;
1818            let event_id = event.id.clone();
1819            let event_json = event.to_relay_message()?;
1820            if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1821                return Err(crate::Error::MessageTooLarge(
1822                    event_json.len(),
1823                    NOSTR_MAX_PAYLOAD_SIZE,
1824                ));
1825            }
1826            Ok((event_id, event_json))
1827        })();
1828
1829        match result {
1830            Ok((event_id, event_json)) => {
1831                self.sign_retry_counts.lock_or_recover().remove(&message_id);
1832                self.pending_confirmation
1833                    .lock_or_recover()
1834                    .insert(message_id.clone(), Instant::now());
1835
1836                Ok(Some(SignedNostrEvent {
1837                    message_id,
1838                    event_id,
1839                    event_json,
1840                }))
1841            }
1842            Err(e) => {
1843                let retriable = match &e {
1844                    // No number of attempts shrinks an oversized event.
1845                    crate::Error::MessageTooLarge(_, _) => false,
1846                    _ => self.record_sign_attempt(&message_id) < MAX_SIGN_RETRIES,
1847                };
1848
1849                if retriable {
1850                    // Re-enqueue for another attempt.
1851                    self.send_queue.lock_or_recover().push_front(message);
1852                } else {
1853                    self.fail_permanently(&message_id, &e);
1854                }
1855                Err(e)
1856            }
1857        }
1858    }
1859
1860    /// Records a signing attempt for `message_id` and returns the running count.
1861    fn record_sign_attempt(&self, message_id: &str) -> u8 {
1862        let mut counts = self.sign_retry_counts.lock_or_recover();
1863        let count = counts.entry(message_id.to_string()).or_insert(0);
1864        *count = count.saturating_add(1);
1865        *count
1866    }
1867
1868    /// Drops a message that cannot be published and records the failure.
1869    ///
1870    /// The message has already been popped off the send queue and was never
1871    /// entered into `pending_confirmation` — that happens only once an event
1872    /// reaches the platform — so the failure is counted directly here.
1873    /// [`Transport::report_send_failure`] is keyed on a pending entry and would
1874    /// be a no-op, leaving the failure invisible to DORS.
1875    fn fail_permanently(&self, message_id: &str, error: &crate::Error) {
1876        self.sign_retry_counts.lock_or_recover().remove(message_id);
1877
1878        let mut metrics = self.metrics.lock_or_recover();
1879        metrics.failure_count = metrics.failure_count.saturating_add(1);
1880        recalculate_delivery_ratios(&mut metrics);
1881        drop(metrics);
1882
1883        tracing::error!(
1884            message_id = %message_id,
1885            error_code = error.code(),
1886            error = %error,
1887            "Nostr event dropped permanently; it will not be published"
1888        );
1889    }
1890
1891    /// Returns the newest accepted event timestamp (unix seconds), or `None`
1892    /// if no event has been accepted since the last watermark install.
1893    ///
1894    /// Read by the engine to persist the watermark.
1895    pub fn receive_watermark_secs(&self) -> Option<i64> {
1896        *self.receive_watermark.lock_or_recover()
1897    }
1898
1899    /// Records that an event dated `created_at_secs` (unix seconds) has been
1900    /// received, moving the receive watermark forward. Returns whether the
1901    /// watermark actually moved.
1902    ///
1903    /// Two rules, both load-bearing:
1904    ///
1905    /// - **Monotonic.** The mark only ever advances, so out-of-order delivery
1906    ///   (the relay streams stored events newest-first) cannot walk it back.
1907    /// - **Never into the future.** Anyone can address an event to our routing
1908    ///   tag — it is derived from a public user id — and `created_at` is
1909    ///   written by whoever published the event. A single event dated far ahead
1910    ///   would otherwise pin the mark there and make every later subscription
1911    ///   ask for events `since` the far future, receiving nothing forever. So
1912    ///   values more than [`NOSTR_FUTURE_DATED_TOLERANCE_SECS`] ahead of local
1913    ///   time are dropped, not clamped.
1914    ///
1915    /// The restore path goes through this same function deliberately: a
1916    /// watermark persisted by a build without the future check must not be able
1917    /// to stall a subscription either.
1918    ///
1919    /// Callers should advance the mark only for frames that actually decoded
1920    /// into a [`Message`]. The mark means "receive progress has reached here",
1921    /// and a frame we cannot parse is one we never processed — counting it
1922    /// would let undecodable traffic push the window past real messages still
1923    /// waiting on the relay. The failure mode of the stricter rule is replaying
1924    /// more, never less, which is the right bias for a delivery path.
1925    pub fn advance_receive_watermark(&self, created_at_secs: i64) -> bool {
1926        if created_at_secs <= 0 {
1927            return false;
1928        }
1929        let ceiling = now_unix_secs().saturating_add(NOSTR_FUTURE_DATED_TOLERANCE_SECS);
1930        if created_at_secs > ceiling {
1931            tracing::debug!(
1932                created_at = created_at_secs,
1933                ceiling,
1934                "Ignoring future-dated Nostr event for the receive watermark"
1935            );
1936            return false;
1937        }
1938
1939        let mut mark = self.receive_watermark.lock_or_recover();
1940        match *mark {
1941            Some(current) if current >= created_at_secs => false,
1942            _ => {
1943                *mark = Some(created_at_secs);
1944                true
1945            }
1946        }
1947    }
1948
1949    /// The `since` this device's subscription filter should carry (unix
1950    /// seconds), derived from the receive watermark.
1951    ///
1952    /// With a watermark, it reaches back past it by the gift-wrap jitter window
1953    /// plus a clock-skew margin — an event published now may legitimately carry
1954    /// a `created_at` up to a jitter window old, and a sender's clock may lag
1955    /// ours, so a `since` sitting exactly at the mark would skip both. The
1956    /// watermark is also clamped to `now` first, so a mark restored from an
1957    /// older build that lacked the future-dated check cannot push `since`
1958    /// forward.
1959    ///
1960    /// With no watermark — a fresh install, a wiped one, or any subscription
1961    /// built before protocol-state storage has been restored — it falls back to
1962    /// [`NOSTR_FIRST_RUN_BACKFILL_SECS`] ago. Never zero: an absent or zero
1963    /// `since` is the unbounded filter this exists to remove.
1964    ///
1965    /// **Residual:** the mark can only be as good as what we have received. A
1966    /// relay that truncates a reconnect's history at
1967    /// [`NOSTR_INITIAL_QUERY_LIMIT`](crate::constants::NOSTR_INITIAL_QUERY_LIMIT)
1968    /// hands back its newest events, so a device that comes back to more than
1969    /// that much stored history advances past what it never saw. The overlap
1970    /// below bounds how much: only events already older than jitter + skew at
1971    /// the moment of truncation are at risk.
1972    fn subscription_since(&self) -> i64 {
1973        let now = now_unix_secs();
1974        match self.receive_watermark_secs() {
1975            Some(mark) => mark
1976                .min(now)
1977                .saturating_sub(NOSTR_CREATED_AT_JITTER_SECS)
1978                .saturating_sub(NOSTR_CLOCK_SKEW_MARGIN_SECS)
1979                .max(0),
1980            None => now.saturating_sub(NOSTR_FIRST_RUN_BACKFILL_SECS).max(0),
1981        }
1982    }
1983
1984    /// Returns a NIP-01 subscription filter JSON for this device's routing tag.
1985    ///
1986    /// The platform should send this to each relay after connecting:
1987    /// `["REQ", "<sub_id>", {"#p": ["<routing_tag>"], "kinds": [4, 1059], "since": T, "limit": N}]`
1988    ///
1989    /// The filter is on the routing tag — not the signing pubkey — so it is
1990    /// stable across signing-key changes and derivable by peers. `since` bounds
1991    /// how far back stored-event replay reaches — it is derived from the
1992    /// receive watermark by the private `subscription_since`, documented on
1993    /// [`Self::advance_receive_watermark`] — while `limit` caps how much of
1994    /// that window one (re)connect pulls down. Neither caps live delivery.
1995    pub fn create_subscription(&self, subscription_id: &str) -> Result<String> {
1996        nostr_crypto::create_subscription_message(
1997            &self.routing_tag,
1998            subscription_id,
1999            self.subscription_since(),
2000        )
2001    }
2002
2003    /// Fails all pending confirmations and records them as failures.
2004    fn fail_all_pending(&self) {
2005        let (pending, slots, tags) = {
2006            let mut map = self.pending_confirmation.lock_or_recover();
2007            let mut slots = Vec::new();
2008            let mut tags = Vec::new();
2009            let mut synthetic = 0usize;
2010            for id in map.keys() {
2011                match synthetic_publication(id) {
2012                    Some(SyntheticPublication::KeyPackage(slot)) => {
2013                        slots.push(slot);
2014                        synthetic += 1;
2015                    }
2016                    Some(SyntheticPublication::Discovery(tag)) => {
2017                        tags.push(tag);
2018                        synthetic += 1;
2019                    }
2020                    // Nothing republishes a deletion; it is counted only so it
2021                    // does not fall through into the delivery failures.
2022                    Some(SyntheticPublication::Deletion) => synthetic += 1,
2023                    None => {}
2024                }
2025            }
2026            // Only the messages count as failures — the publications among
2027            // them are reported to the engine instead. See
2028            // [`SyntheticPublication`].
2029            let count = map.len().saturating_sub(synthetic);
2030            map.clear();
2031            (count, slots, tags)
2032        };
2033        self.mark_publications_failed(slots);
2034        self.mark_discovery_publications_failed(tags);
2035        if pending > 0 {
2036            let mut metrics = self.metrics.lock_or_recover();
2037            metrics.failure_count = metrics.failure_count.saturating_add(pending as u32);
2038            recalculate_delivery_ratios(&mut metrics);
2039        }
2040    }
2041
2042    /// Expires pending confirmations that have exceeded the timeout.
2043    fn drain_expired_pending(&self) {
2044        let timeout = Duration::from_secs(NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS);
2045        let now = Instant::now();
2046        let mut expired_count = 0u32;
2047        let mut expired_slots = Vec::new();
2048        let mut expired_tags = Vec::new();
2049
2050        {
2051            let mut pending = self.pending_confirmation.lock_or_recover();
2052            pending.retain(|message_id, enqueued_at| {
2053                if now.duration_since(*enqueued_at) > timeout {
2054                    // A timed-out publication is reported to the engine, not
2055                    // counted as a delivery failure. See
2056                    // [`SyntheticPublication`].
2057                    match synthetic_publication(message_id) {
2058                        Some(SyntheticPublication::KeyPackage(slot_id)) => {
2059                            expired_slots.push(slot_id)
2060                        }
2061                        Some(SyntheticPublication::Discovery(tag)) => expired_tags.push(tag),
2062                        // Best effort: a deletion the relay never acknowledged
2063                        // is simply gone. The tombstone is what carries the
2064                        // retraction.
2065                        Some(SyntheticPublication::Deletion) => {}
2066                        None => expired_count += 1,
2067                    }
2068                    false
2069                } else {
2070                    true
2071                }
2072            });
2073        }
2074
2075        self.mark_publications_failed(expired_slots);
2076        self.mark_discovery_publications_failed(expired_tags);
2077
2078        if expired_count > 0 {
2079            let mut metrics = self.metrics.lock_or_recover();
2080            metrics.failure_count = metrics.failure_count.saturating_add(expired_count);
2081            recalculate_delivery_ratios(&mut metrics);
2082        }
2083    }
2084}
2085
2086impl Transport for NostrTransport {
2087    fn as_any(&self) -> &dyn std::any::Any {
2088        self
2089    }
2090
2091    fn transport_type(&self) -> TransportType {
2092        TransportType::Nostr
2093    }
2094
2095    fn status(&self) -> TransportStatus {
2096        *self.status.lock_or_recover()
2097    }
2098
2099    fn metrics(&self) -> TransportMetrics {
2100        self.metrics.lock_or_recover().clone()
2101    }
2102
2103    fn send(&self, message: &Message) -> Result<()> {
2104        let status = *self.status.lock_or_recover();
2105        if status != TransportStatus::Available {
2106            return Err(crate::Error::TransportNotAvailable(format!(
2107                "Nostr transport is {:?}",
2108                status
2109            )));
2110        }
2111
2112        // The recipient is the sole preimage of the `#p` tag this frame is
2113        // published under, so it is refused here rather than hashed — the same
2114        // rule `with_config` applies to our own id, and for the same two silent
2115        // failures: a username-shaped id puts a label anyone can recompute from
2116        // the name onto third-party relays, and any non-address id addresses
2117        // the frame where nobody subscribes.
2118        //
2119        // Refused at `send` rather than in `build_event` because a build
2120        // failure is retriable by the drain: a permanently undeliverable
2121        // recipient would burn the whole `MAX_SIGN_RETRIES` ladder, republishing
2122        // that label each time, before failing. Here it costs one error to the
2123        // caller, which routes to another transport or fails the message.
2124        //
2125        // The value stays out of the error for the reason `with_config` keeps
2126        // it out: the id this rejects is, in the case worth catching, a
2127        // username.
2128        if message.recipient.as_str().parse::<Address>().is_err() {
2129            return Err(crate::Error::PeerNotReachable(
2130                "Nostr addresses peers by their derived address, and the \
2131                 recipient id is not one"
2132                    .to_string(),
2133            ));
2134        }
2135
2136        let queue_len = {
2137            let mut queue = self.send_queue.lock_or_recover();
2138            queue.push_back(message.clone());
2139            queue.len()
2140        };
2141
2142        let mut metrics = self.metrics.lock_or_recover();
2143        metrics.queue_depth = queue_len;
2144        metrics.congestion = ((queue_len as f32) / 50.0).clamp(0.0, 1.0);
2145        drop(metrics);
2146
2147        self.notify_messages_available();
2148
2149        Ok(())
2150    }
2151
2152    fn receive(&self) -> Result<Option<Message>> {
2153        let mut queue = self.receive_queue.lock_or_recover();
2154        Ok(queue.pop_front())
2155    }
2156
2157    fn start(&self) -> Result<()> {
2158        // Actual connection is managed by the platform.
2159        // Status is updated via on_status_changed().
2160        Ok(())
2161    }
2162
2163    /// Stops the transport, clearing in-flight queues.
2164    ///
2165    /// `receive_watermark` is deliberately **not** cleared: it records how far
2166    /// receive progress has reached, which a `stop()`/`start()` cycle does not
2167    /// undo. Clearing it would make every restart fall back to the first-run
2168    /// backfill window and replay history the engine has already processed.
2169    fn stop(&self) -> Result<()> {
2170        *self.status.lock_or_recover() = TransportStatus::Disconnected;
2171        self.fail_all_pending();
2172        self.send_queue.lock_or_recover().clear();
2173        self.receive_queue.lock_or_recover().clear();
2174        Ok(())
2175    }
2176
2177    /// Called when connection status changes.
2178    ///
2179    /// Resets reconnect counter on successful connection.
2180    /// Fails all pending confirmations on disconnect.
2181    fn on_status_changed(&self, status: TransportStatus) {
2182        let previous_status = {
2183            let mut guard = self.status.lock_or_recover();
2184            let prev = *guard;
2185            *guard = status;
2186            prev
2187        };
2188
2189        if status == TransportStatus::Available {
2190            let queue_len = self.send_queue.lock_or_recover().len();
2191            *self.reconnect_attempts.lock_or_recover() = 0;
2192
2193            if queue_len > 0 {
2194                tracing::info!(
2195                    pending_messages = queue_len,
2196                    "Nostr transport available, {} messages pending in queue",
2197                    queue_len
2198                );
2199            }
2200        } else if previous_status == TransportStatus::Available
2201            && status != TransportStatus::Available
2202        {
2203            self.fail_all_pending();
2204
2205            let queue_len = self.send_queue.lock_or_recover().len();
2206            if queue_len > 0 {
2207                tracing::warn!(
2208                    pending_messages = queue_len,
2209                    new_status = ?status,
2210                    "Nostr transport disconnected with {} messages in queue (will retry)",
2211                    queue_len
2212                );
2213            }
2214        }
2215    }
2216
2217    /// Queues an inbound frame that has **already been unsealed**.
2218    ///
2219    /// Unsealing needs the wrapper event's `pubkey` (the sender's single-use
2220    /// key), which this signature has no way to carry, so the caller must run
2221    /// [`NostrTransport::unseal_event_payload`] first — the FFI receive entry
2222    /// does. A still-sealed frame arriving here cannot be recovered, so it is
2223    /// reported rather than being left to look like ordinary malformed data.
2224    fn on_data_received(&self, data: Vec<u8>) -> Result<()> {
2225        if nostr_crypto::is_sealed_payload(&data) {
2226            tracing::warn!(
2227                "Sealed Nostr frame reached the unsealed receive path; dropping. \
2228                 Callers must unseal via unseal_event_payload() first."
2229            );
2230            return Ok(());
2231        }
2232        crate::common::on_data_received(&self.receive_queue, data)
2233    }
2234
2235    /// Like [`Transport::on_data_received`], but attaches a
2236    /// transport-verified `peer_id` to the deserialized message.
2237    fn on_data_received_from(&self, data: Vec<u8>, peer_id: String) -> Result<()> {
2238        crate::common::on_data_received_from(&self.receive_queue, data, peer_id)
2239    }
2240
2241    /// **Refused.** This transport has no unsealed whole-message drain.
2242    ///
2243    /// The signature can only return a bare serialized `Message` — the entire
2244    /// protocol envelope, both endpoints included — with no gift wrap, no
2245    /// signature and no event around it, so publishing the result would put
2246    /// exactly the cleartext this transport exists to avoid in front of every
2247    /// relay. There is nowhere to put a signed, sealed event in a
2248    /// `(String, Vec<u8>)`, and a Nostr frame without its event envelope is
2249    /// not deliverable anyway.
2250    ///
2251    /// It used to return that cleartext, with this comment warning callers off
2252    /// it. A doc comment is not a control: this method sits on the generic
2253    /// [`Transport`] trait, which the engine hands out as `dyn Transport` from
2254    /// `TransportManager::get_transport`, so reaching the unsealed bytes took
2255    /// no downcast and no unsafe — and the leak ran regardless of
2256    /// `nostr_sealing_enabled`, since this path never consulted it. The
2257    /// refusal is the enforcement.
2258    ///
2259    /// Poll [`NostrTransport::get_next_signed_event`] instead: it produces a
2260    /// complete signed, sealed `["EVENT", …]` message ready for the wire. That
2261    /// is what the bundled bridges and the UniFFI `nostr_get_next_message`
2262    /// entry call.
2263    ///
2264    /// **Nothing is consumed here.** The refusal returns before the send queue
2265    /// or `pending_confirmation` are read, so a caller that reaches this by
2266    /// mistake cannot also steal a frame out from under the sealed drain — it
2267    /// stays queued, and the next `get_next_signed_event` serves it. What such
2268    /// a caller *will* observe is a queue that only grows: `queue_depth` rises,
2269    /// `congestion` saturates at 1.0 by depth 50, and DORS stops selecting
2270    /// Nostr. That is the intended shape of the failure — traffic reroutes
2271    /// instead of publishing cleartext — but it is a misintegration, not
2272    /// backpressure, which is why it is also logged.
2273    ///
2274    /// The refusal is logged **once** per transport, not per call. Every
2275    /// in-tree caller of this trait method takes the shape
2276    /// `if let Ok(Some((..))) = t.get_next_message()`, which discards an `Err`
2277    /// down the same silent path as `Ok(None)` — so for exactly the mistake
2278    /// this guard exists to catch, returning `Err` is no louder than the
2279    /// `Ok(None)` it was chosen over. The log is what makes it loud; the
2280    /// one-shot is what keeps a polling caller from burying it.
2281    fn get_next_message(&self) -> Result<Option<(String, Vec<u8>)>> {
2282        if !self
2283            .generic_poll_refusal_logged
2284            .swap(true, Ordering::Relaxed)
2285        {
2286            tracing::error!(
2287                "Nostr get_next_message() refused: this transport has no \
2288                 unsealed whole-message drain. Poll get_next_signed_event() \
2289                 instead. Outbound frames stay queued until it is polled. \
2290                 Logged once per transport."
2291            );
2292        }
2293
2294        Err(crate::Error::ConfigurationError(
2295            "Nostr has no unsealed message drain: get_next_message() would \
2296             return the bare protocol envelope in cleartext. Poll \
2297             NostrTransport::get_next_signed_event() instead, which returns a \
2298             signed, sealed [\"EVENT\", …] relay message. The queued frame is \
2299             untouched."
2300                .to_string(),
2301        ))
2302    }
2303
2304    /// Sets the callback invoked when outgoing messages are queued.
2305    fn set_on_messages_available(&self, callback: Arc<dyn Fn() + Send + Sync>) {
2306        *self.on_messages_available.lock_or_recover() = Some(callback);
2307    }
2308
2309    /// Platform confirms a message was sent successfully.
2310    fn confirm_sent(&self, message_id: &str) {
2311        let removed = self
2312            .pending_confirmation
2313            .lock_or_recover()
2314            .remove(message_id);
2315
2316        // A publication is not a message and never moves the delivery metrics
2317        // — see [`SyntheticPublication`] for why counting it would misreport
2318        // this transport's reliability to DORS. The rule is the same for every
2319        // record kind, so this tests only that the id *is* synthetic.
2320        if removed.is_some() && synthetic_publication(message_id).is_none() {
2321            let mut metrics = self.metrics.lock_or_recover();
2322            metrics.success_count = metrics.success_count.saturating_add(1);
2323            recalculate_delivery_ratios(&mut metrics);
2324        }
2325
2326        // Unconditional for the same reason the failure path is: a confirmation
2327        // that races the pending-confirmation timeout finds nothing to remove,
2328        // and the relay accepted the event either way. Reporting it is what
2329        // lets the engine retire a retraction it owes, so dropping it on the
2330        // race would leave that name being retracted on every tick forever.
2331        match synthetic_publication(message_id) {
2332            Some(SyntheticPublication::Discovery(tag)) => {
2333                self.mark_discovery_publication_confirmed(tag)
2334            }
2335            Some(SyntheticPublication::KeyPackage(_)) | Some(SyntheticPublication::Deletion) => {}
2336            None => {}
2337        }
2338    }
2339
2340    /// Platform reports a send failure.
2341    fn report_send_failure(&self, message_id: &str) {
2342        let removed = self
2343            .pending_confirmation
2344            .lock_or_recover()
2345            .remove(message_id);
2346
2347        // Same rule as `confirm_sent`: a publication's outcome is reported to
2348        // the engine below, never to the delivery metrics DORS scores on.
2349        if removed.is_some() && synthetic_publication(message_id).is_none() {
2350            let mut metrics = self.metrics.lock_or_recover();
2351            metrics.failure_count = metrics.failure_count.saturating_add(1);
2352            recalculate_delivery_ratios(&mut metrics);
2353        }
2354
2355        // Unconditional, unlike the metrics above: a report that races the
2356        // confirmation timeout finds no pending entry, and missing a real
2357        // failure strands the record until restart while a redundant republish
2358        // costs one idempotent relay write.
2359        match synthetic_publication(message_id) {
2360            Some(SyntheticPublication::KeyPackage(slot_id)) => {
2361                self.mark_publications_failed(vec![slot_id])
2362            }
2363            Some(SyntheticPublication::Discovery(tag)) => {
2364                self.mark_discovery_publications_failed(vec![tag])
2365            }
2366            Some(SyntheticPublication::Deletion) => {}
2367            None => {}
2368        }
2369    }
2370}
2371
2372/// Builder for [`NostrTransport`].
2373pub struct NostrTransportBuilder {
2374    device_id: String,
2375    config: NostrConfig,
2376}
2377
2378impl NostrTransportBuilder {
2379    /// Creates a new builder.
2380    pub fn new(device_id: impl Into<String>) -> Self {
2381        Self {
2382            device_id: device_id.into(),
2383            config: NostrConfig::default(),
2384        }
2385    }
2386
2387    /// Sets the relay URLs.
2388    pub fn relay_urls(mut self, urls: Vec<String>) -> Self {
2389        self.config.relay_urls = urls;
2390        self
2391    }
2392
2393    /// Adds a single relay URL.
2394    pub fn add_relay_url(mut self, url: impl Into<String>) -> Self {
2395        self.config.relay_urls.push(url.into());
2396        self
2397    }
2398
2399    /// Sets the connection timeout.
2400    pub fn connection_timeout(mut self, timeout: Duration) -> Self {
2401        self.config.connection_timeout = timeout;
2402        self
2403    }
2404
2405    /// Sets whether to auto-reconnect.
2406    pub fn auto_reconnect(mut self, auto_reconnect: bool) -> Self {
2407        self.config.auto_reconnect = auto_reconnect;
2408        self
2409    }
2410
2411    /// Sets the reconnection delay.
2412    pub fn reconnect_delay(mut self, delay: Duration) -> Self {
2413        self.config.reconnect_delay = delay;
2414        self
2415    }
2416
2417    /// Sets the maximum reconnection attempts.
2418    pub fn max_reconnect_attempts(mut self, max: u32) -> Self {
2419        self.config.max_reconnect_attempts = max;
2420        self
2421    }
2422
2423    /// Builds the transport.
2424    pub fn build(self) -> Result<NostrTransport> {
2425        NostrTransport::with_config(self.device_id, self.config)
2426    }
2427}
2428
2429#[cfg(test)]
2430mod tests {
2431    use super::*;
2432    use crate::constants::DEFAULT_MAX_MESSAGE_SIZE;
2433    use offline_protocol_core::{AppId, UserId};
2434
2435    /// The address a test label stands for.
2436    ///
2437    /// Every id this transport touches — its own, a recipient's, a peer being
2438    /// resolved — is a derived address now, and construction refuses anything
2439    /// else, so a fixture cannot simply *be* `"alice"`. It can hold the same
2440    /// address every time it says `"alice"`, which is what this gives.
2441    ///
2442    /// Seeded from the label rather than randomly so a failure reproduces, and
2443    /// built straight from the hash rather than through a real keypair because
2444    /// nothing here verifies the address against a key: the transport only
2445    /// hashes it. The core crate's `test_identity::id` is the version that does
2446    /// come from a key, for fixtures that also have to satisfy MLS.
2447    fn addr(label: &str) -> String {
2448        addr_typed(label).to_string()
2449    }
2450
2451    /// A parsed [`Username`], for the derivations that take one.
2452    fn user(label: &str) -> Username {
2453        label.parse().expect("test username should parse")
2454    }
2455
2456    /// `addr` as the parsed type, for the derivations that take an [`Address`].
2457    fn addr_typed(label: &str) -> Address {
2458        use sha2::{Digest, Sha256};
2459        let digest = Sha256::digest(label.as_bytes());
2460        let mut hash = [0u8; Address::HASH_LEN];
2461        hash.copy_from_slice(&digest[..Address::HASH_LEN]);
2462        Address::from_hash_bytes(hash)
2463    }
2464
2465    fn create_test_message() -> Message {
2466        Message::new(
2467            UserId::new(addr("alice")).unwrap(),
2468            UserId::new(addr("bob")).unwrap(),
2469            AppId::new("test").unwrap(),
2470            "Test message",
2471        )
2472    }
2473
2474    /// An id that is not an address is refused, not hashed into a tag.
2475    ///
2476    /// The two failures this prevents are both silent — a guessable preimage
2477    /// is a disclosure nothing reports, a merely-wrong one addresses this
2478    /// device where nobody writes — so construction is the only place either
2479    /// can be made to surface.
2480    #[test]
2481    fn test_construction_refuses_an_id_that_is_not_an_address() {
2482        for bad in [
2483            "device1",
2484            "alice",
2485            "",
2486            // Right shape, wrong checksum: the check must be the real address
2487            // parse, not a prefix or length test.
2488            "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
2489            // Canonical address, uppercased. Accepting both spellings would
2490            // give one identity two routing tags.
2491            &addr("alice").to_uppercase(),
2492        ] {
2493            assert!(
2494                NostrTransport::new(bad).is_err(),
2495                "construction must refuse a non-address id: {bad:?}"
2496            );
2497        }
2498
2499        assert!(
2500            NostrTransport::new(addr("alice")).is_ok(),
2501            "a derived address is exactly what construction accepts"
2502        );
2503    }
2504
2505    /// A recipient that is not an address is refused at `send`, not hashed
2506    /// into a public routing tag.
2507    ///
2508    /// The constructor already refuses a non-address for *our* id; this is the
2509    /// same rule on the other side of the frame, and it has the same two silent
2510    /// failure modes. It is asserted at `send` specifically because that is the
2511    /// only queue writer: past it, the drain would treat the failure as
2512    /// retriable and republish the label `MAX_SIGN_RETRIES` times.
2513    #[test]
2514    fn test_send_refuses_a_recipient_that_is_not_an_address() {
2515        let transport = NostrTransport::new(addr("alice")).unwrap();
2516        transport.start().unwrap();
2517        transport.on_status_changed(TransportStatus::Available);
2518
2519        let msg = Message::new(
2520            UserId::new(addr("alice")).unwrap(),
2521            UserId::new("bob").unwrap(),
2522            AppId::new("test").unwrap(),
2523            "for a username",
2524        );
2525
2526        assert!(
2527            transport.send(&msg).is_err(),
2528            "a username-shaped recipient must not reach the send queue"
2529        );
2530        assert!(
2531            !transport.has_pending_sends(),
2532            "the refused message must not be queued"
2533        );
2534        assert!(
2535            transport.get_next_signed_event().unwrap().is_none(),
2536            "nothing may be published for a refused recipient"
2537        );
2538
2539        // The address form of the same peer is what does go out.
2540        let ok = Message::new(
2541            UserId::new(addr("alice")).unwrap(),
2542            UserId::new(addr("bob")).unwrap(),
2543            AppId::new("test").unwrap(),
2544            "for an address",
2545        );
2546        assert!(transport.send(&ok).is_ok());
2547    }
2548
2549    /// The resolution queue applies the same rule at its only gate, so
2550    /// `next_query` cannot pop an id whose tag derivation would fail after the
2551    /// entry was already consumed.
2552    #[test]
2553    fn test_resolution_refuses_a_peer_id_that_is_not_an_address() {
2554        let transport = NostrTransport::new(addr("alice")).unwrap();
2555
2556        for bad in ["bob", "", "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"] {
2557            assert!(
2558                !transport.request_peer_key_packages(bad),
2559                "a non-address peer id must not be queued for resolution: {bad:?}"
2560            );
2561        }
2562        assert!(
2563            transport.next_query().unwrap().is_none(),
2564            "no query may be issued for a refused peer id"
2565        );
2566
2567        assert!(
2568            transport.request_peer_key_packages(&addr("bob")),
2569            "an address is exactly what the resolution queue accepts"
2570        );
2571    }
2572
2573    #[test]
2574    fn test_nostr_transport_creation() {
2575        let transport = NostrTransport::new(addr("device1")).unwrap();
2576        assert_eq!(transport.device_id(), addr("device1"));
2577        assert_eq!(transport.transport_type(), TransportType::Nostr);
2578        assert_eq!(transport.status(), TransportStatus::Unavailable);
2579    }
2580
2581    #[test]
2582    fn test_builder() {
2583        let transport = NostrTransportBuilder::new(addr("device1"))
2584            .relay_urls(vec!["wss://relay.example.com".to_string()])
2585            .add_relay_url("wss://relay2.example.com")
2586            .connection_timeout(Duration::from_secs(60))
2587            .auto_reconnect(false)
2588            .reconnect_delay(Duration::from_secs(10))
2589            .max_reconnect_attempts(5)
2590            .build()
2591            .unwrap();
2592        assert_eq!(transport.config().relay_urls.len(), 2);
2593        assert_eq!(
2594            transport.config().connection_timeout,
2595            Duration::from_secs(60)
2596        );
2597        assert!(!transport.config().auto_reconnect);
2598        assert_eq!(transport.config().reconnect_delay, Duration::from_secs(10));
2599        assert_eq!(transport.config().max_reconnect_attempts, 5);
2600    }
2601
2602    // `test_send_receive` lived here. Its two assertions moved to the sealed
2603    // drain rather than being duplicated onto it: the queue-to-pending
2604    // transition is `test_get_next_signed_event`, and the content round trip
2605    // is `test_sealed_frame_round_trips_through_the_recipients_transport`,
2606    // which asserts the sender as well as the id.
2607
2608    #[test]
2609    fn test_send_when_unavailable_fails() {
2610        let transport = NostrTransport::new(addr("device1")).unwrap();
2611        let msg = create_test_message();
2612        assert!(transport.send(&msg).is_err());
2613    }
2614
2615    #[test]
2616    fn test_receive_when_empty_returns_none() {
2617        let transport = NostrTransport::new(addr("device1")).unwrap();
2618        assert!(transport.receive().unwrap().is_none());
2619    }
2620
2621    #[test]
2622    fn test_confirmation_loop() {
2623        let transport = NostrTransport::new(addr("device1")).unwrap();
2624        transport.start().unwrap();
2625        transport.on_status_changed(TransportStatus::Available);
2626
2627        let msg = create_test_message();
2628        transport.send(&msg).unwrap();
2629
2630        let signed = transport.get_next_signed_event().unwrap().unwrap();
2631        transport.confirm_sent(&signed.message_id);
2632
2633        let metrics = transport.metrics();
2634        assert_eq!(metrics.success_count, 1);
2635        assert_eq!(metrics.failure_count, 0);
2636    }
2637
2638    #[test]
2639    fn test_send_failure_reporting() {
2640        let transport = NostrTransport::new(addr("device1")).unwrap();
2641        transport.start().unwrap();
2642        transport.on_status_changed(TransportStatus::Available);
2643
2644        let msg = create_test_message();
2645        transport.send(&msg).unwrap();
2646
2647        let signed = transport.get_next_signed_event().unwrap().unwrap();
2648        transport.report_send_failure(&signed.message_id);
2649
2650        let metrics = transport.metrics();
2651        assert_eq!(metrics.success_count, 0);
2652        assert_eq!(metrics.failure_count, 1);
2653    }
2654
2655    #[test]
2656    fn test_fail_all_pending_on_disconnect() {
2657        let transport = NostrTransport::new(addr("device1")).unwrap();
2658        transport.start().unwrap();
2659        transport.on_status_changed(TransportStatus::Available);
2660
2661        let msg = create_test_message();
2662        transport.send(&msg).unwrap();
2663        let _ = transport.get_next_signed_event().unwrap();
2664
2665        transport.on_status_changed(TransportStatus::Disconnected);
2666
2667        let metrics = transport.metrics();
2668        assert_eq!(metrics.failure_count, 1);
2669    }
2670
2671    #[test]
2672    fn test_stop_fails_pending() {
2673        let transport = NostrTransport::new(addr("device1")).unwrap();
2674        transport.start().unwrap();
2675        transport.on_status_changed(TransportStatus::Available);
2676
2677        let msg = create_test_message();
2678        transport.send(&msg).unwrap();
2679        let _ = transport.get_next_signed_event().unwrap();
2680
2681        transport.stop().unwrap();
2682
2683        let metrics = transport.metrics();
2684        assert_eq!(metrics.failure_count, 1);
2685    }
2686
2687    #[test]
2688    fn test_serialization() {
2689        let transport = NostrTransport::new(addr("device1")).unwrap();
2690        let msg = create_test_message();
2691        let data = transport.serialize_message(&msg).unwrap();
2692        let deserialized = transport.deserialize_message(&data).unwrap();
2693        assert_eq!(deserialized.id, msg.id);
2694    }
2695
2696    #[test]
2697    fn test_reconnect_logic() {
2698        let transport = NostrTransportBuilder::new(addr("device1"))
2699            .max_reconnect_attempts(3)
2700            .build()
2701            .unwrap();
2702
2703        assert!(transport.should_reconnect());
2704        transport.increment_reconnect_attempts();
2705        transport.increment_reconnect_attempts();
2706        assert!(transport.should_reconnect());
2707        transport.increment_reconnect_attempts();
2708        assert!(!transport.should_reconnect());
2709    }
2710
2711    #[test]
2712    fn test_on_data_received_invalid_json_drops_ok() {
2713        let transport = NostrTransport::new(addr("device1")).unwrap();
2714        let result = transport.on_data_received(b"not json".to_vec());
2715        assert!(result.is_ok());
2716        assert!(transport.receive().unwrap().is_none());
2717    }
2718
2719    #[test]
2720    fn test_on_data_received_rejects_oversized_payload() {
2721        let transport = NostrTransport::new(addr("device1")).unwrap();
2722        let oversized = vec![0u8; DEFAULT_MAX_MESSAGE_SIZE + 1];
2723        let result = transport.on_data_received(oversized);
2724        assert!(result.is_err());
2725    }
2726
2727    #[test]
2728    fn test_on_messages_available_callback() {
2729        let transport = NostrTransport::new(addr("device1")).unwrap();
2730        transport.start().unwrap();
2731        transport.on_status_changed(TransportStatus::Available);
2732
2733        let called = Arc::new(Mutex::new(false));
2734        let called_clone = Arc::clone(&called);
2735        transport.set_on_messages_available(Arc::new(move || {
2736            *called_clone.lock().unwrap() = true;
2737        }));
2738
2739        let msg = create_test_message();
2740        transport.send(&msg).unwrap();
2741        assert!(*called.lock().unwrap());
2742    }
2743
2744    #[test]
2745    fn test_messages_available_callback_reentrant_send() {
2746        use std::sync::atomic::{AtomicBool, Ordering};
2747
2748        let transport = Arc::new(NostrTransport::new(addr("device1")).unwrap());
2749        transport.on_status_changed(TransportStatus::Available);
2750
2751        let reentered = Arc::new(AtomicBool::new(false));
2752        let reentered_clone = Arc::clone(&reentered);
2753        let transport_clone = Arc::clone(&transport);
2754        transport.set_on_messages_available(Arc::new(move || {
2755            // Re-enters send() from inside the callback. If send() held the
2756            // callback mutex across this call, the inner send would
2757            // self-deadlock re-locking it.
2758            if !reentered_clone.swap(true, Ordering::SeqCst) {
2759                transport_clone.send(&create_test_message()).unwrap();
2760            }
2761        }));
2762
2763        transport.send(&create_test_message()).unwrap();
2764
2765        assert!(reentered.load(Ordering::SeqCst));
2766        assert_eq!(transport.send_queue.lock().unwrap().len(), 2);
2767    }
2768
2769    #[test]
2770    fn test_update_metrics_preserves_confirmation_counts() {
2771        let transport = NostrTransport::new(addr("device1")).unwrap();
2772        transport.start().unwrap();
2773        transport.on_status_changed(TransportStatus::Available);
2774
2775        let msg = create_test_message();
2776        transport.send(&msg).unwrap();
2777        let signed = transport.get_next_signed_event().unwrap().unwrap();
2778        transport.confirm_sent(&signed.message_id);
2779
2780        let mut new_metrics = TransportMetrics::default();
2781        new_metrics.rssi = Some(-70);
2782        transport.update_metrics(new_metrics);
2783
2784        let metrics = transport.metrics();
2785        assert_eq!(metrics.success_count, 1);
2786        assert_eq!(metrics.rssi, Some(-70));
2787    }
2788
2789    #[test]
2790    fn test_platform_handle() {
2791        let transport = NostrTransport::new(addr("device1")).unwrap();
2792        assert!(transport.platform_handle().is_none());
2793        transport.set_platform_handle(42);
2794        assert_eq!(transport.platform_handle(), Some(42));
2795    }
2796
2797    #[test]
2798    fn test_drain_expired_pending_expires_old_entries() {
2799        let transport = NostrTransport::new(addr("device1")).unwrap();
2800        transport.start().unwrap();
2801        transport.on_status_changed(TransportStatus::Available);
2802
2803        // Insert a pending entry that is already past the timeout by backdating it.
2804        let timeout_secs = NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS;
2805        let expired_at = Instant::now() - Duration::from_secs(timeout_secs + 1);
2806        transport
2807            .pending_confirmation
2808            .lock()
2809            .unwrap()
2810            .insert("expired-msg".to_string(), expired_at);
2811
2812        // Insert a recent pending entry that should survive.
2813        transport
2814            .pending_confirmation
2815            .lock()
2816            .unwrap()
2817            .insert("recent-msg".to_string(), Instant::now());
2818
2819        transport.drain_expired_pending();
2820
2821        let pending = transport.pending_confirmation.lock().unwrap();
2822        assert!(
2823            !pending.contains_key("expired-msg"),
2824            "Expired entry should have been drained"
2825        );
2826        assert!(
2827            pending.contains_key("recent-msg"),
2828            "Recent entry should be retained"
2829        );
2830        drop(pending);
2831
2832        let metrics = transport.metrics();
2833        assert_eq!(
2834            metrics.failure_count, 1,
2835            "Expired entry should be counted as a failure"
2836        );
2837    }
2838
2839    #[test]
2840    fn test_has_pending_sends() {
2841        let transport = NostrTransport::new(addr("device1")).unwrap();
2842        transport.start().unwrap();
2843        transport.on_status_changed(TransportStatus::Available);
2844
2845        assert!(!transport.has_pending_sends());
2846
2847        let msg = create_test_message();
2848        transport.send(&msg).unwrap();
2849        assert!(transport.has_pending_sends());
2850    }
2851
2852    #[test]
2853    fn test_pending_confirmation_count() {
2854        let transport = NostrTransport::new(addr("device1")).unwrap();
2855        transport.start().unwrap();
2856        transport.on_status_changed(TransportStatus::Available);
2857
2858        assert_eq!(transport.pending_confirmation_count(), 0);
2859
2860        let msg = create_test_message();
2861        transport.send(&msg).unwrap();
2862        let _ = transport.get_next_signed_event().unwrap();
2863
2864        assert_eq!(transport.pending_confirmation_count(), 1);
2865    }
2866
2867    #[test]
2868    fn test_default_config() {
2869        let config = NostrConfig::default();
2870        assert!(config.relay_urls.is_empty());
2871        assert_eq!(
2872            config.connection_timeout,
2873            Duration::from_secs(NOSTR_CONNECTION_TIMEOUT_SECS)
2874        );
2875        assert!(config.auto_reconnect);
2876        assert_eq!(config.reconnect_delay, Duration::from_secs(5));
2877        assert_eq!(config.max_reconnect_attempts, 0);
2878    }
2879
2880    #[test]
2881    fn test_get_next_signed_event() {
2882        let transport = NostrTransport::new(addr("device1")).unwrap();
2883        transport.start().unwrap();
2884        transport.on_status_changed(TransportStatus::Available);
2885
2886        let msg = create_test_message();
2887        transport.send(&msg).unwrap();
2888
2889        let signed = transport.get_next_signed_event().unwrap().unwrap();
2890        assert!(!signed.message_id.is_empty());
2891        assert_eq!(signed.event_id.len(), 64); // 32-byte SHA-256 hex
2892        assert!(signed.event_json.starts_with("[\"EVENT\",{"));
2893        assert!(signed.event_json.ends_with("}]"));
2894        assert!(signed.event_json.contains(&signed.event_id));
2895
2896        // Message was dequeued and moved to pending confirmation
2897        assert!(!transport.has_pending_sends());
2898        assert_eq!(transport.pending_confirmation_count(), 1);
2899
2900        // No more messages
2901        assert!(transport.get_next_signed_event().unwrap().is_none());
2902    }
2903
2904    /// The generic whole-message poll must refuse rather than hand back the
2905    /// unsealed envelope, and must cost the sealed drain nothing.
2906    ///
2907    /// Dispatched through `&dyn Transport` on purpose: that is the shape the
2908    /// leak had. `TransportManager::get_transport` hands out an
2909    /// `Arc<dyn Transport>`, so this was reachable with no downcast and no
2910    /// unsafe, and a concrete-typed call here would not pin the vtable entry
2911    /// that actually mattered.
2912    #[test]
2913    fn test_generic_transport_poll_refuses_rather_than_leaking_cleartext() {
2914        let transport = NostrTransport::new(addr("device1")).unwrap();
2915        transport.start().unwrap();
2916        transport.on_status_changed(TransportStatus::Available);
2917
2918        let msg = create_test_message();
2919        transport.send(&msg).unwrap();
2920
2921        let generic: &dyn Transport = &transport;
2922        let err = generic.get_next_message().unwrap_err();
2923        // Asserted on `code()` rather than the variant or the message text:
2924        // `Error` documents the code as the stable contract and `Display` as
2925        // free to change, so this is the surface a caller is entitled to
2926        // classify on.
2927        assert_eq!(err.code(), "CONFIGURATION_ERROR", "got {err:?}");
2928
2929        // The other generic egress door must stay shut too. Nostr is safe here
2930        // only because it does not override the trait's `Ok(None)` default —
2931        // nothing else records that as a requirement, so a later fragmenting
2932        // implementation would reopen this leak on a method nobody thinks of
2933        // as a leak surface.
2934        assert!(
2935            generic.get_next_fragment().unwrap().is_none(),
2936            "Nostr must expose no generic fragment drain either"
2937        );
2938
2939        // The one-shot on the log must gate the *log*, never the refusal: a
2940        // second caller has to be refused exactly as loudly as the first.
2941        let second = generic.get_next_message().unwrap_err();
2942        assert_eq!(second.code(), "CONFIGURATION_ERROR", "got {second:?}");
2943
2944        // The refusal returns before the queue or the pending map are touched,
2945        // so a caller that lands here by mistake cannot also strand the frame
2946        // it failed to get: nothing is dequeued, and nothing is left awaiting a
2947        // confirmation that no bridge will ever send.
2948        assert!(
2949            transport.has_pending_sends(),
2950            "the refused frame must stay queued"
2951        );
2952        assert_eq!(
2953            transport.pending_confirmation_count(),
2954            0,
2955            "a refusal must not enter the confirmation loop"
2956        );
2957
2958        // ...and the sealed drain still serves that same frame.
2959        let signed = transport.get_next_signed_event().unwrap().unwrap();
2960        assert_eq!(signed.message_id, msg.id.to_string());
2961        assert!(signed.event_json.starts_with("[\"EVENT\",{"));
2962    }
2963
2964    #[test]
2965    fn test_get_next_signed_event_confirm_flow() {
2966        let transport = NostrTransport::new(addr("device1")).unwrap();
2967        transport.start().unwrap();
2968        transport.on_status_changed(TransportStatus::Available);
2969
2970        let msg = create_test_message();
2971        transport.send(&msg).unwrap();
2972
2973        let signed = transport.get_next_signed_event().unwrap().unwrap();
2974        transport.confirm_sent(&signed.message_id);
2975
2976        let metrics = transport.metrics();
2977        assert_eq!(metrics.success_count, 1);
2978        assert_eq!(metrics.failure_count, 0);
2979        assert_eq!(transport.pending_confirmation_count(), 0);
2980    }
2981
2982    #[test]
2983    fn test_subscription_filters_on_routing_tag_not_signing_key() {
2984        let transport = NostrTransport::new(addr("device1")).unwrap();
2985        let expected_tag = nostr_crypto::routing_tag_for_address(&addr_typed("device1")).unwrap();
2986
2987        assert_eq!(transport.routing_tag(), expected_tag);
2988        // The signing key is random per install and must never leak into the
2989        // subscription filter, which peers derive from our device_id.
2990        assert_ne!(transport.public_key_hex(), expected_tag);
2991
2992        let filter = transport.create_subscription("sub1").unwrap();
2993        assert!(filter.contains(&expected_tag));
2994        assert!(!filter.contains(&transport.public_key_hex()));
2995    }
2996
2997    #[test]
2998    fn test_install_signing_secret_gives_stable_identity() {
2999        let transport_a = NostrTransport::new(addr("device1")).unwrap();
3000        let transport_b = NostrTransport::new(addr("device1")).unwrap();
3001
3002        // Ephemeral keys are random: two instances differ.
3003        assert_ne!(transport_a.public_key_hex(), transport_b.public_key_hex());
3004
3005        // Installing the same persisted secret (a simulated restart)
3006        // converges both on the same identity.
3007        let secret = [42u8; 32];
3008        transport_a.install_signing_secret(&secret).unwrap();
3009        transport_b.install_signing_secret(&secret).unwrap();
3010        assert_eq!(transport_a.public_key_hex(), transport_b.public_key_hex());
3011
3012        // Addressing is untouched by the key swap.
3013        assert_eq!(
3014            transport_a.routing_tag(),
3015            nostr_crypto::routing_tag_for_address(&addr_typed("device1")).unwrap()
3016        );
3017    }
3018
3019    #[test]
3020    fn test_oversized_event_is_dropped_permanently_and_does_not_block_queue() {
3021        let transport = NostrTransport::new(addr("device1")).unwrap();
3022        transport.start().unwrap();
3023        transport.on_status_changed(TransportStatus::Available);
3024
3025        let oversized = Message::new(
3026            UserId::new(addr("alice")).unwrap(),
3027            UserId::new(addr("bob")).unwrap(),
3028            AppId::new("test").unwrap(),
3029            "x".repeat(NOSTR_MAX_PAYLOAD_SIZE),
3030        );
3031        transport.send(&oversized).unwrap();
3032        let queued_behind = create_test_message();
3033        transport.send(&queued_behind).unwrap();
3034
3035        let err = transport.get_next_signed_event().unwrap_err();
3036        assert!(
3037            matches!(
3038                err,
3039                crate::Error::MessageTooLarge(actual, limit)
3040                    if actual > NOSTR_MAX_PAYLOAD_SIZE && limit == NOSTR_MAX_PAYLOAD_SIZE
3041            ),
3042            "expected MessageTooLarge, got {err:?}"
3043        );
3044
3045        // Dropped on the first attempt rather than re-queued at the front for
3046        // MAX_SIGN_RETRIES rounds, so the message behind it is served now.
3047        let signed = transport.get_next_signed_event().unwrap().unwrap();
3048        assert_eq!(signed.message_id, queued_behind.id.to_string());
3049
3050        assert!(
3051            transport.sign_retry_counts.lock().unwrap().is_empty(),
3052            "an unshrinkable message must not accumulate retry state"
3053        );
3054        assert_eq!(
3055            transport.metrics().failure_count,
3056            1,
3057            "the drop must reach metrics, or DORS never learns Nostr failed"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_size_cap_measures_the_relay_message_not_the_inner_payload() {
3063        let transport = NostrTransport::new(addr("device1")).unwrap();
3064        transport.start().unwrap();
3065        transport.on_status_changed(TransportStatus::Available);
3066
3067        // The serialized message fits under the cap; base64 inflation (4/3)
3068        // pushes the event a relay actually sees over it. Capping the inner
3069        // payload instead would let this onto the wire to be rejected there.
3070        let msg = Message::new(
3071            UserId::new(addr("alice")).unwrap(),
3072            UserId::new(addr("bob")).unwrap(),
3073            AppId::new("test").unwrap(),
3074            "x".repeat(50_000),
3075        );
3076        assert!(
3077            transport.serialize_message(&msg).unwrap().len() < NOSTR_MAX_PAYLOAD_SIZE,
3078            "test premise: the inner payload is under the cap"
3079        );
3080
3081        transport.send(&msg).unwrap();
3082        assert!(matches!(
3083            transport.get_next_signed_event().unwrap_err(),
3084            crate::Error::MessageTooLarge(_, _)
3085        ));
3086    }
3087
3088    #[test]
3089    fn test_default_size_media_chunk_exceeds_the_relay_cap() {
3090        // Ground truth for why the cap matters, and why it is not a
3091        // regression: DORS gives Nostr a media_bonus of 30.0, so media routes
3092        // here — but `Message::binary_content` has no base64 serde adapter, so
3093        // a chunk becomes a JSON array of decimal numbers (~3.6x) before the
3094        // event's own base64 (~1.33x) is applied on top. At the engine's
3095        // 32 KiB DEFAULT_CHUNK_SIZE that is ~156 KB on the wire, well past
3096        // both this cap and the 64-128 KB relays typically accept. Such events
3097        // were never deliverable; they now fail here instead of at the relay.
3098        let transport = NostrTransport::new(addr("device1")).unwrap();
3099        transport.start().unwrap();
3100        transport.on_status_changed(TransportStatus::Available);
3101
3102        let mut chunk_message = create_test_message();
3103        chunk_message.content = String::new();
3104        chunk_message.binary_content = Some(vec![0xABu8; 32 * 1024]);
3105
3106        transport.send(&chunk_message).unwrap();
3107        assert!(matches!(
3108            transport.get_next_signed_event().unwrap_err(),
3109            crate::Error::MessageTooLarge(_, _)
3110        ));
3111
3112        // A BLE-sized 4 KiB chunk still fits, so the cap does not forbid
3113        // media over Nostr outright — only the default chunking for it.
3114        let mut small_chunk = create_test_message();
3115        small_chunk.content = String::new();
3116        small_chunk.binary_content = Some(vec![0xABu8; 4 * 1024]);
3117
3118        transport.send(&small_chunk).unwrap();
3119        assert!(transport.get_next_signed_event().unwrap().is_some());
3120    }
3121
3122    /// Parses the REQ filter object out of a `["REQ", id, {…}]` message.
3123    fn subscription_filter(transport: &NostrTransport) -> serde_json::Value {
3124        let msg = transport.create_subscription("sub1").unwrap();
3125        let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
3126        parsed[2].clone()
3127    }
3128
3129    #[test]
3130    fn test_first_run_since_is_a_recent_window_never_zero() {
3131        // A zero (or absent) `since` is the unbounded filter the watermark
3132        // exists to remove, and no-watermark is the *common* case: the bridges
3133        // subscribe on every relay connect, which can precede the restore.
3134        let transport = NostrTransport::new(addr("device1")).unwrap();
3135        assert!(transport.receive_watermark_secs().is_none());
3136
3137        let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3138        let now = now_unix_secs();
3139
3140        assert!(since > 0, "first-run since must not be zero: {since}");
3141        let backfill = now - since;
3142        assert!(
3143            (NOSTR_FIRST_RUN_BACKFILL_SECS..NOSTR_FIRST_RUN_BACKFILL_SECS + 60).contains(&backfill),
3144            "first-run since should reach back one backfill window, got {backfill}s"
3145        );
3146    }
3147
3148    #[test]
3149    fn test_since_follows_the_watermark_with_a_jitter_and_skew_overlap() {
3150        let transport = NostrTransport::new(addr("device1")).unwrap();
3151        let mark = now_unix_secs() - 30;
3152        assert!(transport.advance_receive_watermark(mark));
3153
3154        let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3155        assert_eq!(
3156            since,
3157            mark - NOSTR_CREATED_AT_JITTER_SECS - NOSTR_CLOCK_SKEW_MARGIN_SECS,
3158            "since must sit a full jitter + skew window below the watermark"
3159        );
3160    }
3161
3162    #[test]
3163    fn test_since_overlap_covers_the_gift_wrap_jitter_window() {
3164        // The sealed-envelope work jitters `created_at` backwards by up to
3165        // NOSTR_CREATED_AT_JITTER_SECS, so an event published *now* can carry a
3166        // timestamp that far in the past. If `since` did not reach back at
3167        // least that far past the mark, those events would be filtered out by
3168        // the very relay query meant to fetch them — a silent delivery loss
3169        // with no error anywhere. This pins the two uses of the constant
3170        // together.
3171        let transport = NostrTransport::new(addr("device1")).unwrap();
3172        let mark = now_unix_secs();
3173        transport.advance_receive_watermark(mark);
3174
3175        let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3176        assert!(
3177            mark - since >= NOSTR_CREATED_AT_JITTER_SECS,
3178            "since must reach at least a jitter window below the mark"
3179        );
3180    }
3181
3182    #[test]
3183    fn test_watermark_only_advances() {
3184        let transport = NostrTransport::new(addr("device1")).unwrap();
3185        let base = now_unix_secs() - 3600;
3186
3187        assert!(transport.advance_receive_watermark(base));
3188        assert_eq!(transport.receive_watermark_secs(), Some(base));
3189
3190        // Relays stream stored events newest-first, so older ones arrive after
3191        // newer ones. They must not walk the mark back.
3192        assert!(!transport.advance_receive_watermark(base - 500));
3193        assert_eq!(transport.receive_watermark_secs(), Some(base));
3194
3195        // Equal timestamps are not progress either.
3196        assert!(!transport.advance_receive_watermark(base));
3197
3198        assert!(transport.advance_receive_watermark(base + 1));
3199        assert_eq!(transport.receive_watermark_secs(), Some(base + 1));
3200    }
3201
3202    #[test]
3203    fn test_future_dated_event_cannot_stall_the_subscription() {
3204        // The routing tag is derived from a public user id, so anyone can
3205        // publish an event addressed to us, and `created_at` is whatever the
3206        // publisher wrote. Accepting a far-future value would pin the mark
3207        // there and every later subscription would ask for events `since` the
3208        // far future — receiving nothing, permanently, with no error raised.
3209        let transport = NostrTransport::new(addr("device1")).unwrap();
3210        let honest = now_unix_secs() - 10;
3211        transport.advance_receive_watermark(honest);
3212
3213        let year_3000 = 32_503_680_000i64;
3214        assert!(
3215            !transport.advance_receive_watermark(year_3000),
3216            "a far-future created_at must not advance the watermark"
3217        );
3218        assert_eq!(transport.receive_watermark_secs(), Some(honest));
3219
3220        let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3221        assert!(
3222            since < now_unix_secs(),
3223            "since must stay in the past: {since}"
3224        );
3225    }
3226
3227    #[test]
3228    fn test_non_positive_created_at_is_ignored() {
3229        // A missing `created_at` reaches the FFI as 0; a malformed one can be
3230        // negative. Neither is receive progress.
3231        let transport = NostrTransport::new(addr("device1")).unwrap();
3232        assert!(!transport.advance_receive_watermark(0));
3233        assert!(!transport.advance_receive_watermark(-1));
3234        assert!(transport.receive_watermark_secs().is_none());
3235    }
3236
3237    #[test]
3238    fn test_stop_preserves_the_receive_watermark() {
3239        // stop() clears in-flight queues, but receive progress is not undone by
3240        // a restart — resetting it here would replay a full backfill window on
3241        // every stop/start cycle.
3242        let transport = NostrTransport::new(addr("device1")).unwrap();
3243        let mark = now_unix_secs() - 60;
3244        transport.advance_receive_watermark(mark);
3245
3246        transport.stop().unwrap();
3247
3248        assert_eq!(transport.receive_watermark_secs(), Some(mark));
3249    }
3250
3251    #[test]
3252    fn test_signed_event_uses_recipient_routing_tag_and_own_signing_key() {
3253        let transport = NostrTransport::new(addr("device1")).unwrap();
3254        transport.start().unwrap();
3255        transport.on_status_changed(TransportStatus::Available);
3256        // Addressing is a property of the unsealed path too; assert it there so
3257        // this test keeps covering the legacy shape rather than duplicating the
3258        // sealed-path tests below.
3259        transport.set_sealing_enabled(false);
3260
3261        // create_test_message is addressed to "bob".
3262        let msg = create_test_message();
3263        transport.send(&msg).unwrap();
3264
3265        let signed = transport.get_next_signed_event().unwrap().unwrap();
3266        let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3267        assert!(
3268            signed.event_json.contains(&bob_tag),
3269            "event must be addressed to the recipient's routing tag"
3270        );
3271        assert!(
3272            signed
3273                .event_json
3274                .contains(&format!("\"pubkey\":\"{}\"", transport.public_key_hex())),
3275            "unsealed event must be signed by this install's signing key"
3276        );
3277    }
3278
3279    // ===================================================================
3280    // Sealed envelope
3281    // ===================================================================
3282
3283    /// Parses the event object out of a signed `["EVENT", {…}]` message.
3284    fn event_object(signed: &SignedNostrEvent) -> serde_json::Value {
3285        let parsed: serde_json::Value = serde_json::from_str(&signed.event_json).unwrap();
3286        parsed[1].clone()
3287    }
3288
3289    #[test]
3290    fn test_relay_visible_payload_contains_no_username() {
3291        // THE regression test for the finding this work closes. Before sealing,
3292        // the event content was base64 of the whole `Message` JSON, so both
3293        // usernames, the app id, the content type, the metadata map and a
3294        // millisecond timestamp were readable by every relay, permanently.
3295        //
3296        // Asserted over the whole event except the ciphertext: a future change
3297        // that moved a username into a tag, or reintroduced a stable signing
3298        // pubkey derived from one, must fail here too. The ciphertext itself is
3299        // excluded only *after* pinning its sealed shape — it is random bytes,
3300        // and a case-folded substring assertion over its base64 spells "bob"
3301        // roughly once per hundred runs, failing CI with no leak present.
3302        let transport = NostrTransport::new(addr("alice")).unwrap();
3303        transport.start().unwrap();
3304        transport.on_status_changed(TransportStatus::Available);
3305
3306        let msg = Message::new(
3307            UserId::new(addr("alice")).unwrap(),
3308            UserId::new(addr("bob")).unwrap(),
3309            AppId::new("example-app").unwrap(),
3310            "the quick brown fox",
3311        );
3312        transport.send(&msg).unwrap();
3313
3314        let signed = transport.get_next_signed_event().unwrap().unwrap();
3315
3316        // The content must be a sealed NIP-44 payload — not the pre-sealing
3317        // leak shape, base64 of the message JSON.
3318        let mut event = event_object(&signed);
3319        let content = base64::engine::general_purpose::STANDARD
3320            .decode(event["content"].as_str().unwrap())
3321            .unwrap();
3322        assert!(
3323            nostr_crypto::is_sealed_payload(&content),
3324            "event content is not a sealed payload: {}",
3325            signed.event_json
3326        );
3327
3328        // With the random ciphertext scrubbed, nothing left in the event may
3329        // name anyone or anything. `content` is the only non-hex field, so
3330        // nothing else can spell these strings by chance.
3331        event["content"] = serde_json::Value::String(String::new());
3332        let wire = serde_json::to_string(&event).unwrap().to_lowercase();
3333
3334        for leak in ["alice", "bob", "example-app", "the quick brown fox"] {
3335            assert!(
3336                !wire.contains(leak),
3337                "relay-visible payload leaks {leak:?}: {}",
3338                signed.event_json
3339            );
3340        }
3341
3342        // The only thing a relay legitimately learns is the recipient's routing
3343        // tag — an opaque label it cannot invert without already knowing the
3344        // user id it is looking for.
3345        let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3346        assert!(wire.contains(&bob_tag));
3347    }
3348
3349    #[test]
3350    fn test_sealed_event_is_a_gift_wrap_not_signed_by_our_install_key() {
3351        let transport = NostrTransport::new(addr("alice")).unwrap();
3352        transport.start().unwrap();
3353        transport.on_status_changed(TransportStatus::Available);
3354        transport.send(&create_test_message()).unwrap();
3355
3356        let signed = transport.get_next_signed_event().unwrap().unwrap();
3357        let event = event_object(&signed);
3358
3359        assert_eq!(
3360            event["kind"].as_u64(),
3361            Some(u64::from(nostr_crypto::NOSTR_GIFT_WRAP_KIND))
3362        );
3363        assert_ne!(
3364            event["pubkey"].as_str().unwrap(),
3365            transport.public_key_hex(),
3366            "the wrapper must not be signed by our stable install key, or every \
3367             event we publish is linkable back to this device"
3368        );
3369    }
3370
3371    #[test]
3372    fn test_sealed_frame_round_trips_through_the_recipients_transport() {
3373        // End-to-end over the two transports, which is what proves the send and
3374        // receive halves agree about which key seals what.
3375        let alice = NostrTransport::new(addr("alice")).unwrap();
3376        alice.start().unwrap();
3377        alice.on_status_changed(TransportStatus::Available);
3378
3379        let bob = NostrTransport::new(addr("bob")).unwrap();
3380        bob.install_signing_secret(&[88u8; 32]).unwrap();
3381        // Alice has seen Bob's key package, so this is the steady state.
3382        alice.set_peer_nostr_pubkey(&addr("bob"), &bob.public_key_hex());
3383
3384        let msg = create_test_message();
3385        alice.send(&msg).unwrap();
3386        let signed = alice.get_next_signed_event().unwrap().unwrap();
3387
3388        let event = event_object(&signed);
3389        let sealed = base64::engine::general_purpose::STANDARD
3390            .decode(event["content"].as_str().unwrap())
3391            .unwrap();
3392        let sender_pubkey = event["pubkey"].as_str().unwrap();
3393
3394        let plaintext = bob.unseal_event_payload(sender_pubkey, &sealed);
3395        let received = bob.deserialize_message(&plaintext).unwrap();
3396        assert_eq!(received.id, msg.id);
3397        assert_eq!(received.sender.as_str(), addr("alice"));
3398    }
3399
3400    #[test]
3401    fn test_bootstrap_frame_round_trips_before_any_key_exchange() {
3402        // Cold first contact: Alice knows only Bob's user id. The frame is
3403        // sealed to Bob's publicly computable key, and Bob's receive path finds
3404        // it on the second attempt.
3405        let alice = NostrTransport::new(addr("alice")).unwrap();
3406        alice.start().unwrap();
3407        alice.on_status_changed(TransportStatus::Available);
3408
3409        let bob = NostrTransport::new(addr("bob")).unwrap();
3410        bob.install_signing_secret(&[91u8; 32]).unwrap();
3411        // Deliberately NOT calling set_peer_nostr_pubkey.
3412
3413        let msg = create_test_message();
3414        alice.send(&msg).unwrap();
3415        let signed = alice.get_next_signed_event().unwrap().unwrap();
3416        let event = event_object(&signed);
3417
3418        // Indistinguishable from the steady-state case on the wire: same kind,
3419        // same tag shape, ephemeral pubkey. A relay cannot filter for "these
3420        // two are just starting to talk".
3421        assert_eq!(
3422            event["kind"].as_u64(),
3423            Some(u64::from(nostr_crypto::NOSTR_GIFT_WRAP_KIND))
3424        );
3425
3426        let sealed = base64::engine::general_purpose::STANDARD
3427            .decode(event["content"].as_str().unwrap())
3428            .unwrap();
3429        let plaintext = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &sealed);
3430        assert_eq!(bob.deserialize_message(&plaintext).unwrap().id, msg.id);
3431    }
3432
3433    #[test]
3434    fn test_unseal_leaves_legacy_unsealed_frames_untouched() {
3435        // A peer on a build from before sealing publishes the old cleartext
3436        // envelope. It must pass straight through, or upgrading breaks every
3437        // conversation with a peer that has not upgraded yet.
3438        let alice = NostrTransport::new(addr("alice")).unwrap();
3439        alice.set_sealing_enabled(false);
3440        alice.start().unwrap();
3441        alice.on_status_changed(TransportStatus::Available);
3442
3443        let msg = create_test_message();
3444        alice.send(&msg).unwrap();
3445        let signed = alice.get_next_signed_event().unwrap().unwrap();
3446        let event = event_object(&signed);
3447        assert_eq!(
3448            event["kind"].as_u64(),
3449            Some(u64::from(nostr_crypto::NOSTR_LEGACY_DM_KIND))
3450        );
3451
3452        let legacy = base64::engine::general_purpose::STANDARD
3453            .decode(event["content"].as_str().unwrap())
3454            .unwrap();
3455
3456        let bob = NostrTransport::new(addr("bob")).unwrap();
3457        let passed_through = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &legacy);
3458        assert_eq!(passed_through.as_ref(), legacy.as_slice());
3459        assert_eq!(bob.deserialize_message(&passed_through).unwrap().id, msg.id);
3460    }
3461
3462    #[test]
3463    fn test_frame_sealed_to_someone_else_is_returned_unchanged_not_forged() {
3464        // An event addressed to our routing tag but sealed to a third party
3465        // must fail closed. Returning the ciphertext unchanged lets the
3466        // ordinary decoder reject it as one undecodable frame — the same
3467        // outcome as random junk, which is what it is to us.
3468        let bob = NostrTransport::new(addr("bob")).unwrap();
3469        let carol_tag = nostr_crypto::routing_tag_for_address(&addr_typed("carol")).unwrap();
3470
3471        let event =
3472            nostr_crypto::NostrEvent::create_gift_wrap(&carol_tag, &carol_tag, b"not for bob")
3473                .unwrap();
3474        let sealed = base64::engine::general_purpose::STANDARD
3475            .decode(&event.content)
3476            .unwrap();
3477
3478        let out = bob.unseal_event_payload(&event.pubkey, &sealed);
3479        assert_eq!(out.as_ref(), sealed.as_slice());
3480        assert!(bob.deserialize_message(&out).is_err());
3481    }
3482
3483    #[test]
3484    fn test_sealed_frame_on_the_unsealed_receive_path_is_dropped_not_queued() {
3485        // `on_data_received` cannot unseal — it has no access to the wrapper's
3486        // pubkey. A bridge wired to it would otherwise enqueue ciphertext as if
3487        // it were a message.
3488        let bob = NostrTransport::new(addr("bob")).unwrap();
3489        let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3490        let event =
3491            nostr_crypto::NostrEvent::create_gift_wrap(&bob_tag, &bob_tag, b"sealed").unwrap();
3492        let sealed = base64::engine::general_purpose::STANDARD
3493            .decode(&event.content)
3494            .unwrap();
3495
3496        assert!(bob.on_data_received(sealed).is_ok());
3497        assert!(bob.receive().unwrap().is_none());
3498    }
3499
3500    #[test]
3501    fn test_peer_key_map_is_bounded_and_rejects_malformed_keys() {
3502        let transport = NostrTransport::new(addr("device1")).unwrap();
3503
3504        transport.set_peer_nostr_pubkey(&addr("bob"), "not-hex");
3505        transport.set_peer_nostr_pubkey(&addr("bob"), &"ab".repeat(31)); // 62 chars
3506        transport.set_peer_nostr_pubkey("", &"ab".repeat(32));
3507        assert!(transport.peer_nostr_pubkey(&addr("bob")).is_none());
3508
3509        // Case is normalized: `#p`-style values are lowercase hex by spec, and a
3510        // mixed-case duplicate must not seal to a different string.
3511        let key = "AB".repeat(32);
3512        transport.set_peer_nostr_pubkey(&addr("bob"), &key);
3513        assert_eq!(
3514            transport.peer_nostr_pubkey(&addr("bob")),
3515            Some(key.to_lowercase())
3516        );
3517
3518        for i in 0..NOSTR_MAX_TRACKED_PEER_KEYS + 10 {
3519            transport.set_peer_nostr_pubkey(&format!("peer{i}"), &"cd".repeat(32));
3520        }
3521        assert!(
3522            transport.peer_nostr_pubkeys.read().unwrap().len() <= NOSTR_MAX_TRACKED_PEER_KEYS,
3523            "wire-keyed map must stay bounded"
3524        );
3525    }
3526
3527    #[test]
3528    fn test_sealing_can_be_disabled_without_breaking_inbound_unsealing() {
3529        // The kill switch gates the send side only. A peer that keeps sealing
3530        // must stay readable, or flipping the flag on one device would sever
3531        // conversations rather than merely un-protecting our own traffic.
3532        let alice = NostrTransport::new(addr("alice")).unwrap();
3533        alice.start().unwrap();
3534        alice.on_status_changed(TransportStatus::Available);
3535
3536        let bob = NostrTransport::new(addr("bob")).unwrap();
3537        bob.set_sealing_enabled(false);
3538        assert!(!bob.sealing_enabled());
3539
3540        let msg = create_test_message();
3541        alice.send(&msg).unwrap();
3542        let signed = alice.get_next_signed_event().unwrap().unwrap();
3543        let event = event_object(&signed);
3544        let sealed = base64::engine::general_purpose::STANDARD
3545            .decode(event["content"].as_str().unwrap())
3546            .unwrap();
3547
3548        let plaintext = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &sealed);
3549        assert_eq!(bob.deserialize_message(&plaintext).unwrap().id, msg.id);
3550    }
3551
3552    #[test]
3553    fn test_sealing_overhead_stays_within_the_relay_cap_for_ordinary_messages() {
3554        // NIP-44 pads to a power-of-two bucket, so sealing can nearly double a
3555        // payload before base64 — considerably more than the ~33% a base64
3556        // layer alone would suggest. Ordinary text messages must still fit
3557        // comfortably, and the cap must be measured on the sealed event.
3558        let transport = NostrTransport::new(addr("alice")).unwrap();
3559        transport.start().unwrap();
3560        transport.on_status_changed(TransportStatus::Available);
3561
3562        let msg = Message::new(
3563            UserId::new(addr("alice")).unwrap(),
3564            UserId::new(addr("bob")).unwrap(),
3565            AppId::new("test").unwrap(),
3566            "x".repeat(4096),
3567        );
3568        transport.send(&msg).unwrap();
3569
3570        let signed = transport.get_next_signed_event().unwrap().unwrap();
3571        assert!(
3572            signed.event_json.len() <= NOSTR_MAX_PAYLOAD_SIZE,
3573            "a 4 KiB message must still fit sealed: {} bytes",
3574            signed.event_json.len()
3575        );
3576    }
3577
3578    // ========================================================================
3579    // Published key packages (cold contact)
3580    // ========================================================================
3581
3582    /// The regression test for the reason published records are sealed at all.
3583    ///
3584    /// A key package carries its owner's user id in the payload *and*,
3585    /// unremovably, in the MLS leaf credential. Published in the clear, a
3586    /// filter naming only the kind — no tag, no author — would return a
3587    /// directory of every username on the relay, which is precisely the
3588    /// preimage the `SHA-256(user_id)` routing tag exists to withhold.
3589    #[test]
3590    fn test_published_key_package_record_leaks_no_username() {
3591        let transport = NostrTransport::new(addr("alice-the-identifiable")).unwrap();
3592        transport.start().unwrap();
3593        transport.on_status_changed(TransportStatus::Available);
3594
3595        // Stand in for a key package: what matters is that the user id appears
3596        // in the plaintext, which it does in both of the real carriers.
3597        let payload = br#"{"user_id":"alice-the-identifiable","key_package_data":[1,2,3]}"#;
3598        transport.publish_key_package("slot-a", payload.to_vec());
3599
3600        let signed = transport.get_next_signed_event().unwrap().unwrap();
3601        assert!(
3602            !signed.event_json.contains("alice-the-identifiable"),
3603            "a published record must not carry the username in the clear: {}",
3604            signed.event_json
3605        );
3606    }
3607
3608    #[test]
3609    fn test_published_record_is_addressable_and_opens_with_the_derivable_key() {
3610        let transport = NostrTransport::new(addr("alice")).unwrap();
3611        transport.install_signing_secret(&[7u8; 32]).unwrap();
3612        transport.start().unwrap();
3613        transport.on_status_changed(TransportStatus::Available);
3614
3615        transport.publish_key_package("slot-a", b"the key package".to_vec());
3616        let signed = transport.get_next_signed_event().unwrap().unwrap();
3617        let event = event_object(&signed);
3618
3619        assert_eq!(
3620            event["kind"].as_u64(),
3621            Some(u64::from(nostr_crypto::NOSTR_KEY_PACKAGE_KIND))
3622        );
3623        // Signed by the real install key — that is how a fetcher learns which
3624        // key to seal to — unlike a gift wrap's throwaway key.
3625        assert_eq!(
3626            event["pubkey"].as_str().unwrap(),
3627            transport.public_key_hex(),
3628            "a fetcher reads the sealing key off this field"
3629        );
3630        let tags = event["tags"].as_array().unwrap();
3631        assert_eq!(tags[0][0].as_str(), Some("d"));
3632        assert_eq!(tags[0][1].as_str(), Some("slot-a"));
3633        assert_eq!(tags[1][0].as_str(), Some("p"));
3634        assert_eq!(tags[1][1].as_str(), Some(transport.routing_tag()));
3635
3636        // A stranger who knows only the username can open it.
3637        let stranger_view =
3638            nostr_crypto::record_seal_keypair_for_address(&addr_typed("alice")).unwrap();
3639        let sealed = base64::engine::general_purpose::STANDARD
3640            .decode(event["content"].as_str().unwrap())
3641            .unwrap();
3642        let opened = nostr_crypto::open_key_package_publication(
3643            &stranger_view,
3644            event["pubkey"].as_str().unwrap(),
3645            &sealed,
3646        )
3647        .unwrap();
3648        assert_eq!(opened, b"the key package");
3649    }
3650
3651    /// A published record's `created_at` must not be jittered backwards the way
3652    /// a gift wrap's is. Relays keep the newest event per `(kind, pubkey, d)`,
3653    /// so a backdated republication is silently discarded — leaving a consumed
3654    /// key package standing as the live record.
3655    #[test]
3656    fn test_republication_is_not_backdated_below_the_record_it_replaces() {
3657        let transport = NostrTransport::new(addr("alice")).unwrap();
3658        transport.start().unwrap();
3659        transport.on_status_changed(TransportStatus::Available);
3660
3661        let now = nostr_crypto::now_unix_secs();
3662        transport.publish_key_package("slot-a", b"first".to_vec());
3663        let first = event_object(&transport.get_next_signed_event().unwrap().unwrap());
3664
3665        let created = first["created_at"].as_i64().unwrap();
3666        assert!(
3667            created >= now,
3668            "publication was backdated by {}s; a relay would drop the replacement",
3669            now - created
3670        );
3671    }
3672
3673    /// Re-queueing a slot before it drains must collapse to the newer payload:
3674    /// the older one names a key package the engine has already replaced, and
3675    /// publishing both would briefly stand a consumed package back up.
3676    #[test]
3677    fn test_requeued_slot_collapses_to_the_newer_payload() {
3678        let transport = NostrTransport::new(addr("alice")).unwrap();
3679        transport.start().unwrap();
3680        transport.on_status_changed(TransportStatus::Available);
3681
3682        transport.publish_key_package("slot-a", b"stale".to_vec());
3683        transport.publish_key_package("slot-a", b"fresh".to_vec());
3684
3685        let signed = transport.get_next_signed_event().unwrap().unwrap();
3686        let event = event_object(&signed);
3687        let sealed = base64::engine::general_purpose::STANDARD
3688            .decode(event["content"].as_str().unwrap())
3689            .unwrap();
3690        let key = nostr_crypto::record_seal_keypair_for_address(&addr_typed("alice")).unwrap();
3691        let opened = nostr_crypto::open_key_package_publication(
3692            &key,
3693            event["pubkey"].as_str().unwrap(),
3694            &sealed,
3695        )
3696        .unwrap();
3697        assert_eq!(opened, b"fresh");
3698
3699        assert!(
3700            transport.get_next_signed_event().unwrap().is_none(),
3701            "the superseded payload must not also publish"
3702        );
3703    }
3704
3705    #[test]
3706    fn test_publications_drain_ahead_of_messages() {
3707        let transport = NostrTransport::new(addr("alice")).unwrap();
3708        transport.start().unwrap();
3709        transport.on_status_changed(TransportStatus::Available);
3710
3711        transport.send(&create_test_message()).unwrap();
3712        transport.publish_key_package("slot-a", b"kp".to_vec());
3713
3714        let first = transport.get_next_signed_event().unwrap().unwrap();
3715        assert!(
3716            first.message_id.starts_with(NOSTR_PUBLICATION_ID_PREFIX),
3717            "a refreshed slot must reach the relay before the traffic that \
3718             depends on it, got {}",
3719            first.message_id
3720        );
3721    }
3722
3723    /// A publication that leaves the queue but never reaches a relay must be
3724    /// reported back, or the engine — which marked the slot published when it
3725    /// queued the record — leaves it looking healthy for the life of the
3726    /// process while the relays hold nothing.
3727    #[test]
3728    fn test_rejected_publication_is_reported_back_for_republication() {
3729        let transport = NostrTransport::new(addr("alice")).unwrap();
3730        transport.start().unwrap();
3731        transport.on_status_changed(TransportStatus::Available);
3732
3733        transport.publish_key_package("slot-a", b"kp".to_vec());
3734        let signed = transport.get_next_signed_event().unwrap().unwrap();
3735        assert!(
3736            transport.take_failed_publications().is_empty(),
3737            "a publication in flight is not yet a failure"
3738        );
3739
3740        transport.report_send_failure(&signed.message_id);
3741
3742        assert_eq!(
3743            transport.take_failed_publications(),
3744            vec!["slot-a".to_string()],
3745            "a relay rejection must surface the slot for republication"
3746        );
3747        assert!(
3748            transport.take_failed_publications().is_empty(),
3749            "draining must consume the report"
3750        );
3751    }
3752
3753    /// The same, for the disconnect path: dropping the relay fails every
3754    /// in-flight event, and a publication among them is no exception.
3755    #[test]
3756    fn test_disconnect_reports_in_flight_publications_for_republication() {
3757        let transport = NostrTransport::new(addr("alice")).unwrap();
3758        transport.start().unwrap();
3759        transport.on_status_changed(TransportStatus::Available);
3760
3761        transport.publish_key_package("slot-a", b"kp".to_vec());
3762        transport.get_next_signed_event().unwrap().unwrap();
3763
3764        transport.on_status_changed(TransportStatus::Disconnected);
3765
3766        assert_eq!(
3767            transport.take_failed_publications(),
3768            vec!["slot-a".to_string()],
3769            "a publication in flight when the relay dropped must be republished"
3770        );
3771    }
3772
3773    /// A message failing must not be mistaken for a publication failing — the
3774    /// slot set is keyed on the synthetic publication id and nothing else.
3775    #[test]
3776    fn test_message_failure_reports_no_publication_slot() {
3777        let transport = NostrTransport::new(addr("alice")).unwrap();
3778        transport.start().unwrap();
3779        transport.on_status_changed(TransportStatus::Available);
3780
3781        transport.send(&create_test_message()).unwrap();
3782        let signed = transport.get_next_signed_event().unwrap().unwrap();
3783        transport.report_send_failure(&signed.message_id);
3784
3785        assert!(transport.take_failed_publications().is_empty());
3786    }
3787
3788    #[test]
3789    fn test_cold_contact_disabled_publishes_nothing_and_resolves_nothing() {
3790        let transport = NostrTransport::new(addr("alice")).unwrap();
3791        transport.set_cold_contact_enabled(false);
3792        transport.start().unwrap();
3793        transport.on_status_changed(TransportStatus::Available);
3794
3795        transport.publish_key_package("slot-a", b"kp".to_vec());
3796        assert!(!transport.has_pending_publications());
3797        assert!(!transport.request_peer_key_packages(&addr("bob")));
3798        assert!(transport.next_query().unwrap().is_none());
3799    }
3800
3801    #[test]
3802    fn test_send_to_an_unknown_peer_queues_a_resolution_and_still_sends() {
3803        let transport = NostrTransport::new(addr("alice")).unwrap();
3804        transport.start().unwrap();
3805        transport.on_status_changed(TransportStatus::Available);
3806
3807        // No key for bob: the frame takes the bootstrap leg *and* asks.
3808        transport.send(&create_test_message()).unwrap();
3809        let signed = transport.get_next_signed_event().unwrap().unwrap();
3810        assert!(
3811            !signed.message_id.starts_with(NOSTR_PUBLICATION_ID_PREFIX),
3812            "the message must still go out rather than waiting on a round-trip"
3813        );
3814
3815        let query = transport.next_query().unwrap().expect("resolution queued");
3816        let expected_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3817        assert!(query.req_json.contains(&expected_tag));
3818        assert!(query
3819            .req_json
3820            .contains(&nostr_crypto::NOSTR_KEY_PACKAGE_KIND.to_string()));
3821    }
3822
3823    #[test]
3824    fn test_resolution_is_rate_limited_per_peer() {
3825        let transport = NostrTransport::new(addr("alice")).unwrap();
3826        assert!(transport.request_peer_key_packages(&addr("bob")));
3827        assert!(
3828            !transport.request_peer_key_packages(&addr("bob")),
3829            "a peer who has published nothing must not mint a round-trip per frame"
3830        );
3831        assert!(transport.request_peer_key_packages(&addr("carol")));
3832    }
3833
3834    /// The probe says what a request would do and does none of it: the engine
3835    /// asks it before a storage read, so a probe that queued or stamped would
3836    /// spend the interval on a peer the engine then decides not to ask for.
3837    #[test]
3838    fn test_resolution_due_agrees_with_the_request_and_changes_nothing() {
3839        let transport = NostrTransport::new(addr("alice")).unwrap();
3840        assert!(transport.resolution_due(&addr("bob")));
3841        assert!(
3842            transport.resolution_due(&addr("bob")),
3843            "asking twice changes nothing"
3844        );
3845        assert!(
3846            transport.next_query().unwrap().is_none(),
3847            "the probe queued nothing"
3848        );
3849
3850        assert!(transport.request_peer_key_packages(&addr("bob")));
3851        assert!(
3852            !transport.resolution_due(&addr("bob")),
3853            "queued, then rate-limited"
3854        );
3855        assert!(transport.resolution_due(&addr("carol")));
3856        assert!(!transport.resolution_due("not-an-address"));
3857
3858        transport.set_cold_contact_enabled(false);
3859        assert!(!transport.resolution_due(&addr("carol")));
3860    }
3861
3862    #[test]
3863    fn test_query_event_opens_only_for_the_peer_it_was_issued_for() {
3864        let alice = NostrTransport::new(addr("alice")).unwrap();
3865        let bob = NostrTransport::new(addr("bob")).unwrap();
3866        bob.install_signing_secret(&[9u8; 32]).unwrap();
3867        bob.start().unwrap();
3868        bob.on_status_changed(TransportStatus::Available);
3869
3870        bob.publish_key_package("slot-a", b"bob's key package".to_vec());
3871        let published = bob.get_next_signed_event().unwrap().unwrap();
3872        let event = event_object(&published);
3873        let event_json = serde_json::to_string(&event).unwrap();
3874
3875        alice.request_peer_key_packages(&addr("bob"));
3876        let query = alice.next_query().unwrap().unwrap();
3877
3878        let opened = alice
3879            .open_query_event(&query.query_id, &event_json)
3880            .unwrap()
3881            .expect("bob's record opens with bob's derivable key");
3882        match opened {
3883            ResolvedRecord::KeyPackage { plaintext, .. } => {
3884                assert_eq!(plaintext, b"bob's key package");
3885            }
3886            ResolvedRecord::Discovery { .. } => {
3887                panic!("a key-package query must not yield a discovery record")
3888            }
3889        }
3890
3891        // The same record delivered under a query for someone else does not
3892        // open: the query id is what says whose key to try.
3893        alice.request_peer_key_packages(&addr("carol"));
3894        let other = alice.next_query().unwrap().unwrap();
3895        assert!(alice
3896            .open_query_event(&other.query_id, &event_json)
3897            .unwrap()
3898            .is_none());
3899    }
3900
3901    #[test]
3902    fn test_query_event_for_an_unknown_or_completed_query_is_dropped() {
3903        let alice = NostrTransport::new(addr("alice")).unwrap();
3904        alice.request_peer_key_packages(&addr("bob"));
3905        let query = alice.next_query().unwrap().unwrap();
3906        alice.complete_query(&query.query_id);
3907
3908        assert!(alice
3909            .open_query_event(&query.query_id, "{\"kind\":30443}")
3910            .unwrap()
3911            .is_none());
3912        assert!(alice
3913            .open_query_event("never-issued", "{\"kind\":30443}")
3914            .unwrap()
3915            .is_none());
3916    }
3917
3918    /// A public routing tag means anyone can leave anything there, so a query
3919    /// returns whatever the relay holds. Junk must be dropped quietly rather
3920    /// than failing the query.
3921    #[test]
3922    fn test_query_event_ignores_wrong_kinds_and_unopenable_content() {
3923        let alice = NostrTransport::new(addr("alice")).unwrap();
3924        alice.request_peer_key_packages(&addr("bob"));
3925        let query = alice.next_query().unwrap().unwrap();
3926
3927        let wrong_kind = r#"{"kind":1059,"pubkey":"aa","content":"AQID"}"#;
3928        assert!(alice
3929            .open_query_event(&query.query_id, wrong_kind)
3930            .unwrap()
3931            .is_none());
3932
3933        let bad_content = r#"{"kind":30443,"pubkey":"not-hex","content":"!!!"}"#;
3934        assert!(alice
3935            .open_query_event(&query.query_id, bad_content)
3936            .unwrap()
3937            .is_none());
3938
3939        assert!(alice
3940            .open_query_event(&query.query_id, "not json at all")
3941            .unwrap()
3942            .is_none());
3943    }
3944
3945    /// A publication is not a message, and DORS does not get to hear about it.
3946    ///
3947    /// The reliability score is `success / (success + failure)` over lifetime
3948    /// counters that never decay, and an idle install publishes far more than
3949    /// it sends — so counting publications would score this transport on
3950    /// something other than its ability to carry messages. A relay that
3951    /// rejects kind 30443 would drive the ratio toward zero and make DORS
3952    /// deprioritise Nostr for traffic that delivers perfectly well.
3953    #[test]
3954    fn test_publication_failures_stay_out_of_the_delivery_metrics() {
3955        let transport = NostrTransport::new(addr("alice")).unwrap();
3956        transport.start().unwrap();
3957        transport.on_status_changed(TransportStatus::Available);
3958
3959        // One real message, delivered.
3960        transport.send(&create_test_message()).unwrap();
3961        let msg = transport.get_next_signed_event().unwrap().unwrap();
3962        transport.confirm_sent(&msg.message_id);
3963
3964        // A full slot set the relay rejects outright.
3965        for i in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
3966            transport.publish_key_package(&format!("slot-{}", i), b"kp".to_vec());
3967        }
3968        for _ in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
3969            let published = transport.get_next_signed_event().unwrap().unwrap();
3970            assert!(published
3971                .message_id
3972                .starts_with(NOSTR_PUBLICATION_ID_PREFIX));
3973            transport.report_send_failure(&published.message_id);
3974        }
3975
3976        let metrics = transport.metrics();
3977        assert_eq!(metrics.success_count, 1);
3978        assert_eq!(
3979            metrics.failure_count, 0,
3980            "publication rejections were counted as message delivery failures"
3981        );
3982        assert_eq!(
3983            metrics.delivery_ratio,
3984            Some(1.0),
3985            "the only message sent was delivered; the ratio DORS reads must say so"
3986        );
3987
3988        // ...and the slots are still reported back for republication.
3989        assert_eq!(
3990            transport.take_failed_publications().len(),
3991            crate::constants::NOSTR_KEY_PACKAGE_SLOTS,
3992            "keeping publications out of the metrics must not lose the reports"
3993        );
3994    }
3995
3996    /// The same rule, for the second record type.
3997    ///
3998    /// This is the test that exists because the rule was *nearly* half-wired
3999    /// once: adding a record kind means adding a discriminator, and a
4000    /// discriminator consulted at three of four sites produces exactly the DORS
4001    /// poisoning the key-package version above prevents — silently, since
4002    /// nothing observes a transport's score directly.
4003    ///
4004    /// It covers all three paths that move the counters: `confirm_sent`,
4005    /// `report_send_failure`, and the confirmation timeout via
4006    /// `fail_all_pending`.
4007    #[test]
4008    fn test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics() {
4009        let transport = NostrTransport::new(addr("alice")).unwrap();
4010        transport.set_discovery_enabled(true);
4011        transport.start().unwrap();
4012        transport.on_status_changed(TransportStatus::Available);
4013
4014        // One real message, delivered.
4015        transport.send(&create_test_message()).unwrap();
4016        let msg = transport.get_next_signed_event().unwrap().unwrap();
4017        transport.confirm_sent(&msg.message_id);
4018
4019        // A claim the relay rejects.
4020        transport.publish_discovery_record(user("alice"), b"{}".to_vec());
4021        let published = transport.get_next_signed_event().unwrap().unwrap();
4022        assert!(published.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4023        transport.report_send_failure(&published.message_id);
4024
4025        // A claim the relay accepts.
4026        transport.publish_discovery_record(user("bob"), b"{}".to_vec());
4027        let accepted = transport.get_next_signed_event().unwrap().unwrap();
4028        transport.confirm_sent(&accepted.message_id);
4029
4030        let metrics = transport.metrics();
4031        assert_eq!(
4032            metrics.success_count, 1,
4033            "an accepted discovery publication was counted as a delivered message"
4034        );
4035        assert_eq!(
4036            metrics.failure_count, 0,
4037            "a rejected discovery publication was counted as a delivery failure"
4038        );
4039        assert_eq!(
4040            metrics.delivery_ratio,
4041            Some(1.0),
4042            "the only message sent was delivered; the ratio DORS reads must say so"
4043        );
4044
4045        // ...and the failed claim is still reported back for republication.
4046        assert_eq!(
4047            transport.take_failed_discovery_publications(),
4048            vec![nostr_crypto::discovery_tag_for_username(&user("alice")).unwrap()],
4049            "keeping publications out of the metrics must not lose the reports"
4050        );
4051    }
4052
4053    /// A retraction's NIP-09 deletion rides the send queue like anything else,
4054    /// so it must be kept out of the metrics too — and, unlike the tombstone,
4055    /// it must *not* be reported for republication, because nothing retries a
4056    /// deletion.
4057    #[test]
4058    fn test_discovery_deletion_is_metrics_neutral_and_never_republished() {
4059        let transport = NostrTransport::new(addr("alice")).unwrap();
4060        transport.set_discovery_enabled(true);
4061        transport.start().unwrap();
4062        transport.on_status_changed(TransportStatus::Available);
4063
4064        transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec());
4065
4066        let tombstone = transport.get_next_signed_event().unwrap().unwrap();
4067        assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4068        transport.confirm_sent(&tombstone.message_id);
4069
4070        let deletion = transport.get_next_signed_event().unwrap().unwrap();
4071        assert!(
4072            deletion.message_id.starts_with(NOSTR_DELETION_ID_PREFIX),
4073            "the deletion must follow the tombstone, not precede it"
4074        );
4075        transport.report_send_failure(&deletion.message_id);
4076
4077        let metrics = transport.metrics();
4078        assert_eq!(metrics.success_count, 0);
4079        assert_eq!(metrics.failure_count, 0);
4080        assert!(
4081            transport.take_failed_discovery_publications().is_empty(),
4082            "a failed deletion must not queue a republication: the tombstone \
4083             already carried the retraction"
4084        );
4085    }
4086
4087    /// A retraction must go out even when discovery has just been switched off,
4088    /// which is the single most likely reason to retract. A gate here would
4089    /// strand exactly the claim the user asked to withdraw.
4090    #[test]
4091    fn test_retraction_is_not_gated_on_discovery_being_enabled() {
4092        let transport = NostrTransport::new(addr("alice")).unwrap();
4093        transport.start().unwrap();
4094        transport.on_status_changed(TransportStatus::Available);
4095
4096        // Never enabled: a publication is refused...
4097        transport.publish_discovery_record(user("alice"), b"{}".to_vec());
4098        assert!(
4099            transport.get_next_signed_event().unwrap().is_none(),
4100            "a claim must not publish while discovery is disabled"
4101        );
4102
4103        // ...but a retraction still goes out.
4104        transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec());
4105        let tombstone = transport
4106            .get_next_signed_event()
4107            .unwrap()
4108            .expect("a retraction must publish even with discovery disabled");
4109        assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4110    }
4111
4112    /// Discovery requires cold contact as well as its own switch: a claim
4113    /// pointing at an address with no published key packages resolves and then
4114    /// dead-ends one hop later.
4115    #[test]
4116    fn test_discovery_requires_cold_contact() {
4117        let transport = NostrTransport::new(addr("alice")).unwrap();
4118        transport.set_discovery_enabled(true);
4119        assert!(transport.discovery_enabled());
4120
4121        transport.set_cold_contact_enabled(false);
4122        assert!(
4123            !transport.discovery_enabled(),
4124            "discovery must be off when cold contact is off, since the claim \
4125             would point at an address with no key packages to fetch"
4126        );
4127    }
4128
4129    /// Republishing a claim before it drains collapses to the newer payload:
4130    /// the older one is by definition superseded, and publishing both would
4131    /// briefly stand the stale one back up.
4132    #[test]
4133    fn test_requeued_discovery_claim_collapses_to_the_newest() {
4134        let transport = NostrTransport::new(addr("alice")).unwrap();
4135        transport.set_discovery_enabled(true);
4136        transport.start().unwrap();
4137        transport.on_status_changed(TransportStatus::Available);
4138
4139        transport.publish_discovery_record(user("alice"), b"first".to_vec());
4140        transport.publish_discovery_record(user("alice"), b"second".to_vec());
4141
4142        assert!(transport.get_next_signed_event().unwrap().is_some());
4143        assert!(
4144            transport.get_next_signed_event().unwrap().is_none(),
4145            "a re-queued claim must replace the pending one, not queue twice"
4146        );
4147    }
4148
4149    /// A discovery query must not accept a key-package record, and vice versa.
4150    /// The kind is checked against what *this query asked for*, so a relay
4151    /// answering the wrong REQ cannot route a record into the other handler.
4152    #[test]
4153    fn test_query_refuses_a_record_of_the_wrong_kind() {
4154        let alice = NostrTransport::new(addr("alice")).unwrap();
4155        alice.set_discovery_enabled(true);
4156
4157        let bob = NostrTransport::new(addr("bob")).unwrap();
4158        bob.install_signing_secret(&[9u8; 32]).unwrap();
4159        bob.start().unwrap();
4160        bob.on_status_changed(TransportStatus::Available);
4161
4162        // A genuine key-package record.
4163        bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4164        let published = bob.get_next_signed_event().unwrap().unwrap();
4165        let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4166
4167        // Delivered under a *discovery* query.
4168        alice.resolve_username(user("bob"));
4169        let query = alice.next_query().unwrap().unwrap();
4170        assert!(
4171            alice
4172                .open_query_event(&query.query_id, &event_json)
4173                .unwrap()
4174                .is_none(),
4175            "a key-package record must not open under a discovery query"
4176        );
4177    }
4178
4179    /// The query carries the username out so the engine can open an
4180    /// accumulator at mint time — which is what lets a name nobody claims still
4181    /// emit an empty answer rather than never completing.
4182    #[test]
4183    fn test_discovery_query_reports_its_username() {
4184        let transport = NostrTransport::new(addr("alice")).unwrap();
4185        transport.set_discovery_enabled(true);
4186
4187        assert_eq!(
4188            transport.resolve_username(user("bob")),
4189            ResolveRequest::Queued
4190        );
4191        let query = transport.next_query().unwrap().unwrap();
4192        assert_eq!(query.discovery_username, Some(user("bob")));
4193
4194        // Completing it hands the username back, so the engine knows which
4195        // accumulated set to flush.
4196        assert_eq!(transport.complete_query(&query.query_id), Some(user("bob")));
4197    }
4198
4199    /// A key-package query completing must not look like a discovery query
4200    /// completing, or the engine would flush a resolution that never existed.
4201    #[test]
4202    fn test_key_package_query_completion_reports_no_username() {
4203        let transport = NostrTransport::new(addr("alice")).unwrap();
4204        transport.request_peer_key_packages(&addr("bob"));
4205        let query = transport.next_query().unwrap().unwrap();
4206        assert_eq!(query.discovery_username, None);
4207        assert_eq!(transport.complete_query(&query.query_id), None);
4208    }
4209
4210    /// Resolution is refused outright while discovery is disabled: no query is
4211    /// minted, so nothing is published to a relay about what this install is
4212    /// looking up.
4213    ///
4214    /// Reported as `Disabled` rather than as a bare refusal, because the
4215    /// engine turns that into an error: nothing will ever answer this lookup,
4216    /// and a caller awaiting the resolution event must not be left waiting.
4217    #[test]
4218    fn test_resolution_is_refused_while_discovery_is_disabled() {
4219        let transport = NostrTransport::new(addr("alice")).unwrap();
4220        assert_eq!(
4221            transport.resolve_username(user("bob")),
4222            ResolveRequest::Disabled
4223        );
4224        assert!(transport.next_query().unwrap().is_none());
4225    }
4226
4227    /// **The attack the event-signature check exists to stop.**
4228    ///
4229    /// A tombstone body is a constant, so nothing inside it is signed and its
4230    /// entire meaning is "who published this". The discovery seal key is
4231    /// public, so an attacker who knows the username can derive the
4232    /// conversation key for the *victim's* author key and seal a retraction
4233    /// attributed to them. Every layer above this one would then honour it:
4234    /// the resolver keys retractions by author and keeps them sticky for the
4235    /// life of the resolution, precisely so a stale copy cannot stand a
4236    /// retracted claim back up.
4237    ///
4238    /// One hostile relay would therefore erase an honest claimant from the
4239    /// answer even while every other relay served their genuine record.
4240    ///
4241    /// The forgery is built exactly as an attacker would build it, and both
4242    /// halves of "exactly" are load-bearing:
4243    ///
4244    /// - the **id** is re-stamped over the forged fields, because an id is a
4245    ///   hash of public fields and therefore free to recompute. A forgery
4246    ///   carrying a stale id is refused one step early and proves nothing about
4247    ///   the check that actually stops this attack;
4248    /// - the **signature** is a well-formed one taken from a genuinely signed
4249    ///   event, because an unparseable signature is refused one step early too.
4250    ///   Only a signature that parses reaches the verification that fails.
4251    ///
4252    /// Both wrong-shaped forgeries are covered by the case below and by
4253    /// `nostr_crypto`'s direct tests, so what is asserted here is the whole
4254    /// pipeline: sealed under the public key anyone can derive, stood up under
4255    /// the victim's name, and refused anyway.
4256    #[test]
4257    fn test_a_forged_tombstone_for_another_author_is_refused() {
4258        let alice = NostrTransport::new(addr("alice")).unwrap();
4259        alice.set_discovery_enabled(true);
4260
4261        // The victim's install, whose author key the attacker will impersonate.
4262        let victim = NostrTransport::new(addr("bob")).unwrap();
4263        victim.install_signing_secret(&[9u8; 32]).unwrap();
4264        let victim_author = victim.public_key_hex();
4265
4266        // The attacker seals a tombstone to the victim's author key, which the
4267        // public discovery-seal key lets anyone do. The event this produces is
4268        // signed by the seal key, so it also hands the attacker a *well-formed*
4269        // signature to dress the forgery in.
4270        let username = user("bob");
4271        let seal = nostr_crypto::discovery_seal_keypair_for_username(&username).unwrap();
4272        let tombstone = br#"{"v":1,"retracted":true}"#;
4273        let (forged_content, well_formed_sig) = {
4274            let event = nostr_crypto::NostrEvent::create_discovery_publication(
4275                &seal,
4276                &nostr_crypto::discovery_tag_for_username(&username).unwrap(),
4277                &victim_author,
4278                tombstone,
4279            )
4280            .unwrap();
4281            (event.content, event.sig)
4282        };
4283
4284        // Stood up as an event *claiming* the victim's author key, with the id
4285        // recomputed over these fields so it passes the cheap check. Only the
4286        // signature is beyond reach, because that is the one thing the victim's
4287        // key is needed for.
4288        let tags = serde_json::json!([["d", "x"], ["p", "x"]]);
4289        // A distinct `created_at` per case, so the two forgeries have distinct
4290        // ids. Sharing one would let the first consume the per-query duplicate
4291        // slot and make the second pass without being examined, which is
4292        // exactly the vacuity this test exists to avoid.
4293        let forge = |sig: &str, created_at: i64| {
4294            let forged_id = hex::encode(
4295                nostr_crypto::nip01_event_id(
4296                    &victim_author,
4297                    created_at,
4298                    nostr_crypto::NOSTR_DISCOVERY_KIND as u64,
4299                    &serde_json::to_string(&tags).expect("tags"),
4300                    &forged_content,
4301                )
4302                .expect("id"),
4303            );
4304            serde_json::json!({
4305                "id": forged_id,
4306                "pubkey": victim_author,
4307                "created_at": created_at,
4308                "kind": nostr_crypto::NOSTR_DISCOVERY_KIND,
4309                "tags": tags,
4310                "content": forged_content,
4311                "sig": sig,
4312            })
4313            .to_string()
4314        };
4315
4316        alice.resolve_username(username);
4317        let query = alice.next_query().unwrap().unwrap();
4318
4319        for (label, sig, created_at) in [
4320            // The one that reaches the verification. A real signature, by a
4321            // real key, over a different event: it parses, so every cheap
4322            // screen passes and only checking it against the *claimed* author
4323            // refuses it.
4324            (
4325                "a well-formed signature by another key",
4326                &well_formed_sig,
4327                1_700_000_000i64,
4328            ),
4329            // And the shape that never parses, one step earlier.
4330            ("an unparseable signature", &"0".repeat(128), 1_700_000_001),
4331        ] {
4332            assert!(
4333                alice
4334                    .open_query_event(&query.query_id, &forge(sig, created_at))
4335                    .unwrap()
4336                    .is_none(),
4337                "a tombstone carrying {label}, attributed to an author who did \
4338                 not sign the event, must never reach the resolver: one hostile \
4339                 relay would otherwise erase an honest claimant from the answer"
4340            );
4341        }
4342    }
4343
4344    /// The id is recomputed, not trusted. An event claiming a genuine record's
4345    /// id would otherwise consume its per-query dedup slot and have the real
4346    /// record dropped behind it as a duplicate.
4347    #[test]
4348    fn test_a_discovery_event_with_a_forged_id_is_refused() {
4349        let alice = NostrTransport::new(addr("alice")).unwrap();
4350        alice.set_discovery_enabled(true);
4351
4352        let publisher = NostrTransport::new(addr("bob")).unwrap();
4353        publisher.install_signing_secret(&[9u8; 32]).unwrap();
4354        publisher.set_discovery_enabled(true);
4355        publisher.start().unwrap();
4356        publisher.on_status_changed(TransportStatus::Available);
4357        publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec());
4358        let published = publisher.get_next_signed_event().unwrap().unwrap();
4359
4360        let mut event = event_object(&published);
4361        // Genuine event, genuine signature, one field edited. The signature is
4362        // over the *recomputed* id, so any edit anywhere breaks it.
4363        event["created_at"] = serde_json::json!(1_700_000_001);
4364
4365        alice.resolve_username(user("bob"));
4366        let query = alice.next_query().unwrap().unwrap();
4367        assert!(
4368            alice
4369                .open_query_event(&query.query_id, &event.to_string())
4370                .unwrap()
4371                .is_none(),
4372            "an event whose id does not hash its own fields must be refused"
4373        );
4374    }
4375
4376    /// The negative control for the two tests above: a genuine discovery event
4377    /// must still open. A verifier that refused everything would pass both.
4378    #[test]
4379    fn test_a_genuine_discovery_event_still_opens() {
4380        let alice = NostrTransport::new(addr("alice")).unwrap();
4381        alice.set_discovery_enabled(true);
4382
4383        let publisher = NostrTransport::new(addr("bob")).unwrap();
4384        publisher.install_signing_secret(&[9u8; 32]).unwrap();
4385        publisher.set_discovery_enabled(true);
4386        publisher.start().unwrap();
4387        publisher.on_status_changed(TransportStatus::Available);
4388        publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec());
4389        let published = publisher.get_next_signed_event().unwrap().unwrap();
4390        let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4391
4392        alice.resolve_username(user("bob"));
4393        let query = alice.next_query().unwrap().unwrap();
4394        match alice
4395            .open_query_event(&query.query_id, &event_json)
4396            .unwrap()
4397            .expect("a genuine discovery event must open")
4398        {
4399            ResolvedRecord::Discovery {
4400                author, plaintext, ..
4401            } => {
4402                assert_eq!(plaintext, b"{\"v\":1}");
4403                assert_eq!(
4404                    author,
4405                    publisher.public_key_hex(),
4406                    "the author is reported lower-cased, since the resolver keys on it"
4407                );
4408            }
4409            ResolvedRecord::KeyPackage { .. } => {
4410                panic!("a discovery query must not yield a key-package record")
4411            }
4412        }
4413    }
4414
4415    /// A second request for a name already queued reports `AlreadyQueued`,
4416    /// which is a *success* from the caller's side — the queued lookup answers
4417    /// both — and must stay distinguishable from the two refusals that answer
4418    /// nobody.
4419    #[test]
4420    fn test_a_duplicate_lookup_is_reported_as_already_queued() {
4421        let transport = NostrTransport::new(addr("alice")).unwrap();
4422        transport.set_discovery_enabled(true);
4423
4424        assert_eq!(
4425            transport.resolve_username(user("bob")),
4426            ResolveRequest::Queued
4427        );
4428        assert_eq!(
4429            transport.resolve_username(user("bob")),
4430            ResolveRequest::AlreadyQueued
4431        );
4432    }
4433
4434    /// The same rule in the other direction: a successful publication must not
4435    /// inflate the ratio and mask real message failures.
4436    #[test]
4437    fn test_publication_successes_do_not_mask_message_failures() {
4438        let transport = NostrTransport::new(addr("alice")).unwrap();
4439        transport.start().unwrap();
4440        transport.on_status_changed(TransportStatus::Available);
4441
4442        transport.send(&create_test_message()).unwrap();
4443        let msg = transport.get_next_signed_event().unwrap().unwrap();
4444        transport.report_send_failure(&msg.message_id);
4445
4446        for i in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
4447            transport.publish_key_package(&format!("slot-{}", i), b"kp".to_vec());
4448        }
4449        for _ in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
4450            let published = transport.get_next_signed_event().unwrap().unwrap();
4451            transport.confirm_sent(&published.message_id);
4452        }
4453
4454        let metrics = transport.metrics();
4455        assert_eq!(metrics.success_count, 0);
4456        assert_eq!(metrics.failure_count, 1);
4457        assert_eq!(
4458            metrics.delivery_ratio,
4459            Some(0.0),
4460            "publications that reached a relay masked a message that did not"
4461        );
4462    }
4463
4464    /// A resolution query is broadcast to every connected relay, so the same
4465    /// record comes back once per relay. Opening it more than once re-runs the
4466    /// key-package handler — two durable secure-storage writes a time — to
4467    /// import a package we already hold.
4468    #[test]
4469    fn test_duplicate_records_from_several_relays_open_once() {
4470        let alice = NostrTransport::new(addr("alice")).unwrap();
4471        let bob = NostrTransport::new(addr("bob")).unwrap();
4472        bob.install_signing_secret(&[9u8; 32]).unwrap();
4473        bob.start().unwrap();
4474        bob.on_status_changed(TransportStatus::Available);
4475
4476        bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4477        let published = bob.get_next_signed_event().unwrap().unwrap();
4478        let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4479
4480        alice.request_peer_key_packages(&addr("bob"));
4481        let query = alice.next_query().unwrap().unwrap();
4482
4483        assert!(
4484            alice
4485                .open_query_event(&query.query_id, &event_json)
4486                .unwrap()
4487                .is_some(),
4488            "the first relay's copy must open"
4489        );
4490        for _ in 0..4 {
4491            assert!(
4492                alice
4493                    .open_query_event(&query.query_id, &event_json)
4494                    .unwrap()
4495                    .is_none(),
4496                "the same record must be taken once per query, not once per relay"
4497            );
4498        }
4499    }
4500
4501    /// A relay is free to ignore the REQ's `limit` and stream whatever it
4502    /// likes, and every record that opens costs durable writes behind this
4503    /// call — so the ceiling has to be ours, not the relay's.
4504    #[test]
4505    fn test_a_query_stops_accepting_events_at_its_ceiling() {
4506        let alice = NostrTransport::new(addr("alice")).unwrap();
4507        let bob = NostrTransport::new(addr("bob")).unwrap();
4508        bob.install_signing_secret(&[9u8; 32]).unwrap();
4509        bob.start().unwrap();
4510        bob.on_status_changed(TransportStatus::Available);
4511        bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4512        let published = bob.get_next_signed_event().unwrap().unwrap();
4513        let genuine = serde_json::to_string(&event_object(&published)).unwrap();
4514
4515        alice.request_peer_key_packages(&addr("bob"));
4516        let query = alice.next_query().unwrap().unwrap();
4517
4518        // Distinct ids, so it is the ceiling that stops this and not the dedup.
4519        for i in 0..MAX_QUERY_EVENTS {
4520            let junk = format!(
4521                r#"{{"id":"{:064x}","kind":30443,"pubkey":"aa","content":"AQID"}}"#,
4522                i
4523            );
4524            assert!(alice
4525                .open_query_event(&query.query_id, &junk)
4526                .unwrap()
4527                .is_none());
4528        }
4529
4530        assert!(
4531            alice
4532                .open_query_event(&query.query_id, &genuine)
4533                .unwrap()
4534                .is_none(),
4535            "the query kept accepting events past its ceiling"
4536        );
4537
4538        // The record itself is fine, and the ceiling is per query: a fresh one
4539        // opens it.
4540        let alice2 = NostrTransport::new(addr("alice")).unwrap();
4541        alice2.request_peer_key_packages(&addr("bob"));
4542        let fresh = alice2.next_query().unwrap().unwrap();
4543        assert!(alice2
4544            .open_query_event(&fresh.query_id, &genuine)
4545            .unwrap()
4546            .is_some());
4547    }
4548
4549    /// The same malformed key, by the route that was already shipping.
4550    ///
4551    /// `unseal_event_payload` takes the event's `pubkey` field verbatim off a
4552    /// public relay and hands it to the NIP-44 derive, which used to reach a
4553    /// fixed-size decoder that aborts the process on a wrong-length key. One
4554    /// hostile event was enough; it must be an unopenable frame instead.
4555    #[test]
4556    fn test_malformed_event_pubkey_does_not_abort_the_receive_path() {
4557        let transport = NostrTransport::new(addr("alice")).unwrap();
4558        let sealed = vec![0x02u8; 160];
4559
4560        for pubkey in ["", "aa", "abcd", &"ab".repeat(64)] {
4561            let out = transport.unseal_event_payload(pubkey, &sealed);
4562            assert_eq!(
4563                &*out,
4564                &sealed[..],
4565                "a malformed pubkey must leave the frame untouched, not abort"
4566            );
4567        }
4568    }
4569
4570    /// A request the queue refuses for capacity was never looked up, so it must
4571    /// not burn the retry interval — otherwise the overflow policy's promise
4572    /// that a dropped peer "is retried on the next send to them" is false, and
4573    /// the peer instead waits out `RESOLUTION_RETRY_INTERVAL`.
4574    #[test]
4575    fn test_a_resolution_refused_at_capacity_does_not_burn_the_rate_limit() {
4576        let transport = NostrTransport::new(addr("alice")).unwrap();
4577
4578        for i in 0..MAX_PENDING_RESOLUTIONS {
4579            assert!(transport.request_peer_key_packages(&addr(&format!("peer-{}", i))));
4580        }
4581
4582        assert!(
4583            !transport.request_peer_key_packages(&addr("bob")),
4584            "the queue is full, so this request is refused"
4585        );
4586
4587        // Draining one makes room; bob must be admitted straight away.
4588        transport.next_query().unwrap().unwrap();
4589        assert!(
4590            transport.request_peer_key_packages(&addr("bob")),
4591            "a request dropped at capacity burned the retry interval"
4592        );
4593    }
4594}