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,