mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-15 11:18:55 +00:00
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:
+36
-113
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user