From 7e7d8e5325ba71b5f2f8a5523c3308a187743e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 19:40:00 +0000 Subject: [PATCH 01/10] feat: private reactions on unsealed rumors + leak prevention for private notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unsealed NIP-59 rumors (private replies/posts arriving in gift wraps from other clients) were indexed as ordinary notes: public reactions, reposts, edits, pins, OTS timestamps, labels, public bookmarks, and deletion requests could all e-tag the private rumor id onto public relays. - Note.isPrivateRumor(): empty-signature discriminator (rumors are the only notes materialized with an empty sig; draft inners are never indexed as standalone notes) - ReactionAction: reactions inherit the target's privacy — empty-sig targets get gift-wrapped kind-7s fanned to the rumor author, every tagged user, and the sender's self-copy (add-only; un-react would need a public NIP-09 deletion that leaks the rumor id) - AccountViewModel.reactToOrDelete: never NIP-09-delete rumor reactions (also fixes the same leak for existing NIP-17 chat reactions), tracked-broadcast mode excluded for rumor targets - ReactionsRow: hide reply/boost/zap on private rumors (each publishes a public e-tag of the target); like stays, now wrapped - DropDownMenu/NoteQuickActionMenu: hide broadcast, edit, timestamp, pin, hashtag label, public bookmarks, deletion request for rumors; private bookmarks and block/report stay available - Lock badge in the note header (reuses existing Lock glyph, no font regen needed) Covered by ReactionActionTest (public vs rumor fan-out, jvmTest green). Plan: commons/plans/2026-06-10-private-replies-reactions-posts.md https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/ui/note/NoteCompose.kt | 14 +++ .../amethyst/ui/note/NoteQuickActionMenu.kt | 20 ++-- .../amethyst/ui/note/ReactionsRow.kt | 41 ++++--- .../amethyst/ui/note/elements/DropDownMenu.kt | 78 ++++++++----- .../ui/screen/loggedIn/AccountViewModel.kt | 9 +- amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/commons/model/Note.kt | 10 ++ .../model/nip25Reactions/ReactionAction.kt | 28 +++-- .../nip25Reactions/ReactionActionTest.kt | 107 ++++++++++++++++++ 10 files changed, 249 insertions(+), 65 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4d9bd5df60..417ebcd712 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -732,8 +732,10 @@ class Account( val eventHint = note.toEventHint() ?: return null - // For NIP-17 private groups, we don't support tracked mode (too complex) - if (eventHint.event is NIP17Group) return null + // For NIP-17 private groups, we don't support tracked mode (too complex). + // Unsealed rumors (empty sig) must never get a public reaction — + // the e-tag would leak the private rumor id to public relays. + if (eventHint.event is NIP17Group || eventHint.event.sig.isEmpty()) return null val event = ReactionAction.reactTo(eventHint, reaction, signer) val relays = computeRelayListToBroadcast(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b33649a6a0..ddf088f174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1752,6 +1752,10 @@ fun FirstUserInfoRow( DisplayDraft() } + if (baseNote.isPrivateRumor()) { + PrivateRumorMark() + } + if (isPinned) { PinnedMark() } @@ -1780,6 +1784,16 @@ fun PinnedMark() { ) } +@Composable +fun PrivateRumorMark() { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = stringRes(R.string.private_rumor_mark), + modifier = Modifier.padding(start = 5.dp).size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} + @Composable fun JumpToParentReplyButton( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7591b7d582..5dda5bfab2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -385,14 +385,18 @@ fun CardBody( } } - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - icon = MaterialSymbols.Dns, - label = stringRes(R.string.broadcast), - ) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() + // Unsealed rumors are unsigned and private — rebroadcasting one + // to public relays is never valid. + if (!note.isPrivateRumor()) { + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + icon = MaterialSymbols.Dns, + label = stringRes(R.string.broadcast), + ) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } } VerticalDivider(color = primaryLight) if (isOwnNote && note.isDraft()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 2e352ab051..a4015bed95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -259,21 +259,28 @@ private fun InnerReactionRow( }, reactions = reactionRowItems, renderReaction = { item -> + // Unsealed rumors (private replies/posts) must not receive public + // replies, reposts, quotes, or zaps: each would e-tag the private + // rumor id onto public relays. Reactions stay enabled because + // ReactionAction gift-wraps them for empty-sig targets. + val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { - ReplyReactionWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - showCounter = item.showCounter, - voiceRecordingState = voiceRecordingState, - ) + if (!isPrivateRumor) { + ReplyReactionWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + showCounter = item.showCounter, + voiceRecordingState = voiceRecordingState, + ) + } } ReactionRowAction.Boost -> { val isDM = baseNote.event is ChatroomKeyable - if (!isDM) { + if (!isDM && !isPrivateRumor) { BoostWithDialog( baseNote, editState, @@ -296,13 +303,15 @@ private fun InnerReactionRow( } ReactionRowAction.Zap -> { - ZapReaction( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav = nav, - showCounter = item.showCounter, - ) + if (!isPrivateRumor) { + ZapReaction( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav = nav, + showCounter = item.showCounter, + ) + } } ReactionRowAction.Share -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index fd4d2b5695..43184744fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -161,6 +161,12 @@ fun NoteDropDownMenu( val actContext = LocalContext.current val scope = rememberCoroutineScope() + // Unsealed rumors (private replies/posts received in gift wraps) are + // unsigned and must never be referenced by a public event: hide every + // action that would publish an e-tag of this note (broadcast, edit, + // OTS timestamp, pin, label, public bookmark, deletion request). + val isPrivateRumor = note.isPrivateRumor() + // Follow section M3ActionSection { if (!state.isFollowingAuthor) { @@ -241,7 +247,7 @@ fun NoteDropDownMenu( nav.nav { routeEditDraftTo(note, accountViewModel.account) } } } - if (!note.isDraft()) { + if (!note.isDraft() && !isPrivateRumor) { if (note.event is TextNoteEvent) { if (state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) { @@ -258,23 +264,27 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { - accountViewModel.broadcast(note) - onDismiss() + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { + accountViewModel.broadcast(note) + onDismiss() + } } } // Timestamp & Bookmarks section M3ActionSection { - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } - } else { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { - accountViewModel.timestamp(note) - onDismiss() + if (!isPrivateRumor) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } + } else { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { + accountViewModel.timestamp(note) + onDismiss() + } } } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { if (state.isPinnedNote) { M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) { accountViewModel.removePin(note) @@ -287,8 +297,10 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { - addLabelDialogShowing = true + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { + addLabelDialogShowing = true + } } // Pick exactly one curation flow per kind: music tracks go to playlists, emoji // packs go to the emoji list, everything else gets the standard bookmark rows. @@ -323,15 +335,19 @@ fun NoteDropDownMenu( } else -> { - val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) - M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { - if (note.event is LongTextNoteEvent) { - nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) - } else { - nav.nav(Route.PostBookmarkManagement(note.idHex)) + if (!isPrivateRumor) { + val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) + M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { + if (note.event is LongTextNoteEvent) { + nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) + } + onDismiss() } - onDismiss() } + // Private bookmarks are stored inside the list's encrypted + // content, so a private rumor's id stays off public relays. if (state.isPrivateBookmarkNote) { M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { accountViewModel.removePrivateBookmark(note) @@ -343,15 +359,17 @@ fun NoteDropDownMenu( onDismiss() } } - if (state.isPublicBookmarkNote) { - M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { - accountViewModel.removePublicBookmark(note) - onDismiss() - } - } else { - M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { - accountViewModel.addPublicBookmark(note) - onDismiss() + if (!isPrivateRumor) { + if (state.isPublicBookmarkNote) { + M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { + accountViewModel.removePublicBookmark(note) + onDismiss() + } + } else { + M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { + accountViewModel.addPublicBookmark(note) + onDismiss() + } } } } @@ -372,7 +390,7 @@ fun NoteDropDownMenu( } onDismiss() } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index dcfca17eca..eb57ccafe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -487,9 +487,14 @@ class AccountViewModel( launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { - account.delete(currentReactions) + // Gift-wrapped reactions are add-only for now: a public NIP-09 + // deletion would e-tag the private rumor id onto public relays. + val deletable = currentReactions.filter { !it.isPrivateRumor() } + if (deletable.isNotEmpty()) { + account.delete(deletable) + } } else { - if (settings.useTrackedBroadcasts() && note.event !is NIP17Group) { + if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5845409944..eec58147de 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -869,6 +869,7 @@ Bookmark this article is a public bookmark here is a private bookmark here + Private — only visible to tagged participants is not a bookmark here Remove bookmark from list Add bookmark to list diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 7c61246550..5d3ece2d1d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -325,6 +325,16 @@ open class Note( fun isDraft() = event is DraftWrapEvent + /** + * True when this note's event is an unsealed NIP-59 rumor (a private + * reply, private reaction, or chat message that arrived inside a gift + * wrap). Rumors are unsigned by design — they are materialized with an + * empty signature — so they must never be e-tagged, quoted, reposted, + * or rebroadcast on public relays: any public event referencing this + * note's id leaks the private rumor id. + */ + fun isPrivateRumor() = event?.sig?.isEmpty() == true + fun loadEvent( event: Event, author: User, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index 5a1511b74c..fe4b41d552 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.commons.model.nip25Reactions import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -82,10 +84,14 @@ object ReactionAction { ): ReactionEvent = reactTo(event, "+", signer) /** - * Advanced: React to an event with support for NIP-17 private groups. + * Advanced: React to an event with support for NIP-17 private groups + * and unsealed rumors (private replies/posts in the public feed). * - * This method handles both public and private group reactions: + * This method handles both public and private reactions: * - For NIP17Group events: Creates private reactions within the group + * - For unsealed rumors (empty signature): Creates gift-wrapped + * reactions fanned out to the rumor's author and every tagged user, + * so the private rumor id never lands on a public relay * - For regular events: Creates public reactions * * @param event The event to react to @@ -108,10 +114,18 @@ object ReactionAction { val event = eventHint.event - // Check if this is a NIP-17 private group event - if (event is NIP17Group) { - val users = event.groupMembers().toList() + // Privacy is inherited from the target: reactions to private group + // messages and to unsealed rumors must themselves be gift-wrapped. + // createWraps adds the sender's self-copy back, so removing the + // signer here only avoids a redundant entry. + val privateRecipients: List? = + when { + event is NIP17Group -> event.groupMembers().toList() + event.sig.isEmpty() -> (event.taggedUserIds() + event.pubKey).distinct().minus(signer.pubKey) + else -> null + } + if (privateRecipients != null) { // Handle custom emoji reactions in groups if (reaction.startsWith(":")) { val emojiUrl = EmojiUrlTag.decode(reaction) @@ -120,7 +134,7 @@ object ReactionAction { NIP17Factory().createReactionWithinGroup( emojiUrl = emojiUrl, originalNote = eventHint, - to = users, + to = privateRecipients, signer = signer, ), ) @@ -133,7 +147,7 @@ object ReactionAction { NIP17Factory().createReactionWithinGroup( content = reaction, originalNote = eventHint, - to = users, + to = privateRecipients, signer = signer, ), ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt new file mode 100644 index 0000000000..c17e2aed36 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip25Reactions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +class ReactionActionTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + private val carolPriv = "0000000000000000000000000000000000000000000000000000000000000009" + private val carolSigner = NostrSignerInternal(KeyPair(carolPriv.hexToByteArray())) + + @Test + fun reactionToPublicNote_isPublic() = + runTest { + val note = aliceSigner.sign(TextNoteEvent.build("hello world")) + + var publicCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(note, null), + reaction = "+", + signer = bobSigner, + onPublic = { reaction -> + publicCalls++ + assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed") + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id }) + }, + onPrivate = { fail("reaction to a public note must not be gift-wrapped") }, + ) + assertEquals(1, publicCalls) + } + + @Test + fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() = + runTest { + // Alice's private reply (rumor) tagging Bob and Carol. Receivers + // materialize rumors with an empty signature. + val signed = + aliceSigner.sign( + TextNoteEvent.build("private reply") { + pTags( + listOf( + PTag(bobSigner.pubKey, null), + PTag(carolSigner.pubKey, null), + ), + ) + }, + ) + val rumor = TextNoteEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, "") + + // Bob reacts: the reaction must be wrapped to every participant + // (Alice the author, Carol the other p-tag, and Bob's self-copy) + // and never reach the public callback. + var privateCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(rumor, null), + reaction = "+", + signer = bobSigner, + onPublic = { fail("reaction to a rumor must not be public: its e-tag would leak the rumor id") }, + onPrivate = { result -> + privateCalls++ + assertTrue(result.msg.tags.any { it.size >= 2 && it[0] == "e" && it[1] == rumor.id }) + + val recipients = result.wraps.mapNotNull { it.recipientPubKey() }.toSet() + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey, carolSigner.pubKey), + recipients, + "wraps must cover the rumor author, every tagged user, and the sender's self-copy", + ) + }, + ) + assertEquals(1, privateCalls) + } +} From 0fc81cf79ddcebe455c15775c8b615c73d8720f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:17:40 +0000 Subject: [PATCH 02/10] feat: compose private replies and private posts via NIP-17 gift wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the private-notes plan: the short-note composer gains a private (lock) toggle that gift-wraps the kind-1 to its p-tagged users plus a self-copy instead of publishing it. - NIP17Factory.createNoteNIP17: wraps a TextNoteEvent template to its taggedUserIds + the sender (only the unsigned rumor form travels) - Account.sendPrivateNote: signs, wraps, and routes each wrap to the recipient's DM relays via the existing broadcastPrivately path - ShortNotePostViewModel: wantsPrivateNote/privateNoteLocked state; forced ON and locked when replying to an unsealed rumor (and when reloading a drafted private reply); private wins over anonymous and scheduled modes so a locked reply can never fall through to a public publish path - ShortNotePostScreen: lock toggle in the bottom action row; mutually exclusive with polls; schedule and anonymous hidden while private - ReactionsRow: reply re-enabled on private rumors now that the composer locks privacy for them Drafts stay enabled: TextNoteEvent does not implement ExposeInDraft, so draft wrappers carry no anchor e-tags — the parent rumor id only exists inside the NIP-44 encrypted draft content. Verified by PrivateNoteFactoryTest: wraps cover p-tags + self, and the recipient's unwrap yields a rumor with the same id and an empty sig (the Note.isPrivateRumor() discriminator). https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 12 +++ .../amethyst/ui/note/ReactionsRow.kt | 25 +++--- .../loggedIn/home/ShortNotePostScreen.kt | 52 ++++++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 36 +++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../commons/actions/PrivateNoteFactoryTest.kt | 78 +++++++++++++++++++ .../quartz/nip17Dm/NIP17Factory.kt | 21 +++++ 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 417ebcd712..b1e3ba8258 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2247,6 +2247,18 @@ class Account( broadcastPrivately(events) } + /** + * Publishes a kind-1 note privately: signs the template, then gift-wraps + * the rumor to every p-tagged user plus a self-copy and sends each wrap + * to the recipient's DM relays. Used for private replies (the parent's + * author and participants are already p-tagged) and for private posts + * (the Notify list is the audience). Nothing reaches public relays. + */ + suspend fun sendPrivateNote(template: EventTemplate) { + if (!isWriteable()) return + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) + } + override suspend fun sendGiftWraps(wraps: List) { wraps.forEach { wrap -> val relayList = computeRelayListToBroadcast(wrap) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a4015bed95..097d628b32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -260,22 +260,21 @@ private fun InnerReactionRow( reactions = reactionRowItems, renderReaction = { item -> // Unsealed rumors (private replies/posts) must not receive public - // replies, reposts, quotes, or zaps: each would e-tag the private - // rumor id onto public relays. Reactions stay enabled because - // ReactionAction gift-wraps them for empty-sig targets. + // reposts, quotes, or zaps: each would e-tag the private rumor id + // onto public relays. Replies and reactions stay enabled because + // the composer locks private mode for rumor parents and + // ReactionAction gift-wraps reactions to empty-sig targets. val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { - if (!isPrivateRumor) { - ReplyReactionWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - showCounter = item.showCounter, - voiceRecordingState = voiceRecordingState, - ) - } + ReplyReactionWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + showCounter = item.showCounter, + voiceRecordingState = voiceRecordingState, + ) } ReactionRowAction.Boost -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 01d66f089e..7eeaf1723d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -332,7 +332,11 @@ private fun NewPostScreenBody( Box( modifier = Modifier.clickable { - postViewModel.wantsAnonymousPost = true + // Private notes are wrapped with the real key — the + // recipients must know who is talking to them. + if (!postViewModel.wantsPrivateNote) { + postViewModel.wantsAnonymousPost = true + } }, ) { BaseUserPicture( @@ -708,7 +712,18 @@ private fun BottomRowActions( maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) - if (postViewModel.canUsePoll || postViewModel.canUseZapPoll) { + // Polls publish kinds that can't travel inside a private wrap, so the + // two toggles are mutually exclusive. + if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll) { + AddPrivateNoteButton( + isActive = postViewModel.wantsPrivateNote, + isLocked = postViewModel.privateNoteLocked, + ) { + postViewModel.togglePrivateNote() + } + } + + if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && !postViewModel.wantsPrivateNote) { AddPollButton(postViewModel.wantsPoll || postViewModel.wantsZapPoll) { val isActive = postViewModel.wantsPoll || postViewModel.wantsZapPoll if (isActive) { @@ -738,7 +753,11 @@ private fun BottomRowActions( postViewModel.toggleExpirationDate() } - ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) + // Private wraps are built and sent immediately; scheduling them would + // require wrapping at publish time, so the option is hidden for now. + if (!postViewModel.wantsPrivateNote) { + ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) + } AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash @@ -767,6 +786,33 @@ private fun BottomRowActionsPreview() { } } +@Composable +private fun AddPrivateNoteButton( + isActive: Boolean, + isLocked: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + enabled = !isLocked, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = + stringRes( + id = + when { + isLocked -> R.string.private_note_locked + isActive -> R.string.disable_private_note + else -> R.string.private_note + }, + ), + modifier = Modifier.height(22.dp), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 19852adb1f..214e53d88c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -310,6 +310,18 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // Private (gift-wrapped) note: instead of publishing, the kind-1 is + // wrapped to every p-tagged user plus a self-copy and sent to their DM + // relays. Locked ON when replying to an unsealed rumor — a public reply + // would e-tag the parent's private id onto public relays. + var wantsPrivateNote by mutableStateOf(false) + var privateNoteLocked by mutableStateOf(false) + + fun togglePrivateNote() { + if (privateNoteLocked) return + wantsPrivateNote = !wantsPrivateNote + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -453,6 +465,8 @@ open class ShortNotePostViewModel : } } else { originalNote = replyingTo + privateNoteLocked = replyingTo?.isPrivateRumor() == true + wantsPrivateNote = privateNoteLocked replyingTo?.let { replyNote -> if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -650,6 +664,12 @@ open class ShortNotePostViewModel : canUsePoll = originalNote == null canUseZapPoll = originalNote == null + // A drafted private reply must come back locked private: the parent + // rumor's id is inside the draft's e-tags, and posting it publicly + // would leak that id. + privateNoteLocked = originalNote?.isPrivateRumor() == true + wantsPrivateNote = privateNoteLocked + if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } @@ -848,8 +868,22 @@ open class ShortNotePostViewModel : val version = draftTag.current val anonymous = wantsAnonymousPost val scheduledFor = scheduledForSec + val privately = wantsPrivateNote cancel() + if (privately && template.kind == TextNoteEvent.KIND) { + // Gift-wrap to the p-tagged users instead of publishing. Private + // wins over the anonymous and scheduled modes: a locked private + // reply must never fall through to a public publish path (the UI + // hides those toggles while private mode is on). + @Suppress("UNCHECKED_CAST") + accountViewModel.account.sendPrivateNote(template as EventTemplate) + accountViewModel.launchSigner { + accountViewModel.account.deleteDraftIgnoreErrors(version) + } + return + } + if (scheduledFor != null && !anonymous) { // Re-stamp the template with created_at = scheduled time so the post, // when published later, shows up at its scheduled moment in feeds @@ -1250,6 +1284,8 @@ open class ShortNotePostViewModel : wantsAnonymousPost = false anonymousSignerCache = null scheduledForSec = null + wantsPrivateNote = false + privateNoteLocked = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eec58147de..c933180082 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -870,6 +870,9 @@ is a public bookmark here is a private bookmark here Private — only visible to tagged participants + Make private: gift-wrap the note to the notified users only + Make public + Replies to a private note always stay private is not a bookmark here Remove bookmark from list Add bookmark to list diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt new file mode 100644 index 0000000000..6ef4c3533f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * End-to-end check of the private note path: a kind-1 template is wrapped + * to its p-tagged users plus the sender's self-copy, and a recipient who + * unwraps it lands on a rumor with the same id and an EMPTY signature — + * the discriminator Note.isPrivateRumor() relies on. + */ +class PrivateNoteFactoryTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun privateNote_wrapsToTaggedUsersAndSelf_andUnwrapsToEmptySigRumor() = + runTest { + val template = + TextNoteEvent.build("for your eyes only") { + pTags(listOf(PTag(bobSigner.pubKey, null))) + } + + val result = NIP17Factory().createNoteNIP17(template, aliceSigner) + + assertEquals(TextNoteEvent.KIND, result.msg.kind) + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey), + result.wraps.mapNotNull { it.recipientPubKey() }.toSet(), + "wraps must cover every p-tagged user plus the sender's self-copy", + ) + + // Bob unwraps his copy: same note id, but materialized as an + // unsigned rumor. + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + + assertNotNull(rumor, "recipient must be able to unwrap and unseal") + assertEquals(result.msg.id, rumor.id, "rumor id must match the signed inner event's id") + assertEquals(aliceSigner.pubKey, rumor.pubKey) + assertEquals("for your eyes only", rumor.content) + assertTrue(rumor.sig.isEmpty(), "unsealed rumors must carry an empty signature") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 5c1d6e4916..2818dbdaf8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -83,6 +85,25 @@ class NIP17Factory { ) } + /** + * Gift-wraps a kind-1 note (a private reply or private post for the + * public feed) instead of publishing it. Recipients are exactly the + * p-tags carried by the template, plus the sender's self-copy. The + * signed inner event never leaves the device — only its unsigned rumor + * form travels inside the seals. + */ + suspend fun createNoteNIP17( + template: EventTemplate, + signer: NostrSigner, + ): Result { + val senderNote = signer.sign(template) + val wraps = createWraps(senderNote, senderNote.taggedUserIds().plus(signer.pubKey).toSet(), signer) + return Result( + msg = senderNote, + wraps = wraps, + ) + } + suspend fun createEncryptedFileNIP17( template: EventTemplate, signer: NostrSigner, From 880995e17c7edd9c6196d3d2cdb70e674dce044e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:17:44 +0000 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20editable=20Notify=20block=20?= =?UTF-8?q?=E2=80=94=20pick=20receivers=20for=20private=20posts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the private-notes plan: the composer's Notify row gains an '+ Add' chip backed by the existing user-suggestion search, so users can p-tag people who aren't cited in the text — for any post, public or private. While the private toggle is ON the row is always visible, relabeled 'Visible to' (the p-tags ARE the audience of the wrap), and an empty list shows a 'only you will see this' hint for self-only notes. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../ui/note/creators/notify/Notifying.kt | 26 ++++++++++++-- .../loggedIn/home/ShortNotePostScreen.kt | 34 ++++++++++++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 27 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 +++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt index 3f7bd02376..00b361f211 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -52,20 +52,23 @@ import kotlinx.collections.immutable.ImmutableList fun Notifying( baseMentions: ImmutableList?, accountViewModel: AccountViewModel, + label: String? = null, + showWhenEmpty: Boolean = false, + onAddUser: (() -> Unit)? = null, onClick: (User) -> Unit, ) { val mentions = baseMentions?.toSet() FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { + if (!mentions.isNullOrEmpty() || showWhenEmpty) { Text( - stringRes(R.string.reply_notify), + label ?: stringRes(R.string.reply_notify), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.align(CenterVertically), ) - mentions.forEachIndexed { _, user -> + mentions?.forEachIndexed { _, user -> Button( shape = ButtonBorder, colors = @@ -77,6 +80,23 @@ fun Notifying( DisplayUserNameWithDeleteMark(user, accountViewModel) } } + + if (onAddUser != null) { + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = onAddUser, + ) { + Text( + text = stringRes(R.string.notify_add_user), + color = Color.White, + textAlign = TextAlign.Center, + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 7eeaf1723d..67c08f787a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf @@ -306,11 +308,41 @@ private fun NewPostScreenBody( } Row { - Notifying(postViewModel.pTags?.toImmutableList(), accountViewModel) { + Notifying( + baseMentions = postViewModel.pTags?.toImmutableList(), + accountViewModel = accountViewModel, + label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null, + showWhenEmpty = postViewModel.wantsPrivateNote, + onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser }, + ) { postViewModel.removeFromReplyList(it) } } + if (postViewModel.wantsToAddNotifyUser) { + OutlinedThinPaddingTextField( + state = postViewModel.notifyUserSearchText, + onTextChanged = postViewModel::onNotifyUserSearchTextChanged, + label = { Text(text = stringRes(R.string.notify_search_and_add_user)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.zap_split_search_and_add_user_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + } + + if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) { + Text( + text = stringRes(R.string.private_note_no_receivers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 214e53d88c..5eeec24635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -167,6 +167,7 @@ enum class UserSuggestionAnchor { MAIN_MESSAGE, FORWARD_ZAPS, TO_USERS, + NOTIFY, } @Stable @@ -322,6 +323,26 @@ open class ShortNotePostViewModel : wantsPrivateNote = !wantsPrivateNote } + // Notify / Visible-to editor: lets the user p-tag people who aren't + // cited in the message. For private notes the Notify list IS the + // audience, so this is how receivers are picked. + var wantsToAddNotifyUser by mutableStateOf(false) + val notifyUserSearchText = TextFieldState() + + fun onNotifyUserSearchTextChanged() { + if (notifyUserSearchText.selection.collapsed) { + val lastWord = notifyUserSearchText.text.toString() + userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY + userSuggestions?.processCurrentWord(lastWord) + } + } + + fun addToReplyList(user: User) { + if (pTags?.contains(user) != true) { + pTags = (pTags ?: emptyList()).plus(user) + } + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -1286,6 +1307,8 @@ open class ShortNotePostViewModel : scheduledForSec = null wantsPrivateNote = false privateNoteLocked = false + wantsToAddNotifyUser = false + notifyUserSearchText.clearText() forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() @@ -1350,6 +1373,10 @@ open class ShortNotePostViewModel : } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.clearText() + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { + addToReplyList(item) + notifyUserSearchText.clearText() + wantsToAddNotifyUser = false } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c933180082..23413bfa64 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -873,6 +873,10 @@ Make private: gift-wrap the note to the notified users only Make public Replies to a private note always stay private + Visible to + No receivers yet: only you will be able to see this note. Add people to share it with. + + Add + Search and add a user to notify is not a bookmark here Remove bookmark from list Add bookmark to list From 7985377a38f3139afdc6f3bc052f2c933f47ab1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:45:59 +0000 Subject: [PATCH 04/10] feat: private un-react via gift-wrapped deletions + force-private zaps on private notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gift-wrapped un-react: - NIP17Factory.createDeletionNIP17 wraps a NIP-09 deletion to explicit recipients + self-copy, so the retracted rumor id never reaches public relays; DeletionIndex keys by (id, pubkey) and rumor pubkeys are forced to the seal's, so wrapped deletions are authenticated on receive - Account.deletePrivately sends the wrapped deletion to the target rumor's participants (author + tagged users) - AccountViewModel.reactToOrDelete now partitions reactions: public ones get a public NIP-09, rumor reactions get a wrapped one — un-react on private notes and NIP-17 chats works instead of no-op Force-private zaps on private rumors: - AccountViewModel.zap forces ZapType.PRIVATE for empty-sig targets (NONZAP kept: no receipt at all is even more private) - ZapCustomDialog only offers Private/None for private targets - Zap button re-enabled on private rumors; nutzap (public kind 9321) is refused with an explanatory error and the onchain rail is hidden, as both would e-tag the rumor id publicly - Note: the LN provider's public 9735 receipt still carries the e-tag — the private zap type protects sender identity and comment, not the zapped id itself Also verified: ReactionEvent consume counts empty-sig rumors (wasVerified path) so wrapped reactions tally correctly. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 42 +++++++++++-------- .../amethyst/ui/note/ZapCustomDialog.kt | 19 ++++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 38 ++++++++++++++--- amethyst/src/main/res/values/strings.xml | 1 + .../commons/actions/PrivateNoteFactoryTest.kt | 34 +++++++++++++++ .../quartz/nip17Dm/NIP17Factory.kt | 20 +++++++++ 7 files changed, 151 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b1e3ba8258..c3c576ecbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -975,6 +975,28 @@ class Account( } } + /** + * Retracts rumor-only events (private reactions/replies) with a + * gift-wrapped NIP-09 deletion delivered to the same participants as + * the [target] rumor they referenced. A public deletion would e-tag + * the private rumor ids onto public relays. + */ + suspend fun deletePrivately( + notes: List, + target: Note, + ) { + if (!isWriteable()) return + val targetEvent = target.event ?: return + + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } + if (myRumors.isEmpty()) return + + val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey) + broadcastPrivately( + NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer), + ) + } + suspend fun delete( event: Event, additionalRelays: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 097d628b32..e80244fdb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -260,10 +260,11 @@ private fun InnerReactionRow( reactions = reactionRowItems, renderReaction = { item -> // Unsealed rumors (private replies/posts) must not receive public - // reposts, quotes, or zaps: each would e-tag the private rumor id - // onto public relays. Replies and reactions stay enabled because - // the composer locks private mode for rumor parents and - // ReactionAction gift-wraps reactions to empty-sig targets. + // reposts or quotes: each would e-tag the private rumor id onto + // public relays. Replies, reactions, and zaps stay enabled because + // the composer locks private mode for rumor parents, ReactionAction + // gift-wraps reactions to empty-sig targets, and zaps are forced to + // the PRIVATE type with public rails suppressed. val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { @@ -302,15 +303,16 @@ private fun InnerReactionRow( } ReactionRowAction.Zap -> { - if (!isPrivateRumor) { - ZapReaction( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav = nav, - showCounter = item.showCounter, - ) - } + // Zaps stay enabled on private rumors: AccountViewModel.zap + // forces the PRIVATE zap type, and the public nutzap/onchain + // rails are suppressed for them. + ZapReaction( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav = nav, + showCounter = item.showCounter, + ) } ReactionRowAction.Share -> { @@ -1250,10 +1252,16 @@ fun ZapReaction( nav.nav(Route.UpdateZapAmount()) } }, - onOnchainAmount = { amount -> - wantsToZap = false - onchainZapRequest = OnchainZapRequest(amount) - }, + onOnchainAmount = + if (baseNote.isPrivateRumor()) { + // Onchain zap events are public and would e-tag the rumor id. + null + } else { + { amount -> + wantsToZap = false + onchainZapRequest = OnchainZapRequest(amount) + } + }, onError = { _, message, user -> scope.launch { zappingProgress = 0f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5bd7d3624c..c037859a59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -131,6 +131,10 @@ fun ZapCustomDialog( LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } + // Zaps on private rumors are forced private (AccountViewModel.zap), so + // only offer the choices that match what will actually be sent. + val isPrivateTarget = baseNote.isPrivateRumor() + val zapTypes = listOf( Triple( @@ -153,10 +157,21 @@ fun ZapCustomDialog( stringRes(id = R.string.zap_type_nonzap), stringRes(id = R.string.zap_type_nonzap_explainer), ), - ) + ).filter { + !isPrivateTarget || it.first == LnZapEvent.ZapType.PRIVATE || it.first == LnZapEvent.ZapType.NONZAP + } var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.defaultZapType()) } + remember(accountViewModel) { + val default = accountViewModel.defaultZapType() + mutableStateOf( + if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + default + }, + ) + } val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index eb57ccafe3..7458deaebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -487,11 +487,15 @@ class AccountViewModel( launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { - // Gift-wrapped reactions are add-only for now: a public NIP-09 - // deletion would e-tag the private rumor id onto public relays. - val deletable = currentReactions.filter { !it.isPrivateRumor() } - if (deletable.isNotEmpty()) { - account.delete(deletable) + // Gift-wrapped reactions are retracted with a gift-wrapped + // deletion to the same participants — a public NIP-09 would + // e-tag the private rumor id onto public relays. + val (privateRumors, publicReactions) = currentReactions.partition { it.isPrivateRumor() } + if (publicReactions.isNotEmpty()) { + account.delete(publicReactions) + } + if (privateRumors.isNotEmpty()) { + account.deletePrivately(privateRumors, note) } } else { if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { @@ -906,6 +910,18 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + val requestedType = zapType ?: defaultZapType() + + // Zaps on private rumors are forced to PRIVATE so the sender and + // comment stay encrypted. NONZAP is kept: paying without a zap + // request produces no receipt at all, which is even more private. + val effectiveType = + if (note.isPrivateRumor() && requestedType != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + requestedType + } + ZapPaymentHandler(account).zap( note = note, amountMilliSats = amountInMillisats, @@ -917,7 +933,7 @@ class AccountViewModel( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - zapType = zapType ?: defaultZapType(), + zapType = effectiveType, ) } @@ -935,6 +951,16 @@ class AccountViewModel( onError: (String, String, User?) -> Unit, onProgress: (Float) -> Unit = {}, ) = launchSigner { + // Nutzap events (kind 9321) are public and e-tag the zapped note — + // on a private rumor that would leak the rumor id to public relays. + if (baseNote.isPrivateRumor()) { + onError( + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_private_note), + baseNote.author, + ) + return@launchSigner + } val recipient = baseNote.author?.pubkeyHex if (recipient == null) { onError( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 23413bfa64..626953ddeb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2098,6 +2098,7 @@ Nutzap Nutzap failed No recipient pubkey on the note + Nutzaps are public and would reveal this private note. Use a Lightning zap instead. Cannot build event reference Create token Redeem diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt index 6ef4c3533f..29e8d2617e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import kotlinx.coroutines.test.runTest @@ -75,4 +76,37 @@ class PrivateNoteFactoryTest { assertEquals("for your eyes only", rumor.content) assertTrue(rumor.sig.isEmpty(), "unsealed rumors must carry an empty signature") } + + @Test + fun privateDeletion_wrapsToExplicitRecipients_andRetractsTheRumorId() = + runTest { + // Alice retracts a private reaction she previously wrapped to Bob. + val reaction = aliceSigner.sign(TextNoteEvent.build("the rumor being retracted")) + + val result = + NIP17Factory().createDeletionNIP17( + template = DeletionEvent.build(listOf(reaction)), + to = listOf(bobSigner.pubKey), + signer = aliceSigner, + ) + + assertEquals(DeletionEvent.KIND, result.msg.kind) + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey), + result.wraps.mapNotNull { it.recipientPubKey() }.toSet(), + "deletion wraps must cover the explicit recipients plus the sender's self-copy", + ) + + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + + assertNotNull(rumor, "recipient must be able to unwrap and unseal the deletion") + assertEquals(DeletionEvent.KIND, rumor.kind) + assertEquals(aliceSigner.pubKey, rumor.pubKey, "deletions only apply when the author matches") + assertTrue(rumor.sig.isEmpty(), "the deletion travels as an unsigned rumor") + assertTrue( + rumor.tags.any { it.size >= 2 && it[0] == "e" && it[1] == reaction.id }, + "the deletion must e-tag the retracted rumor id (inside the wrap only)", + ) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 2818dbdaf8..62ef1da15d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -117,6 +118,25 @@ class NIP17Factory { ) } + /** + * Gift-wraps a NIP-09 deletion request that retracts rumor-only events + * (private reactions/replies). The deletion must reach the same + * participants the retracted rumor was wrapped to — published publicly + * it would e-tag the private rumor id onto public relays. + */ + suspend fun createDeletionNIP17( + template: EventTemplate, + to: List, + signer: NostrSigner, + ): Result { + val deletion = signer.sign(template) + val wraps = createWraps(deletion, to.plus(signer.pubKey).toSet(), signer) + return Result( + msg = deletion, + wraps = wraps, + ) + } + suspend fun createReactionWithinGroup( content: String, originalNote: EventHintBundle, From dd9ee0b5c68010ccc929f005a7fe3eeb5603bfd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:29:23 +0000 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20close=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20report=20leak,=20model-level=20rumor=20guards,=20hi?= =?UTF-8?q?de=20share/bookmarks=20on=20private=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge-readiness audit follow-ups: - Account.report(note): reporting a private rumor now reports the AUTHOR (p-tag only) instead of publishing a kind-1984 that e-tags the private rumor id onto public relays (the one confirmed leak) - Defense-in-depth guards at the model layer so the invariant no longer relies on UI gating alone: RepostAction.repost returns null / throws for empty-sig targets (covers Account.boost, createBoostEvent, and the desktop call path), ReactionAction.reactTo (simple overload) throws, and Account.broadcast no-ops for unsigned non-wrapped events — without the guard it would disclose the rumor JSON to relays even though they reject the signature - Hide remaining actions that can't work on private rumors, per review: share buttons (action row, both note menus) and all bookmark/playlist/ emoji-list rows (their lists reference an id other devices can't resolve; public lists would also leak it) - ZapCustomDialog: remember(accountViewModel, baseNote) so the preselected zap type can't go stale on lazy-list slot reuse Broadcast of the gift wrap itself (like DMs do via WrappedEvent.host) needs host tracking for non-WrappedEvent rumor kinds in quartz — left as a follow-up; the broadcast row stays hidden for kind-1 rumors. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 16 ++++- .../amethyst/ui/note/NoteQuickActionMenu.kt | 3 + .../amethyst/ui/note/ReactionsRow.kt | 10 +-- .../amethyst/ui/note/ZapCustomDialog.kt | 2 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 68 ++++++++++--------- .../model/nip18Reposts/RepostAction.kt | 6 ++ .../model/nip25Reactions/ReactionAction.kt | 6 ++ 7 files changed, 72 insertions(+), 39 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c3c576ecbe..643c05ea67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -945,7 +945,15 @@ class Account( note: Note, type: ReportType, content: String = "", - ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + ) { + if (note.isPrivateRumor()) { + // A kind-1984 e-tagging the rumor would leak the private id onto + // public relays. Report the author instead (p-tag only). + note.author?.let { report(it, type, content) } + } else { + sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + } + } suspend fun report( user: User, @@ -1320,6 +1328,12 @@ class Account( suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> + if (noteEvent !is WrappedEvent && noteEvent.sig.isEmpty()) { + // Unsealed rumor without a host wrap (e.g. a kind-1 private + // reply): publishing it would disclose the private content to + // relays even though they reject the missing signature. + return + } if (noteEvent is WrappedEvent && noteEvent.host != null) { // download the event and send it. noteEvent.host?.let { host -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 5dda5bfab2..48327b6790 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -406,6 +406,9 @@ fun CardBody( ) { onWantsToEditDraft() } + } else if (note.isPrivateRumor()) { + // No external share link for private rumors: nobody can + // resolve the id from relays and sharing it leaks the id. } else { NoteQuickActionItem( icon = MaterialSymbols.Share, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e80244fdb6..9573f040ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -316,10 +316,12 @@ private fun InnerReactionRow( } ReactionRowAction.Share -> { - ShareReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - ) + if (!isPrivateRumor) { + ShareReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + ) + } } ReactionRowAction.Pay -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index c037859a59..8c29e8e01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -162,7 +162,7 @@ fun ZapCustomDialog( } var selectedZapType by - remember(accountViewModel) { + remember(accountViewModel, baseNote) { val default = accountViewModel.defaultZapType() mutableStateOf( if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 43184744fc..e43869f47d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -226,17 +226,19 @@ fun NoteDropDownMenu( onDismiss() } } - M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) - putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) - } - val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - actContext.startActivity(shareIntent) - onDismiss() + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) + putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + actContext.startActivity(shareIntent) + onDismiss() + } } } @@ -307,6 +309,12 @@ fun NoteDropDownMenu( // Showing both at once is noisy and makes "bookmark" feel like the catch-all when // it really isn't for these kinds. when { + isPrivateRumor -> { + // No bookmark/playlist/emoji-list rows for private rumors: + // those lists reference the note by id, which other devices + // can't resolve from relays and public lists would leak. + } + note.event is MusicTrackEvent && note is AddressableNote -> { // Music tracks (kind 36787) belong in playlists (kind 34139). The // sheet behind this nav lets the user toggle membership across all of @@ -335,19 +343,15 @@ fun NoteDropDownMenu( } else -> { - if (!isPrivateRumor) { - val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) - M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { - if (note.event is LongTextNoteEvent) { - nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) - } else { - nav.nav(Route.PostBookmarkManagement(note.idHex)) - } - onDismiss() + val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) + M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { + if (note.event is LongTextNoteEvent) { + nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) } + onDismiss() } - // Private bookmarks are stored inside the list's encrypted - // content, so a private rumor's id stays off public relays. if (state.isPrivateBookmarkNote) { M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { accountViewModel.removePrivateBookmark(note) @@ -359,17 +363,15 @@ fun NoteDropDownMenu( onDismiss() } } - if (!isPrivateRumor) { - if (state.isPublicBookmarkNote) { - M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { - accountViewModel.removePublicBookmark(note) - onDismiss() - } - } else { - M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { - accountViewModel.addPublicBookmark(note) - onDismiss() - } + if (state.isPublicBookmarkNote) { + M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { + accountViewModel.removePublicBookmark(note) + onDismiss() + } + } else { + M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { + accountViewModel.addPublicBookmark(note) + onDismiss() } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt index 0da9932423..9034277c0a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt @@ -47,6 +47,11 @@ object RepostAction { if (!signer.isWriteable()) { throw IllegalStateException("Cannot repost: signer is not writeable") } + if (eventHint.event.sig.isEmpty()) { + // Unsealed private rumor: a public kind-6/16 would e-tag the + // private rumor id onto public relays. + throw IllegalStateException("Cannot repost a private rumor") + } // Use NIP-18 RepostEvent (kind 6) for text notes (kind 1) // Use GenericRepostEvent (kind 16) for all other kinds @@ -76,6 +81,7 @@ object RepostAction { ): Event? { // All validation in commons if (!signer.isWriteable()) return null + if (note.isPrivateRumor()) return null if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null val hint = note.toEventHint() ?: return null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index fe4b41d552..ffeeaff9d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -57,6 +57,12 @@ object ReactionAction { if (!signer.isWriteable()) { throw IllegalStateException("Cannot react: signer is not writeable") } + if (eventHint.event.sig.isEmpty()) { + // Unsealed private rumor: a public kind-7 would e-tag the private + // rumor id onto public relays. Use reactToWithGroupSupport, which + // gift-wraps reactions for empty-sig targets. + throw IllegalStateException("Cannot react publicly to a private rumor") + } // Handle custom emoji reactions (format: ":emoji_name:") val template = From 1bb48e25df36803c6160e45c46b6a16a1cca461b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 22:39:01 +0000 Subject: [PATCH 06/10] feat: broadcast private rumors as their delivering gift wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings DM-style broadcast to kind-1 private replies. Kind-14 chats carry the kind-1059 host pointer on the event (WrappedEvent); other rumor kinds can't, so LocalCache gains a rumorHosts index (rumor id → wrap HostStub) populated when seals are unsealed and on seal replays after a cache rebuild. - Account.rumorHost(event): host from the event (WrappedEvent) or the index (other rumor kinds) - Account.broadcast: any rumor with a known host re-downloads the wrap by id and republishes it — the unsigned rumor itself is never sent; rumors with no known wrap stay non-broadcastable - Broadcast menu rows reappear for rumors when the wrap is known (AccountViewModel.canBroadcast), restoring the DM behavior the earlier blanket isPrivateRumor gate had also hidden for kind-14s https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 65 ++++++++++++------- .../amethyst/model/LocalCache.kt | 10 +++ .../amethyst/ui/note/NoteQuickActionMenu.kt | 7 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 5 +- .../ui/screen/loggedIn/AccountViewModel.kt | 11 ++++ .../loggedIn/DecryptAndIndexProcessor.kt | 9 +++ 6 files changed, 78 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 643c05ea67..6fa652e9e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -222,6 +222,7 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent @@ -1326,34 +1327,48 @@ class Account( } } + /** + * The kind-1059 gift wrap that delivered [event], when [event] is a + * rumor: WrappedEvent rumors (kind-14 chats) carry it on the event; + * other rumor kinds (kind-1 private replies) are looked up in the + * cache's rumor-host index. + */ + fun rumorHost(event: Event): HostStub? = + if (event is WrappedEvent) { + event.host + } else if (event.sig.isEmpty()) { + cache.rumorHosts.get(event.id) + } else { + null + } + suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> - if (noteEvent !is WrappedEvent && noteEvent.sig.isEmpty()) { - // Unsealed rumor without a host wrap (e.g. a kind-1 private - // reply): publishing it would disclose the private content to - // relays even though they reject the missing signature. + val host = rumorHost(noteEvent) + if (host != null) { + // Rumors are rebroadcast as their delivering wrap: + // download the wrap and send it. + client + .fetchFirst( + filters = + note.relays.associateWith { _ -> + listOf( + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), + ids = listOf(host.id), + ), + ) + }, + )?.let { downloadedEvent -> + val toRelays = computeRelayListToBroadcast(downloadedEvent) + client.publish(downloadedEvent, toRelays) + } + } else if (noteEvent.sig.isEmpty()) { + // Rumor with no known wrap: publishing it would disclose the + // private content to relays even though they reject the + // missing signature. return - } - if (noteEvent is WrappedEvent && noteEvent.host != null) { - // download the event and send it. - noteEvent.host?.let { host -> - client - .fetchFirst( - filters = - note.relays.associateWith { _ -> - listOf( - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ), - ) - }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - client.publish(downloadedEvent, toRelays) - } - } } else { client.publish(noteEvent, computeRelayListToBroadcast(note)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6c8b5dc779..93cb0f0e06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -210,6 +210,7 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -395,6 +396,15 @@ object LocalCache : ILocalCache, ICacheProvider { val deletionIndex = DeletionIndex() + /** + * Rumor id → the kind-1059 gift wrap that delivered it. Lets the + * broadcast action republish the WRAP (never the unsigned rumor) for + * rumor kinds that aren't WrappedEvent subclasses and so can't carry a + * host pointer themselves (e.g. kind-1 private replies). Kind-14 chat + * messages carry the host on the event and don't need this index. + */ + val rumorHosts = LargeCache() + /** * Inverted index over the active [Observable]s. New events fan * out only to observers whose filter actually narrows on a field diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 48327b6790..7d4d6ce281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -385,9 +385,10 @@ fun CardBody( } } - // Unsealed rumors are unsigned and private — rebroadcasting one - // to public relays is never valid. - if (!note.isPrivateRumor()) { + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { VerticalDivider(color = primaryLight) NoteQuickActionItem( icon = MaterialSymbols.Dns, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index e43869f47d..f4043c7f95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -266,7 +266,10 @@ fun NoteDropDownMenu( } } } - if (!isPrivateRumor) { + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { accountViewModel.broadcast(note) onDismiss() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7458deaebd..e4906a91c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1126,6 +1126,17 @@ class AccountViewModel( fun broadcast(note: Note) = launchSigner { account.broadcast(note) } + /** + * Broadcast republishes public events directly and rumors as their + * delivering kind-1059 wrap. A rumor whose wrap is unknown can't be + * broadcast at all — publishing the unsigned event would disclose the + * private content. + */ + fun canBroadcast(note: Note): Boolean { + val event = note.event ?: return false + return event.sig.isNotEmpty() || account.rumorHost(event) != null + } + fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } fun delete(notes: List) = launchSigner { account.delete(notes) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a1e77258de..cb72ec752e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -412,6 +412,9 @@ class SealedRumorEventHandler( if (rumorId == null) { processNewSealedRumor(event, eventNote, publicNote) } else { + // Replayed seal: re-link the rumor to its delivering wrap so + // broadcast can republish the wrap after a cache rebuild. + event.host?.let { cache.rumorHosts.put(rumorId, it) } processExistingSealedRumor(rumorId, publicNote) } } @@ -437,6 +440,12 @@ class SealedRumorEventHandler( eventNote.event = event.copyNoContent() + // Remember which kind-1059 wrap delivered this rumor. Rumor kinds + // that aren't WrappedEvent subclasses (kind-1 private replies) can't + // carry the host pointer on the event, and broadcast must republish + // the wrap — never the unsigned rumor. + event.host?.let { cache.rumorHosts.put(innerRumor.id, it) } + cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) From 3e9fce185805d373fec6b8e7067b6f38778f1e54 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:18:50 +0000 Subject: [PATCH 07/10] refactor: replace WrappedEvent host tracking with the RumorHosts index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery metadata no longer lives on quartz event classes. The mutable host var on @Immutable events (with its dual @Transient annotations) is gone, and any event kind can now be a rumor without subclassing anything — kind-14 chats and kind-1 private replies use one mechanism. - commons RumorHosts: rumor id → delivering envelope (the kind-1059 wrap normally, a bare kind-13 seal otherwise), populated by the gift-wrap ingestion pipeline from the publicNote threaded through the handlers (the seal's host pointer was never needed) - Note.toNEvent cites the envelope for ANY rumor — this also fixes kind-1 private replies, whose nevent previously exposed the private rumor id (kind-14s were already wrap-cited) - Account: rumorHost() reads the index; relay computation refuses seals, inner DM messages, and unsigned rumors explicitly - LocalCache: deleteWraps → deleteEnvelopes (also removes the seal layer the old host-chain walk missed); removeIfWrap and chat-history pruning read the index; index entries are dropped with their rumor - quartz: SealedRumorEvent and BaseDMGroupEvent extend Event directly; GiftWrapEvent.unwrap no longer injects host stubs; WrappedEvent deleted https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 23 +++--- .../amethyst/model/LocalCache.kt | 81 ++++++++++--------- .../loggedIn/DecryptAndIndexProcessor.kt | 17 ++-- .../amethyst/commons/model/Note.kt | 26 +++--- .../commons/model/nip59Giftwrap/RumorHosts.kt | 66 +++++++++++++++ .../commons/model/privateChats/Chatroom.kt | 6 +- .../quartz/nip17Dm/base/BaseDMGroupEvent.kt | 4 +- .../quartz/nip59Giftwrap/HostStub.kt | 12 +-- .../quartz/nip59Giftwrap/WrappedEvent.kt | 40 --------- .../nip59Giftwrap/seals/SealedRumorEvent.kt | 8 +- .../nip59Giftwrap/wraps/GiftWrapEvent.kt | 5 -- 11 files changed, 150 insertions(+), 138 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6fa652e9e4..267be80d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagL import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -178,6 +179,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -223,8 +225,8 @@ import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -1206,7 +1208,9 @@ class Account( emptySet() } } - if (event is WrappedEvent) { + // Seals, inner DM messages, and unsigned rumors never get broadcast + // relays: they only travel inside gift wraps. + if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { return emptySet() } @@ -1328,19 +1332,10 @@ class Account( } /** - * The kind-1059 gift wrap that delivered [event], when [event] is a - * rumor: WrappedEvent rumors (kind-14 chats) carry it on the event; - * other rumor kinds (kind-1 private replies) are looked up in the - * cache's rumor-host index. + * The envelope (kind-1059 gift wrap, or bare kind-13 seal) that + * delivered [event], when [event] is a rumor. */ - fun rumorHost(event: Event): HostStub? = - if (event is WrappedEvent) { - event.host - } else if (event.sig.isEmpty()) { - cache.rumorHosts.get(event.id) - } else { - null - } + fun rumorHost(event: Event): HostStub? = RumorHosts.of(event) suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 93cb0f0e06..e556b94fa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -115,6 +116,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -210,8 +212,6 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent @@ -396,15 +396,6 @@ object LocalCache : ILocalCache, ICacheProvider { val deletionIndex = DeletionIndex() - /** - * Rumor id → the kind-1059 gift wrap that delivered it. Lets the - * broadcast action republish the WRAP (never the unsigned rumor) for - * rumor kinds that aren't WrappedEvent subclasses and so can't carry a - * host pointer themselves (e.g. kind-1 private replies). Kind-14 chat - * messages carry the host on the event and don't need this index. - */ - val rumorHosts = LargeCache() - /** * Inverted index over the active [Observable]s. New events fan * out only to observers whose filter actually narrows on a field @@ -1389,30 +1380,39 @@ object LocalCache : ILocalCache, ICacheProvider { * resurrected by `computeReplyTo` as a second Note for the same id. * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. * - * Gift-wrapped events additionally drop their decrypted inner host. + * Rumors additionally drop the envelope notes that delivered them. */ private fun deleteNote(deleteNote: Note) { - (deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) } + deleteNote.event?.let { deleteEnvelopes(it) } deleteNote.detachFromChildren() unlinkAndRemove(deleteNote) } - fun deleteWraps(event: WrappedEvent) { - event.host?.let { hostStub -> - // seal - getNoteIfExists(hostStub.id)?.let { hostNote -> - val noteEvent = hostNote.event - if (noteEvent is WrappedEvent) { - deleteWraps(noteEvent) - } - hostNote.clearFlow() - refreshDeletedNoteObservers(hostNote) - } + /** + * Removes the envelope notes that delivered [rumor]: the indexed host + * (normally the kind-1059 wrap; a bare kind-13 seal otherwise) and, + * when the host is a wrap, the seal layer it carried. Public events + * have no envelopes and are ignored. + */ + fun deleteEnvelopes(rumor: Event) { + val host = RumorHosts.of(rumor) ?: return - notes.remove(hostStub.id) + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + sealNote.clearFlow() + refreshDeletedNoteObservers(sealNote) + } + notes.remove(sealId) + } + hostNote.clearFlow() + refreshDeletedNoteObservers(hostNote) } + + notes.remove(host.id) + RumorHosts.remove(rumor.id) } fun consume( @@ -2697,7 +2697,7 @@ object LocalCache : ILocalCache, ICacheProvider { val childrenToBeRemoved = mutableListOf() // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. - // Gift wraps page by the OUTER wrap time (from the rumor's host stub); NIP-04 by the event's + // Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. val giftWrapPruned = HashMap() val accountNip04Pruned = HashMap() @@ -2708,9 +2708,9 @@ object LocalCache : ILocalCache, ICacheProvider { toBeRemoved.forEach { note -> when (val ev = note.event) { - is WrappedEvent -> + is BaseDMGroupEvent -> if (giftWrapFloor != null) { - val outerUntil = ev.host?.createdAt ?: ev.createdAt + val outerUntil = RumorHosts.get(ev.id)?.createdAt ?: ev.createdAt if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } } is PrivateDmEvent -> { @@ -2753,21 +2753,22 @@ object LocalCache : ILocalCache, ICacheProvider { } fun removeIfWrap(note: Note): List { - val noteEvent = note.event + val noteEvent = note.event ?: return emptyList() + val host = RumorHosts.of(noteEvent) ?: return emptyList() - val children = - if (noteEvent is WrappedEvent) { - noteEvent.host?.id?.let { - getNoteIfExists(it)?.let { it2 -> - unlinkAndRemove(it2) - it2.clearChildLinks() - } + val children = mutableListOf() + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + unlinkAndRemove(sealNote) + children.addAll(sealNote.clearChildLinks()) } - } else { - null } - - return children ?: emptyList() + unlinkAndRemove(hostNote) + children.addAll(hostNote.clearChildLinks()) + } + RumorHosts.remove(noteEvent.id) + return children } fun prunePastVersionsOfReplaceables() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index cb72ec752e..99194dfd1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.model.Account @@ -412,9 +413,11 @@ class SealedRumorEventHandler( if (rumorId == null) { processNewSealedRumor(event, eventNote, publicNote) } else { - // Replayed seal: re-link the rumor to its delivering wrap so + // Replayed seal: re-link the rumor to its delivering envelope so // broadcast can republish the wrap after a cache rebuild. - event.host?.let { cache.rumorHosts.put(rumorId, it) } + // publicNote is the outermost event of this unwrap chain — the + // kind-1059 wrap normally, the seal itself when it arrived bare. + publicNote.event?.let { envelope -> RumorHosts.put(rumorId, envelope) } processExistingSealedRumor(rumorId, publicNote) } } @@ -440,11 +443,11 @@ class SealedRumorEventHandler( eventNote.event = event.copyNoContent() - // Remember which kind-1059 wrap delivered this rumor. Rumor kinds - // that aren't WrappedEvent subclasses (kind-1 private replies) can't - // carry the host pointer on the event, and broadcast must republish - // the wrap — never the unsigned rumor. - event.host?.let { cache.rumorHosts.put(innerRumor.id, it) } + // Remember which envelope delivered this rumor (publicNote is the + // kind-1059 wrap normally, the seal itself when it arrived bare). + // Consumers cite/broadcast/prune/evict through this index — the + // unsigned rumor itself must never be referenced publicly. + publicNote.event?.let { envelope -> RumorHosts.put(innerRumor.id, envelope) } cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 5d3ece2d1d..1af10fbae6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread import com.vitorpamplona.amethyst.commons.util.KmpLock @@ -61,7 +62,6 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -237,19 +237,17 @@ open class Note( open fun idNote() = toNEvent() open fun toNEvent(): String { - val myEvent = event - return if (myEvent is WrappedEvent) { - val host = myEvent.host - if (host != null) { - NEvent.create( - host.id, - host.pubKey, - host.kind, - relayHintUrl(), - ) - } else { - NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) - } + // Rumors are cited by the envelope that delivered them: the rumor id + // resolves to nothing on public relays and exposing it would leak the + // private event's identity. + val host = event?.let { RumorHosts.of(it) } + return if (host != null) { + NEvent.create( + host.id, + host.pubKey, + host.kind, + relayHintUrl(), + ) } else { NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt new file mode 100644 index 0000000000..e648462f02 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip59Giftwrap + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub +import com.vitorpamplona.quartz.utils.cache.LargeCache + +/** + * Rumor id → the envelope that delivered it (normally the kind-1059 gift + * wrap; a bare kind-13 seal when one arrives unwrapped). + * + * Unsealed rumors are unsigned and must never be referenced or republished + * directly on public relays. Consumers use this index to act on the + * delivering envelope instead: broadcast republishes the wrap, nevent + * citations point at the wrap id, chat pruning pages by the outer wrap + * time, and cache eviction removes the envelope notes alongside the rumor. + * + * Delivery metadata is deliberately kept OUT of the quartz event classes — + * any event kind can be a rumor without subclassing anything. Populated by + * each front end's gift-wrap ingestion pipeline. + */ +object RumorHosts { + private val index = LargeCache() + + fun put( + rumorId: HexKey, + host: HostStub, + ) = index.put(rumorId, host) + + /** Records [envelope] as the delivering event of [rumorId]. */ + fun put( + rumorId: HexKey, + envelope: Event, + ) = index.put(rumorId, HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt)) + + fun get(rumorId: HexKey): HostStub? = index.get(rumorId) + + /** The delivering envelope of [event], when [event] is a rumor. */ + fun of(event: Event): HostStub? = if (event.sig.isEmpty()) index.get(event.id) else null + + fun remove(rumorId: HexKey) { + index.remove(rumorId) + } + + fun clear() = index.clear() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 5c9f6cc7b3..3932d5f769 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -145,9 +145,9 @@ class Chatroom : NotesGatherer { } else { // Old conversation, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is BaseDMGroupEvent } // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 - // (WrappedEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a + // (BaseDMGroupEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a // room is kept. The caller realigns the per-relay download window for the dropped messages so // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index a44593e8a1..37b0912d3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip17Dm.base import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import kotlinx.collections.immutable.toImmutableSet @Immutable @@ -37,7 +37,7 @@ open class BaseDMGroupEvent( tags: Array>, content: String, sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable, NIP17Group, PubKeyHintProvider { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index 8fdd3c92d8..05008be9bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.quartz.nip59Giftwrap import com.vitorpamplona.quartz.nip01Core.core.HexKey /** - * A lightweight reference to the host event a [WrappedEvent] was extracted from — kept on the inner - * event so callers can broadcast / delete / locate the outer wrap without holding the full event. + * A lightweight reference to the envelope event a rumor was extracted from — kept by the caller's + * rumor-host index so it can broadcast / delete / locate the outer wrap without holding the full + * event. Delivery metadata is intentionally not stored on the event classes themselves. * * [createdAt] is the host's own `created_at` (e.g. the kind:1059 gift-wrap timestamp, randomized per - * NIP-59), carried here so a decrypted rumor self-describes its outer-wrap time. The history pager - * cursors page gift wraps by that outer time, so the prune path uses it to realign the per-relay - * download window when a wrapped message is pruned (the chatroom only keeps the inner rumor, whose - * `created_at` is the real message time, not the wrap time). + * NIP-59). The history pager cursors page gift wraps by that outer time, so the prune path uses it + * to realign the per-relay download window when a wrapped message is pruned (the chatroom only keeps + * the inner rumor, whose `created_at` is the real message time, not the wrap time). */ class HostStub( val id: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt deleted file mode 100644 index 929d1f00dd..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip59Giftwrap - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey - -@Immutable -open class WrappedEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @kotlinx.serialization.Transient - @kotlin.jvm.Transient - var host: HostStub? = null // host event to broadcast when needed -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index ad2c9cf873..4dfbf62037 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +38,7 @@ class SealedRumorEvent( tags: Array>, content: String, sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), HasInnerEvent { @kotlinx.serialization.Transient @kotlin.jvm.Transient @@ -57,7 +55,6 @@ class SealedRumorEvent( sig, ) - copy.host = host copy.innerEventId = innerEventId return copy @@ -69,9 +66,6 @@ class SealedRumorEvent( val rumor = Rumor.fromJson(plainContent(signer)) val event = rumor.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind, this.createdAt) - } innerEventId = event.id return event diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 7d19424ddc..292422646c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -31,8 +31,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -73,9 +71,6 @@ open class GiftWrapEvent( val giftStr = plainContent(signer) val gift = fromJson(giftStr) - if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind, this.createdAt) - } innerEventId = gift.id return gift From 96eaefda9b33444f3d3d426e5aa280777d3342a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:35:25 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20tie=20rumor=20host=20lifetime=20to?= =?UTF-8?q?=20the=20Note=20=E2=80=94=20replace=20the=20RumorHosts=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A memory audit found the global strong-reference RumorHosts index could never be kept in sync with LocalCache.notes, which holds WeakReferences: seven prune paths (pruneExpiredEvents — rumors inherit the seal's expiration tag — hidden/old-message/replaceable/reaction/hidden-event prunes, and cleanMemory) plus silent GC eviction dropped rumor notes without clearing their entries, clear() had no callers (logout, account removal, memory trim), and orphaned stubs accumulated unbounded. The stub now lives on the Note (Note.rumorHost): whatever removes or garbage-collects the note frees the stub, closing every leak path by construction. Cost is one nullable reference per Note (~200-400 KB at a 50k-note steady state) versus the index's per-entry map overhead plus unbounded orphan growth. All consumers already held the Note: toNEvent, Account.broadcast, deleteEnvelopes, removeIfWrap, chat pruning, and the ingestion pipeline. RumorHosts is deleted. Also fixes the desktop regression the audit surfaced: the desktop gift-wrap handler now records the wrap on the rumor note, so desktop nevent citations of chat messages point at the wrap id again instead of exposing the private rumor id. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 10 +-- .../amethyst/model/LocalCache.kt | 24 ++++--- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/DecryptAndIndexProcessor.kt | 15 ++--- .../amethyst/commons/model/Note.kt | 22 ++++++- .../commons/model/nip59Giftwrap/RumorHosts.kt | 66 ------------------- .../vitorpamplona/amethyst/desktop/Main.kt | 3 + 7 files changed, 43 insertions(+), 99 deletions(-) delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 267be80d8f..321c334aac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagL import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -224,7 +223,6 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent @@ -1331,15 +1329,9 @@ class Account( } } - /** - * The envelope (kind-1059 gift wrap, or bare kind-13 seal) that - * delivered [event], when [event] is a rumor. - */ - fun rumorHost(event: Event): HostStub? = RumorHosts.of(event) - suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> - val host = rumorHost(noteEvent) + val host = note.rumorHost if (host != null) { // Rumors are rebroadcast as their delivering wrap: // download the wrap and send it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e556b94fa9..991162980e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -1383,7 +1382,7 @@ object LocalCache : ILocalCache, ICacheProvider { * Rumors additionally drop the envelope notes that delivered them. */ private fun deleteNote(deleteNote: Note) { - deleteNote.event?.let { deleteEnvelopes(it) } + deleteEnvelopes(deleteNote) deleteNote.detachFromChildren() @@ -1391,13 +1390,13 @@ object LocalCache : ILocalCache, ICacheProvider { } /** - * Removes the envelope notes that delivered [rumor]: the indexed host - * (normally the kind-1059 wrap; a bare kind-13 seal otherwise) and, - * when the host is a wrap, the seal layer it carried. Public events - * have no envelopes and are ignored. + * Removes the envelope notes that delivered [rumorNote]'s rumor: its + * host (normally the kind-1059 wrap; a bare kind-13 seal otherwise) + * and, when the host is a wrap, the seal layer it carried. Public + * events have no envelopes and are ignored. */ - fun deleteEnvelopes(rumor: Event) { - val host = RumorHosts.of(rumor) ?: return + fun deleteEnvelopes(rumorNote: Note) { + val host = rumorNote.rumorHost ?: return getNoteIfExists(host.id)?.let { hostNote -> (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> @@ -1412,7 +1411,7 @@ object LocalCache : ILocalCache, ICacheProvider { } notes.remove(host.id) - RumorHosts.remove(rumor.id) + rumorNote.rumorHost = null } fun consume( @@ -2710,7 +2709,7 @@ object LocalCache : ILocalCache, ICacheProvider { when (val ev = note.event) { is BaseDMGroupEvent -> if (giftWrapFloor != null) { - val outerUntil = RumorHosts.get(ev.id)?.createdAt ?: ev.createdAt + val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } } is PrivateDmEvent -> { @@ -2753,8 +2752,7 @@ object LocalCache : ILocalCache, ICacheProvider { } fun removeIfWrap(note: Note): List { - val noteEvent = note.event ?: return emptyList() - val host = RumorHosts.of(noteEvent) ?: return emptyList() + val host = note.rumorHost ?: return emptyList() val children = mutableListOf() getNoteIfExists(host.id)?.let { hostNote -> @@ -2767,7 +2765,7 @@ object LocalCache : ILocalCache, ICacheProvider { unlinkAndRemove(hostNote) children.addAll(hostNote.clearChildLinks()) } - RumorHosts.remove(noteEvent.id) + note.rumorHost = null return children } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5e32335ddf..2ef48b7b55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1138,7 +1138,7 @@ class AccountViewModel( */ fun canBroadcast(note: Note): Boolean { val event = note.event ?: return false - return event.sig.isNotEmpty() || account.rumorHost(event) != null + return event.sig.isNotEmpty() || note.rumorHost != null } fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 99194dfd1a..e05a9627a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.model.Account @@ -417,7 +416,7 @@ class SealedRumorEventHandler( // broadcast can republish the wrap after a cache rebuild. // publicNote is the outermost event of this unwrap chain — the // kind-1059 wrap normally, the seal itself when it arrived bare. - publicNote.event?.let { envelope -> RumorHosts.put(rumorId, envelope) } + publicNote.event?.let { envelope -> cache.getOrCreateNote(rumorId).recordRumorHost(envelope) } processExistingSealedRumor(rumorId, publicNote) } } @@ -443,17 +442,17 @@ class SealedRumorEventHandler( eventNote.event = event.copyNoContent() - // Remember which envelope delivered this rumor (publicNote is the - // kind-1059 wrap normally, the seal itself when it arrived bare). - // Consumers cite/broadcast/prune/evict through this index — the - // unsigned rumor itself must never be referenced publicly. - publicNote.event?.let { envelope -> RumorHosts.put(innerRumor.id, envelope) } - cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + // Remember which envelope delivered this rumor (publicNote is the + // kind-1059 wrap normally, the seal itself when it arrived bare). + // Consumers cite/broadcast/prune/evict through this stub — the + // unsigned rumor itself must never be referenced publicly. + publicNote.event?.let { envelope -> innerRumorNote.recordRumorHost(envelope) } + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler // is the actual point at which we see the kind:444 inner. Route it // to the MLS flow for group joining in addition to caching — there's diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1af10fbae6..3f0de80179 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread import com.vitorpamplona.amethyst.commons.util.KmpLock @@ -62,6 +61,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -120,6 +120,24 @@ open class Note( var author: User? = null var replyTo: List? = null + /** + * The envelope that delivered this note when [event] is an unsealed + * rumor: normally the kind-1059 gift wrap, a bare kind-13 seal when + * one arrives unwrapped. Null for public events. + * + * Rumors are unsigned and must never be referenced or republished + * directly on public relays — consumers cite, broadcast, prune, and + * evict through this stub instead. Living on the Note (not on a + * global index, not on the quartz event) ties its lifetime to the + * note: whatever removes or garbage-collects the note frees the stub. + */ + var rumorHost: HostStub? = null + + /** Records the envelope that delivered this rumor. */ + fun recordRumorHost(envelope: Event) { + rumorHost = HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt) + } + var inGatherers: List? = null fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } @@ -240,7 +258,7 @@ open class Note( // Rumors are cited by the envelope that delivered them: the rumor id // resolves to nothing on public relays and exposing it would leak the // private event's identity. - val host = event?.let { RumorHosts.of(it) } + val host = rumorHost return if (host != null) { NEvent.create( host.id, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt deleted file mode 100644 index e648462f02..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.model.nip59Giftwrap - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.utils.cache.LargeCache - -/** - * Rumor id → the envelope that delivered it (normally the kind-1059 gift - * wrap; a bare kind-13 seal when one arrives unwrapped). - * - * Unsealed rumors are unsigned and must never be referenced or republished - * directly on public relays. Consumers use this index to act on the - * delivering envelope instead: broadcast republishes the wrap, nevent - * citations point at the wrap id, chat pruning pages by the outer wrap - * time, and cache eviction removes the envelope notes alongside the rumor. - * - * Delivery metadata is deliberately kept OUT of the quartz event classes — - * any event kind can be a rumor without subclassing anything. Populated by - * each front end's gift-wrap ingestion pipeline. - */ -object RumorHosts { - private val index = LargeCache() - - fun put( - rumorId: HexKey, - host: HostStub, - ) = index.put(rumorId, host) - - /** Records [envelope] as the delivering event of [rumorId]. */ - fun put( - rumorId: HexKey, - envelope: Event, - ) = index.put(rumorId, HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt)) - - fun get(rumorId: HexKey): HostStub? = index.get(rumorId) - - /** The delivering envelope of [event], when [event] is a rumor. */ - fun of(event: Event): HostStub? = if (event.sig.isEmpty()) index.get(event.id) else null - - fun remove(rumorId: HexKey) { - index.remove(rumorId) - } - - fun clear() = index.clear() -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 2805abe27b..c178077a5b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1364,6 +1364,9 @@ fun MainContent( if (innerNote.event == null) { innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) } + // Rumors are unsigned: citing or rebroadcasting them must + // go through the wrap that delivered them. + innerNote.recordRumorHost(event) iAccount.chatroomList.addMessage( innerEvent.chatroomKey(iAccount.pubKey), innerNote, From 917e38c19f84fd3b51b3811aa9ea09f34fa8584f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:56:44 +0000 Subject: [PATCH 09/10] fix: make rumor wrap rebroadcast reachable in both audit edge cases - Bare-seal hosts (kind 13) carry no p tag, so the re-download filter's p constraint silently matched nothing for them; the p filter is now wrap-only (the ids filter is sufficient for seals) - A just-sent private note has no relays until its self-wrap echoes back from the DM relays; the fetch now falls back to the account's own DM inbox relay set when note.relays is empty Also pins the citation guarantee with RumorHostCitationTest: a rumor note's nevent must encode the delivering wrap's id, never the private rumor id, and public notes keep citing their own id. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 32 +++--- .../commons/model/RumorHostCitationTest.kt | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 321c334aac..d7904795d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1333,20 +1333,28 @@ class Account( note.event?.let { noteEvent -> val host = note.rumorHost if (host != null) { - // Rumors are rebroadcast as their delivering wrap: - // download the wrap and send it. + // Rumors are rebroadcast as their delivering envelope: the + // cached copy is content-stripped, so download it and send it. + // A just-sent note has no relays until its self-wrap echoes + // back — fall back to our own DM inbox relays. Bare seals + // (kind 13) carry no p tag, so that filter is wrap-only. + val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() } + val filter = + if (host.kind == SealedRumorEvent.KIND) { + Filter( + kinds = listOf(host.kind), + ids = listOf(host.id), + ) + } else { + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), + ids = listOf(host.id), + ) + } client .fetchFirst( - filters = - note.relays.associateWith { _ -> - listOf( - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ), - ) - }, + filters = relays.associateWith { _ -> listOf(filter) }, )?.let { downloadedEvent -> val toRelays = computeRelayListToBroadcast(downloadedEvent) client.publish(downloadedEvent, toRelays) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt new file mode 100644 index 0000000000..55f863fc5d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Pins the citation guarantee for private notes: an nevent of a rumor must + * encode the delivering envelope's id — never the rumor's own id, which is + * the private event's identity and resolves to nothing on public relays. + */ +class RumorHostCitationTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun rumorNote_toNEvent_citesTheDeliveringWrap() = + runTest { + // Alice sends Bob a private note; Bob unwraps his copy. + val template = + TextNoteEvent.build("psst") { + pTags(listOf(PTag(bobSigner.pubKey, null))) + } + val result = NIP17Factory().createNoteNIP17(template, aliceSigner) + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + assertNotNull(rumor) + assertTrue(rumor.sig.isEmpty()) + + // Bob's cache materializes the rumor note and records the wrap. + val note = Note(rumor.id) + note.event = rumor + note.recordRumorHost(bobWrap) + + val nevent = note.toNEvent() + assertEquals( + NEvent.create(bobWrap.id, bobWrap.pubKey, bobWrap.kind, null), + nevent, + "rumor citations must encode the wrap, not the rumor", + ) + assertFalse( + nevent == NEvent.create(rumor.id, rumor.pubKey, rumor.kind, null), + "the private rumor id must never be encoded", + ) + } + + @Test + fun publicNote_toNEvent_citesItsOwnId() = + runTest { + val event = aliceSigner.sign(TextNoteEvent.build("hello world")) + + val note = Note(event.id) + note.event = event + + // toNEvent reads the author from the Note (unset here), so the + // expected nevent carries a null author too. + assertEquals( + NEvent.create(event.id, null, event.kind, null), + note.toNEvent(), + ) + } +} From 0b5f926dd849b204e1cd9fa50b2951aa5073a579 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:52:15 +0000 Subject: [PATCH 10/10] docs: TODO for gift-wrap deletion requests (recipient-authored kind 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the verified current behavior (author-keyed DeletionIndex, no wrap→rumor cascade, accidental seal-id blocking) and the agreed design: recipient special case in hasBeenDeleted, recipient field on HostStub, and the reverse-lookup live cascade in LocalCache. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../2026-06-12-giftwrap-deletion-requests.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 quartz/plans/2026-06-12-giftwrap-deletion-requests.md diff --git a/quartz/plans/2026-06-12-giftwrap-deletion-requests.md b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md new file mode 100644 index 0000000000..f2da7294af --- /dev/null +++ b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md @@ -0,0 +1,61 @@ +# TODO: Deletion Requests (kind 5) for Gift Wraps + +**Date:** 2026-06-12 +**Status:** Open — design agreed, not implemented +**Modules:** quartz (`DeletionIndex`), amethyst (`LocalCache`) + +## The special case + +Gift wraps (kind 1059) are signed by a discarded throwaway key, so the +normal NIP-09 rule — a deletion only applies when its author equals the +target's author — can never match a wrap. The intended rule: **a kind-5 +authored by the wrap's `p` tag (the recipient) may delete it.** A +recipient deleting their own received wrap is also the only deletion a +client can express without leaking the private rumor id (the rumor id +must never appear in a public kind-5). + +## Current behavior (verified 2026-06-12) + +`DeletionIndex` is strictly author-keyed (`DeletionRequest(targetId, +deleterPubkey)`), the live-delete path requires `deleteNote.author == +deletion.pubKey`, and no downward cascade (wrap → seal → rumor) exists — +`deleteEnvelopes` only walks upward via `Note.rumorHost`. + +| Deletion e-tags | authored by | live message deleted? | blocks later insert? | +|---|---|---|---| +| wrap id | recipient (p tag) | no (key mismatch + no cascade + wrap note usually GC'd) | no (tombstone keyed `(wrapId, recipient)`, check uses `(wrapId, throwawayKey)`) | +| seal id | sender | seal note only; message survives (no cascade) | **yes** — accidental: seals are sender-signed, and `GiftWrapEventHandler` gates the unwrap on `justConsume(seal)` | +| rumor id | sender | yes (private un-react path; `deleteEnvelopes` cascades upward) | yes | + +Only the rumor-id direction works; the wrap-id direction — the one the +special case describes — does nothing. + +## Implementation sketch + +1. **Insertion blocking (quartz, small):** in `DeletionIndex.hasBeenDeleted`, + when the event is a `GiftWrapEvent`, additionally check + `DeletionRequest(event.id, event.recipientPubKey())`. A + recipient-authored tombstone then blocks the wrap in `justConsume` + before it is ever unwrapped, which blocks the message. + +2. **Recipient on the stub (commons, tiny):** add `recipient: HexKey?` to + `HostStub` (one shared-string reference per rumor), populated from + `GiftWrapEvent.recipientPubKey()` at unseal time, so the validation + below works after the wrap note is GC'd. + +3. **Live cascade (amethyst `LocalCache.consume(DeletionEvent)`):** the + wrap note that knew its `innerEventId` is GC'd by the time a deletion + arrives, so find the rumor by reverse lookup: scan notes for + `note.rumorHost?.id == deletedId` (precedent: the addressable pass in + the same function already does a full `notes.forEach`; deletions are + rare). Validate `deletion.pubKey == note.rumorHost.recipient`, then + `deleteNote(rumor)` — envelope cleanup and chatroom removal already + follow from the existing deleted-notes pipeline. + +4. **Tests:** block-before-unwrap (tombstone first, wrap second → message + never materializes) and delete-after-unwrap (message in a chatroom, + recipient-authored kind-5 for the wrap id → rumor and envelopes gone). + +Note: only wraps addressed to the local user are ever in the cache, so +the live-cascade case in practice means "another of my devices retracted +a DM" — rare, not a hot path.