1
0
forked from GRIN/grim

goblin: switch, catch-up and shared-balance receive across identities

The active identity drives the single live gift-wrap subscription and every
send/display, and every identity redeems into the ONE shared grin balance.
Switching reuses the rotate/import stop-wait-start machinery: it verifies the
wallet password by unlocking the target BEFORE any teardown (a wrong password
never strands the wallet with no running identity), gates on no in-flight
send, tears down the service and stands a fresh one on the target key against
the SAME shared store (so processed-dedup carries across and nothing double
redeems), and moves only the active pointer (identity.json is never
overwritten). The catch-up now looks back from when THIS identity last
listened, so a payment that arrived while it was dormant is fetched and
redeemed on switch-in; the service surfaces a syncing state and a count of
payments redeemed during that catch-up for a "you were paid while away" cue.

TxNostrMeta gains recipient_pubkey (serde-default empty = primary) to tag
which front door a payment used, for per-identity activity and a later
accounting split. Wallet gains nostr_identities / add_nostr_identity /
switch_nostr_identity, init_nostr adopts the held-identity index with legacy
migration, and a wallet-password change now re-encrypts every held identity
through the same NIP-49 path. Only the active nsec is ever decrypted in
memory; the rest stay encrypted at rest.
This commit is contained in:
2ro
2026-07-05 16:11:18 -04:00
parent 26da66710e
commit 2e2d96016e
4 changed files with 312 additions and 13 deletions
+79 -6
View File
@@ -137,6 +137,15 @@ 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.
@@ -176,9 +185,39 @@ 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);
}
/// 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);
}
/// Whether the service is mid switch-catch-up ("Syncing…").
pub fn is_switch_syncing(&self) -> bool {
self.switch_syncing.load(Ordering::Relaxed)
}
/// 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)
}
/// Own public key.
pub fn public_key(&self) -> PublicKey {
self.keys.public_key()
@@ -1116,12 +1155,20 @@ 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.
let since = svc
.store
.last_connected_at()
.map(|t| t - LOOKBACK_SECS)
.unwrap_or_else(|| unix_time() - LOOKBACK_SECS)
.max(0) as u64;
// 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;
let filter = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(svc.public_key())
@@ -1146,6 +1193,10 @@ 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
{
@@ -1186,6 +1237,9 @@ 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
@@ -1266,6 +1320,7 @@ 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;
@@ -2130,6 +2185,11 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, event: Event) {
proof_amount: None,
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(),
});
// Commit dedup markers now the receive is durable, BEFORE
// the reply + sync tail. A crash there must not let this
@@ -2138,6 +2198,12 @@ 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.
@@ -2229,6 +2295,12 @@ 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);
@@ -2405,6 +2477,7 @@ mod tests {
proof_amount: Some(1_000),
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: String::new(),
}
}
+1
View File
@@ -173,6 +173,7 @@ mod tests {
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: String::new(),
}
}
+8
View File
@@ -118,6 +118,14 @@ pub struct TxNostrMeta {
/// send, which publishes no receipt at all.
#[serde(default)]
pub receipt_sent: bool,
/// The wallet's OWN nostr identity (pubkey hex) that was ACTIVE when this row
/// was created — the "front door" the payment came in / went out on. One
/// wallet can hold several identities that all redeem into the single shared
/// grin balance; this tags which one so activity can be shown per identity and
/// a later per-identity accounting split has the data. Serde-default empty on
/// pre-feature rows, which are treated as identity #1 (the primary).
#[serde(default)]
pub recipient_pubkey: String,
}
/// A contact: another nostr user we can pay.
+224 -7
View File
@@ -14,7 +14,7 @@
use crate::AppConfig;
use crate::node::{Node, NodeConfig};
use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore};
use crate::nostr::{HeldIdentities, NostrConfig, NostrIdentity, NostrService, NostrStore};
use crate::wallet::seed::WalletSeed;
use crate::wallet::store::TxHeightStore;
use crate::wallet::types::{
@@ -61,6 +61,22 @@ use std::time::Duration;
use std::{fs, path, thread};
use uuid::Uuid;
/// A held nostr identity as the identity switcher needs to render it. Carries no
/// secret material — the nsec stays encrypted at rest as its own NIP-49 ncryptsec.
#[derive(Clone, Debug)]
pub struct HeldIdentitySummary {
/// Public key, lowercase hex (the stable id passed to `switch_nostr_identity`).
pub pubkey_hex: String,
/// Public key, bech32 npub (for display/copy).
pub npub: String,
/// Claimed @name without the domain, if this identity has one.
pub name: Option<String>,
/// Short human label (its @name, or "Primary").
pub label: String,
/// Whether this is the currently active identity.
pub active: bool,
}
/// Contains wallet instance, configuration and state, handles wallet commands.
#[derive(Clone)]
pub struct Wallet {
@@ -455,7 +471,7 @@ impl Wallet {
// is deliberately independent of the wallet seed: the seed must never
// double as identity evidence, and a rotation must not be derivable
// from it. The nsec backup is therefore the identity backup.
let identity = match NostrIdentity::load(&nostr_dir) {
let legacy = match NostrIdentity::load(&nostr_dir) {
Some(identity) => identity,
None => match NostrIdentity::create_random(password) {
Ok((identity, _)) => {
@@ -471,6 +487,18 @@ 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,
None => {
error!("nostr: held-identity index unreadable; running the legacy identity");
legacy
}
};
let keys = match identity.unlock(password) {
Ok(keys) => keys,
Err(e) => {
@@ -683,6 +711,172 @@ impl Wallet {
.map_err(|e| format!("backup failed: {e}"))
}
// ── Held nostr identities (one wallet, one balance, many front doors) ──────
//
// One grin seed / one balance, but the wallet can HOLD several nostr
// identities and present a different one at will. Exactly one is ACTIVE: it
// drives the single live gift-wrap subscription and all display, and every
// identity redeems into the SAME shared grin balance. Only the active nsec is
// ever decrypted into memory; the rest rest as ncryptsec on disk. Switching is
// mechanically identical to `rotate_nostr_identity` (stop the service, rebuild
// it on the target key, restart), plus a per-identity catch-up so payments that
// arrived while an identity was dormant are redeemed on switch-in.
/// The held identities for the identity switcher (no secrets). Empty if nostr
/// is disabled/not running.
pub fn nostr_identities(&self) -> Vec<HeldIdentitySummary> {
let nostr_dir = self.get_config().get_nostr_path();
let Some(index) = HeldIdentities::load(&nostr_dir) else {
return Vec::new();
};
index
.identities
.iter()
.filter_map(|entry| {
let identity = entry.load(&nostr_dir)?;
let name = identity
.nip05
.as_deref()
.and_then(|n| n.split('@').next())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Some(HeldIdentitySummary {
pubkey_hex: entry.pubkey.clone(),
npub: identity.npub.clone(),
name,
label: entry.label.clone(),
active: entry.pubkey == index.active,
})
})
.collect()
}
/// The active identity's pubkey (hex), or `None` if nostr is not running.
pub fn active_nostr_pubkey(&self) -> Option<String> {
self.nostr_service().map(|s| s.public_key().to_hex())
}
/// Add a nostr identity to this wallet WITHOUT switching to it: generate a
/// fresh random nsec (`import` is `None`) or import an existing one (`import`
/// is `Some(nsec)`). The new key is encrypted under the wallet password like
/// every held identity, then recorded in the index. Returns the new npub. The
/// caller may follow with `switch_nostr_identity` to make it active.
///
/// The wallet password is proven against the CURRENT identity first, so a
/// wrong password can neither add a mis-encrypted key nor disturb anything.
pub fn add_nostr_identity(
&self,
import: Option<String>,
password: String,
) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
// Prove the wallet password against the current identity before writing a
// new key, so all held identities share the one wallet password.
svc.identity
.read()
.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
let (identity, _keys) = match import {
Some(nsec) => NostrIdentity::create_imported(nsec.trim(), &password)
.map_err(|_| "Invalid nsec".to_string())?,
None => NostrIdentity::create_random(&password)
.map_err(|e| format!("key generation failed: {e}"))?,
};
let nostr_dir = self.get_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.
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
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 mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
}
new_svc.start(self.clone());
info!("nostr: switched active identity to {}", new_npub);
Ok(new_npub)
}
/// Get keychain mask [`SecretKey`].
pub fn keychain_mask(&self) -> Option<SecretKey> {
let r_key = self.keychain_mask.read();
@@ -1815,11 +2009,30 @@ impl Wallet {
/// Change wallet password.
pub fn change_password(&self, old: String, new: String) -> Result<(), Error> {
let r_inst = self.instance.as_ref().read();
let instance = r_inst.clone().unwrap();
let mut wallet_lock = instance.lock();
let lc = wallet_lock.lc_provider()?;
lc.change_password(None, ZeroingString::from(old), ZeroingString::from(new))
{
let r_inst = self.instance.as_ref().read();
let instance = r_inst.clone().unwrap();
let mut wallet_lock = instance.lock();
let lc = wallet_lock.lc_provider()?;
lc.change_password(
None,
ZeroingString::from(old.clone()),
ZeroingString::from(new.clone()),
)?;
}
// The grin seed password changed; re-encrypt EVERY held nostr identity's
// ncryptsec from old to new through the same NIP-49 path, so all front
// doors follow the one wallet password. Best-effort and non-fatal: a
// failure is logged (never swallowed as success), and the grin password
// change already committed above. Takes effect for the running service on
// the next wallet open (it reloads the active identity from disk).
let nostr_dir = self.get_config().get_nostr_path();
if let Some(index) = HeldIdentities::load(&nostr_dir)
&& let Err(e) = index.reencrypt_all(&nostr_dir, &old, &new)
{
error!("nostr: re-encrypting held identities after password change: {e}");
}
Ok(())
}
/// Initiate wallet repair by scanning its outputs.
@@ -2540,6 +2753,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
proof_amount: if proof_mode { Some(*a) } else { None },
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.send_creating.store(false, Ordering::Relaxed);
@@ -2680,6 +2894,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.invoice_creating.store(false, Ordering::Relaxed);
@@ -2931,6 +3146,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
match service
.send_payment_dm(&request.npub, &text, None, &[])
@@ -2981,6 +3197,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
match service
.send_payment_dm(&request.npub, &text, None, &[])