From b7ad5d669ceff37c44789a882209ee16c0bfe9be Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:17:39 -0400 Subject: [PATCH] fix(nostr): gate incoming-slate money ops behind a wallet lock Add a money-path lock to Wallet that keeps the wallet open and the nostr service's relay connections live (payments keep arriving) while sealing the money seed from automatic use. When locked, the gift-wrap ingest defers every seed-touching decision (AutoReceive / FinalizePost): the raw wrap is buffered on the service and deliberately NOT marked processed, so relay catch-up still backstops it across a restart. Surface/request/drop still run, so a payment lands as a visibly pending card. On unlock the service loop drains the buffer, replaying each wrap through the normal, now-unlocked ingest so payments received while locked complete normally (receive + S2 reply, or finalize+post); the replay is idempotent via the existing dedup + decide() guards. - Wallet: locked flag with is_locked/set_locked/lock/unlock; cleared on open() and close(). - ingest: pure defer_while_locked/decision_touches_seed gate + unit test. - NostrService: bounded, id-deduped deferred_wraps buffer + drain. (cherry picked from commit 133d0248dc7cfe3f8707ee7fa0a66b748bcd205c) --- src/nostr/client/mod.rs | 44 ++++++++++++++++++++++++++++++++++++- src/nostr/client/service.rs | 29 ++++++++++++++++++++++++ src/nostr/ingest.rs | 38 ++++++++++++++++++++++++++++++++ src/wallet/wallet.rs | 43 ++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) 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();