diff --git a/src/nostr/client/mod.rs b/src/nostr/client/mod.rs index fe749120..5a2a5bac 100644 --- a/src/nostr/client/mod.rs +++ b/src/nostr/client/mod.rs @@ -30,7 +30,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::Duration; -use crate::nostr::ingest::{IngestContext, IngestDecision, decide}; +use crate::nostr::ingest::{IngestContext, IngestDecision, decide, defer_while_locked}; use crate::nostr::protocol; use crate::nostr::relays::MAX_DM_RELAYS; use crate::nostr::types::*; @@ -192,8 +192,23 @@ pub struct NostrService { /// would freeze in the paused service (the Build 153 QR-trust bug). /// Transient and tiny (one entry per grant this run). announced_ok: RwLock>, + /// Gift wraps that arrived while the wallet's money path was LOCKED. Their + /// money-seed operation (receive/finalize) is deferred, so the raw wrap is + /// buffered here — NOT marked processed — and replayed through the normal + /// ingest once the wallet unlocks. Bounded and deduped by event id; because + /// nothing is marked processed, a process death while locked loses only these + /// in-memory copies and the relay re-delivers them on the next catch-up, so no + /// payment is dropped. + deferred_wraps: Mutex>, } +/// Cap on gift wraps buffered while locked, so a relay redelivering the same +/// backlog for a long locked stretch can't grow this without bound. Well above +/// any realistic count of payments arriving during one lock; excess is dropped +/// from memory only (still recoverable via relay catch-up, never marked +/// processed). +const DEFERRED_WRAP_CAP: usize = 512; + /// Transport-aware connection state for the UI status lines. Tor and clearnet /// are distinct so a Tor-off wallet never reads as "connecting over Tor". See /// [`NostrService::transport_status`]. @@ -265,9 +280,36 @@ impl NostrService { money_answers: Mutex::new(Vec::new()), session_notice: RwLock::new(None), announced_ok: RwLock::new(std::collections::HashSet::new()), + deferred_wraps: Mutex::new(Vec::new()), }) } + /// Buffer a gift wrap whose money-seed operation was deferred because the + /// wallet is locked. Deduped by event id and capped ([`DEFERRED_WRAP_CAP`]); + /// the wrap is deliberately NOT marked processed, so relay catch-up remains + /// the durable backstop across a restart. + pub fn buffer_deferred_wrap(&self, event: Event) { + let mut buf = self.deferred_wraps.lock(); + if buf.iter().any(|e| e.id == event.id) { + return; + } + if buf.len() >= DEFERRED_WRAP_CAP { + return; + } + buf.push(event); + } + + /// Take every buffered deferred gift wrap, clearing the buffer. Called on + /// unlock to replay them through the normal ingest. + pub fn take_deferred_wraps(&self) -> Vec { + std::mem::take(&mut *self.deferred_wraps.lock()) + } + + /// Whether any gift wrap is buffered awaiting unlock. + pub fn has_deferred_wraps(&self) -> bool { + !self.deferred_wraps.lock().is_empty() + } + /// Own (active) public key. pub fn public_key(&self) -> PublicKey { self.keys.read().public_key() diff --git a/src/nostr/client/service.rs b/src/nostr/client/service.rs index a16ca83f..f1cd1f36 100644 --- a/src/nostr/client/service.rs +++ b/src/nostr/client/service.rs @@ -443,6 +443,20 @@ pub(super) async fn run_service(svc: Arc, wallet: Wallet) { } } _ = status_tick.tick() => { + // Unlock drain: the wallet just went from locked to unlocked and + // gift wraps arrived while it was locked. Replay each buffered wrap + // through the normal ingest now that money-seed operations are + // allowed, so payments received while locked complete normally + // (receive + S2 reply, or finalize+post). Idempotent — handle_wrap + // re-dedupes and decide() drops anything already handled — so a wrap + // that also came back via relay catch-up is processed at most once. + if !wallet.is_locked() && svc.has_deferred_wraps() { + let buffered = svc.take_deferred_wraps(); + info!("nostr: unlocked; draining {} buffered payment(s)", buffered.len()); + for ev in buffered { + handle_wrap(&svc, &wallet, ev).await; + } + } // A tunnel reselect (new exit) bumps the generation. The current // relay sockets rode the now-dead exit, so drop them and re-dial // through the fresh tunnel, re-establishing the kind:1059 @@ -1748,6 +1762,21 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { decision ); + // Money-path lock gate: while the wallet is locked, a receive or finalize + // must NOT run against the money seed. Buffer the raw wrap (deliberately NOT + // marked processed, so relay catch-up still backstops it across a restart) and + // replay it through this same path on unlock. Surface/request/drop are not + // seed operations, so they fall through and run — a payment still lands as a + // visibly pending card while locked. + if defer_while_locked(wallet.is_locked(), decision) { + info!( + "nostr: money path locked; deferring {:?} for slate {} until unlock", + decision, slate.id + ); + svc.buffer_deferred_wrap(event); + return; + } + match decision { IngestDecision::AutoReceive => { svc.ensure_contact(&sender_hex); diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 06b582a4..da481796 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -60,6 +60,26 @@ pub struct IngestContext<'a> { pub allow_requests: bool, } +/// Whether this decision runs a MONEY-SEED operation on the wallet (a receive +/// or a finalize+post). The surface/drop/request outcomes never touch the seed: +/// they only store a visible pending card or discard. Used to gate the ingest +/// while the money path is locked. +pub fn decision_touches_seed(decision: IngestDecision) -> bool { + matches!( + decision, + IngestDecision::AutoReceive | IngestDecision::FinalizePost + ) +} + +/// The money-path lock gate: `true` when this incoming slate must be DEFERRED +/// (buffered, not processed) because the wallet is locked and the decision would +/// otherwise run a money-seed operation. When unlocked, nothing is deferred; when +/// locked, only seed-touching decisions defer — surface/request/drop still run so +/// a payment stays visibly pending and controls are still honoured. +pub fn defer_while_locked(locked: bool, decision: IngestDecision) -> bool { + locked && decision_touches_seed(decision) +} + /// Pure policy function — unit tested, no side effects. pub fn decide(ctx: &IngestContext) -> IngestDecision { match ctx.state { @@ -339,6 +359,24 @@ mod tests { assert!(matches!(decide(&c2), IngestDecision::Drop(_))); } + #[test] + fn locked_defers_only_seed_touching_decisions() { + // While locked, a receive or a finalize+post must be deferred (buffered, + // not run against the money seed)... + assert!(defer_while_locked(true, IngestDecision::AutoReceive)); + assert!(defer_while_locked(true, IngestDecision::FinalizePost)); + // ...but surfacing a pending card, surfacing a request, or dropping never + // touches the seed, so they still run while locked (payment stays visibly + // pending; controls are still honoured). + assert!(!defer_while_locked(true, IngestDecision::SurfaceIncoming)); + assert!(!defer_while_locked(true, IngestDecision::SurfaceRequest)); + assert!(!defer_while_locked(true, IngestDecision::Drop("x"))); + // Unlocked: nothing is ever deferred — every decision processes normally. + assert!(!defer_while_locked(false, IngestDecision::AutoReceive)); + assert!(!defer_while_locked(false, IngestDecision::FinalizePost)); + assert!(!defer_while_locked(false, IngestDecision::SurfaceIncoming)); + } + #[test] fn terminal_states_drop() { for state in [ diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index b4ad7246..33ff09ae 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -148,6 +148,13 @@ pub struct Wallet { reopen: Arc, /// Flag to check if wallet is open. is_open: Arc, + /// Money-path lock. When set, the wallet stays open in memory and the nostr + /// service keeps its relay connections live (payments keep ARRIVING), but NO + /// money-seed/keychain operation runs on an incoming slatepack: the gift-wrap + /// ingest defers receive/finalize (buffering the raw event) until unlock, when + /// the buffered payments are drained and completed normally. Independent of + /// `is_open` on purpose — the seed is present but untouched while locked. + locked: Arc, /// Flag to check if wallet is closing. closing: Arc, /// Flag to check if wallet was deleted to remove it from the list. @@ -216,6 +223,7 @@ impl Wallet { more_txs_loading: Arc::new(AtomicBool::new(false)), reopen: Arc::new(AtomicBool::new(false)), is_open: Arc::from(AtomicBool::new(false)), + locked: Arc::new(AtomicBool::new(false)), closing: Arc::new(AtomicBool::new(false)), deleted: Arc::new(AtomicBool::new(false)), foreign_api_server: Arc::new(RwLock::new(None)), @@ -454,6 +462,8 @@ impl Wallet { thread_w.clone().unwrap().unpark(); } self.is_open.store(true, Ordering::Relaxed); + // A freshly opened wallet is never locked; clear any stale flag. + self.locked.store(false, Ordering::Relaxed); } Err(e) => { if !self.syncing() { @@ -1395,6 +1405,37 @@ impl Wallet { self.is_open.load(Ordering::Relaxed) } + /// Whether the money path is locked. While locked the wallet stays open and + /// the nostr service keeps its relays connected (payments keep arriving), but + /// the gift-wrap ingest defers every money-seed operation (receive/finalize) + /// until unlock. See the `locked` field. + pub fn is_locked(&self) -> bool { + self.locked.load(Ordering::Relaxed) + } + + /// Set the money-path lock flag directly. `lock()`/`unlock()` are the intended + /// entry points; this is exposed for callers that already own the boolean. + pub fn set_locked(&self, locked: bool) { + self.locked.store(locked, Ordering::Relaxed); + } + + /// Lock the money path WITHOUT tearing down the wallet or its nostr service: + /// relays stay connected so payments keep arriving, but no incoming slatepack + /// is received/finalized until [`Wallet::unlock`]. A no-op on a wallet that + /// is not open. + pub fn lock(&self) { + if self.is_open() { + self.locked.store(true, Ordering::Relaxed); + } + } + + /// Clear the money-path lock. The nostr service loop then drains any incoming + /// payments that arrived and were buffered while locked, completing them + /// normally. + pub fn unlock(&self) { + self.locked.store(false, Ordering::Relaxed); + } + /// Check if wallet is closing. pub fn is_closing(&self) -> bool { self.closing.load(Ordering::Relaxed) @@ -1445,6 +1486,8 @@ impl Wallet { Self::close_wallet(&instance); wallet_close.closing.store(false, Ordering::Relaxed); wallet_close.is_open.store(false, Ordering::Relaxed); + // A fully closed wallet is not "locked" — it is gone from memory. + wallet_close.locked.store(false, Ordering::Relaxed); // Setup current connection. { let mut w_conn = conn.write();