goblin: unlock-all / listen-all identities with instant switch (Build 145)

Reworks the multi-identity model. When the wallet is unlocked, EVERY held
identity's nsec is decrypted into memory (unlock_all_identities), and the
service listens for gift wraps addressed to ALL of them at once, each redeeming
into the one shared balance. Switching is now an instant, purely-local change of
which identity is presented and used for sending — no password, no service
teardown, no catch-up.

How receives stay deduped across all identities: a SINGLE gift-wrap subscription
with a multi-pubkey filter (OR over #p) and a SINGLE sequential notification
handler. Each wrap is p-tagged to exactly one identity, so it arrives once and is
processed once through the unchanged processed-set — dedup is exactly as safe as
the single-identity path, with no concurrent wrap processing. handle_wrap opens
each wrap by trying each held key until one succeeds; that key is the recipient
identity, recorded as the tx's recipient_pubkey. publish_identity now advertises
every held identity's DM-relay list (and profile, if named), each signed with its
own key, on the shared relay set.

NostrService holds all identities (recv) plus the active keys/identity (swapped
in place on switch); the switch-sync signals (is_switch_syncing/switch_received
and the "Syncing/Caught up/You were paid while away" flow) are removed — there is
nothing to catch up. The password modal remains only for add/import; the Build
144 import button and modal-position fixes are kept. init/add/import/rotate
rebuild the service with all identities; a plain switch does not.

Per-identity Tor-circuit isolation is deferred: all identities share the wallet
relay set for now.
This commit is contained in:
2ro
2026-07-05 20:37:49 -04:00
parent 5d500540c8
commit 51d1675ad6
4 changed files with 339 additions and 369 deletions
+36 -113
View File
@@ -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<String>),
}
struct PendingAdd(Option<String>);
/// 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<PendingIdentityAction>,
/// The add the password modal is currently gating.
pending: Option<PendingAdd>,
/// 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<std::sync::Mutex<Option<Result<String, String>>>>,
/// 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();
}
}
+186 -161
View File
@@ -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<Keys>,
/// Active identity file state (display, username claim, etc.).
pub identity: RwLock<NostrIdentity>,
/// 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<Vec<HeldIdentityKeys>>,
/// Per-wallet configuration.
pub config: RwLock<NostrConfig>,
/// 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<HeldIdentityKeys>,
active_hex: &str,
config: NostrConfig,
store: NostrStore,
nostr_dir: PathBuf,
) -> Arc<Self> {
// 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<PublicKey> {
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<HeldIdentityKeys> {
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<String> {
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<Tag>,
) -> Result<String, String> {
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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>) {
/// on discovery relays.
async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
let advertised: Vec<String> = svc.relays().into_iter().take(MAX_DM_RELAYS).collect();
let allow_requests = svc.config.read().allow_incoming_requests();
let mut dm_tags: Vec<Tag> = 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<Tag> = 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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);
+1 -1
View File
@@ -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;
+116 -94
View File
@@ -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<HeldIdentityKeys>, 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<Arc<NostrService>> {
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<String, String> {
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<String, String> {
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)
}