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).
140 let attachment_urls: Vec<String> = unique_attachments
141 .iter()
142 .flat_map(|a| a.all_urls().map(str::to_string))
143 .collect();
144
145 let wraps_total = keys.len();
146 let mut wraps_dispatched = 0usize;
147
148 // Layer 1 — relay-level nuke. Only possible when we still hold
149 // retained wrap keys for this rumor.
150 for stored in keys.iter() {
151 let client = client.clone();
152 let wrap_event_id = stored.wrap_event_id;
153 let secret = stored.secret.clone();
154 let relay_urls = stored.relay_urls.clone();
155 crate::db::spawn_bound(async move {
156 delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
157 if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
158 crate::log_warn!("[NIP-17 delete] failed to purge wrap key: {}", e);
159 }
160 });
161 wraps_dispatched += 1;
162 }
163
164 // Layer 2 — cooperative hide. Always send a notice if we know the
165 // recipient: tells live Vector clients to drop their local copy.
166 // Prefer the recipient pubkey from a retained wrap key if we have
167 // one (recipient role); fall back to the chat counterpart from
168 // STATE. The notice itself is signed by our main key, so this
169 // works even when retained wrap keys are missing.
170 let cooperative_recipient = keys
171 .iter()
172 .find(|k| {
173 k.role == crate::db::nip17_keys::WrapRole::Recipient
174 || k.role == crate::db::nip17_keys::WrapRole::Retry
175 })
176 .map(|k| k.recipient_pubkey)
177 .or(recipient_from_state);
178
179 let mut cooperative_hide_sent = false;
180 if let Some(recipient) = cooperative_recipient {
181 match publish_cooperative_hide(&client, rumor_id, &recipient, 14).await {
182 Ok(()) => cooperative_hide_sent = true,
183 Err(e) => crate::log_warn!("[NIP-17 delete] cooperative-hide notice failed: {}", e),
184 }
185 }
186
187 // Layer 3 — Blossom blob delete. Route through the active client
188 // signer so bunker accounts sign DELETE auth under the user's
189 // identity (Blossom enforces "uploader == authorized signer";
190 // signing with the NIP-46 client keypair returns 401).
191 let mut blobs_dispatched = 0usize;
192 if !attachment_urls.is_empty() {
193 if let Ok(signer) = crate::signer::active_signer() {
194 blobs_dispatched = attachment_urls.len();
195 crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
196 }
197 }
198
199 let any_network_action =
200 wraps_dispatched > 0 || cooperative_hide_sent || blobs_dispatched > 0;
201
202 Ok(DeleteOutcome {
203 wraps_total,
204 wraps_dispatched,
205 cooperative_hide_sent,
206 blobs_dispatched,
207 any_network_action,
208 })
209}
210
211/// Revoke one of OUR OWN DM reactions. Mirrors `delete_own_dm` but the
212/// target is a kind-7 reaction rumor: Layer 1 nukes each retained gift-wrap
213/// from its relays via the stored ephemeral key; Layer 2 sends a cooperative
214/// hide (k=7) to the counterpart + self so live clients drop the chip. No
215/// Blossom layer — reactions carry no attachments. `recipient` is the DM
216/// counterpart, used for the cooperative hide when no recipient-role wrap key
217/// is retained (e.g. only the self-wrap survived).
218pub async fn delete_own_reaction(
219 reaction_id: &EventId,
220 recipient: PublicKey,
221) -> Result<DeleteOutcome, String> {
222 let client = nostr_client().ok_or("Not logged in")?;
223 let keys = crate::db::nip17_keys::get_wrap_keys_for_rumor(reaction_id)
224 .unwrap_or_default();
225
226 let wraps_total = keys.len();
227 let mut wraps_dispatched = 0usize;
228
229 // Layer 1 — relay-level nuke per retained wrap key.
230 for stored in keys.iter() {
231 let client = client.clone();
232 let wrap_event_id = stored.wrap_event_id;
233 let secret = stored.secret.clone();
234 let relay_urls = stored.relay_urls.clone();
235 crate::db::spawn_bound(async move {
236 delete_wrap_per_relay(&client, wrap_event_id, secret, relay_urls).await;
237 if let Err(e) = crate::db::nip17_keys::purge_wrap_keys(&[wrap_event_id]) {
238 crate::log_warn!("[reaction delete] failed to purge wrap key: {}", e);
239 }
240 });
241 wraps_dispatched += 1;
242 }
243
244 // Layer 2 — cooperative hide (k=7). Prefer a recipient pulled from a
245 // retained recipient/retry wrap key; fall back to the passed-in
246 // counterpart. The notice is signed by our main key, so it works even
247 // when no wrap keys survive (pre-retention reactions stay undeletable at
248 // the relay layer, but a live counterpart still drops the chip).
249 let cooperative_recipient = keys
250 .iter()
251 .find(|k| {
252 k.role == crate::db::nip17_keys::WrapRole::Recipient
253 || k.role == crate::db::nip17_keys::WrapRole::Retry
254 })
255 .map(|k| k.recipient_pubkey)
256 .unwrap_or(recipient);
257
258 let mut cooperative_hide_sent = false;
259 match publish_cooperative_hide(&client, reaction_id, &cooperative_recipient, 7).await {
260 Ok(()) => cooperative_hide_sent = true,
261 Err(e) => crate::log_warn!("[reaction delete] cooperative-hide failed: {}", e),
262 }
263
264 let any_network_action = wraps_dispatched > 0 || cooperative_hide_sent;
265 Ok(DeleteOutcome {
266 wraps_total,
267 wraps_dispatched,
268 cooperative_hide_sent,
269 blobs_dispatched: 0,
270 any_network_action,
271 })
272}
273
274/// Per-relay deletion for a single wrap. Subscribes to the wrap's
275/// publish tracker and fires NIP-09 to each relay as soon as that
276/// relay confirms receiving the wrap. Relays where the publish failed
277/// don't get NIP-09 (no event there to delete).
278///
279/// If no live tracker exists (cross-restart: the original publishes
280/// completed in a previous session), falls back to a best-effort
281/// broadcast against every targeted relay. Relays that don't have
282/// the wrap will no-op the deletion; that's safe.
283async fn delete_wrap_per_relay(
284 client: &Client,
285 wrap_event_id: EventId,
286 secret: SecretKey,
287 targeted_relays: Vec<String>,
288) {
289 let ephemeral_keys = Keys::new(secret);
290 let deletion = match EventBuilder::new(Kind::EventDeletion, "")
291 .tag(Tag::event(wrap_event_id))
292 .tag(Tag::custom("k", ["1059"]))
293 .finalize(&ephemeral_keys)
294 {
295 Ok(ev) => ev,
296 Err(e) => {
297 eprintln!(
298 "[NIP-17 delete] failed to sign deletion for wrap {}: {}",
299 wrap_event_id.to_hex(),
300 e
301 );
302 return;
303 }
304 };
305
306 if let Some(tracker) = get_publish_tracker(&wrap_event_id) {
307 // Live tracker: walk the success stream as relays settle.
308 let mut delivered = 0usize;
309 let mut cursor = 0usize;
310 while let Some(url) = tracker.next_success(&mut cursor).await {
311 if send_to_one_relay(client, &url, &deletion).await {
312 delivered += 1;
313 }
314 }
315 crate::log_info!(
316 "[NIP-17 delete] wrap {} — NIP-09 delivered to {} relay(s) via tracker",
317 wrap_event_id.to_hex(),
318 delivered
319 );
320 } else {
321 // No tracker (cross-restart or already-GC'd). Best-effort
322 // broadcast: fire NIP-09 at every targeted relay; relays
323 // that lack the wrap silently no-op.
324 let urls: Vec<RelayUrl> = targeted_relays
325 .iter()
326 .filter_map(|s| RelayUrl::parse(s).ok())
327 .collect();
328 let total = urls.len();
329 let mut delivered = 0usize;
330 for url in urls {
331 if send_to_one_relay(client, &url, &deletion).await {
332 delivered += 1;
333 }
334 }
335 crate::log_info!(
336 "[NIP-17 delete] wrap {} — fallback broadcast: NIP-09 delivered to {}/{} relay(s)",
337 wrap_event_id.to_hex(),
338 delivered,
339 total
340 );
341 }
342}
343
344/// Best-effort delete of every cached attachment file for a message.
345/// Only unlinks files that canonicalize to a path under Vector's
346/// managed download directory — files the user has moved or copied
347/// elsewhere are never touched. Symlink and `..` escape attempts are
348/// rejected by the canonicalize check.
349///
350/// Used by both sender-initiated deletes (so the user's own cached
351/// copy disappears alongside the network deletion) and cooperative-
352/// hide receivers (so the recipient's downloaded copy goes when the
353/// sender asks for the message to disappear). Mirrors the
354/// "delete-for-everyone" semantics of iMessage/Signal at the file
355/// layer, scoped to Vector's own cache.
356pub fn delete_cached_attachment_files_pub(attachments: &[crate::types::Attachment]) {
357 delete_cached_attachment_files(attachments);
358}
359
360/// Filter `attachments` down to those NOT referenced by any OTHER
361/// undeleted message in STATE.
362///
363/// Vector dedupes uploads by SHA-256 hash: re-sending the same file
364/// reuses the on-disk cache + the same Blossom URL across multiple
365/// messages. Without this filter, deleting one of those messages
366/// would unlink the cached file (or DELETE the Blossom blob) even
367/// though sibling messages still reference it — the user's other
368/// copies would 404 and lose their local preview.
369///
370/// `excluding_message_id` is the id of the message we're deleting,
371/// so it doesn't count as a "reference" to itself when determining
372/// whether the attachment is shared.
373pub async fn filter_unreferenced_attachments(
374 excluding_message_id: &str,
375 attachments: Vec<crate::types::Attachment>,
376) -> Vec<crate::types::Attachment> {
377 if attachments.is_empty() {
378 return attachments;
379 }
380 let state = crate::state::STATE.lock().await;
381 attachments
382 .into_iter()
383 .filter(|att| {
384 // Dedup key: the attachment's SHA-256 (Attachment.id).
385 // Empty id can't be matched, so treat as unique.
386 let hash = &*att.id;
387 if hash.is_empty() {
388 return true;
389 }
390 let referenced_elsewhere = state.chats.iter().any(|chat| {
391 chat.iter_compact().any(|m| {
392 let msg_id_hex = m.id_hex();
393 msg_id_hex != excluding_message_id
394 && m.attachments.iter().any(|a| a.id_eq(hash))
395 })
396 });
397 !referenced_elsewhere
398 })
399 .collect()
400}
401
402fn delete_cached_attachment_files(attachments: &[crate::types::Attachment]) {
403 if attachments.is_empty() {
404 return;
405 }
406 let download_dir = match crate::db::get_download_dir().canonicalize() {
407 Ok(d) => d,
408 Err(_) => return,
409 };
410 for att in attachments {
411 if att.path.is_empty() {
412 continue;
413 }
414 let candidate = match std::path::PathBuf::from(&*att.path).canonicalize() {
415 Ok(p) => p,
416 // File already gone, or path unresolvable — nothing to do.
417 Err(_) => continue,
418 };
419 if !candidate.starts_with(&download_dir) {
420 // User-managed path (moved/copied out of Vector's cache);
421 // never touch.
422 continue;
423 }
424 if let Err(e) = std::fs::remove_file(&candidate) {
425 crate::log_warn!(
426 "[delete] failed to remove cached attachment {}: {}",
427 candidate.display(),
428 e
429 );
430 }
431 }
432}
433
434/// Direct publish to a single relay handle. Returns true if the relay
435/// acknowledged. Returns false if the relay isn't in our pool, the
436/// publish hit a non-rate-limit error, or rate-limit retries were
437/// exhausted.
438///
439/// Per-URL outcome is logged so the user can pinpoint which relay is
440/// keeping a wrap alive after deletion — relays that ACK a NIP-09 but
441/// don't actually drop the event are non-compliant; the
442/// `verify_relay_dropped` probe scheduled below is the receipt that
443/// identifies them.
444///
445/// Rate-limit handling: relays like damus.io will reject NIP-09s with
446/// "rate-limited: you are noting too much" when the user deletes a
447/// few messages in quick succession. The deletion isn't a real
448/// failure, just back-pressure — so we wait and retry up to
449/// `MAX_RATELIMIT_RETRIES` times (each retry sleeps 30s). The whole
450/// loop runs inside the per-relay deletion task (already spawned), so
451/// the user's UX is unaffected; the wrap stays on the relay only
452/// until we get through.
453///
454/// On successful ACK, schedules a verification probe (~2s later) that
455/// re-queries the relay for the original wrap event id and reports
456/// whether the relay actually honored the deletion. Catches relays
457/// that lie about NIP-09 compliance.
458async fn send_to_one_relay(client: &Client, url: &RelayUrl, event: &Event) -> bool {
459 /// Max attempts to push past a rate-limit. With 30s between
460 /// attempts that's a 10-minute window — generous for any sane
461 /// per-IP rate limit. If the relay is still rate-limiting us
462 /// after that, something else is wrong and we give up so the
463 /// task doesn't loop forever.
464 const MAX_RATELIMIT_RETRIES: u32 = 20;
465 /// Pause between rate-limit retries.
466 const RATELIMIT_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
467
468 let pool = client;
469 let relays = pool.relays().await;
470 let relay = match relays.get(url) {
471 Some(r) => r.clone(),
472 None => {
473 crate::log_warn!("[delete] relay {} not in pool — NIP-09 not delivered", url);
474 return false;
475 }
476 };
477 drop(relays);
478
479 let mut retries = 0u32;
480 loop {
481 match relay.send_event(event).await {
482 Ok(_) => {
483 if retries == 0 {
484 crate::log_info!("[delete] relay {} ACK'd NIP-09", url);
485 } else {
486 crate::log_info!(
487 "[delete] relay {} ACK'd NIP-09 (after {} retr{})",
488 url,
489 retries,
490 if retries == 1 { "y" } else { "ies" }
491 );
492 }
493 if let Some(wrap_id) = extract_target_event_id(event) {
494 let url_clone = url.clone();
495 let client_clone = client.clone();
496 crate::db::spawn_bound(async move {
497 verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
498 });
499 }
500 return true;
501 }
502 Err(e) => {
503 let err_str = e.to_string();
504 let lc = err_str.to_ascii_lowercase();
505 let is_rate_limit = lc.contains("rate-limit")
506 || lc.contains("rate limit")
507 || lc.contains("noting too much");
508 let is_transient = lc.contains("timeout")
509 || lc.contains("timed out")
510 || lc.contains("connection reset")
511 || lc.contains("connection refused")
512 || lc.contains("connection closed")
513 || lc.contains("broken pipe")
514 || lc.contains("not connected");
515 let retryable = is_rate_limit || is_transient;
516 if retryable && retries < MAX_RATELIMIT_RETRIES {
517 retries += 1;
518 let reason = if is_rate_limit { "rate-limited" } else { "transient error" };
519 crate::log_warn!(
520 "[delete] relay {} {} (attempt {}/{}; err: {}); waiting {}s",
521 url,
522 reason,
523 retries,
524 MAX_RATELIMIT_RETRIES,
525 err_str,
526 RATELIMIT_BACKOFF.as_secs()
527 );
528 tokio::time::sleep(RATELIMIT_BACKOFF).await;
529 continue;
530 }
531 if retryable {
532 crate::log_warn!(
533 "[delete] relay {} still failing after {} retries ({}); scheduling verify probe in case it eventually accepted",
534 url,
535 retries,
536 err_str
537 );
538 // Even though our publish never ACK'd, the relay may
539 // have actually received and processed the event
540 // (timeouts often mean "ACK lost on the way back").
541 // Schedule a verify probe so we still log whether the
542 // wrap is gone.
543 if let Some(wrap_id) = extract_target_event_id(event) {
544 let url_clone = url.clone();
545 let client_clone = client.clone();
546 crate::db::spawn_bound(async move {
547 verify_relay_dropped(&client_clone, &url_clone, &wrap_id).await;
548 });
549 }
550 } else {
551 crate::log_warn!("[delete] relay {} rejected NIP-09: {}", url, err_str);
552 }
553 return false;
554 }
555 }
556 }
557}
558
559/// Pull the target event id from a NIP-09 deletion event's first
560/// `["e", ...]` tag. Used by the verification probe to know which
561/// wrap to look for after asking the relay to delete it.
562fn extract_target_event_id(deletion: &Event) -> Option<EventId> {
563 deletion.tags.iter().find_map(|tag| {
564 let s = tag.as_slice();
565 if s.len() >= 2 && s[0] == "e" {
566 EventId::from_hex(&s[1]).ok()
567 } else {
568 None
569 }
570 })
571}
572
573/// 2s after a relay ACKs our NIP-09, ask it whether the target wrap
574/// is actually gone. Logs a clear "GONE" or "STILL PRESENT" so we can
575/// identify non-compliant relays without bisecting via external tools.
576async fn verify_relay_dropped(client: &Client, url: &RelayUrl, wrap_event_id: &EventId) {
577 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
578
579 let pool = client;
580 let relays = pool.relays().await;
581 let relay = match relays.get(url) {
582 Some(r) => r.clone(),
583 None => return,
584 };
585
586 let filter = Filter::new().id(*wrap_event_id);
587 match relay
588 .fetch_events(filter)
589 .timeout(std::time::Duration::from_secs(5))
590 .policy(ReqExitPolicy::ExitOnEOSE)
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("k", [original_kind.to_string()]))
644 .tag(Tag::expiration(Timestamp::from(expiration_ts)))
645 .finalize_unsigned_with_id(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}