Skip to main content

vector_core/
deletion.rs

1//! Message deletion — Vector's "delete from network" capability.
2//!
3//! NIP-17 DMs are wrapped in kind-1059 gift-wrap events signed by an
4//! ephemeral key. The standard NIP-59 implementation discards that key
5//! after signing, making the wrap permanently un-deletable: privacy by
6//! obscurity, since the wrap continues to sit on inbox relays
7//! decryptable by anyone with the recipient key.
8//!
9//! Vector retains the ephemeral key (see `db::nip17_keys`) so that on
10//! user request we can publish an author-signed NIP-09 deletion against
11//! every wrap and have relays drop it. Privacy by control.
12//!
13//! Scope: this module deletes the user's **own** outbound messages. It
14//! does not (and cannot) delete messages sent by others — those wraps
15//! were signed by ephemeral keys we never held.
16
17use crate::event_ext::FinalizeUnsignedWithId;
18use nostr_sdk::prelude::*;
19
20use crate::inbox_relays::{get_publish_tracker, send_gift_wrap};
21use crate::state::{my_public_key, nostr_client};
22
23/// Cooperative-hide notice expiry: 30 days. After this window relays
24/// drop the gift-wrap (NIP-40) and clients that come online later won't
25/// see the deletion notice — but they also won't see the original wrap
26/// (it was nuked from relays in Layer 1), so there's nothing to delete
27/// on their side anyway. Recipients who already fetched and decrypted
28/// the original need the notice to drop their local copy; 30 days is
29/// generous coverage for "live" use.
30const COOPERATIVE_HIDE_EXPIRY_SECS: u64 = 60 * 60 * 24 * 30;
31
32/// Outcome of a delete-own-* operation.
33///
34/// Vector's deletion is layered and best-effort: any subset of the
35/// layers may be available depending on whether we hold retained
36/// ephemeral keys, whether the message has attachments, etc. The
37/// outcome reports what was attempted so the caller can show an
38/// honest post-action summary.
39#[derive(serde::Serialize, Debug, Clone, Default)]
40pub struct DeleteOutcome {
41    /// Number of retained wraps for which we dispatched a NIP-09
42    /// deletion task (Layer 1 — relay-level nuke). Zero when the
43    /// message predates retention or was sent from a different device.
44    pub wraps_dispatched: usize,
45    /// Total wraps we had keys for at delete time (= wraps_dispatched
46    /// for now; reserved for future "skipped due to error" reporting).
47    pub wraps_total: usize,
48    /// Whether we sent a cooperative-hide notice (Layer 2). Always true
49    /// for own deletions in groups/DMs that succeed; tells live Vector
50    /// clients to drop the row from local UI.
51    pub cooperative_hide_sent: bool,
52    /// Number of Blossom blobs we asked the upload server to delete.
53    /// Best-effort: actual server response is logged, not surfaced.
54    pub blobs_dispatched: usize,
55    /// True iff at least one of (wrap nuke, cooperative hide, blob
56    /// delete) was attempted. False means the only thing the operation
57    /// could do is a local-state drop — caller can use this to surface
58    /// "we couldn't actually remove this from the network" copy.
59    pub any_network_action: bool,
60}
61
62/// Delete an outbound DM from the network by publishing NIP-09
63/// deletions against every retained gift-wrap for `rumor_id`.
64///
65/// Per-relay event-driven dispatch: the publish/delete race is closed
66/// by listening to each wrap's `WrapPublishTracker` (registered at
67/// send time). NIP-09 fires at each relay only **after** that relay
68/// has confirmed receiving the wrap — relays that haven't received it
69/// yet wait until they do, relays that already have it get NIP-09
70/// immediately, relays where the publish failed get nothing (no event
71/// there to delete).
72///
73/// Each per-wrap deletion runs as a background tokio task so the API
74/// returns immediately. Local UI removal happens synchronously; the
75/// caller's UX never blocks on relay roundtrips.
76///
77/// Returns `Err` if no retained keys exist for the rumor (predates
78/// the retention feature, sent from a different device, etc).
79pub async fn delete_own_dm(rumor_id: &EventId) -> Result<DeleteOutcome, String> {
80    let client = nostr_client().ok_or("Not logged in")?;
81    // Session captured for the relay-nuke loop — without this, an
82    // account-A wrap-key purge could land in account B's nip17_keys.
83    // Durable tombstone FIRST: NIP-09 is best-effort and relays ignore it
84    // freely — without this, a surviving wrap re-served after a restart
85    // re-ingests cleanly (the events row is gone, the session set is empty)
86    // and the message resurrects, now key-less and only Limited-deletable.
87    let rumor_hex = rumor_id.to_hex();
88    if let Err(e) = crate::db::events::add_message_tombstone(&rumor_hex) {
89        crate::log_warn!("[NIP-17 delete] tombstone write failed: {}", e);
90    }
91    crate::state::note_message_deleted(&rumor_hex);
92    let keys = crate::db::nip17_keys::get_wrap_keys_for_rumor(rumor_id)
93        .unwrap_or_default();
94
95    // Snapshot the message + recipient BEFORE any state/DB cleanup.
96    // Attachment URLs feed Blossom DELETE; the attachments themselves
97    // feed local-cache file removal; recipient pubkey feeds the
98    // cooperative-hide gift wrap. We try to recover all three even when
99    // no retained keys exist (older messages, pre-retention sends).
100    //
101    // INVARIANT: `chat.id` for a DM is the counterpart's npub. The
102    // `find_message` lookup here is unscoped, but `delete_own_dm` is
103    // only ever called from the DM branch of the Tauri command — the
104    // group branch routes to `delete_own_group_message` instead.
105    // `from_bech32` silently yields `None` if the invariant ever
106    // breaks; that just disables Layer 2 cooperative-hide for the
107    // call. Layer 1 (retained-key relay nuke) and Layer 3 (Blossom)
108    // remain functional.
109    let (all_attachments, recipient_from_state) = {
110        let state = crate::state::STATE.lock().await;
111        match state.find_message(&rumor_id.to_hex()) {
112            Some((chat, msg)) => {
113                debug_assert!(
114                    matches!(chat.chat_type, crate::chat::ChatType::DirectMessage),
115                    "delete_own_dm called on non-DM chat — caller bug"
116                );
117                let recipient = nostr_sdk::prelude::PublicKey::from_bech32(&chat.id).ok();
118                (msg.attachments.clone(), recipient)
119            }
120            None => (Vec::new(), None),
121        }
122    };
123
124    // Refcount filter: drop attachments still referenced by sibling
125    // messages so we don't yank cached files / Blossom blobs from
126    // messages that still need them. Vector dedupes uploads by SHA-256:
127    // re-sending the same file produces multiple messages pointing at
128    // the same cached path AND the same Blossom blob. Deleting one
129    // shouldn't delete the underlying resources.
130    let unique_attachments = filter_unreferenced_attachments(
131        &rumor_id.to_hex(),
132        all_attachments,
133    ).await;
134
135    // Local cache nuke (canonicalize + managed-dir-only inside helper).
136    delete_cached_attachment_files(&unique_attachments);
137
138    // Blossom URLs derived from the filtered (refcount-aware) set — the
139    // primary plus every mirror (same hash, other origins) — then gated
140    // against the DB ledger: a smart-forwarded blob backs OTHER messages too.
141    let attachment_urls: Vec<String> = scrub_safe_urls(&unique_attachments, &rumor_id.to_hex());
142
143    let wraps_total = keys.len();
144    let mut wraps_dispatched = 0usize;
145
146    // Layer 1 — relay-level nuke. Only possible when we still hold
147    // retained wrap keys for this rumor.
148    for stored in keys.iter() {
149        let client = client.clone();
150        let wrap_event_id = stored.wrap_event_id;
151        let secret = stored.secret.clone();
152        let relay_urls = stored.relay_urls.clone();
153        crate::db::spawn_bound(async move {
154            delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
155            if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
156                crate::log_warn!("[NIP-17 delete] failed to purge wrap key: {}", e);
157            }
158        });
159        wraps_dispatched += 1;
160    }
161
162    // Layer 2 — cooperative hide. Always send a notice if we know the
163    // recipient: tells live Vector clients to drop their local copy.
164    // Prefer the recipient pubkey from a retained wrap key if we have
165    // one (recipient role); fall back to the chat counterpart from
166    // STATE. The notice itself is signed by our main key, so this
167    // works even when retained wrap keys are missing.
168    let cooperative_recipient = keys
169        .iter()
170        .find(|k| {
171            k.role == crate::db::nip17_keys::WrapRole::Recipient
172                || k.role == crate::db::nip17_keys::WrapRole::Retry
173        })
174        .map(|k| k.recipient_pubkey)
175        .or(recipient_from_state);
176
177    let mut cooperative_hide_sent = false;
178    if let Some(recipient) = cooperative_recipient {
179        match publish_cooperative_hide(&client, rumor_id, &recipient, 14).await {
180            Ok(()) => cooperative_hide_sent = true,
181            Err(e) => crate::log_warn!("[NIP-17 delete] cooperative-hide notice failed: {}", e),
182        }
183    }
184
185    // Layer 3 — Blossom blob delete. Route through the active client
186    // signer so bunker accounts sign DELETE auth under the user's
187    // identity (Blossom enforces "uploader == authorized signer";
188    // signing with the NIP-46 client keypair returns 401).
189    let mut blobs_dispatched = 0usize;
190    if !attachment_urls.is_empty() {
191        if let Ok(signer) = crate::signer::active_signer() {
192            blobs_dispatched = attachment_urls.len();
193            crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
194        }
195    }
196
197    let any_network_action =
198        wraps_dispatched > 0 || cooperative_hide_sent || blobs_dispatched > 0;
199
200    Ok(DeleteOutcome {
201        wraps_total,
202        wraps_dispatched,
203        cooperative_hide_sent,
204        blobs_dispatched,
205        any_network_action,
206    })
207}
208
209/// Revoke one of OUR OWN DM reactions. Mirrors `delete_own_dm` but the
210/// target is a kind-7 reaction rumor: Layer 1 nukes each retained gift-wrap
211/// from its relays via the stored ephemeral key; Layer 2 sends a cooperative
212/// hide (k=7) to the counterpart + self so live clients drop the chip. No
213/// Blossom layer — reactions carry no attachments. `recipient` is the DM
214/// counterpart, used for the cooperative hide when no recipient-role wrap key
215/// is retained (e.g. only the self-wrap survived).
216pub async fn delete_own_reaction(
217    reaction_id: &EventId,
218    recipient: PublicKey,
219) -> Result<DeleteOutcome, String> {
220    let client = nostr_client().ok_or("Not logged in")?;
221    // Durable tombstone first, as for messages: a surviving self-wrap re-served later
222    // would otherwise bring the reaction back.
223    let reaction_hex = reaction_id.to_hex();
224    if let Err(e) = crate::db::events::add_message_tombstone(&reaction_hex) {
225        crate::log_warn!("[reaction delete] tombstone write failed: {}", e);
226    }
227    crate::state::note_message_deleted(&reaction_hex);
228    let keys = crate::db::nip17_keys::get_wrap_keys_for_rumor(reaction_id)
229        .unwrap_or_default();
230
231    let wraps_total = keys.len();
232    let mut wraps_dispatched = 0usize;
233
234    // Layer 1 — relay-level nuke per retained wrap key.
235    for stored in keys.iter() {
236        let client = client.clone();
237        let wrap_event_id = stored.wrap_event_id;
238        let secret = stored.secret.clone();
239        let relay_urls = stored.relay_urls.clone();
240        crate::db::spawn_bound(async move {
241            delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
242            if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
243                crate::log_warn!("[reaction delete] failed to purge wrap key: {}", e);
244            }
245        });
246        wraps_dispatched += 1;
247    }
248
249    // Layer 2 — cooperative hide (k=7). Prefer a recipient pulled from a
250    // retained recipient/retry wrap key; fall back to the passed-in
251    // counterpart. The notice is signed by our main key, so it works even
252    // when no wrap keys survive (pre-retention reactions stay undeletable at
253    // the relay layer, but a live counterpart still drops the chip).
254    let cooperative_recipient = keys
255        .iter()
256        .find(|k| {
257            k.role == crate::db::nip17_keys::WrapRole::Recipient
258                || k.role == crate::db::nip17_keys::WrapRole::Retry
259        })
260        .map(|k| k.recipient_pubkey)
261        .unwrap_or(recipient);
262
263    let mut cooperative_hide_sent = false;
264    match publish_cooperative_hide(&client, reaction_id, &cooperative_recipient, 7).await {
265        Ok(()) => cooperative_hide_sent = true,
266        Err(e) => crate::log_warn!("[reaction delete] cooperative-hide failed: {}", e),
267    }
268
269    let any_network_action = wraps_dispatched > 0 || cooperative_hide_sent;
270    Ok(DeleteOutcome {
271        wraps_total,
272        wraps_dispatched,
273        cooperative_hide_sent,
274        blobs_dispatched: 0,
275        any_network_action,
276    })
277}
278
279/// Per-relay deletion for a single wrap. Subscribes to the wrap's
280/// publish tracker and fires NIP-09 to each relay as soon as that
281/// relay confirms receiving the wrap. Relays where the publish failed
282/// don't get NIP-09 (no event there to delete).
283///
284/// If no live tracker exists (cross-restart: the original publishes
285/// completed in a previous session), falls back to a best-effort
286/// broadcast against every targeted relay. Relays that don't have
287/// the wrap will no-op the deletion; that's safe.
288async fn delete_wrap_per_relay(
289    client: &Client,
290    wrap_event_id: EventId,
291    secret: SecretKey,
292    targeted_relays: Vec<String>,
293) {
294    let ephemeral_keys = Keys::new(secret);
295    let deletion = match EventBuilder::new(Kind::EventDeletion, "")
296        .tag(Tag::event(wrap_event_id))
297        .tag(Tag::custom("k", ["1059"]))
298        .finalize(&ephemeral_keys)
299    {
300        Ok(ev) => ev,
301        Err(e) => {
302            eprintln!(
303                "[NIP-17 delete] failed to sign deletion for wrap {}: {}",
304                wrap_event_id.to_hex(),
305                e
306            );
307            return;
308        }
309    };
310
311    if let Some(tracker) = get_publish_tracker(&wrap_event_id) {
312        // Live tracker: walk the success stream as relays settle.
313        let mut delivered = 0usize;
314        let mut cursor = 0usize;
315        while let Some(url) = tracker.next_success(&mut cursor).await {
316            if send_to_one_relay(client, &url, &deletion).await {
317                delivered += 1;
318            }
319        }
320        crate::log_info!(
321            "[NIP-17 delete] wrap {} — NIP-09 delivered to {} relay(s) via tracker",
322            wrap_event_id.to_hex(),
323            delivered
324        );
325    } else {
326        // No tracker (cross-restart or already-GC'd). Best-effort
327        // broadcast: fire NIP-09 at every targeted relay; relays
328        // that lack the wrap silently no-op.
329        let urls: Vec<RelayUrl> = targeted_relays
330            .iter()
331            .filter_map(|s| RelayUrl::parse(s).ok())
332            .collect();
333        let total = urls.len();
334        let mut delivered = 0usize;
335        for url in urls {
336            if send_to_one_relay(client, &url, &deletion).await {
337                delivered += 1;
338            }
339        }
340        crate::log_info!(
341            "[NIP-17 delete] wrap {} — fallback broadcast: NIP-09 delivered to {}/{} relay(s)",
342            wrap_event_id.to_hex(),
343            delivered,
344            total
345        );
346    }
347}
348
349/// Best-effort delete of every cached attachment file for a message.
350/// Only unlinks files that canonicalize to a path under Vector's
351/// managed download directory — files the user has moved or copied
352/// elsewhere are never touched. Symlink and `..` escape attempts are
353/// rejected by the canonicalize check.
354///
355/// Used by both sender-initiated deletes (so the user's own cached
356/// copy disappears alongside the network deletion) and cooperative-
357/// hide receivers (so the recipient's downloaded copy goes when the
358/// sender asks for the message to disappear). Mirrors the
359/// "delete-for-everyone" semantics of iMessage/Signal at the file
360/// layer, scoped to Vector's own cache.
361pub fn delete_cached_attachment_files_pub(attachments: &[crate::types::Attachment]) {
362    delete_cached_attachment_files(attachments);
363}
364
365/// Filter `attachments` down to those NOT referenced by any OTHER
366/// undeleted message in STATE.
367///
368/// Vector dedupes uploads by SHA-256 hash: re-sending the same file
369/// reuses the on-disk cache + the same Blossom URL across multiple
370/// messages. Without this filter, deleting one of those messages
371/// would unlink the cached file (or DELETE the Blossom blob) even
372/// though sibling messages still reference it — the user's other
373/// copies would 404 and lose their local preview.
374///
375/// `excluding_message_id` is the id of the message we're deleting,
376/// so it doesn't count as a "reference" to itself when determining
377/// whether the attachment is shared.
378pub async fn filter_unreferenced_attachments(
379    excluding_message_id: &str,
380    attachments: Vec<crate::types::Attachment>,
381) -> Vec<crate::types::Attachment> {
382    if attachments.is_empty() {
383        return attachments;
384    }
385    // STATE pass first, lock released before any DB read (lock-order hygiene).
386    let state_referenced: Vec<bool> = {
387        let state = crate::state::STATE.lock().await;
388        attachments
389            .iter()
390            .map(|att| {
391                // Dedup key: the attachment's SHA-256 (Attachment.id).
392                // Empty id can't be matched, so treat as unique.
393                let hash = &*att.id;
394                if hash.is_empty() {
395                    return false;
396                }
397                state.chats.iter().any(|chat| {
398                    chat.iter_compact().any(|m| {
399                        let msg_id_hex = m.id_hex();
400                        msg_id_hex != excluding_message_id
401                            && m.attachments.iter().any(|a| a.id_eq(hash))
402                    })
403                })
404            })
405            .collect()
406    };
407    // DB pass second: STATE windows to recent messages, so a reference in an
408    // older message is invisible there — the ledger is the authority. A read
409    // failure counts as referenced: deleting less is recoverable, deleting a
410    // still-shared file is not.
411    attachments
412        .into_iter()
413        .zip(state_referenced)
414        .filter(|(att, in_state)| {
415            if *in_state {
416                return false;
417            }
418            let hash = &*att.id;
419            if hash.is_empty() {
420                return true;
421            }
422            !crate::db::attachments::hash_referenced_elsewhere(hash, excluding_message_id).unwrap_or(true)
423        })
424        .map(|(att, _)| att)
425        .collect()
426}
427
428/// Of these attachments' remote urls, the ones no OTHER event still references
429/// — the only ones a delete may scrub from Blossom. One blob backs many
430/// messages under smart-forward, and the shared copy must outlive every send
431/// but the last. A ledger read failure scrubs nothing, same reasoning as above.
432pub fn scrub_safe_urls(attachments: &[crate::types::Attachment], excluding_event_id: &str) -> Vec<String> {
433    let urls: Vec<String> = attachments.iter().flat_map(|a| a.all_urls().map(str::to_string)).collect();
434    if urls.is_empty() {
435        return urls;
436    }
437    crate::db::attachments::urls_unreferenced_elsewhere(&urls, excluding_event_id).unwrap_or_default()
438}
439
440fn delete_cached_attachment_files(attachments: &[crate::types::Attachment]) {
441    if attachments.is_empty() {
442        return;
443    }
444    #[cfg(target_arch = "wasm32")]
445    {
446        let dir = crate::db::get_download_dir();
447        let paths: Vec<std::path::PathBuf> = attachments
448            .iter()
449            .map(|a| std::path::PathBuf::from(&*a.path))
450            .filter(|p| p.starts_with(&dir) && !p.components().any(|c| c == std::path::Component::ParentDir))
451            .collect();
452        crate::db::spawn_bound(async move {
453            for p in paths {
454                crate::webfiles::remove(&p).await;
455            }
456        });
457    }
458    #[cfg(not(target_arch = "wasm32"))]
459    delete_cached_attachment_files_native(attachments);
460}
461
462#[cfg(not(target_arch = "wasm32"))]
463fn delete_cached_attachment_files_native(attachments: &[crate::types::Attachment]) {
464    let download_dir = match crate::db::get_download_dir().canonicalize() {
465        Ok(d) => d,
466        Err(_) => return,
467    };
468    for att in attachments {
469        if att.path.is_empty() {
470            continue;
471        }
472        let candidate = match std::path::PathBuf::from(&*att.path).canonicalize() {
473            Ok(p) => p,
474            // File already gone, or path unresolvable — nothing to do.
475            Err(_) => continue,
476        };
477        if !candidate.starts_with(&download_dir) {
478            // User-managed path (moved/copied out of Vector's cache);
479            // never touch.
480            continue;
481        }
482        if let Err(e) = std::fs::remove_file(&candidate) {
483            crate::log_warn!(
484                "[delete] failed to remove cached attachment {}: {}",
485                candidate.display(),
486                e
487            );
488        }
489    }
490}
491
492/// Direct publish to a single relay handle. Returns true if the relay
493/// acknowledged. Returns false if the relay isn't in our pool, the
494/// publish hit a non-rate-limit error, or rate-limit retries were
495/// exhausted.
496///
497/// Per-URL outcome is logged so the user can pinpoint which relay is
498/// keeping a wrap alive after deletion — relays that ACK a NIP-09 but
499/// don't actually drop the event are non-compliant; the
500/// `verify_relay_dropped` probe scheduled below is the receipt that
501/// identifies them.
502///
503/// Rate-limit handling: relays like damus.io will reject NIP-09s with
504/// "rate-limited: you are noting too much" when the user deletes a
505/// few messages in quick succession. The deletion isn't a real
506/// failure, just back-pressure — so we wait and retry up to
507/// `MAX_RATELIMIT_RETRIES` times (each retry sleeps 30s). The whole
508/// loop runs inside the per-relay deletion task (already spawned), so
509/// the user's UX is unaffected; the wrap stays on the relay only
510/// until we get through.
511///
512/// On successful ACK, schedules a verification probe (~2s later) that
513/// re-queries the relay for the original wrap event id and reports
514/// whether the relay actually honored the deletion. Catches relays
515/// that lie about NIP-09 compliance.
516async fn send_to_one_relay(client: &Client, url: &RelayUrl, event: &Event) -> bool {
517    /// Max attempts to push past a rate-limit. With 30s between
518    /// attempts that's a 10-minute window — generous for any sane
519    /// per-IP rate limit. If the relay is still rate-limiting us
520    /// after that, something else is wrong and we give up so the
521    /// task doesn't loop forever.
522    const MAX_RATELIMIT_RETRIES: u32 = 20;
523    /// Pause between rate-limit retries.
524    const RATELIMIT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
525
526    let pool = client;
527    let relays = pool.relays().await;
528    let relay = match relays.get(url) {
529        Some(r) => r.clone(),
530        None => {
531            crate::log_warn!("[delete] relay {} not in pool — NIP-09 not delivered", url);
532            return false;
533        }
534    };
535    drop(relays);
536
537    let mut retries = 0u32;
538    loop {
539        match relay.send_event(event).await {
540            Ok(_) => {
541                if retries == 0 {
542                    crate::log_info!("[delete] relay {} ACK'd NIP-09", url);
543                } else {
544                    crate::log_info!(
545                        "[delete] relay {} ACK'd NIP-09 (after {} retr{})",
546                        url,
547                        retries,
548                        if retries == 1 { "y" } else { "ies" }
549                    );
550                }
551                if let Some(wrap_id) = extract_target_event_id(event) {
552                    let url_clone = url.clone();
553                    let client_clone = client.clone();
554                    crate::db::spawn_bound(async move {
555                        verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
556                    });
557                }
558                return true;
559            }
560            Err(e) => {
561                let err_str = e.to_string();
562                let lc = err_str.to_ascii_lowercase();
563                let is_rate_limit = lc.contains("rate-limit")
564                    || lc.contains("rate limit")
565                    || lc.contains("noting too much");
566                let is_transient = lc.contains("timeout")
567                    || lc.contains("timed out")
568                    || lc.contains("connection reset")
569                    || lc.contains("connection refused")
570                    || lc.contains("connection closed")
571                    || lc.contains("broken pipe")
572                    || lc.contains("not connected");
573                let retryable = is_rate_limit || is_transient;
574                if retryable && retries < MAX_RATELIMIT_RETRIES {
575                    retries += 1;
576                    let reason = if is_rate_limit { "rate-limited" } else { "transient error" };
577                    crate::log_warn!(
578                        "[delete] relay {} {} (attempt {}/{}; err: {}); waiting {}s",
579                        url,
580                        reason,
581                        retries,
582                        MAX_RATELIMIT_RETRIES,
583                        err_str,
584                        RATELIMIT_BACKOFF.as_secs()
585                    );
586                    crate::rt::time::sleep(RATELIMIT_BACKOFF).await;
587                    continue;
588                }
589                if retryable {
590                    crate::log_warn!(
591                        "[delete] relay {} still failing after {} retries ({}); scheduling verify probe in case it eventually accepted",
592                        url,
593                        retries,
594                        err_str
595                    );
596                    // Even though our publish never ACK'd, the relay may
597                    // have actually received and processed the event
598                    // (timeouts often mean "ACK lost on the way back").
599                    // Schedule a verify probe so we still log whether the
600                    // wrap is gone.
601                    if let Some(wrap_id) = extract_target_event_id(event) {
602                        let url_clone = url.clone();
603                        let client_clone = client.clone();
604                        crate::db::spawn_bound(async move {
605                            verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
606                        });
607                    }
608                } else {
609                    crate::log_warn!("[delete] relay {} rejected NIP-09: {}", url, err_str);
610                }
611                return false;
612            }
613        }
614    }
615}
616
617/// Pull the target event id from a NIP-09 deletion event's first
618/// `["e", ...]` tag. Used by the verification probe to know which
619/// wrap to look for after asking the relay to delete it.
620fn extract_target_event_id(deletion: &Event) -> Option<EventId> {
621    deletion.tags.iter().find_map(|tag| {
622        let s = tag.as_slice();
623        if s.len() >= 2 && s[0] == "e" {
624            EventId::from_hex(&s[1]).ok()
625        } else {
626            None
627        }
628    })
629}
630
631/// 2s after a relay ACKs our NIP-09, ask it whether the target wrap
632/// is actually gone. Logs a clear "GONE" or "STILL PRESENT" so we can
633/// identify non-compliant relays without bisecting via external tools.
634async fn verify_relay_dropped(client: &Client, url: &RelayUrl, wrap_event_id: &EventId) {
635    crate::rt::time::sleep(std::time::Duration::from_secs(2)).await;
636
637    let pool = client;
638    let relays = pool.relays().await;
639    let relay = match relays.get(url) {
640        Some(r) => r.clone(),
641        None => return,
642    };
643
644    let filter = Filter::new().id(*wrap_event_id);
645    match relay
646        .fetch_events(filter)
647        .timeout(std::time::Duration::from_secs(5))
648        .policy(ReqExitPolicy::ExitOnEOSE)
649        .await
650    {
651        Ok(events) => {
652            let still_present = events.into_iter().next().is_some();
653            if still_present {
654                crate::log_warn!(
655                    "[delete-verify] relay {} STILL HAS wrap {} (non-compliant — relay ACK'd NIP-09 but did not drop the event)",
656                    url,
657                    wrap_event_id.to_hex()
658                );
659            } else {
660                crate::log_info!(
661                    "[delete-verify] relay {} confirmed wrap {} is GONE",
662                    url,
663                    wrap_event_id.to_hex()
664                );
665            }
666        }
667        Err(e) => {
668            crate::log_warn!(
669                "[delete-verify] relay {} probe failed for wrap {}: {}",
670                url,
671                wrap_event_id.to_hex(),
672                e
673            );
674        }
675    }
676}
677
678/// Publish the Layer-2 cooperative-hide notice — a kind-5 NIP-09 rumor
679/// signed by the user's main key, gift-wrapped to the recipient and to
680/// self. Carries a NIP-40 expiration tag (30 days) so relays drop the
681/// wrap once the live-client window has passed.
682async fn publish_cooperative_hide(
683    client: &Client,
684    target_rumor_id: &EventId,
685    recipient: &PublicKey,
686    original_kind: u16,
687) -> Result<(), String> {
688    let my_pk = my_public_key().ok_or("Public key not set")?;
689    let now = web_time::SystemTime::now()
690        .duration_since(web_time::UNIX_EPOCH)
691        .map_err(|e| e.to_string())?
692        .as_secs();
693    let expiration_ts = now + COOPERATIVE_HIDE_EXPIRY_SECS;
694
695    // Build the kind-5 rumor (signed by our main key via the gift-wrap
696    // path's seal step). Reference the inner rumor id with `e`, hint at
697    // the original kind via `k` (14 = DM message, 7 = reaction), expire
698    // after 30 days. The `k` lets the receiver remove the right thing.
699    let rumor = EventBuilder::new(Kind::EventDeletion, "")
700        .tag(Tag::event(*target_rumor_id))
701        .tag(Tag::custom("k", [original_kind.to_string()]))
702        .tag(Tag::expiration(Timestamp::from(expiration_ts)))
703        .finalize_unsigned_with_id(my_pk);
704
705    // Wrap and send to recipient. Also wrap and send to self so other
706    // devices belonging to the user drop the message from their local
707    // view too. Best-effort, fire-and-forget.
708    let r1 = send_gift_wrap(client, recipient, rumor.clone(), []).await;
709    let r2 = send_gift_wrap(client, &my_pk, rumor, []).await;
710
711    if r1.is_err() && r2.is_err() {
712        return Err("both cooperative-hide deliveries failed".to_string());
713    }
714    Ok(())
715}