diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 42673957..52c04f7b 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -277,6 +277,17 @@ impl SendFlow { } return; } + // A "Trust with Goblin" (Authorize Sessions) request is grabbed BEFORE any + // pay parsing too: it establishes a signing session, never a payment, and a + // trust-shaped payload that fails validation is dropped entirely. A valid + // one is stashed for the Goblin surface, whose per-frame router closes this + // flow and opens the trust-grant modal. + if crate::nostr::trusturi::is_trust_shaped(text) { + if let Some(t) = crate::nostr::trusturi::parse(text) { + crate::set_pending_trust(t); + } + return; + } // Proof-on-request context (frozen contract 4.1) rides alongside the // recipient/amount/memo routing and is independent of whether we land on // Review or Search, so pull it from the same pure parse. `order` is a diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 48c51c94..d127701e 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -520,6 +520,12 @@ impl WalletsContent { } None } + Some(d) if crate::nostr::trusturi::is_trust_shaped(&d) => { + if let Some(t) = crate::nostr::trusturi::parse(&d) { + crate::set_pending_trust(t); + } + None + } Some(d) if crate::nostr::payuri::is_pay_uri(&d) => { crate::set_pending_pay_uri(d); None diff --git a/src/lib.rs b/src/lib.rs index 68c1978f..748ee9bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -574,6 +574,28 @@ lazy_static! { /// are dropped at the dispatch site and never reach the UI. static ref PENDING_AUTHORIZE: Arc>> = Arc::new(RwLock::new(None)); + /// A pending, already-VALIDATED "Trust with Goblin" (Authorize Sessions) + /// request (`goblin:trust?...` deep link or QR), waiting for the Goblin + /// surface to open its trust-grant modal. Only a fully validated + /// [`nostr::trusturi::TrustUri`] (nonce, domain binding, channel key, relay + /// hint, and a kind set that survives ceiling stripping) is ever stashed + /// here; rejected trust URIs are dropped at the dispatch site. + static ref PENDING_TRUST: Arc>> = + Arc::new(RwLock::new(None)); +} + +/// Stash a VALIDATED trust request for the Goblin surface to open its trust-grant +/// modal (see [`take_pending_trust`]). The most recent request wins; the surface +/// ignores new requests while one approval (login, authorize, or trust) is +/// already pending. +pub fn set_pending_trust(trust: nostr::trusturi::TrustUri) { + *PENDING_TRUST.write() = Some(trust); +} + +/// Take (and clear) a pending trust request, if any. The Goblin wallet view +/// polls this each frame and opens the "Trust ?" grant modal. +pub fn take_pending_trust() -> Option { + PENDING_TRUST.write().take() } /// Stash a payment deep link for the Goblin surface to open (see diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 519f4080..0eeedcbf 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -52,6 +52,11 @@ pub struct NostrProfile { /// duplicate REQs on the relays. const GIFTWRAP_SUB: &str = "goblin-giftwrap"; +/// Stable subscription id for the Authorize Sessions encrypted channel (kind +/// 24140), re-subscribed (replace-not-duplicate) whenever the session set +/// changes so newly granted sessions start receiving immediately. +const CHANNEL_SUB: &str = "goblin-session-channel"; + /// The Goblin news publisher (kind 30023 long-form). The Home news panel shows /// this key's latest post, fetched from our own relay set. const NEWS_NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; @@ -156,6 +161,22 @@ 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<()>, + /// Active Authorize Sessions (v2). In memory only: quitting the wallet ends + /// every session. The service loop subscribes each session's encrypted + /// channel, serves silent low-tier signs, and routes money-tier requests to + /// the GUI money prompt. + sessions: RwLock>, + /// Set whenever the session set changes so the loop re-subscribes the channel + /// filter and publishes `session-open` for any new session. + sessions_dirty: AtomicBool, + /// Money-tier requests awaiting the GUI's per-action password prompt (FIFO). + money_pending: Mutex>, + /// The GUI's answers to money prompts (the answered request plus approve/decline), + /// drained by the loop which then signs (or declines) and publishes on the channel. + money_answers: Mutex>, + /// A single non-blocking "signing a lot" notice for the GUI toast, set when a + /// session trips the soft rate cap. + session_notice: RwLock>, } /// Phase of the most recent outgoing send, polled by the send UI. @@ -206,6 +227,11 @@ impl NostrService { last_send_error: RwLock::new(None), cancel_notice: RwLock::new(None), cancel_finalize_lock: Mutex::new(()), + sessions: RwLock::new(Vec::new()), + sessions_dirty: AtomicBool::new(false), + money_pending: Mutex::new(Vec::new()), + money_answers: Mutex::new(Vec::new()), + session_notice: RwLock::new(None), }) } @@ -231,6 +257,107 @@ impl NostrService { self.recv.read().iter().any(|h| &h.keys.public_key() == pk) } + // --- Authorize Sessions (v2) ------------------------------------------- + + /// Register a freshly granted session and wake the loop to subscribe its + /// channel and publish `session-open`. + pub fn add_session(&self, session: crate::nostr::session::Session) { + self.sessions.write().push(session); + self.sessions_dirty.store(true, Ordering::SeqCst); + } + + /// True when at least one session is live (for the Trusted Sites badge/list). + pub fn has_sessions(&self) -> bool { + !self.sessions.read().is_empty() + } + + /// Read-only snapshots for the Trusted Sites list, newest last. + pub fn session_summaries(&self) -> Vec { + let now = unix_time() as u64; + self.sessions + .read() + .iter() + .map(|s| s.summary(now)) + .collect() + } + + /// End (revoke) the session for `domain`: mark it ended, send the courtesy + /// `session-end`, and drop it. Immediate and unilateral. + pub fn end_session(&self, domain: &str) { + let now = unix_time() as u64; + let mut end_event = None; + { + let mut sessions = self.sessions.write(); + if let Some(s) = sessions.iter_mut().find(|s| s.domain == domain) { + s.end(); + end_event = s.session_end_event(now).ok(); + } + sessions.retain(|s| s.domain != domain); + } + self.sessions_dirty.store(true, Ordering::SeqCst); + // Best-effort courtesy notice to the site; teardown already happened. + if let Some(ev) = end_event { + self.publish_event_best_effort(ev); + } + } + + /// Resume a paused session (the user tapped "resume" in Trusted Sites). + pub fn resume_session(&self, domain: &str) { + let now = unix_time() as u64; + if let Some(s) = self + .sessions + .write() + .iter_mut() + .find(|s| s.domain == domain) + { + s.resume(now); + } + } + + /// The front money-tier request awaiting the user, if any (GUI polls this to + /// raise its per-action password prompt). + pub fn peek_money_prompt(&self) -> Option { + self.money_pending.lock().first().cloned() + } + + /// Record the user's answer to a money prompt: remove it from the display + /// queue and hand the full request to the loop, which signs (or declines) and + /// publishes the result on the channel. + pub fn answer_money_prompt(&self, req_id: &str, approved: bool) { + let answered = { + let mut pending = self.money_pending.lock(); + let idx = pending.iter().position(|p| p.req.id == req_id); + idx.map(|i| pending.remove(i)) + }; + if let Some(p) = answered { + self.money_answers.lock().push((p, approved)); + } + } + + /// Take (and clear) the "signing a lot" notice, if any. + pub fn take_session_notice(&self) -> Option { + self.session_notice.write().take() + } + + /// Publish an event on the service runtime without blocking the caller + /// (used for the best-effort `session-end` courtesy). No-op if the loop is + /// not running. + fn publish_event_best_effort(&self, event: nostr_sdk::Event) { + let (Some(client), Some(handle)) = + (self.client.read().clone(), self.rt_handle.read().clone()) + else { + return; + }; + let urls: Vec = self.relays(); + handle.spawn(async move { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.send_event_to(&urls, &event), + ) + .await; + }); + } + /// Update a held identity's PRIVATE tag in the in-memory set (and the active /// copy when it is the same identity), so the switcher re-renders immediately /// after a rename without a service rebuild. The caller persists the file. @@ -1326,6 +1453,9 @@ async fn run_service(svc: Arc, wallet: Wallet) { // names right away (so you see refreshed info from app open), unless one ran // within the last interval. let mut last_name_sweep = svc.store.last_name_sweep_at().unwrap_or(0); + // Tracks the app foreground state so a background→foreground transition drains + // any session-channel requests queued on the relay while the wallet slept. + let mut was_foreground = crate::app_foreground(); loop { if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() { break; @@ -1334,9 +1464,11 @@ async fn run_service(svc: Arc, wallet: Wallet) { notification = notifications.recv() => { match notification { Ok(RelayPoolNotification::Event { event, .. }) => { - // News long-form posts and gift wraps ride the same feed; - // route by kind (handle_wrap ignores non-1059 anyway). - if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote { + // News long-form posts, session-channel envelopes, and gift + // wraps ride the same feed; route by kind. + if event.kind.as_u16() == crate::nostr::session::CHANNEL_EVENT_KIND { + handle_channel(&svc, &client, &event).await; + } else if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote { handle_news(&svc, pk, *event).await; } else { handle_wrap(&svc, &wallet, *event).await; @@ -1408,6 +1540,21 @@ async fn run_service(svc: Arc, wallet: Wallet) { svc.resolve_contact_identity(&c.npub); } } + // Authorize Sessions (v2): when the session set changed, re-subscribe + // the encrypted channel and publish `session-open` for new sessions; + // then sign/decline any money-tier prompts the user answered. + if svc.sessions_dirty.swap(false, Ordering::SeqCst) { + resubscribe_channel(&client, &relays, &svc).await; + announce_new_sessions(&svc, &client, &relays).await; + } + serve_money_answers(&svc, &client).await; + // Drain requests queued while backgrounded on a resume (the Build-95 + // frame-heartbeat pattern), gated on the app being foregrounded. + let fg = crate::app_foreground(); + if fg && !was_foreground && svc.has_sessions() { + drain_channel(&svc, &client, &relays).await; + } + was_foreground = fg; } } } @@ -1906,6 +2053,208 @@ fn first_tag_value(event: &Event, name: &str) -> Option { }) } +/// Serve one Authorize Sessions channel event (kind 24140). Matches it to a +/// live session by the site's channel key, decrypts under the session key, +/// enforces every rule (via the pure `session` core), and either publishes a +/// signed `sign_result` back to the site, enqueues a money-tier prompt for the +/// GUI, or tears the session down on a `session-end` signal. Fails closed and +/// silent on anything it cannot match, decrypt, or parse. +async fn handle_channel(svc: &Arc, client: &Client, event: &Event) { + use crate::nostr::session::{self, PendingMoney, SignRequest, SignResult}; + if event.kind.as_u16() != session::CHANNEL_EVENT_KIND || event.verify().is_err() { + return; + } + let now = unix_time() as u64; + let mut publish: Option = None; + let mut money: Option = None; + let mut notice = false; + let mut ended = false; + { + let mut sessions = svc.sessions.write(); + // Origin binding: the only key allowed to request is the site channel key + // bound at grant time. Nothing else can even open an envelope. + let Some(s) = sessions + .iter_mut() + .find(|s| s.site_session_pubkey == event.pubkey && !s.ended) + else { + return; + }; + let Ok(plaintext) = s.decrypt(&event.pubkey, &event.content) else { + return; + }; + if !session::envelope_within_cap(&plaintext) { + return; + } + let Ok(val) = serde_json::from_str::(&plaintext) else { + return; + }; + match val.get("type").and_then(|t| t.as_str()) { + Some("session-end") => { + s.end(); + ended = true; + } + Some("sign") => { + let Ok(req) = serde_json::from_value::(val) else { + return; + }; + // The signing identity's unlocked keys from the in-memory snapshot. + let keys = svc + .recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key() == s.identity_pubkey) + .map(|h| h.keys); + match keys { + Some(keys) => { + let served = session::serve(s, &req, &keys, now); + notice = served.notify_high_volume; + if served.money_pending { + money = Some(PendingMoney { + domain: s.domain.clone(), + identity_pubkey: s.identity_pubkey, + req, + }); + } else if let Some(json) = served.response { + publish = s.wrap_channel_event(&json, now).ok(); + } + } + None => { + // Identity no longer held: honest refusal, not a hang. + let json = serde_json::to_string(&SignResult::refused( + &req.id, + session::SignError::IdentityMismatch, + )) + .unwrap_or_default(); + publish = s.wrap_channel_event(&json, now).ok(); + } + } + } + _ => {} + } + } + if ended { + svc.sessions.write().retain(|s| !s.ended); + svc.sessions_dirty.store(true, Ordering::SeqCst); + } + if notice { + *svc.session_notice.write() = Some("signing".to_string()); + } + if let Some(p) = money { + svc.money_pending.lock().push(p); + } + if let Some(ev) = publish { + let urls = svc.relays(); + let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await; + } +} + +/// Drain the GUI's answers to money-tier prompts: sign (or decline) each and +/// publish the `sign_result` on its session channel. Called from the loop tick. +async fn serve_money_answers(svc: &Arc, client: &Client) { + use crate::nostr::session; + let answers: Vec<(session::PendingMoney, bool)> = + std::mem::take(&mut *svc.money_answers.lock()); + if answers.is_empty() { + return; + } + let now = unix_time() as u64; + let snapshot = svc.recv_snapshot(); + for (pending, approved) in answers { + let mut publish: Option = None; + { + let mut sessions = svc.sessions.write(); + if let Some(s) = sessions + .iter_mut() + .find(|s| s.domain == pending.domain && !s.ended) + { + let keys = snapshot + .iter() + .find(|h| h.keys.public_key() == s.identity_pubkey) + .map(|h| h.keys.clone()); + if let Some(keys) = keys { + let json = session::complete_money(s, &pending.req, &keys, approved, now); + publish = s.wrap_channel_event(&json, now).ok(); + } + } + } + if let Some(ev) = publish { + let urls = svc.relays(); + let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await; + } + } +} + +/// The channel subscription/fetch filter over the live sessions' wallet channel +/// keys, or `None` when there are no sessions. Bounded `since` to the request +/// expiration: anything older has lapsed its NIP-40 expiration anyway. +fn channel_filter(svc: &Arc) -> Option { + let pks: Vec = svc + .sessions + .read() + .iter() + .filter(|s| !s.ended) + .map(|s| s.wallet_channel_pk) + .collect(); + if pks.is_empty() { + return None; + } + let now = unix_time() as u64; + let since = now.saturating_sub(crate::nostr::session::REQUEST_EXPIRATION_SECS); + Some( + Filter::new() + .kind(Kind::from(crate::nostr::session::CHANNEL_EVENT_KIND)) + .pubkeys(pks) + .since(Timestamp::from_secs(since)), + ) +} + +/// (Re)subscribe the encrypted session channel over the current session set. +async fn resubscribe_channel(client: &Client, relays: &[String], svc: &Arc) { + if let Some(filter) = channel_filter(svc) { + if let Err(e) = client + .subscribe_with_id_to(relays, SubscriptionId::new(CHANNEL_SUB), filter, None) + .await + { + warn!("nostr: session-channel subscribe failed: {e}"); + } + } +} + +/// Publish the one-time `session-open` for every session not yet announced, and +/// mark them announced. Called when the session set changes. +async fn announce_new_sessions(svc: &Arc, client: &Client, relays: &[String]) { + let now = unix_time() as u64; + let mut events = Vec::new(); + { + let mut sessions = svc.sessions.write(); + for s in sessions.iter_mut().filter(|s| !s.announced && !s.ended) { + if let Ok(ev) = s.session_open_event(now) { + events.push(ev); + } + s.announced = true; + } + } + for ev in events { + let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(relays, &ev)).await; + } +} + +/// Drain any channel requests queued on the relay while the wallet was asleep, +/// serving each. Called on a background→foreground transition (the Build-95 +/// frame-heartbeat resume pattern) and once at loop start. +async fn drain_channel(svc: &Arc, client: &Client, relays: &[String]) { + let Some(filter) = channel_filter(svc) else { + return; + }; + if let Ok(events) = client + .fetch_events_from(relays, filter, FETCH_TIMEOUT) + .await + { + for ev in events.into_iter() { + handle_channel(svc, client, &ev).await; + } + } +} + /// Ingest one kind-30023 news post from the Goblin news key and cache it (the /// store dedupes newest-per-`d`). Guards kind + author so a stray event on the /// news subscription can't spoof the panel. diff --git a/src/nostr/session.rs b/src/nostr/session.rs index 1c36d366..b84bef2c 100644 --- a/src/nostr/session.rs +++ b/src/nostr/session.rs @@ -575,6 +575,9 @@ pub struct Session { pub paused: bool, /// Set when the session has ended (logout, wallet end, TTL, idle). pub ended: bool, + /// True once the wallet has published its `session-open` envelope and + /// subscribed the channel for this session (a one-time runtime step). + pub announced: bool, /// Replay-dedup ring: request id -> cached response JSON. A duplicate id /// returns the cached result, never a second signature. seen: HashMap, @@ -629,6 +632,7 @@ impl Session { last_used_at: now, paused: false, ended: false, + announced: false, seen: HashMap::new(), seen_order: VecDeque::new(), silent_times: VecDeque::new(), @@ -745,6 +749,71 @@ impl Session { self.ended = true; } + /// The wallet's ephemeral channel keypair (reconstructed from the stored + /// secret) for signing and decrypting channel envelopes. + fn channel_keys(&self) -> Keys { + Keys::new(self.wallet_channel_sk.clone()) + } + + /// Decrypt a channel envelope from the site (its outer event `pubkey` must be + /// `site_session_pubkey`, checked by the caller) into its plaintext payload. + pub fn decrypt(&self, sender: &PublicKey, payload: &str) -> Result { + open_envelope(&self.wallet_channel_sk, sender, payload) + } + + /// Wrap a plaintext payload as a signed, NIP-44-encrypted, addressed channel + /// event (kind [`CHANNEL_EVENT_KIND`]) carrying a NIP-40 `expiration` tag, + /// ready to publish back to the site. + pub fn wrap_channel_event(&self, plaintext: &str, now: u64) -> Result { + let content = seal_envelope( + &self.wallet_channel_sk, + &self.site_session_pubkey, + plaintext, + )?; + let exp = Timestamp::from(now + REQUEST_EXPIRATION_SECS); + EventBuilder::new(Kind::from(CHANNEL_EVENT_KIND), content) + .tags(vec![ + Tag::public_key(self.site_session_pubkey), + Tag::expiration(exp), + ]) + .sign_with_keys(&self.channel_keys()) + .map_err(|e| e.to_string()) + } + + /// The `session-open` channel event: hands the site the wallet channel key + /// (the event's own `pubkey`) and confirms the signing identity. + pub fn session_open_event(&self, now: u64) -> Result { + let open = SessionOpen { + msg_type: "session-open".to_string(), + wallet_pubkey: self.wallet_channel_pk.to_hex(), + identity: self.identity_pubkey.to_hex(), + }; + let json = serde_json::to_string(&open).map_err(|e| e.to_string())?; + self.wrap_channel_event(&json, now) + } + + /// The `session-end` channel event: tells the site the wallet ended the + /// session (a courtesy on wallet-side revocation; the teardown is unilateral + /// regardless of delivery). + pub fn session_end_event(&self, now: u64) -> Result { + let end = SessionEnd { + msg_type: "session-end".to_string(), + }; + let json = serde_json::to_string(&end).map_err(|e| e.to_string())?; + self.wrap_channel_event(&json, now) + } + + /// A read-only snapshot for the Trusted Sites list. + pub fn summary(&self, now: u64) -> SessionSummary { + SessionSummary { + domain: self.domain.clone(), + label: self.label.clone(), + categories: render_grant(&self.silent_kind_set).categories, + ttl_remaining_secs: self.ttl_remaining(now), + paused: self.paused, + } + } + /// Resume a paused session (the user tapped "resume"). Clears the pause and /// the rate window so counting starts fresh. pub fn resume(&mut self, now: u64) { @@ -795,6 +864,121 @@ pub fn envelope_within_cap(plaintext: &str) -> bool { plaintext.len() <= MAX_ENVELOPE_BYTES } +/// A read-only snapshot of a session for the Trusted Sites list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSummary { + pub domain: String, + pub label: String, + /// The low-tier categories this session can sign silently. + pub categories: Vec, + /// Seconds until the hard TTL (the session-detail "time remaining"). + pub ttl_remaining_secs: u64, + /// True when the hard rate cap paused the silent path. + pub paused: bool, +} + +/// A money-tier request awaiting the user's per-action password prompt. The +/// runtime hands one of these to the GUI, which raises the v1-style authorize +/// modal; the GUI's answer routes back through [`complete_money`]. +#[derive(Debug, Clone)] +pub struct PendingMoney { + /// The trusted domain the request arrived on. + pub domain: String, + /// The signing identity for this session (looked up to sign on approval). + pub identity_pubkey: PublicKey, + /// The full request, replayed verbatim to sign on approval. + pub req: SignRequest, +} + +// --------------------------------------------------------------------------- +// Runtime serving orchestration (thin, so the async relay loop stays dumb). +// --------------------------------------------------------------------------- + +/// The upshot of serving one decoded request against a session. The async loop +/// acts on it and never touches classification, signing, or bookkeeping itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Served { + /// A `sign_result` JSON to publish back to the site on the channel, or `None` + /// when the request is a money-tier prompt still pending the user. + pub response: Option, + /// True when the soft rate cap tripped: surface a single non-blocking notice. + pub notify_high_volume: bool, + /// True when this request needs the money-tier password prompt (the loop + /// enqueues it for the GUI and publishes nothing yet). + pub money_pending: bool, +} + +/// Serve a decoded sign request against a live session. Silent low-tier requests +/// are signed here and turned into a `sign_result` JSON; refusals and cached +/// duplicates likewise return a JSON to publish; a money-tier request returns +/// `money_pending` with no response (the GUI raises the prompt, then the loop +/// calls [`complete_money`]). `sign_keys` are the session identity's unlocked +/// keys, looked up by the loop from the wallet's in-memory snapshot. +pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u64) -> Served { + match session.decide(req, now) { + Decision::Duplicate(json) => Served { + response: Some(json), + notify_high_volume: false, + money_pending: false, + }, + Decision::Refuse(err) => { + let json = + serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default(); + session.remember(&req.id, &json, false, now); + Served { + response: Some(json), + notify_high_volume: false, + money_pending: false, + } + } + Decision::MoneyPrompt => Served { + response: None, + notify_high_volume: false, + money_pending: true, + }, + Decision::Silent { notify_high_volume } => { + let result = match sign_session_event(sign_keys, &req.event, now) { + Ok(ev) => SignResult::ok(&req.id, &ev), + Err(err) => SignResult::refused(&req.id, err), + }; + let json = serde_json::to_string(&result).unwrap_or_default(); + // A produced silent signature counts toward the rate window; a refusal + // on the silent path does not. + session.remember(&req.id, &json, result.ok, now); + Served { + response: Some(json), + notify_high_volume, + money_pending: false, + } + } + } +} + +/// Complete a money-tier request after the user answered the password prompt. +/// `approved` true signs the event and returns the `sign_result`; false returns +/// the `user_declined` refusal. Either way the result is remembered so a replay +/// of the same id returns it verbatim. Money signs are individually gated and so +/// never count toward the silent rate window. +pub fn complete_money( + session: &mut Session, + req: &SignRequest, + sign_keys: &Keys, + approved: bool, + now: u64, +) -> String { + let result = if approved { + match sign_session_event(sign_keys, &req.event, now) { + Ok(ev) => SignResult::ok(&req.id, &ev), + Err(err) => SignResult::refused(&req.id, err), + } + } else { + SignResult::refused(&req.id, SignError::UserDeclined) + }; + let json = serde_json::to_string(&result).unwrap_or_default(); + session.remember(&req.id, &json, false, now); + json +} + #[cfg(test)] mod tests { use super::*; @@ -1217,4 +1401,57 @@ mod tests { assert_eq!(req.event.kind, 7); assert_eq!(req.event.tags, vec![vec!["e".to_string(), "x".to_string()]]); } + + #[test] + fn serve_silent_signs_and_publishes_result() { + let now = 1_751_800_000u64; + let (mut s, id) = mk_session(&[7], now); + let req = mk_req(&id, 7, "+", now, "sv1"); + let out = serve(&mut s, &req, &id, now); + assert!(!out.money_pending); + let json = out.response.expect("silent sign yields a response"); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("\"id\":\"sv1\"")); + // The signed event rides back in the result and verifies. + let parsed: SignResult = serde_json::from_str(&json).unwrap(); + let ev: Event = serde_json::from_value(parsed.event.unwrap()).unwrap(); + assert!(ev.verify().is_ok()); + assert_eq!(ev.pubkey, id.public_key()); + } + + #[test] + fn serve_money_is_pending_then_completes_or_declines() { + let now = 1_751_800_000u64; + let (mut s, id) = mk_session(&[1], now); + let req = mk_req(&id, 17, "{}", now, "mv1"); + let out = serve(&mut s, &req, &id, now); + assert!(out.money_pending); + assert!(out.response.is_none(), "money pends: nothing published yet"); + // User approves -> signed result. + let json = complete_money(&mut s, &req, &id, true, now); + assert!(json.contains("\"ok\":true")); + // A replay of the same id now returns the cached signed result. + match s.decide(&req, now) { + Decision::Duplicate(cached) => assert!(cached.contains("\"ok\":true")), + other => panic!("expected Duplicate, got {other:?}"), + } + // A fresh money request the user declines -> user_declined. + let req2 = mk_req(&id, 17, "{}", now, "mv2"); + let declined = complete_money(&mut s, &req2, &id, false, now); + assert!(declined.contains("\"error\":\"user_declined\"")); + } + + #[test] + fn serve_refuses_kind_not_in_set() { + let now = 1_751_800_000u64; + let (mut s, id) = mk_session(&[7], now); + let req = mk_req(&id, 1, "hi", now, "rf1"); + let out = serve(&mut s, &req, &id, now); + assert!(!out.money_pending); + assert!( + out.response + .unwrap() + .contains("\"error\":\"kind_not_in_session\"") + ); + } }