Skip to main content

vector_core/
signer.rs

1//! Polymorphic signer — local key vault, NIP-46 remote bunker, or an
2//! external signer (NIP-55 app, NIP-07 browser extension).
3//!
4//! Vector supports two signer modes per account:
5//!
6//! - **Local** — the user's nsec lives in `MY_SECRET_KEY` (GuardedKey vault)
7//!   on this device. Signing is local; the key materialises in plaintext only
8//!   for microseconds per operation.
9//! - **Bunker** — the user's nsec lives on a remote NIP-46 signer (Amber,
10//!   nsec.app, ...). Vector holds only a *client keypair* (in `MY_SECRET_KEY`)
11//!   used to RPC the bunker. Every signing request takes a round-trip; the
12//!   user's identity key never touches this device.
13//!
14//! The discriminator is persisted in the per-account settings DB
15//! (`signer_type` key) and materialised into the `SIGNER_KIND` atomic at
16//! login. Hot paths read the atomic; cold paths read the DB directly.
17//!
18//! Storage layout for bunker accounts (see `db::settings`):
19//! - `signer_type = "bunker"`
20//! - `bunker_url`  = the `bunker://<remote_pubkey>?relay=...&secret=...`
21//!   string, encrypted-at-rest if the account uses pin/pass encryption (same
22//!   path as `pkey`).
23//! - `bunker_remote_pubkey` = the signer's pubkey, plaintext (routing only).
24//! - `pkey` = the NIP-46 client keypair (encrypted-at-rest under the same
25//!   path as local accounts). Reusing the existing vault avoids a second
26//!   GuardedKey slot; see the "Client-keypair storage note" section below.
27
28use std::sync::atomic::{AtomicU8, Ordering};
29use std::sync::{LazyLock, RwLock};
30use std::time::Duration;
31
32use nostr_sdk::prelude::*;
33use nostr_connect::prelude::{AuthUrlHandler, NostrConnect, NostrConnectUri};
34
35// ============================================================================
36// SignerError + VectorSigner — the capability-trait bundle
37// ============================================================================
38
39/// Boxed future returned by every async signer capability.
40///
41/// nostr 0.45.0 inlined this shape into its trait signatures and stopped
42/// exporting an alias, so Vector owns the name.
43pub type BoxedFuture<'a, T> =
44    std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
45
46/// Error from any signing backend.
47///
48/// nostr 0.45 deleted `NostrSigner` and split it into per-capability traits,
49/// each carrying its own associated `Error`. Vector normalises all four onto
50/// this one type so the polymorphic seam stays a single trait bound and callers
51/// keep one error to handle.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SignerError(String);
54
55impl SignerError {
56    /// Wrap a backend error (bunker RPC, NIP-55 IPC, local crypto).
57    #[inline]
58    pub fn backend<E>(e: E) -> Self
59    where
60        E: core::fmt::Display,
61    {
62        Self(e.to_string())
63    }
64}
65
66impl core::fmt::Display for SignerError {
67    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
68        f.write_str(&self.0)
69    }
70}
71
72impl core::error::Error for SignerError {}
73
74impl From<&str> for SignerError {
75    #[inline]
76    fn from(s: &str) -> Self {
77        Self(s.to_string())
78    }
79}
80
81impl From<String> for SignerError {
82    #[inline]
83    fn from(s: String) -> Self {
84        Self(s)
85    }
86}
87
88/// Every capability Vector's polymorphic signing paths need, in one bound.
89///
90/// Pinning `Error = SignerError` is what makes the bundle usable as a single
91/// bound; it's viable because the concrete-`Keys` paths have their own
92/// non-generic overloads (`build_list_event` vs `build_list_event_signed`), so
93/// bare `Keys` is never passed here.
94pub trait VectorSigner:
95    AsyncGetPublicKey<Error = SignerError>
96    + AsyncSignEvent<Error = SignerError>
97    + AsyncNip04<Error = SignerError>
98    + AsyncNip44<Error = SignerError>
99{
100}
101
102impl<T> VectorSigner for T
103where
104    T: ?Sized
105        + AsyncGetPublicKey<Error = SignerError>
106        + AsyncSignEvent<Error = SignerError>
107        + AsyncNip04<Error = SignerError>
108        + AsyncNip44<Error = SignerError>,
109{
110}
111
112// ============================================================================
113// ActiveSigner — the session's signer, resolved on demand
114// ============================================================================
115
116/// The signer for the active session.
117///
118/// nostr 0.45 removed `ClientBuilder::signer` / `Client::signer`: events are
119/// built and signed outside the client now, so Vector owns this dispatch.
120///
121/// A concrete enum rather than `Arc<dyn ...>` on purpose. 0.45 ships no blanket
122/// capability impls for `Arc<T>`, and the orphan rule forbids adding them, so a
123/// trait object would force every call site to deref. This also keeps dispatch
124/// static.
125///
126/// Deliberately NOT cached in a static: [`active_signer`] rebuilds it per call
127/// from state that is already swap-managed (`SIGNER_KIND`, `BUNKER_SIGNER`,
128/// `MY_PUBLIC_KEY`). A cached signer would be one more per-account global to
129/// invalidate on `reset_session`, and a stale one signs the new account's events
130/// under the old identity.
131#[derive(Debug, Clone)]
132#[allow(clippy::large_enum_variant)] // boxing a public variant's payload is an API break
133pub enum ActiveSigner {
134    /// Local key from the GuardedKey vault.
135    Local(crate::crypto::GuardedSigner),
136    /// Remote NIP-46 bunker, with reachability reporting.
137    Bunker(WatchedBunkerSigner),
138    /// On-device NIP-55 signer app reached over Android IPC.
139    Nip55(crate::nip55::Nip55Signer),
140    /// NIP-07 browser extension in the page hosting a web build.
141    Nip07(crate::nip07::Nip07Signer),
142    /// Raw keys — headless/CLI consumers and tests, which have a vault key but
143    /// no notion of signer modes.
144    Keys(Keys),
145}
146
147macro_rules! dispatch {
148    ($self:ident, $method:ident $(, $arg:expr)*) => {
149        match $self {
150            ActiveSigner::Local(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
151            ActiveSigner::Bunker(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
152            ActiveSigner::Nip55(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
153            ActiveSigner::Nip07(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
154            ActiveSigner::Keys(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
155        }
156    };
157}
158
159impl AsyncGetPublicKey for ActiveSigner {
160    type Error = SignerError;
161
162    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
163        Box::pin(async move { dispatch!(self, get_public_key_async) })
164    }
165}
166
167impl AsyncSignEvent for ActiveSigner {
168    type Error = SignerError;
169
170    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
171        Box::pin(async move { dispatch!(self, sign_event_async, unsigned) })
172    }
173}
174
175impl AsyncNip04 for ActiveSigner {
176    type Error = SignerError;
177
178    fn nip04_encrypt_async<'a>(
179        &'a self,
180        public_key: &'a PublicKey,
181        content: &'a str,
182    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
183        Box::pin(async move { dispatch!(self, nip04_encrypt_async, public_key, content) })
184    }
185
186    fn nip04_decrypt_async<'a>(
187        &'a self,
188        public_key: &'a PublicKey,
189        encrypted_content: &'a str,
190    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
191        Box::pin(async move { dispatch!(self, nip04_decrypt_async, public_key, encrypted_content) })
192    }
193}
194
195impl AsyncNip44 for ActiveSigner {
196    type Error = SignerError;
197
198    fn nip44_encrypt_async<'a>(
199        &'a self,
200        public_key: &'a PublicKey,
201        content: &'a str,
202    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
203        Box::pin(async move { dispatch!(self, nip44_encrypt_async, public_key, content) })
204    }
205
206    fn nip44_decrypt_async<'a>(
207        &'a self,
208        public_key: &'a PublicKey,
209        payload: &'a str,
210    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
211        Box::pin(async move { dispatch!(self, nip44_decrypt_async, public_key, payload) })
212    }
213}
214
215/// Test-only signer override consulted by [`active_signer`].
216#[cfg(test)]
217static TEST_SIGNER: LazyLock<RwLock<Option<ActiveSigner>>> =
218    LazyLock::new(|| RwLock::new(None));
219
220/// Install (or clear) the test signer override.
221#[cfg(test)]
222pub(crate) fn set_test_signer(signer: Option<ActiveSigner>) {
223    if let Ok(mut g) = TEST_SIGNER.write() {
224        *g = signer;
225    }
226}
227
228/// Unwrap a NIP-59 gift wrap with the active session's signer. A local key opens it
229/// under one vault read; remote signers go through their round-trips.
230pub async fn unwrap_gift_wrap(gift_wrap: &Event) -> Result<UnwrappedGift, String> {
231    #[cfg(test)]
232    let overridden = TEST_SIGNER.read().is_ok_and(|g| g.is_some());
233    #[cfg(not(test))]
234    let overridden = false;
235    if !overridden && signer_kind() == SignerKind::Local {
236        return crate::crypto::GuardedSigner::unwrap_gift_wrap(gift_wrap).map_err(|e| e.to_string());
237    }
238    let signer = active_signer()?;
239    UnwrappedGift::from_gift_wrap_async(&signer, gift_wrap).await.map_err(|e| e.to_string())
240}
241
242/// [`unwrap_gift_wrap`] for many wraps, one result per wrap in order. A local key opens
243/// the whole batch under one vault read.
244pub async fn unwrap_gift_wraps(gift_wraps: &[&Event]) -> Vec<Result<UnwrappedGift, String>> {
245    #[cfg(test)]
246    let overridden = TEST_SIGNER.read().is_ok_and(|g| g.is_some());
247    #[cfg(not(test))]
248    let overridden = false;
249    if !overridden && signer_kind() == SignerKind::Local {
250        return crate::crypto::GuardedSigner::unwrap_batch(gift_wraps)
251            .into_iter()
252            .map(|r| r.map_err(|e| e.to_string()))
253            .collect();
254    }
255    let mut out = Vec::with_capacity(gift_wraps.len());
256    for wrap in gift_wraps {
257        out.push(unwrap_gift_wrap(wrap).await);
258    }
259    out
260}
261
262/// Resolve the active session's signer.
263///
264/// Fails CLOSED on identity mismatch: a remote-signer account's vault holds its
265/// *client* keypair, whose pubkey is not the identity. Signing with it emits
266/// wrong-identity events that self-reject on every reader (AuthorMismatch), so
267/// erroring here surfaces the misconfiguration instead of a silently
268/// undeliverable send.
269pub fn active_signer() -> Result<ActiveSigner, String> {
270    // Tests model a remote-signer account (identity signable, local vault empty),
271    // which production resolves from `BUNKER_SIGNER`. There's no such handle to
272    // fabricate in-process, so tests inject the signer directly.
273    #[cfg(test)]
274    if let Some(s) = TEST_SIGNER.read().ok().and_then(|g| g.clone()) {
275        return Ok(s);
276    }
277    match signer_kind() {
278        SignerKind::Bunker => {
279            let inner = bunker_signer()
280                .ok_or("bunker account has no live signer (not yet connected)")?;
281            Ok(ActiveSigner::Bunker(WatchedBunkerSigner::new(inner)))
282        }
283        SignerKind::Nip55 => {
284            let pk = crate::state::my_public_key().ok_or("no active identity")?;
285            Ok(ActiveSigner::Nip55(crate::nip55::Nip55Signer::new(pk)))
286        }
287        SignerKind::Nip07 => {
288            let pk = crate::state::my_public_key().ok_or("no active identity")?;
289            Ok(ActiveSigner::Nip07(crate::nip07::Nip07Signer::new(pk)))
290        }
291        SignerKind::Local => {
292            let keys = crate::state::MY_SECRET_KEY
293                .to_keys()
294                .ok_or("no signer available (no local key)")?;
295            if let Some(pk) = crate::state::my_public_key() {
296                if keys.public_key() != pk {
297                    return Err("local key does not match the active identity (remote-signer account with no live signer)".to_string());
298                }
299                return Ok(ActiveSigner::Local(crate::crypto::GuardedSigner::new(pk)));
300            }
301            // No bound identity: headless/CLI consumers and tests.
302            Ok(ActiveSigner::Keys(keys))
303        }
304    }
305}
306
307// ============================================================================
308// SignerKind — discriminator
309// ============================================================================
310
311/// Which signer backs the active account.
312#[derive(Copy, Clone, Debug, Eq, PartialEq)]
313#[repr(u8)]
314pub enum SignerKind {
315    /// The user's nsec lives in `MY_SECRET_KEY` on this device.
316    Local = 0,
317    /// The user's nsec lives on a remote NIP-46 signer; we only hold the
318    /// client keypair used to RPC it.
319    Bunker = 1,
320    /// The user's nsec lives in an on-device NIP-55 signer app (Amber) reached
321    /// over local Android IPC. Nothing secret is stored on this device at all
322    /// (not even a client keypair). Android-only.
323    Nip55 = 2,
324    /// The user's nsec lives in a NIP-07 browser extension. Web-only; keyless
325    /// like NIP-55.
326    Nip07 = 3,
327}
328
329impl SignerKind {
330    /// Persisted form used by the per-account settings KV.
331    #[inline]
332    pub fn as_setting_str(self) -> &'static str {
333        match self {
334            SignerKind::Local => "local",
335            SignerKind::Bunker => "bunker",
336            SignerKind::Nip55 => "nip55",
337            SignerKind::Nip07 => "nip07",
338        }
339    }
340
341    /// Parse from the on-disk setting string. Unknown values fall back to
342    /// `Local` so an upgrade path from pre-NIP-46 accounts (which have no
343    /// `signer_type` row) is the obvious default.
344    #[inline]
345    pub fn from_setting_str(s: &str) -> Self {
346        match s {
347            "bunker" => SignerKind::Bunker,
348            "nip55" => SignerKind::Nip55,
349            "nip07" => SignerKind::Nip07,
350            _ => SignerKind::Local,
351        }
352    }
353}
354
355static SIGNER_KIND: AtomicU8 = AtomicU8::new(SignerKind::Local as u8);
356
357/// The signer kind for the active session. Cheap to read; backed by an atomic.
358#[inline]
359pub fn signer_kind() -> SignerKind {
360    match SIGNER_KIND.load(Ordering::Acquire) {
361        1 => SignerKind::Bunker,
362        2 => SignerKind::Nip55,
363        3 => SignerKind::Nip07,
364        _ => SignerKind::Local,
365    }
366}
367
368/// Install the signer kind for the active session. Call at login after the
369/// settings row has been read, and on swap before any signing work runs.
370#[inline]
371pub fn set_signer_kind(kind: SignerKind) {
372    SIGNER_KIND.store(kind as u8, Ordering::Release);
373}
374
375/// `true` iff the active account signs via a remote NIP-46 bunker. Hot-path
376/// helper for code that needs to branch on signer mode (e.g. parallelising
377/// gift-wrap signing harder when each call pays a round-trip).
378///
379/// Reserve this for genuinely NIP-46-relay-specific logic. For "we don't hold
380/// the identity key on this device" gates (key export refusal, keyless-account
381/// feature availability) use `is_keyless()` instead — a NIP-55 account is
382/// keyless but not a bunker.
383#[inline]
384pub fn is_bunker() -> bool {
385    signer_kind() == SignerKind::Bunker
386}
387
388/// `true` iff the identity key is NOT held on this device — i.e. any remote /
389/// external signer (NIP-46 bunker or NIP-55 Amber). The local `MY_SECRET_KEY`
390/// vault does not hold the signing identity for these accounts, so anything
391/// that reads or exports the raw nsec must gate on this and route through
392/// `client.signer()` instead.
393#[inline]
394pub fn is_keyless() -> bool {
395    signer_kind() != SignerKind::Local
396}
397
398// ============================================================================
399// Client-keypair storage note
400// ============================================================================
401//
402// The NIP-46 client keypair (used to RPC the bunker — not the user's
403// identity) lives in the existing `MY_SECRET_KEY` vault for bunker accounts.
404// This is intentional: every existing call site that loads "the active
405// signing key" gets the client key, which is what the NIP-46 layer wants for
406// its RPC envelope. For events the *user* sends, the path goes through
407// `client.signer()` → NostrConnect, which tunnels to the bunker — so user
408// events are signed by the user's identity, RPC envelopes by the client key.
409//
410// This avoids needing a second GuardedKey vault and the slot-coordination
411// problem that comes with it. The trade-off: bunker accounts share the same
412// memory-protection footprint as local accounts (the user's identity isn't
413// on this device at all).
414
415// ============================================================================
416// BUNKER_SIGNER — live NostrConnect handle
417// ============================================================================
418
419/// Active `NostrConnect` handle. `None` for local-signer sessions.
420///
421/// `NostrConnect` is internally `Arc`-counted (relay pool, OnceCell-backed
422/// remote pubkey cache), so cloning it for per-call use is cheap. The lock is
423/// only held briefly to snapshot the inner value.
424pub static BUNKER_SIGNER: LazyLock<RwLock<Option<NostrConnect>>> =
425    LazyLock::new(|| RwLock::new(None));
426
427/// Snapshot the active bunker handle. Returns `None` for local-signer sessions.
428#[inline]
429pub fn bunker_signer() -> Option<NostrConnect> {
430    BUNKER_SIGNER.read().ok().and_then(|g| g.as_ref().cloned())
431}
432
433/// Install the bunker handle for the active session. Replaces any prior handle
434/// without shutting it down — callers swapping should `take_bunker_signer()`
435/// first and `.shutdown().await` the old one to drain its relay pool cleanly.
436#[inline]
437pub fn set_bunker_signer(signer: NostrConnect) {
438    if let Ok(mut g) = BUNKER_SIGNER.write() {
439        *g = Some(signer);
440    }
441}
442
443/// Atomically remove the bunker handle. Used by session teardown so the
444/// caller can `.shutdown()` it without racing readers.
445#[inline]
446pub fn take_bunker_signer() -> Option<NostrConnect> {
447    BUNKER_SIGNER.write().ok().and_then(|mut g| g.take())
448}
449
450// ============================================================================
451// Construction helpers
452// ============================================================================
453
454/// Parse a `bunker://` URL and return the relay URLs it lists. Used by the
455/// Settings UI to render "Connected via <relay>" without re-bootstrapping.
456/// Returns an empty Vec on any parse failure — the caller treats this as a
457/// display-only signal and renders a generic fallback instead of erroring.
458pub fn parse_bunker_relays(bunker_url: &str) -> Vec<String> {
459    match NostrConnectUri::parse(bunker_url) {
460        Ok(NostrConnectUri::Bunker { relays, .. }) => {
461            relays.into_iter().map(|r| r.to_string()).collect()
462        }
463        _ => Vec::new(),
464    }
465}
466
467/// Inspect a `bunker://` URL without bootstrapping: returns the remote
468/// signer's pubkey (hex). Used by login flows to check whether a re-submitted
469/// URL points at the same bunker as the active session (idempotent re-login)
470/// versus a different bunker (which requires logout first). Cheap — no
471/// network.
472pub fn parse_bunker_remote_pubkey(bunker_url: &str) -> Result<String, String> {
473    let uri = NostrConnectUri::parse(bunker_url)
474        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
475    match uri {
476        NostrConnectUri::Bunker { remote_signer_public_key, .. } => {
477            // Force lowercase. `to_hex()` already returns lowercase per
478            // nostr-sdk, but normalising here lets callers compare hex
479            // forms with `==` without worrying about a future upstream
480            // shift to mixed-case.
481            Ok(remote_signer_public_key.to_hex().to_ascii_lowercase())
482        }
483        // Client-initiated URIs aren't supported as login entry points in v1;
484        // they're for the reverse direction (we hand a URL to the signer).
485        NostrConnectUri::Client { .. } => {
486            Err("Client-initiated URIs not supported here; use a bunker:// URL".into())
487        }
488    }
489}
490
491// ============================================================================
492// Vector app identity — surfaced to remote signers via NIP-46 metadata
493// ============================================================================
494
495/// Application name shown to the user by the remote signer when approving the
496/// connection (e.g. on Amber's pairing screen).
497pub const VECTOR_APP_NAME: &str = "Vector";
498
499/// Marketing site — surfaced as the signer's "More info" link.
500pub const VECTOR_APP_URL: &str = "https://vectorapp.io";
501
502/// Icon shown by the signer alongside the app name. PNG, served from the
503/// public GitHub mirror so the URL stays valid even if vectorapp.io changes
504/// its asset layout. Signers cache by URL, so a stable target avoids
505/// re-fetches on every pairing.
506pub const VECTOR_APP_ICON: &str = "https://raw.githubusercontent.com/VectorPrivacy/Vector/master/src-tauri/icons/icon.png";
507
508/// NIP-46 permission scope Vector requests on client-initiated pairings.
509///
510/// Sent as the `perms=` query parameter on `nostrconnect://` URIs. Signer apps
511/// that honour it (Amber, nsec.app) surface this list on their pairing screen
512/// and refuse RPC calls outside the granted scope. Vector intentionally never
513/// requests `get_private_key`: the whole point of a Remote Signer is that the
514/// identity nsec stays on the signer device, so allowing extraction would
515/// defeat the threat model. Adding a method here is an explicit policy
516/// decision; signer apps that don't enforce `perms` server-side still benefit
517/// from a smaller surface in their pairing UI.
518pub const VECTOR_NIP46_PERMS: &[&str] = &[
519    "get_public_key",
520    "sign_event",
521    "nip04_encrypt",
522    "nip04_decrypt",
523    "nip44_encrypt",
524    "nip44_decrypt",
525];
526
527/// Build the NIP-46 metadata payload Vector advertises in client-initiated
528/// `nostrconnect://` URIs. The signer reads this to render the approval
529/// prompt — name and icon are the bits the user actually sees.
530pub fn vector_metadata() -> NostrConnectMetadata {
531    let mut md = NostrConnectMetadata::new(VECTOR_APP_NAME);
532    if let Ok(url) = Url::parse(VECTOR_APP_URL) {
533        md = md.url(url);
534    }
535    if let Ok(icon) = Url::parse(VECTOR_APP_ICON) {
536        md = md.icons(vec![icon]);
537    }
538    md
539}
540
541/// Build a client-initiated `nostrconnect://` URI. The user copies this URL
542/// into their signer app (or scans the QR rendering of it); the signer
543/// initiates the connection back to the listed relays.
544///
545/// Multi-relay by design — single-relay connect URIs are a centralisation
546/// trap: if that one relay goes down, the user can't reconnect to their own
547/// account. Pass the live trusted-relay list from `state::TRUSTED_RELAYS`.
548pub fn build_nostrconnect_uri(
549    client_pubkey: PublicKey,
550    relays: Vec<RelayUrl>,
551) -> NostrConnectUri {
552    NostrConnectUri::Client {
553        public_key: client_pubkey,
554        relays,
555        metadata: vector_metadata(),
556        secret: random_connect_secret(),
557    }
558}
559
560/// Fresh NIP-46 pairing secret. The signer echoes it in the `connect` response;
561/// a mismatch means someone else answered, so it must be unguessable per session.
562fn random_connect_secret() -> String {
563    use ::rand::RngCore;
564    let mut bytes = [0u8; 16];
565    ::rand::rngs::OsRng.fill_bytes(&mut bytes);
566    bytes.iter().map(|b| format!("{b:02x}")).collect()
567}
568
569/// Build a `NostrConnect` for a client-initiated session — generates the
570/// `nostrconnect://` URI from the client keys + relays + Vector metadata,
571/// constructs the underlying `NostrConnect` with the Vector auth-URL handler
572/// already attached, and returns both for the caller to (a) display the URI
573/// to the user (QR + copy button) and (b) install the signer.
574///
575/// Note: doesn't bootstrap. The caller is expected to install the returned
576/// `NostrConnect` in `BUNKER_SIGNER` and await `get_public_key()` to wait
577/// for the signer's connect response.
578pub fn build_nostrconnect_session(
579    client_keys: Keys,
580    relays: Vec<RelayUrl>,
581    timeout: Duration,
582) -> Result<(NostrConnect, String), String> {
583    let uri = build_nostrconnect_uri(client_keys.public_key(), relays);
584    // Append the NIP-46 `perms=` scope. nostr-sdk's `Display` impl doesn't
585    // write it, so the SDK-built URI is fine to hand back to `NostrConnect`
586    // (which doesn't read perms locally), while the signer app on the other
587    // side parses the appended query param to render its pairing screen.
588    //
589    // The NIP-46 `secret` IS emitted now (0.45 requires it, and its response
590    // parser accepts both a spec-compliant secret echo and Amber's bare `"ack"`),
591    // so spoof detection costs nothing in interop.
592    let mut uri_string = uri.to_string();
593    let perms = VECTOR_NIP46_PERMS.join(",");
594    if !perms.is_empty() {
595        uri_string.push_str("&perms=");
596        uri_string.push_str(&perms);
597    }
598    let mut nc = NostrConnect::new(uri, client_keys, timeout, None)
599        .map_err(|e| format!("Bunker init failed: {}", e))?;
600    nc.auth_url_handler(VectorAuthUrlHandler);
601    Ok((nc, uri_string))
602}
603
604/// Build a `NostrConnect` from a `bunker://` URL + client keypair. Doesn't
605/// connect yet — `NostrConnect` bootstraps lazily on the first signing call.
606/// Use `prewarm()` if you want the connection up before the user's first send.
607///
608/// `timeout` bounds each RPC round-trip. 60s is the upstream example; we
609/// expose it so chat-send paths can tighten this for snappier failure surfacing.
610pub fn build_bunker_signer(
611    bunker_url: &str,
612    client_keys: Keys,
613    timeout: Duration,
614) -> Result<NostrConnect, String> {
615    let uri = NostrConnectUri::parse(bunker_url)
616        .map_err(|e| format!("Invalid bunker URL: {}", e))?;
617    NostrConnect::new(uri, client_keys, timeout, None)
618        .map_err(|e| format!("Bunker init failed: {}", e))
619}
620
621/// Force a bunker bootstrap and discover the user's identity pubkey.
622///
623/// The signer's *device* pubkey (returned by `bunker_uri()`) is NOT the user
624/// identity for signers like Amber — bypassing this RPC produces events
625/// signed under the wrong key. In Amber's "Manually approve each" mode this
626/// prompts the user once during initial pairing.
627pub async fn prewarm_bunker(signer: &NostrConnect) -> Result<PublicKey, String> {
628    signer
629        .get_public_key_async()
630        .await
631        .map_err(|e| format!("Bunker prewarm failed: {}", e))
632}
633
634// ============================================================================
635// BunkerConnectionState — observable connection lifecycle
636// ============================================================================
637
638/// Observable state of the bunker connection. The atomic backs hot-path reads
639/// (e.g. send paths checking "is it safe to issue a sign call?"); state changes
640/// also fan out to the frontend via `EventEmitter` so the UI can show a banner.
641#[derive(Copy, Clone, Debug, Eq, PartialEq)]
642#[repr(u8)]
643pub enum BunkerConnectionState {
644    /// No active bunker session. Either we're on a local account, or we're
645    /// between login and the first successful bootstrap.
646    Idle = 0,
647    /// Currently bootstrapping (relay connect + remote-pubkey discovery).
648    Connecting = 1,
649    /// Bunker is reachable; signing calls should succeed.
650    Online = 2,
651    /// Bunker is unreachable. Hot-path sends will fail fast; the next signing
652    /// call will retry the underlying NostrConnect path which may reconnect.
653    Offline = 3,
654}
655
656impl BunkerConnectionState {
657    /// User-facing label, mirrored to the frontend in `bunker_state` events.
658    pub fn as_label(self) -> &'static str {
659        match self {
660            BunkerConnectionState::Idle => "idle",
661            BunkerConnectionState::Connecting => "connecting",
662            BunkerConnectionState::Online => "online",
663            BunkerConnectionState::Offline => "offline",
664        }
665    }
666}
667
668static BUNKER_STATE: AtomicU8 = AtomicU8::new(BunkerConnectionState::Idle as u8);
669
670/// Read the live bunker connection state. Backed by an atomic; cheap to call.
671#[inline]
672pub fn bunker_state() -> BunkerConnectionState {
673    match BUNKER_STATE.load(Ordering::Acquire) {
674        1 => BunkerConnectionState::Connecting,
675        2 => BunkerConnectionState::Online,
676        3 => BunkerConnectionState::Offline,
677        _ => BunkerConnectionState::Idle,
678    }
679}
680
681/// Install a new bunker state and fan out a `bunker_state` event to the
682/// frontend. No-op if the state didn't change — avoids spamming the UI with
683/// duplicate transitions when a signing call confirms what's already known.
684pub fn set_bunker_state(new_state: BunkerConnectionState) {
685    let prev = BUNKER_STATE.swap(new_state as u8, Ordering::AcqRel);
686    if prev == new_state as u8 {
687        return;
688    }
689    crate::traits::emit_event_json(
690        "bunker_state",
691        serde_json::json!({ "state": new_state.as_label() }),
692    );
693}
694
695// ============================================================================
696// WatchedBunkerSigner — wrap NostrConnect with bunker_state observability
697// ============================================================================
698//
699// Every signing operation (sign_event, nip44_encrypt, nip04_*) flows through
700// this adapter when a bunker account is active. On success we flip
701// `BUNKER_STATE` to Online; on error we flip to Offline. The frontend's
702// `bunker_state` listener picks up the transition and surfaces a banner /
703// toast so the user knows when their signer becomes unreachable mid-session.
704//
705// State flips are deduplicated by `set_bunker_state` (same-value writes are
706// no-ops), so the per-call overhead is just one atomic load.
707
708/// `VectorSigner` wrapper that emits `BunkerConnectionState` transitions on
709/// every signing outcome. The inner `NostrConnect` is cheaply clonable
710/// (internally Arc'd), so this is also Clone.
711///
712/// Captures a `std::sync::Arc<crate::db::Session>` at construction; state flips after `reset_session`
713/// are no-ops to avoid leaking signer-state events across an account swap (an
714/// in-flight signing call resolving after the new account is installed would
715/// otherwise emit `bunker_state: offline` against a local-account session).
716#[derive(Debug, Clone)]
717pub struct WatchedBunkerSigner {
718    inner: NostrConnect,
719    session: std::sync::Arc<crate::db::Session>,
720}
721
722impl WatchedBunkerSigner {
723    pub fn new(inner: NostrConnect) -> Self {
724        Self { inner, session: crate::db::current_session() }
725    }
726
727    /// Flip state only when the captured session is still active.
728    #[inline]
729    fn flip(&self, state: BunkerConnectionState) {
730        if self.session.is_live() {
731            set_bunker_state(state);
732        }
733    }
734
735    /// Test-only view onto the captured guard so a test can assert the
736    /// wrapper is bound to the session generation at construction.
737    #[cfg(test)]
738    pub(crate) fn session_id_for_test(&self) -> u64 {
739        self.session.id()
740    }
741}
742
743impl WatchedBunkerSigner {
744    /// Record the reachability implied by an outcome and normalise the bunker's
745    /// error into `SignerError`.
746    #[inline]
747    fn watch<T, E>(&self, res: Result<T, E>) -> Result<T, SignerError>
748    where
749        E: core::fmt::Display,
750    {
751        match res {
752            Ok(v) => {
753                self.flip(BunkerConnectionState::Online);
754                Ok(v)
755            }
756            Err(e) => {
757                self.flip(BunkerConnectionState::Offline);
758                Err(SignerError::backend(e))
759            }
760        }
761    }
762}
763
764impl AsyncGetPublicKey for WatchedBunkerSigner {
765    type Error = SignerError;
766
767    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
768        Box::pin(async move { self.watch(self.inner.get_public_key_async().await) })
769    }
770}
771
772impl AsyncSignEvent for WatchedBunkerSigner {
773    type Error = SignerError;
774
775    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
776        Box::pin(async move { self.watch(self.inner.sign_event_async(unsigned).await) })
777    }
778}
779
780impl AsyncNip04 for WatchedBunkerSigner {
781    type Error = SignerError;
782
783    fn nip04_encrypt_async<'a>(
784        &'a self,
785        public_key: &'a PublicKey,
786        content: &'a str,
787    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
788        Box::pin(async move { self.watch(self.inner.nip04_encrypt_async(public_key, content).await) })
789    }
790
791    fn nip04_decrypt_async<'a>(
792        &'a self,
793        public_key: &'a PublicKey,
794        encrypted_content: &'a str,
795    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
796        Box::pin(async move {
797            self.watch(self.inner.nip04_decrypt_async(public_key, encrypted_content).await)
798        })
799    }
800}
801
802impl AsyncNip44 for WatchedBunkerSigner {
803    type Error = SignerError;
804
805    fn nip44_encrypt_async<'a>(
806        &'a self,
807        public_key: &'a PublicKey,
808        content: &'a str,
809    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
810        Box::pin(async move { self.watch(self.inner.nip44_encrypt_async(public_key, content).await) })
811    }
812
813    fn nip44_decrypt_async<'a>(
814        &'a self,
815        public_key: &'a PublicKey,
816        payload: &'a str,
817    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
818        Box::pin(async move { self.watch(self.inner.nip44_decrypt_async(public_key, payload).await) })
819    }
820}
821
822// ============================================================================
823// VectorAuthUrlHandler — bridge bunker permission prompts to the frontend
824// ============================================================================
825//
826// NIP-46 signers occasionally need user approval (e.g. signing an event kind
827// the user hasn't yet granted blanket permission for). Amber and nsec.app
828// respond with an `auth_url` the user must visit; on completion the signing
829// retry succeeds. This handler emits the URL to the frontend so the UI can
830// show a "Open signer" prompt — we deliberately don't auto-open a browser
831// from the core because (a) the core doesn't own the platform-specific
832// browser-open path, and (b) frontends may prefer in-app webview.
833
834/// Auth-URL handler that forwards bunker prompts to the frontend via the
835/// `EventEmitter` trait. The frontend receives a `bunker_auth_url` event and
836/// is responsible for opening the URL (in-app webview, system browser, ...).
837#[derive(Debug, Clone, Default)]
838pub struct VectorAuthUrlHandler;
839
840impl AuthUrlHandler for VectorAuthUrlHandler {
841    fn on_auth_url<'a>(&'a self, auth_url: Url) -> BoxedFuture<'a, std::result::Result<(), nostr_connect::error::Error>> {
842        Box::pin(async move {
843            crate::traits::emit_event_json(
844                "bunker_auth_url",
845                serde_json::json!({ "url": auth_url.to_string() }),
846            );
847            Ok(())
848        })
849    }
850}
851
852// ============================================================================
853// attempt_bunker_login — end-to-end: build → prewarm → install
854// ============================================================================
855
856/// Build a `NostrConnect`, attach the Vector auth-URL handler, bootstrap it,
857/// and install it as the active bunker signer. Returns the discovered remote
858/// signer pubkey on success.
859///
860/// Emits `bunker_state` transitions: Connecting → Online (on success) or
861/// Connecting → Offline (on failure). The caller is expected to update the
862/// account-level discriminator (`signer_kind`) separately — this helper deals
863/// only with the live connection.
864pub async fn attempt_bunker_login(
865    bunker_url: &str,
866    client_keys: Keys,
867    timeout: Duration,
868) -> Result<PublicKey, String> {
869    set_bunker_state(BunkerConnectionState::Connecting);
870
871    let mut nc = match build_bunker_signer(bunker_url, client_keys, timeout) {
872        Ok(nc) => nc,
873        Err(e) => {
874            set_bunker_state(BunkerConnectionState::Offline);
875            return Err(e);
876        }
877    };
878    nc.auth_url_handler(VectorAuthUrlHandler);
879
880    match prewarm_bunker(&nc).await {
881        Ok(remote_pk) => {
882            // If a prior NostrConnect is already installed (retry-after-blip
883            // path), take it out and shut it down on a background task so
884            // its relay pool drains cleanly. Without this, repeated calls
885            // leak Arc'd RelayPool handles fighting for connection slots.
886            //
887            if let Some(old) = take_bunker_signer() {
888                // spawn-detached: drains the replaced signer's relay pool; it holds no
889                // account state and must finish even if the account swaps meanwhile.
890                crate::rt::spawn(async move { let _ = old.shutdown().await; });
891            }
892            set_bunker_signer(nc);
893            set_bunker_state(BunkerConnectionState::Online);
894            Ok(remote_pk)
895        }
896        Err(e) => {
897            // The just-built `nc`'s Drop will release its half-opened relay
898            // connections asynchronously; we don't need a shutdown call here
899            // because we never installed it as the active signer.
900            set_bunker_state(BunkerConnectionState::Offline);
901            Err(e)
902        }
903    }
904}
905
906// ============================================================================
907// Teardown
908// ============================================================================
909
910/// Clear all bunker-specific state. Called by `reset_session()` so a swap
911/// from bunker → local (or between two bunker accounts) leaves no stale
912/// keying material, relay-pool handles, or stale connection-state observed
913/// by the frontend. The caller is responsible for `.shutdown().await`-ing
914/// the returned signer outside the lock.
915pub fn drain_bunker_state() -> Option<NostrConnect> {
916    // Resets SIGNER_KIND for EVERY signer kind, not just bunker — `reset_session`
917    // calls this unconditionally, so a NIP-55 (or any) → local swap lands the
918    // discriminator back at Local before the next account's login re-reads its
919    // own signer_type. Do NOT make this bunker-conditional or is_keyless() gets
920    // stuck across swaps.
921    set_signer_kind(SignerKind::Local);
922    set_bunker_state(BunkerConnectionState::Idle);
923    take_bunker_signer()
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    #[test]
931    fn setting_roundtrip() {
932        assert_eq!(SignerKind::from_setting_str("local"), SignerKind::Local);
933        assert_eq!(SignerKind::from_setting_str("bunker"), SignerKind::Bunker);
934        assert_eq!(SignerKind::from_setting_str("nip55"), SignerKind::Nip55);
935        assert_eq!(SignerKind::from_setting_str("nip07"), SignerKind::Nip07);
936        assert_eq!(SignerKind::Nip07.as_setting_str(), "nip07");
937        assert_eq!(SignerKind::Local.as_setting_str(), "local");
938        assert_eq!(SignerKind::Bunker.as_setting_str(), "bunker");
939        assert_eq!(SignerKind::Nip55.as_setting_str(), "nip55");
940        // Unknown values fall back to Local — upgrade path for pre-NIP-46 rows.
941        assert_eq!(SignerKind::from_setting_str(""), SignerKind::Local);
942        assert_eq!(SignerKind::from_setting_str("garbage"), SignerKind::Local);
943    }
944
945    // SIGNER_KIND + BUNKER_SIGNER + BUNKER_STATE are process-wide atomics /
946    // locks. Cargo runs `#[test]` functions in parallel, so any pair of
947    // tests that mutate the same global races and produces flaky failures.
948    // Bundled into one test function so the sequence is deterministic —
949    // mirrors `session_helpers_round_trip_and_clear` in state.rs which
950    // does the same for `MY_PUBLIC_KEY` / `PENDING_INVITE`.
951    #[test]
952    fn atomic_state_round_trips_and_drains() {
953        // Defensive cleanup: a previous test panic could have left a non-
954        // default value behind.
955        set_signer_kind(SignerKind::Local);
956        set_bunker_state(BunkerConnectionState::Idle);
957
958        // atomic kind roundtrip
959        set_signer_kind(SignerKind::Bunker);
960        assert_eq!(signer_kind(), SignerKind::Bunker);
961        assert!(is_bunker());
962        assert!(is_keyless());
963        set_signer_kind(SignerKind::Local);
964        assert_eq!(signer_kind(), SignerKind::Local);
965        assert!(!is_bunker());
966        assert!(!is_keyless());
967
968        // NIP-55 is keyless but NOT a bunker — the two gates must not conflate.
969        set_signer_kind(SignerKind::Nip55);
970        assert_eq!(signer_kind(), SignerKind::Nip55);
971        assert!(!is_bunker());
972        assert!(is_keyless());
973        set_signer_kind(SignerKind::Local);
974
975        // drain resets discriminator + state and returns the (absent) signer
976        set_signer_kind(SignerKind::Bunker);
977        set_bunker_state(BunkerConnectionState::Online);
978        let drained = drain_bunker_state();
979        assert!(drained.is_none());
980        assert_eq!(signer_kind(), SignerKind::Local);
981        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
982
983        // drain is idempotent — running again on already-cleared state is
984        // safe (no panic, no spurious event), and leaves things clean.
985        let drained_again = drain_bunker_state();
986        assert!(drained_again.is_none());
987        assert_eq!(signer_kind(), SignerKind::Local);
988        assert_eq!(bunker_state(), BunkerConnectionState::Idle);
989    }
990
991    #[test]
992    fn bunker_state_label_covers_all_variants() {
993        // Whenever a new BunkerConnectionState is added, this test forces a
994        // matching label so the frontend's `bunker_state` listener never sees
995        // an unlabelled discriminant.
996        assert_eq!(BunkerConnectionState::Idle.as_label(), "idle");
997        assert_eq!(BunkerConnectionState::Connecting.as_label(), "connecting");
998        assert_eq!(BunkerConnectionState::Online.as_label(), "online");
999        assert_eq!(BunkerConnectionState::Offline.as_label(), "offline");
1000    }
1001
1002    #[test]
1003    fn parse_bunker_relays_returns_relays_from_bunker_uri() {
1004        let signer_keys = Keys::generate();
1005        let r1 = RelayUrl::parse("wss://relay1.example").unwrap();
1006        let r2 = RelayUrl::parse("wss://relay2.example").unwrap();
1007        let uri = NostrConnectUri::Bunker {
1008            remote_signer_public_key: signer_keys.public_key(),
1009            relays: vec![r1.clone(), r2.clone()],
1010            secret: None,
1011        };
1012        let relays = parse_bunker_relays(&uri.to_string());
1013        assert_eq!(relays.len(), 2);
1014        assert!(relays.iter().any(|r| r.contains("relay1.example")));
1015        assert!(relays.iter().any(|r| r.contains("relay2.example")));
1016    }
1017
1018    #[test]
1019    fn parse_bunker_relays_returns_empty_on_invalid_input() {
1020        // Display-only signal; never panics, never errors. Bad input collapses
1021        // to "no relays known" so the Security panel falls back to "unknown"
1022        // instead of crashing.
1023        assert!(parse_bunker_relays("").is_empty());
1024        assert!(parse_bunker_relays("not a url").is_empty());
1025        assert!(parse_bunker_relays("http://example.com").is_empty());
1026
1027        // Client-initiated URIs also return empty — they're not the bunker
1028        // form we want to surface relays for.
1029        let client_keys = Keys::generate();
1030        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1031        let client_uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
1032        assert!(parse_bunker_relays(&client_uri.to_string()).is_empty(),
1033            "client URI must not surface as a bunker relay list");
1034    }
1035
1036    #[test]
1037    fn parse_bunker_remote_pubkey_invalid_url() {
1038        assert!(parse_bunker_remote_pubkey("not a url").is_err());
1039        assert!(parse_bunker_remote_pubkey("").is_err());
1040        assert!(parse_bunker_remote_pubkey("http://example.com").is_err());
1041    }
1042
1043    #[test]
1044    fn parse_bunker_remote_pubkey_rejects_client_uri() {
1045        // A client-initiated `nostrconnect://` URI is not a login entry point;
1046        // accepting it would let a hostile clipboard string register an
1047        // attacker-controlled client pubkey as "the remote signer".
1048        let client_keys = Keys::generate();
1049        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1050        let uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
1051        let err = parse_bunker_remote_pubkey(&uri.to_string())
1052            .expect_err("client URI must be rejected");
1053        assert!(err.contains("Client-initiated"), "unexpected error: {}", err);
1054    }
1055
1056    #[test]
1057    fn parse_bunker_remote_pubkey_normalizes_lowercase() {
1058        // Build a valid bunker URI with a known pubkey and verify the parse
1059        // result is forced to lowercase regardless of upstream casing choice.
1060        let signer_keys = Keys::generate();
1061        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1062        let uri = NostrConnectUri::Bunker {
1063            remote_signer_public_key: signer_keys.public_key(),
1064            relays: vec![relay],
1065            secret: None,
1066        };
1067        let parsed = parse_bunker_remote_pubkey(&uri.to_string())
1068            .expect("valid bunker URI");
1069        assert_eq!(parsed, signer_keys.public_key().to_hex().to_ascii_lowercase());
1070        assert_eq!(parsed, parsed.to_ascii_lowercase(),
1071            "callers may compare with == — output must already be lowercase");
1072    }
1073
1074    #[test]
1075    fn vector_metadata_carries_app_name_and_icon() {
1076        let md = vector_metadata();
1077        let json = serde_json::to_string(&md).expect("metadata serializes");
1078        assert!(json.contains(VECTOR_APP_NAME),
1079            "metadata must include app name for the signer's approval prompt; got {}", json);
1080        assert!(json.contains("vectorapp.io"),
1081            "metadata must reference the app URL for the signer's 'More info' link");
1082    }
1083
1084    #[test]
1085    fn nip46_perms_list_excludes_get_private_key() {
1086        // The whole point of a Remote Signer is keeping the identity nsec on
1087        // the signer device. Adding `get_private_key` to the requested perms
1088        // would invite the signer to expose it back to Vector and defeat the
1089        // threat model. This test fails loudly if a future edit re-adds it.
1090        for perm in VECTOR_NIP46_PERMS {
1091            assert!(!perm.contains("get_private_key"),
1092                "VECTOR_NIP46_PERMS must never include get_private_key (found: {})", perm);
1093            assert!(!perm.contains("private_key"),
1094                "perm string looks dangerous: {}", perm);
1095        }
1096    }
1097
1098    #[test]
1099    fn build_nostrconnect_session_appends_perms_query_param() {
1100        let client_keys = Keys::generate();
1101        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1102        let (_nc, uri) = build_nostrconnect_session(
1103            client_keys,
1104            vec![relay],
1105            std::time::Duration::from_secs(1),
1106        ).expect("session builds");
1107        assert!(uri.contains("perms="),
1108            "URI must carry perms query param so signers can scope the pairing; got: {}", uri);
1109        // Every permission we DO ask for must appear in the URI.
1110        for perm in VECTOR_NIP46_PERMS {
1111            assert!(uri.contains(perm),
1112                "URI missing permission '{}': {}", perm, uri);
1113        }
1114        // And get_private_key must NOT.
1115        assert!(!uri.contains("get_private_key"),
1116            "URI must never request get_private_key: {}", uri);
1117    }
1118
1119    #[test]
1120    fn build_nostrconnect_session_rejects_empty_uri() {
1121        // `build_nostrconnect_session` is the QR-flow entry. Constructing one
1122        // with zero relays would produce a URI that no signer can connect
1123        // back to — caller-side check is in `start_nostrconnect_session`, but
1124        // this is a sanity test that NostrConnect itself does not silently
1125        // accept an empty relay list at the URI level.
1126        let client_keys = Keys::generate();
1127        let session = build_nostrconnect_session(
1128            client_keys,
1129            vec![],
1130            std::time::Duration::from_secs(1),
1131        );
1132        // We don't assert pass/fail — different upstream versions may treat
1133        // empty relays differently — only that we don't panic.
1134        let _ = session;
1135    }
1136
1137    // Combined into one #[test] to serialise mutation of process-wide globals
1138    // (BUNKER_STATE, BUNKER_SIGNER). See the rationale on
1139    // `atomic_state_round_trips_and_drains` above.
1140    #[test]
1141    fn watched_signer_session_gate_and_state_transitions() {
1142
1143        // Build a real NostrConnect so we can wrap it. We never call any of
1144        // its async methods (those would require a relay) — only the inner
1145        // wrapper's session-guard semantics are under test.
1146        let client_keys = Keys::generate();
1147        let relay = RelayUrl::parse("wss://relay.example").unwrap();
1148        let signer_keys = Keys::generate();
1149        let uri = NostrConnectUri::Bunker {
1150            remote_signer_public_key: signer_keys.public_key(),
1151            relays: vec![relay],
1152            secret: None,
1153        };
1154        let nc = NostrConnect::new(
1155            uri,
1156            client_keys,
1157            std::time::Duration::from_secs(1),
1158            None,
1159        ).expect("NostrConnect builds");
1160
1161        let gen_before = crate::db::current_session_id();
1162        let watched = WatchedBunkerSigner::new(nc);
1163        assert_eq!(watched.session_id_for_test(), gen_before,
1164            "WatchedBunkerSigner must capture the live session generation at construction");
1165
1166        // Pre-swap: flip emits because the captured guard matches.
1167        set_bunker_state(BunkerConnectionState::Idle);
1168        watched.flip(BunkerConnectionState::Online);
1169        assert_eq!(bunker_state(), BunkerConnectionState::Online,
1170            "flip with valid session must update bunker_state");
1171
1172        // Simulate a session swap (logout / account swap). The captured
1173        // guard goes stale; subsequent flips must be ignored so a leftover
1174        // in-flight signing call from the previous account can't leak
1175        // bunker_state changes into the new session.
1176        crate::db::close_database();
1177        set_bunker_state(BunkerConnectionState::Online);
1178        watched.flip(BunkerConnectionState::Offline);
1179        assert_eq!(bunker_state(), BunkerConnectionState::Online,
1180            "flip with stale session must be a no-op");
1181
1182        // Cleanup so subsequent test runs / siblings see a sane state.
1183        set_bunker_state(BunkerConnectionState::Idle);
1184    }
1185}