diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index be0f82c6..e271b51e 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -321,19 +321,16 @@ impl Default for ImportState { /// mirroring the wallet-open password modal. const IDENTITY_PASS_MODAL: &str = "goblin_identity_pass_modal"; -/// Which identity action the wallet-password modal is unlocking for. +/// A pending add the wallet-password modal is gating: generate a fresh key +/// (`None`) or import an nsec (`Some`). Switching no longer uses the modal (it is +/// instant and local), so this is the only action the modal encrypts for. #[derive(Clone)] -enum PendingIdentityAction { - /// Switch the active identity to this held one (pubkey hex). - Switch(String), - /// Add a new identity: generate a fresh key (`None`) or import an nsec. - Add(Option), -} +struct PendingAdd(Option); -/// Identity switcher page state: the add-identity sub-form, the wallet-password -/// modal buffer (the unlock step for BOTH switching and adding — there is no -/// inline password field on the page), and the last switch/add result. Holds no -/// secret key material at rest. +/// Identity switcher page state: the add-identity sub-form and the wallet-password +/// modal buffer (the encrypt step for ADDING an identity — switching is instant +/// and needs no password), plus the last add result. Holds no secret key material +/// at rest. #[derive(Default)] struct IdentitySwitchState { /// Add-identity sub-form is open. @@ -342,21 +339,18 @@ struct IdentitySwitchState { import: bool, /// Pasted nsec when importing. nsec: String, - /// The action the password modal is currently gating (switch or add). - pending: Option, + /// The add the password modal is currently gating. + pending: Option, /// Password typed into the modal; cleared as soon as it is consumed. pass: String, /// The modal password didn't unlock — show the wrong-password line. wrong_pass: bool, - /// A background switch/add is running. + /// A background add is running. busy: bool, /// Transient error to show (invalid nsec, already held, at capacity). error: String, - /// Result slot for the background switch/add worker: Ok(npub) or Err(message). + /// Result slot for the background add worker: Ok(npub) or Err(message). result: std::sync::Arc>>>, - /// True while the last completed action was a switch (drives the syncing - /// banner), false for a plain add. - awaiting_sync: bool, } /// Inline "change name authority" (federation) editor state. @@ -4867,13 +4861,13 @@ impl GoblinWalletView { } Err(e) => { self.identity_switch.error = e; - self.identity_switch.awaiting_sync = false; } } } - // Wallet-password modal — the unlock step for BOTH switching and adding, - // mirroring the wallet-open password modal (dimmed backdrop, same buttons). + // Wallet-password modal — the unlock step for ADDING an identity only + // (switching is instant and local, no password), mirroring the wallet-open + // password modal (dimmed backdrop, same buttons). if Modal::opened() == Some(IDENTITY_PASS_MODAL) { Modal::ui(ui.ctx(), cb, |ui, modal, cb| { self.identity_pass_modal_content(ui, modal, wallet, cb); @@ -4881,12 +4875,6 @@ impl GoblinWalletView { } let identities = wallet.nostr_identities(); - let service = wallet.nostr_service(); - let syncing = service - .as_ref() - .map(|s| s.is_switch_syncing()) - .unwrap_or(false); - let paid = service.as_ref().map(|s| s.switch_received()).unwrap_or(0); ScrollArea::vertical() .auto_shrink([false; 2]) @@ -4905,58 +4893,9 @@ impl GoblinWalletView { ); ui.add_space(14.0); - // Syncing / "you were paid while away" banner after a switch. - if self.identity_switch.awaiting_sync { - let (icon, line, color) = if syncing { - ( - crate::gui::icons::ARROWS_CLOCKWISE, - t!("goblin.identities.syncing").to_string(), - t.surface_text_dim, - ) - } else if paid > 0 { - let msg = if crate::AppConfig::hide_amounts() { - t!("goblin.identities.paid_while_away").to_string() - } else { - t!("goblin.identities.paid_while_away_n", n => paid.to_string()) - .to_string() - }; - (crate::gui::icons::CHECK_CIRCLE, msg, t.pos) - } else { - ( - crate::gui::icons::CHECK_CIRCLE, - t!("goblin.identities.caught_up").to_string(), - t.surface_text_dim, - ) - }; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - if syncing { - View::small_loading_spinner(ui); - } else { - ui.label( - RichText::new(icon) - .font(FontId::new(16.0, fonts::regular())) - .color(color), - ); - } - ui.add_space(8.0); - ui.label( - RichText::new(line) - .font(FontId::new(13.5, fonts::semibold())) - .color(color), - ); - }); - }); - if syncing { - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(500)); - } - ui.add_space(12.0); - } - - // Held identities. Tap a non-active one to switch to it (the - // wallet-password modal opens to unlock and decrypt its nsec). + // Held identities. Tap a non-active one to switch to it INSTANTLY — + // all identities are already unlocked and listening, so a switch is a + // local change of which one is presented and used for sending. w::kicker(ui, &t!("goblin.identities.held")); ui.add_space(8.0); let busy = self.identity_switch.busy; @@ -5023,17 +4962,13 @@ impl GoblinWalletView { ui.add_space(6.0); } - // Tapping a held identity opens the password modal to unlock and - // switch to it (only the active nsec is ever decrypted). + // Tapping a held identity switches to it INSTANTLY — no password, no + // sync (it was already unlocked and listening). Purely local. if let Some(target) = switch_to { self.identity_switch.error.clear(); - self.identity_switch.pass.clear(); - self.identity_switch.wrong_pass = false; - self.identity_switch.pending = Some(PendingIdentityAction::Switch(target)); - Modal::new(IDENTITY_PASS_MODAL) - .position(ModalPosition::CenterTop) - .title(t!("goblin.identities.title")) - .show(); + if let Err(e) = wallet.switch_nostr_identity(target) { + self.identity_switch.error = e; + } } ui.add_space(8.0); @@ -5168,7 +5103,7 @@ impl GoblinWalletView { self.identity_switch.pass.clear(); self.identity_switch.wrong_pass = false; self.identity_switch.pending = - Some(PendingIdentityAction::Add(import_nsec)); + Some(PendingAdd(import_nsec)); Modal::new(IDENTITY_PASS_MODAL) .position(ModalPosition::CenterTop) .title(t!("goblin.identities.add_title")) @@ -5206,12 +5141,12 @@ impl GoblinWalletView { }); } - /// Content of the wallet-password modal that gates BOTH switching to a held - /// identity (unlock + decrypt its nsec) and adding a new one (encrypt + store). - /// Mirrors the wallet-open password modal (open.rs): explanation, masked field, - /// wrong-password line, Cancel/Continue. A correct password is verified - /// synchronously — like the wallet-open modal — before the add/switch worker is - /// spawned, so a wrong password stays in the modal instead of failing later. + /// Content of the wallet-password modal that gates ADDING an identity (encrypt + /// + store its new nsec). Switching no longer uses this — it is instant and + /// local. Mirrors the wallet-open password modal (open.rs): explanation, masked + /// field, wrong-password line, Cancel/Continue. A correct password is verified + /// synchronously before the add worker is spawned, so a wrong password stays in + /// the modal instead of failing later. fn identity_pass_modal_content( &mut self, ui: &mut egui::Ui, @@ -5276,30 +5211,18 @@ impl GoblinWalletView { if go && !self.identity_switch.pass.is_empty() { if !wallet.verify_nostr_password(&self.identity_switch.pass) { self.identity_switch.wrong_pass = true; - } else if let Some(action) = self.identity_switch.pending.take() { + } else if let Some(PendingAdd(import_nsec)) = self.identity_switch.pending.take() { let password = std::mem::take(&mut self.identity_switch.pass); self.identity_switch.wrong_pass = false; self.identity_switch.error.clear(); self.identity_switch.busy = true; let slot = self.identity_switch.result.clone(); let w = wallet.clone(); - match action { - PendingIdentityAction::Switch(hex) => { - self.identity_switch.awaiting_sync = true; - std::thread::spawn(move || { - let r = w.switch_nostr_identity(hex, password); - *slot.lock().unwrap() = Some(r); - }); - } - PendingIdentityAction::Add(import_nsec) => { - // Add only — never auto-switch into the new identity. - self.identity_switch.awaiting_sync = false; - std::thread::spawn(move || { - let r = w.add_nostr_identity(import_nsec, password); - *slot.lock().unwrap() = Some(r); - }); - } - } + // Add only — never auto-switch into the new identity. + std::thread::spawn(move || { + let r = w.add_nostr_identity(import_nsec, password); + *slot.lock().unwrap() = Some(r); + }); Modal::close(); } } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 166f3ddf..e2127eb6 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -95,12 +95,31 @@ const NAME_REVERIFY_INTERVAL_SECS: i64 = 6 * 3600; /// instead of bursting dozens of simultaneous mixnet lookups at once. const NAME_REVERIFY_MAX_PER_TICK: usize = 8; +/// One held identity live in memory: its decrypted keys (for unwrapping incoming +/// gift wraps addressed to it, and for signing when it is the active identity) +/// and its file state (for publishing its DM-relay list and, if named, its +/// profile). Every held identity of the open wallet is kept here so the wallet +/// LISTENS for ALL of them at once; switching is then a purely local change of +/// which one is presented and used for sending. +#[derive(Clone)] +pub struct HeldIdentityKeys { + pub keys: Keys, + pub identity: NostrIdentity, +} + /// Per-wallet nostr service. pub struct NostrService { - /// Identity keys (decrypted for the session). - keys: Keys, - /// Identity file state. + /// The ACTIVE identity's keys — used for sending, signing, and display. A + /// switch swaps this in place (the target is already unlocked in `recv`). + keys: RwLock, + /// Active identity file state (display, username claim, etc.). pub identity: RwLock, + /// EVERY held identity of the open wallet, decrypted for the session. The + /// wallet subscribes to gift wraps for all of their pubkeys at once and + /// unwraps each incoming wrap with whichever of these keys opens it, redeeming + /// into the one shared balance. Rebuilt on add/import/rotate; a plain switch + /// leaves it untouched and only re-points `keys`/`identity`. + recv: RwLock>, /// Per-wallet configuration. pub config: RwLock, /// Metadata archive. @@ -137,15 +156,6 @@ pub struct NostrService { /// Serializes a manual payment-cancel against a concurrent S2 finalize+post /// so the two can't both succeed (cancel the outputs AND post on-chain). cancel_finalize_lock: Mutex<()>, - /// This service was stood up by an identity SWITCH and is running its first - /// catch-up (the "Syncing…" state). Cleared once that catch-up fetch completes. - /// Only the ACTIVE identity ever listens, so a switch is mechanically a fresh - /// service on a different key; this flag just lets the UI show the catch-up. - switch_syncing: AtomicBool, - /// Count of payments redeemed during the switch catch-up (payments that - /// arrived for this identity while it was dormant). Read by the UI to show - /// "you were paid while away"; 0 means a quiet "caught up". - switch_received: std::sync::atomic::AtomicU32, } /// Phase of the most recent outgoing send, polled by the send UI. @@ -160,17 +170,28 @@ pub mod send_phase { } impl NostrService { - /// Create the service for an unlocked identity. + /// Create the service holding EVERY held identity of the open wallet (all + /// unlocked at wallet-open), with `active_hex` marking which one is presented + /// and used for sending. The wallet listens for all of them at once. pub fn new( - keys: Keys, - identity: NostrIdentity, + recv: Vec, + active_hex: &str, config: NostrConfig, store: NostrStore, nostr_dir: PathBuf, ) -> Arc { + // Pick the active identity (fall back to the first if the pointer doesn't + // resolve — the service must always have a running identity). + let active = recv + .iter() + .find(|h| h.keys.public_key().to_hex() == active_hex) + .or_else(|| recv.first()) + .cloned() + .expect("nostr service created with no identities"); Arc::new(Self { - keys, - identity: RwLock::new(identity), + keys: RwLock::new(active.keys), + identity: RwLock::new(active.identity), + recv: RwLock::new(recv), config: RwLock::new(config), store: Arc::new(store), nostr_dir, @@ -185,42 +206,54 @@ impl NostrService { last_send_error: RwLock::new(None), cancel_notice: RwLock::new(None), cancel_finalize_lock: Mutex::new(()), - switch_syncing: AtomicBool::new(false), - switch_received: std::sync::atomic::AtomicU32::new(0), }) } - /// Arm the switch-catch-up UI state before this (freshly stood-up) service - /// starts: the next catch-up fetch is the "Syncing…" pass for a just-switched - /// identity. Called by [`crate::wallet::Wallet::switch_nostr_identity`] before - /// `start`, so the UI shows the syncing state from the first frame. - pub fn begin_switch_sync(&self) { - self.switch_received - .store(0, std::sync::atomic::Ordering::SeqCst); - self.switch_syncing.store(true, Ordering::SeqCst); + /// Every held identity's pubkey — the recipients the gift-wrap subscription + /// filter names, so the wallet receives for all identities at once. + pub fn recv_pubkeys(&self) -> Vec { + self.recv + .read() + .iter() + .map(|h| h.keys.public_key()) + .collect() } - /// End the switch-catch-up state (the catch-up fetch has completed). The - /// redeemed count stays readable so the UI can show "you were paid while away". - fn end_switch_sync(&self) { - self.switch_syncing.store(false, Ordering::SeqCst); + /// Snapshot of every held identity live in memory (for unwrapping a wrap with + /// whichever key opens it, and for publishing each identity's relay list). + pub fn recv_snapshot(&self) -> Vec { + self.recv.read().clone() } - /// Whether the service is mid switch-catch-up ("Syncing…"). - pub fn is_switch_syncing(&self) -> bool { - self.switch_syncing.load(Ordering::Relaxed) + /// Whether `pk` is one of THIS wallet's own held identities (used to ignore + /// our own wrap-to-self copies across any identity). + pub fn is_own_pubkey(&self, pk: &PublicKey) -> bool { + self.recv.read().iter().any(|h| &h.keys.public_key() == pk) } - /// Payments redeemed during the switch catch-up (for "you were paid while - /// away"); 0 once caught up with nothing waiting. - pub fn switch_received(&self) -> u32 { - self.switch_received - .load(std::sync::atomic::Ordering::Relaxed) + /// Instant, purely-local identity switch: re-point the active keys/identity to + /// a held identity already unlocked and already listening. No password, no + /// teardown, no catch-up. `false` if `hex` is not held. + pub fn set_active_by_pubkey(&self, hex: &str) -> bool { + let held = self + .recv + .read() + .iter() + .find(|h| h.keys.public_key().to_hex() == hex) + .cloned(); + match held { + Some(h) => { + *self.keys.write() = h.keys; + *self.identity.write() = h.identity; + true + } + None => false, + } } - /// Own public key. + /// Own (active) public key. pub fn public_key(&self) -> PublicKey { - self.keys.public_key() + self.keys.read().public_key() } /// Own npub bech32. @@ -240,21 +273,21 @@ impl NostrService { .filter_map(|r| RelayUrl::parse(r).ok()) .take(2) .collect(); - Nip19Profile::new(self.keys.public_key(), relays) + Nip19Profile::new(self.keys.read().public_key(), relays) .to_bech32() .ok() .unwrap_or_else(|| self.npub()) } - /// Own nsec (secret key) bech32 — for explicit user backup only. + /// Own (active) nsec (secret key) bech32 — for explicit user backup only. pub fn nsec(&self) -> Option { - self.keys.secret_key().to_bech32().ok() + self.keys.read().secret_key().to_bech32().ok() } - /// The service's signing keys, for in-process signing (e.g. NIP-98 auth) - /// without ever serializing the secret to a plaintext `String`. + /// The active identity's signing keys, for in-process signing (e.g. NIP-98 + /// auth) without ever serializing the secret to a plaintext `String`. pub fn keys(&self) -> Keys { - self.keys.clone() + self.keys.read().clone() } /// Fetch a pubkey's published kind-0 profile (one shot, short timeout). @@ -700,7 +733,7 @@ impl NostrService { ), ]; let wrap = wrapv3::wrap_kind( - &self.keys, + &self.keys.read().clone(), &receiver, Kind::Custom(17), proof_json.to_string(), @@ -729,7 +762,7 @@ impl NostrService { tags: Vec, ) -> Result { let sent = if v3 { - let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?; + let wrap = wrapv3::wrap(&self.keys.read().clone(), &receiver, content, tags)?; tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls.clone(), &wrap)).await } else { tokio::time::timeout( @@ -1046,7 +1079,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); let client = Client::builder() - .signer(svc.keys.clone()) + .signer(svc.keys.read().clone()) .websocket_transport(TorWebSocketTransport) .build(); // Wait for the embedded Tor client before any network work (relay dials, pool @@ -1155,23 +1188,23 @@ async fn run_service(svc: Arc, wallet: Wallet) { // advertised set only. A pool-wide subscription would be inherited by // relays added later for sends and discovery fan-out, handing them a REQ // filter that names our pubkey as a listener. - // Catch up from when THIS identity last listened, not merely when the wallet - // last connected on any identity — so a payment that arrived while this - // identity was dormant (e.g. before a switch to it) is fetched and redeemed - // now. Falls back to the wallet-wide last connection, then to now, always - // minus the same generous lookback (a switched-to identity that has been - // inactive longer than the lookback is the documented edge; the relay - // retention window is the real bound). - let active_hex = svc.public_key().to_hex(); - let since = crate::nostr::catchup_since( - svc.store.last_active_at(&active_hex), - svc.store.last_connected_at(), - unix_time(), - LOOKBACK_SECS, - ) as u64; + // Catch up from the wallet's last connection (all held identities listen + // continuously, so there is nothing identity-specific to catch up — the whole + // wallet was offline together). The generous lookback bounds re-fetch; the + // relay retention window is the real bound. + let since = svc + .store + .last_connected_at() + .map(|t| t - LOOKBACK_SECS) + .unwrap_or_else(|| unix_time() - LOOKBACK_SECS) + .max(0) as u64; + // One subscription for gift wraps addressed to ANY held identity: a single + // filter with all our pubkeys (OR over #p). Each wrap is p-tagged to exactly + // one identity, so it arrives once and is handled once — dedup stays exactly + // as safe as the single-identity path (no concurrent processing). let filter = Filter::new() .kind(Kind::GiftWrap) - .pubkey(svc.public_key()) + .pubkeys(svc.recv_pubkeys()) .since(Timestamp::from_secs(since)); // News feed: the owner's kind-30023 long-form posts on our own relay set. @@ -1193,10 +1226,6 @@ async fn run_service(svc: Arc, wallet: Wallet) { handle_wrap(&svc, &wallet, event).await; } } - // The switch catch-up fetch is done: leave "Syncing…" and let the UI read - // switch_received() to decide between "you were paid while away" and a quiet - // "caught up". A no-op unless this service was armed by a switch. - svc.end_switch_sync(); if let (Some(pk), Some(nf)) = (news_pk, news_filter.clone()) && let Ok(events) = client.fetch_events_from(&relays, nf, FETCH_TIMEOUT).await { @@ -1237,9 +1266,6 @@ async fn run_service(svc: Arc, wallet: Wallet) { } svc.store.set_last_connected_at(unix_time()); - // Stamp when THIS identity was last live so a later switch back to it catches - // up from here, not from the wallet-wide last connection. - svc.store.set_last_active_at(&active_hex, unix_time()); svc.store.prune_processed(); // Reflect the connection the moment we reach the loop instead of leaving the @@ -1320,7 +1346,6 @@ async fn run_service(svc: Arc, wallet: Wallet) { if now - last_heartbeat >= 30 { last_heartbeat = now; svc.store.set_last_connected_at(now); - svc.store.set_last_active_at(&active_hex, now); if now - last_prune >= 3600 { svc.store.prune_processed(); last_prune = now; @@ -1479,65 +1504,65 @@ async fn ensure_advertised_set(svc: &Arc) { /// on discovery relays. async fn publish_identity(svc: &Arc, client: &Client) { let advertised: Vec = svc.relays().into_iter().take(MAX_DM_RELAYS).collect(); + let allow_requests = svc.config.read().allow_incoming_requests(); - let mut dm_tags: Vec = advertised - .iter() - .map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()])) - .collect(); - // NIP-17 backward-compat extension: advertise our NIP-44 capabilities, - // space-separated best-first, so v3-aware senders pick v3 (G4). - dm_tags.push(Tag::custom( - TagKind::custom("encryption"), - [wrapv3::ENCRYPTION_CAPABILITY.to_string()], - )); - let mut builders = vec![ - EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags), - // The NIP-65 list mirrors the same set, unmarked (read + write). - EventBuilder::relay_list( - advertised - .iter() - .filter_map(|r| nostr_sdk::RelayUrl::parse(r).ok()) - .map(|u| (u, None)), - ), - ]; - - let (anonymous, nip05) = { - let identity = svc.identity.read(); - (identity.anonymous, identity.nip05.clone()) - }; - if !anonymous { - if let Some(nip05) = nip05 { - let name = nip05.split('@').next().unwrap_or_default().to_string(); - // Advertise the request opt-out so requesters see it before sending. - let allow_requests = svc.config.read().allow_incoming_requests(); - let metadata = Metadata::new() - .name(name) - .nip05(nip05) - .custom_field("goblin_accepts_requests", allow_requests); - builders.push(EventBuilder::metadata(&metadata)); - } - } - - // Sign each event ONCE so the advertised set and the indexers receive the - // same replaceable event, and sends stay targeted (a plain send would also - // hit whatever recipient relays happen to be connected). + // Publish the DM-relay list (kind 10050 + NIP-65) for EVERY held identity, and + // a kind-0 profile for each named one, so senders can route to any of them — + // all listen on this shared advertised set. Each event is signed with ITS OWN + // identity key (not the active one), and all are collected for the discovery + // fan-out below. let mut events = vec![]; - for builder in builders { - match client.sign_event_builder(builder).await { - Ok(event) => events.push(event), - Err(e) => warn!("nostr: identity event signing failed: {e}"), + for h in svc.recv_snapshot() { + let mut dm_tags: Vec = advertised + .iter() + .map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()])) + .collect(); + // NIP-17 backward-compat extension: advertise our NIP-44 capabilities, + // space-separated best-first, so v3-aware senders pick v3 (G4). + dm_tags.push(Tag::custom( + TagKind::custom("encryption"), + [wrapv3::ENCRYPTION_CAPABILITY.to_string()], + )); + let mut builders = vec![ + EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags), + // The NIP-65 list mirrors the same set, unmarked (read + write). + EventBuilder::relay_list( + advertised + .iter() + .filter_map(|r| nostr_sdk::RelayUrl::parse(r).ok()) + .map(|u| (u, None)), + ), + ]; + if !h.identity.anonymous { + if let Some(nip05) = h.identity.nip05.clone() { + let name = nip05.split('@').next().unwrap_or_default().to_string(); + let metadata = Metadata::new() + .name(name) + .nip05(nip05) + .custom_field("goblin_accepts_requests", allow_requests); + builders.push(EventBuilder::metadata(&metadata)); + } } - } - for event in &events { - // Time-box each publish (mirrors dispatch_dm's SEND_TIMEOUT): this loop is - // awaited before the catch-up fetch and the kind:1059 subscription below, so - // an untimed send to a stalled relay would delay real incoming-message - // delivery. On timeout, warn and move on to the next event — never abort the - // identity sequence. - match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&advertised, event)).await { - Ok(Ok(_)) => {} - Ok(Err(e)) => warn!("nostr: publish kind {} failed: {e}", event.kind), - Err(_) => warn!("nostr: publish kind {} timed out", event.kind), + for builder in builders { + // Sign with THIS identity's key so each advertisement is authored by the + // identity it describes. + let event = match builder.sign_with_keys(&h.keys) { + Ok(event) => event, + Err(e) => { + warn!("nostr: identity event signing failed: {e}"); + continue; + } + }; + // Time-box each publish (mirrors dispatch_dm's SEND_TIMEOUT) so a stalled + // relay never delays incoming-message delivery; warn and move on. + match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&advertised, &event)) + .await + { + Ok(Ok(_)) => {} + Ok(Err(e)) => warn!("nostr: publish kind {} failed: {e}", event.kind), + Err(_) => warn!("nostr: publish kind {} timed out", event.kind), + } + events.push(event); } } @@ -2003,25 +2028,37 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed), // dispatched on the NIP-44 payload version byte: 0x02 = the unchanged // nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly. - let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await { - Ok(u) => u, - Err(e) => { - // A gift wrap that reached here is p-tagged to US (both the catch-up - // fetch and the live subscription filter name our pubkey), so a decrypt - // failure is a wrap ADDRESSED to us that we could not open — most often a - // NIP-44 v2/v3 negotiation mismatch or a decrypt bug, i.e. potentially a - // real incoming payment. Do NOT silently drop it: surface the failure, - // and do NOT mark it processed, so a corrected build can re-attempt the - // unwrap on the next catch-up instead of the dedup cache eating the - // payment forever. Total unwrap work stays bounded by the global decrypt - // ceiling checked above, which is the designed spam guard. + // + // The wallet listens for ALL held identities, so the wrap may be addressed to + // any of them. Try each held key until one opens it; the key that succeeds is + // the RECIPIENT identity (the front door this payment came in on). Trying is + // bounded (a handful of held keys) and only runs for wraps the subscription + // already restricted to our own pubkeys; the global decrypt ceiling above + // still bounds total unwrap work against spam. + let held = svc.recv_snapshot(); + let mut opened: Option<(PublicKey, nostr_sdk::nips::nip59::UnwrappedGift)> = None; + for h in &held { + if let Ok(u) = wrapv3::unwrap(&h.keys, &event).await { + opened = Some((h.keys.public_key(), u)); + break; + } + } + let (recipient_pk, unwrapped) = match opened { + Some(x) => x, + None => { + // Addressed to one of our identities (the filter names only our + // pubkeys) but no held key opened it — most often a NIP-44 v2/v3 + // negotiation mismatch or a decrypt bug, i.e. potentially a real + // incoming payment. Do NOT mark processed, so a corrected build can + // re-attempt on the next catch-up instead of the dedup cache eating it. warn!( - "nostr: gift wrap {wrap_id} addressed to us failed to unwrap: {e}; \ - leaving unprocessed for retry" + "nostr: gift wrap {wrap_id} addressed to us failed to unwrap with any \ + held identity; leaving unprocessed for retry" ); return; } }; + let recipient_hex = recipient_pk.to_hex(); let sender = unwrapped.sender; let mut rumor = unwrapped.rumor; // 4. The rumor author must be the seal signer (NIP-17 requirement). @@ -2030,8 +2067,8 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { svc.store.mark_processed(&wrap_id); return; } - // Ignore our own messages (e.g. wrap-to-self copies). - if sender == svc.public_key() { + // Ignore our own messages (e.g. wrap-to-self copies) from ANY held identity. + if svc.is_own_pubkey(&sender) { svc.store.mark_processed(&wrap_id); return; } @@ -2186,10 +2223,10 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { proof_delivered: false, receipt_sent: false, // Tag the front door this payment came in on: the identity - // active right now. All identities redeem into the one grin - // balance; this only records provenance for per-identity - // activity and a future accounting split. - recipient_pubkey: svc.public_key().to_hex(), + // this wrap was actually addressed to (whichever held key + // opened it), NOT necessarily the active one. All identities + // redeem into the one grin balance; this records provenance. + recipient_pubkey: recipient_hex.clone(), }); // Commit dedup markers now the receive is durable, BEFORE // the reply + sync tail. A crash there must not let this @@ -2198,12 +2235,6 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { svc.store.mark_processed(&wrap_id); svc.store.mark_processed(&rumor_id); svc.store.mark_processed(&slate_marker); - // If this landed during a switch catch-up, count it toward the - // "you were paid while away" summary. - if svc.is_switch_syncing() { - svc.switch_received - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } // "Payment received" system notification (Android; no-op // on desktop): payer's display name (or short npub) and // the human-readable amount. @@ -2295,12 +2326,6 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { svc.store.mark_processed(&wrap_id); svc.store.mark_processed(&rumor_id); svc.store.mark_processed(&slate_marker); - // A finalized request-reply that landed during a switch catch-up - // also counts toward "you were paid while away". - if svc.is_switch_syncing() { - svc.switch_received - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } if let Some(mut contact) = svc.store.contact(&sender_hex) { contact.last_paid_at = Some(unix_time()); svc.store.save_contact(&contact); diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index e3eadc08..53b4bde8 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -43,7 +43,7 @@ pub mod ingest; pub use ingest::*; mod client; -pub use client::{NostrProfile, NostrService, send_phase}; +pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase}; pub mod avatar; pub mod nip05; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 0e7cd3d2..08e72dee 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -14,7 +14,9 @@ use crate::AppConfig; use crate::node::{Node, NodeConfig}; -use crate::nostr::{HeldIdentities, NostrConfig, NostrIdentity, NostrService, NostrStore}; +use crate::nostr::{ + HeldIdentities, HeldIdentityKeys, NostrConfig, NostrIdentity, NostrService, NostrStore, +}; use crate::wallet::seed::WalletSeed; use crate::wallet::store::TxHeightStore; use crate::wallet::types::{ @@ -44,7 +46,7 @@ use grin_wallet_libwallet::{ SlatepackAddress, StatusMessage, StoredProofInfo, TxLogEntry, TxLogEntryType, WalletBackend, WalletInitStatus, WalletInst, WalletLCProvider, address, }; -use log::{error, info}; +use log::{error, info, warn}; use num_bigint::BigInt; use parking_lot::RwLock; use rand::Rng; @@ -490,32 +492,68 @@ impl Wallet { } }, }; - // Adopt the held-identity index and run whichever identity is ACTIVE. A - // pre-feature wallet has only `identity.json`; `load_or_migrate` records it - // as the single, active identity #1 in place — no key regeneration, and the - // grin seed/balance are never touched (this cannot reach them). Only the - // active identity's ncryptsec is decrypted below; the others rest encrypted. - let identity = match HeldIdentities::load_or_migrate(&nostr_dir, &legacy) { - Some((_index, active)) => active, + // Adopt the held-identity index (migrating a pre-feature wallet's bare + // identity.json into it) and UNLOCK EVERY held identity now: with the wallet + // open, all identities' keys live in memory so the wallet listens for all of + // them at once (same trust boundary as the grin seed already in memory). + if HeldIdentities::load_or_migrate(&nostr_dir, &legacy).is_none() { + error!("nostr: held-identity index unreadable"); + } + let (recv, active_hex) = match self.unlock_all_identities(&nostr_dir, password) { + Some(v) => v, None => { - error!("nostr: held-identity index unreadable; running the legacy identity"); - legacy - } - }; - let keys = match identity.unlock(password) { - Ok(keys) => keys, - Err(e) => { - error!("nostr: identity unlock failed: {e}"); + error!("nostr: no identity could be unlocked; nostr disabled this session"); return; } }; - info!("nostr: identity ready: {}", identity.npub); + info!( + "nostr: {} identit(ies) unlocked; active {}", + recv.len(), + active_hex + ); let store = NostrStore::new(config.get_nostr_db_path()); - let service = NostrService::new(keys, identity, nostr_config, store, nostr_dir); + let service = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir); let mut w_nostr = self.nostr.write(); *w_nostr = Some(service); } + /// Unlock EVERY held identity with the wallet password, returning the decrypted + /// set plus the active identity's pubkey hex. All held nsecs share the one + /// wallet password. Identities that fail to unlock (corrupt file) are skipped + /// with a warning; `None` only if not a single one could be unlocked. Used at + /// wallet-open and when rebuilding the service after add/import/rotate. + fn unlock_all_identities( + &self, + nostr_dir: &PathBuf, + password: &str, + ) -> Option<(Vec, String)> { + let index = HeldIdentities::load(nostr_dir)?; + let mut recv = Vec::new(); + for entry in &index.identities { + let Some(identity) = entry.load(nostr_dir) else { + warn!("nostr: identity file unreadable: {}", entry.path); + continue; + }; + match identity.unlock(password) { + Ok(keys) => recv.push(HeldIdentityKeys { keys, identity }), + Err(e) => warn!("nostr: identity {} failed to unlock: {e}", entry.pubkey), + } + } + if recv.is_empty() { + return None; + } + // Prefer the recorded active pointer; fall back to the first that unlocked. + let active_hex = if recv + .iter() + .any(|h| h.identity.pubkey_hex().as_deref() == Some(index.active.as_str())) + { + index.active.clone() + } else { + recv[0].identity.pubkey_hex().unwrap_or_default() + }; + Some((recv, active_hex)) + } + /// Get the nostr service when available. pub fn nostr_service(&self) -> Option> { let r_nostr = self.nostr.read(); @@ -560,7 +598,7 @@ impl Wallet { .map_err(|_| "Wrong password".to_string())?; // Generate the replacement identity. - let (mut new_identity, new_keys) = NostrIdentity::create_random(&password) + let (mut new_identity, _new_keys) = NostrIdentity::create_random(&password) .map_err(|e| format!("key generation failed: {e}"))?; // Release the username first (the server also deletes its avatar); @@ -600,7 +638,13 @@ impl Wallet { let nostr_config = NostrConfig::load(wallet_dir); let store = NostrStore::new(config.get_nostr_db_path()); let new_npub = new_identity.npub.clone(); - let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + // Rebuild the service holding ALL held identities (the primary entry now + // resolves to the new identity), with the new one active. + let (recv, _) = self + .unlock_all_identities(&nostr_dir, &password) + .ok_or_else(|| "identity unlock failed".to_string())?; + let active_hex = new_identity.pubkey_hex().unwrap_or_default(); + let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir); { let mut w_nostr = self.nostr.write(); *w_nostr = Some(new_svc.clone()); @@ -689,7 +733,14 @@ impl Wallet { let nostr_config = NostrConfig::load(wallet_dir); let store = NostrStore::new(config.get_nostr_db_path()); let new_npub = new_identity.npub.clone(); - let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + // Rebuild the service holding ALL held identities, with the imported one + // active (the primary entry now resolves to it). + let _ = new_keys; + let (recv, _) = self + .unlock_all_identities(&nostr_dir, &password) + .ok_or_else(|| "identity unlock failed".to_string())?; + let active_hex = new_identity.pubkey_hex().unwrap_or_default(); + let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir); { let mut w_nostr = self.nostr.write(); *w_nostr = Some(new_svc.clone()); @@ -798,73 +849,24 @@ impl Wallet { None => NostrIdentity::create_random(&password) .map_err(|e| format!("key generation failed: {e}"))?, }; - let nostr_dir = self.get_config().get_nostr_path(); + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); let mut index = HeldIdentities::load(&nostr_dir) .ok_or_else(|| "identity index unavailable".to_string())?; index .add(&nostr_dir, &identity) .map_err(|e| e.to_string())?; - info!("nostr: added held identity {}", identity.npub); - Ok(identity.npub) - } - - /// Switch the ACTIVE nostr identity to a held one (by pubkey hex). Tears down - /// the running service and stands a fresh one on the target key against the - /// SAME shared store (so processed-dedup carries across, preventing any - /// double-redeem), then runs a catch-up from when that identity last listened - /// so payments that arrived while it was dormant land in the one shared - /// balance. Reuses the `rotate_nostr_identity` stop-wait-start machinery. - /// - /// The wallet password is verified by unlocking the TARGET before any teardown, - /// so a wrong password never leaves the wallet with no running identity. Gated - /// on no in-flight send, so an S1 signed by one identity is never followed by a - /// mid-flow service swap. Returns the target npub. - pub fn switch_nostr_identity( - &self, - target_hex: String, - password: String, - ) -> Result { - let svc = self - .nostr_service() - .ok_or_else(|| "nostr is not running".to_string())?; - // Don't swap the signing key out from under an in-flight send. - if svc.send_phase() == crate::nostr::send_phase::WORKING { - return Err("Finish the payment in progress before switching identity".to_string()); - } - // Already active? Nothing to do. - if svc.public_key().to_hex() == target_hex { - return Ok(svc.npub()); - } - let config = self.get_config(); - let nostr_dir = config.get_nostr_path(); - let mut index = HeldIdentities::load(&nostr_dir) - .ok_or_else(|| "identity index unavailable".to_string())?; - let entry = index - .entry(&target_hex) - .ok_or_else(|| "identity not held by this wallet".to_string())?; - let target_identity = entry - .load(&nostr_dir) - .ok_or_else(|| "identity file unreadable".to_string())?; - // Verify the password BEFORE any teardown (a wrong password must keep the - // current identity running). - let target_keys = target_identity - .unlock(&password) - .map_err(|_| "Wrong password".to_string())?; - - // Stamp the outgoing identity as last-active NOW: it stops listening here, - // so a later switch back to it catches up from this moment. - let old_hex = svc.public_key().to_hex(); - svc.store - .set_last_active_at(&old_hex, crate::nostr::unix_time()); - - // Move the active pointer (the legacy identity.json is never overwritten, - // so an older build still opens the wallet on identity #1). - index - .set_active(&nostr_dir, &target_hex) - .map_err(|e| e.to_string())?; - - // Tear down the running service and wait for it to drain, exactly as - // rotate/import do. + let new_npub = identity.npub.clone(); + info!("nostr: added held identity {new_npub}"); + // Bring the new identity ONLINE: rebuild the service holding ALL identities + // (including the one just added) so it starts listening immediately. The + // active identity is unchanged — adding never switches. + let active_hex = svc.public_key().to_hex(); + let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path())); + let store = NostrStore::new(config.get_nostr_db_path()); + let (recv, _) = self + .unlock_all_identities(&nostr_dir, &password) + .ok_or_else(|| "identity unlock failed".to_string())?; svc.stop(); for _ in 0..100 { if !svc.is_running() { @@ -872,21 +874,41 @@ impl Wallet { } thread::sleep(Duration::from_millis(100)); } - - // Stand up the new service on the target key against the SAME shared store. - let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path())); - let store = NostrStore::new(config.get_nostr_db_path()); - let new_npub = target_identity.npub.clone(); - let new_svc = - NostrService::new(target_keys, target_identity, nostr_config, store, nostr_dir); - // Arm the "Syncing…" UI state before start so the switcher shows it from - // the first frame; run_service clears it once the catch-up fetch completes. - new_svc.begin_switch_sync(); + let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir); { let mut w_nostr = self.nostr.write(); *w_nostr = Some(new_svc.clone()); } new_svc.start(self.clone()); + Ok(new_npub) + } + + /// INSTANT identity switch: a purely-local change of which held identity is + /// presented and used for sending. Every held identity is already unlocked and + /// already listening, so there is NO password prompt, NO service teardown, and + /// NO catch-up — payments were never missed. Just re-points the running + /// service's active keys/identity and persists the active pointer. Returns the + /// target npub. + pub fn switch_nostr_identity(&self, target_hex: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Already active? Nothing to do. + if svc.public_key().to_hex() == target_hex { + return Ok(svc.npub()); + } + // Re-point the active identity in memory (the target is already unlocked and + // listening); fails only if it isn't a held identity of this wallet. + if !svc.set_active_by_pubkey(&target_hex) { + return Err("identity not held by this wallet".to_string()); + } + // Persist the active pointer so the next open lands on it too. The legacy + // identity.json is never overwritten (an older build still opens on #1). + let nostr_dir = self.get_config().get_nostr_path(); + if let Some(mut index) = HeldIdentities::load(&nostr_dir) { + let _ = index.set_active(&nostr_dir, &target_hex); + } + let new_npub = svc.npub(); info!("nostr: switched active identity to {}", new_npub); Ok(new_npub) }