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