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 bdad25dece..bad6754111 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.concordChannel import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -1630,6 +1631,63 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { else -> null } + /** + * The Concord channel a Chat Plane rumor is bound to, found by its `["channel", ]` tag alone + * (CORD-03). A rumor commits to only the channel half of a [ConcordChannelId] — the community + * half lives on the session whose key opened the wrap — so the pair is recovered from the channel + * index. Two joined communities would have to have drawn the same random 32-byte channel id for + * this to be ambiguous, so a single match is the answer and anything else counts as unknown. + */ + fun getConcordChannelByChannelId(channelIdHex: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.channelId == channelIdHex }.singleOrNull() + + /** + * The chat channel a [note] belongs to, using every clue available rather than only the event + * tags [getAnyChannel] reads. This is the "where do I fetch this note from" resolver the event + * loaders ask (see `potentialRelaysToFindEvent`), so it has to answer for the channel kinds whose + * identity is *not* fully in the event: + * - **Concord** — the rumor names its channel but not its community, so the complete + * [ConcordChannelId] only exists on the gatherer the ingest path attached (or, failing that, + * in the channel index keyed by channel id). + * - **NIP-29 / Buzz relay group** — the group is keyed by (host relay, group id) and the host + * relay is the note's provenance, not a tag, so it needs the [Note], not the [Event]. + * + * It also answers for a note whose **own event never arrived** — a reply's unloaded parent, the + * usual shape in a notification feed — by borrowing the room from a child that points at it. + */ + fun getChannelToLoadFrom(note: Note): Channel? { + channelOfLoadedNote(note)?.let { return it } + + // The event never arrived, so nothing about this note itself says where it lives. Everything + // that points AT it, though, was posted in the same room: a Concord rumor is cryptographically + // bound to its channel and a NIP-29 message carries the group's `h` tag, so a reply or a + // reaction we did consume names the room its parent must be fetched from. + if (note.event == null) { + note.replies.firstNotNullOfOrNull { channelOfLoadedNote(it) }?.let { return it } + note.reactions.values + .firstNotNullOfOrNull { sameKind -> sameKind.firstNotNullOfOrNull { channelOfLoadedNote(it) } } + ?.let { return it } + } + + return null + } + + /** The non-recursive half of [getChannelToLoadFrom]: resolves only from the note's own event/gatherers. */ + private fun channelOfLoadedNote(note: Note): Channel? { + // The gatherer the ingest path attached is authoritative (see unlinkAndRemove) and is the only + // complete source for a Concord channel. + note.inGatherers?.firstNotNullOfOrNull { it as? Channel }?.let { return it } + + val noteEvent = note.event ?: return null + + getAnyChannel(noteEvent)?.let { return it } + + noteEvent.tags.concordChannel()?.let { channelIdHex -> + getConcordChannelByChannelId(channelIdHex)?.let { return it } + } + + return getRelayGroupChannelForContent(note) + } + /** * NIP-09 delete of a single targeted event. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt index 251cf0fa2c..f2f8e6398a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt @@ -38,12 +38,12 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex)) - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -51,7 +51,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumors.kt new file mode 100644 index 0000000000..73adddc621 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumors.kt @@ -0,0 +1,142 @@ +/* + * 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.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * How many plane wraps one backfill window pulls. Sized like the Concord catch-up window + * ([com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner.channelPreviewFilters]): + * a thread reply answers something recent, so the message it replies to is a handful of wraps below + * it. A reply to a much older message still opens fine from the message itself — the minichat and + * the channel screen mount the channel's backward-history pager, which walks the whole plane. + */ +private const val CONCORD_BACKFILL_LIMIT = 50 + +/** + * The Concord channel a missing [note] can only be fetched through, or null when it isn't Concord + * content. Also the "never ask a relay for this by id" test [filterMissingEvents] applies. + */ +fun concordChannelToLoadFrom(note: Note): ConcordChannel? = LocalCache.getChannelToLoadFrom(note) as? ConcordChannel + +/** + * Re-requests the Concord Chat Plane a missing chat message rode in on. + * + * A Concord message is never published as a bare event: it is an unsigned *rumor* sealed inside a + * kind-1059 wrap authored by the channel's derived plane key (CORD-03), and its id only comes into + * existence once that wrap decrypts locally. So `{"ids":[]}` can never find one — no relay + * indexes it — and asking would publish a private rumor id to public relays, exactly what + * [Note.isPrivateRumor] warns against. [filterMissingEvents] therefore skips these ids, and this + * fetches them the only way that works: a bounded backward window on the channel plane, on the + * community's relays, ending at the newest thing we hold that references the message. The wraps land + * through the normal ingest path (`concordSessions.ingest` → decrypt → `consumeConcordRumor`), which + * populates the missing [Note] and lets the reply render its parent. + * + * This is what makes a **reply notification** render its parent: the notification's kind-1111 reply + * arrived over the plane, so it carries the channel — and thus the plane keys and relays — even though + * we have never seen the message it answers. The plane keys span every held epoch, so the window still + * reaches a parent written before a CORD-06 Refounding. + */ +fun filterMissingConcordRumors(keys: List): List { + if (keys.isEmpty()) return emptyList() + + // Deduped by value: several visible replies to the same unloaded parent — or to parents in the same + // channel whose newest reference lands on the same second — collapse into one REQ per relay instead + // of one per note. [RelayBasedFilter]/[Filter] carry no value equality, hence the explicit key. + val windows = LinkedHashSet() + + keys.forEach { key -> + missingConcordNotes(key).forEach { (note, channel) -> + val planePks = + key.account.concordSessions + .sessionFor(channel.channelId.communityId) + ?.channelPlaneAddressesAllEpochs(channel.channelId.channelId) + ?.sorted() + + // Empty until the Control Plane folds the channel; the always-on plane preload gets us there. + if (planePks.isNullOrEmpty()) return@forEach + + val until = untilFor(note) + channel.relays().forEach { relay -> windows.add(ConcordPlaneWindow(relay, planePks, until)) } + } + } + + return windows.map { window -> + RelayBasedFilter( + relay = window.relay, + filter = + Filter( + // Stored wraps only — the ephemeral 21059 is a typing heartbeat and carries no history. + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = window.planePks, + until = window.until, + limit = CONCORD_BACKFILL_LIMIT, + ), + ) + } +} + +/** One backfill window: the channel's plane keys across every held epoch, on one relay, up to [until]. */ +private data class ConcordPlaneWindow( + val relay: NormalizedRelayUrl, + val planePks: List, + val until: Long, +) + +/** The unloaded Concord notes this key needs: the note itself and whatever it replies to. */ +private fun missingConcordNotes(key: EventFinderQueryState): List> = + buildList { + addIfMissingConcord(key.note) + key.note.replyTo?.forEach { addIfMissingConcord(it) } + } + +private fun MutableList>.addIfMissingConcord(note: Note) { + if (note is AddressableNote || note.event != null) return + concordChannelToLoadFrom(note)?.let { add(note to it) } +} + +/** + * The top of the backfill window: the newest event we hold that points at [note]. A reply or a + * reaction is necessarily younger than what it answers, so the wanted wrap sits at or below that + * instant (`until` is inclusive). Falls back to now when nothing dates the reference — and stays a + * pure function of the cache, so re-deriving the filters after EOSE produces the identical REQ + * instead of churning the subscription. + */ +private fun untilFor(note: Note): Long { + val newestReference = + ( + note.replies.mapNotNull { it.createdAt() } + + note.reactions.values + .flatten() + .mapNotNull { it.createdAt() } + ).maxOrNull() + + return newestReference ?: TimeUtils.now() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 94cf6ebdb6..6904d6e060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -38,12 +38,16 @@ fun potentialRelaysToFindEvent(note: Note): Set { note.author?.outboxRelays()?.let { set.addAll(it) } - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + // getChannelToLoadFrom, not getAnyChannel: a note we have never seen has no event to read a + // channel off, and the two chat kinds keyed outside the event tags (Concord, NIP-29/Buzz relay + // groups) only resolve through the gatherers/provenance it also consults. That is what aims a + // missing group message at its host relay instead of the account's default relay set. + LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -51,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -97,13 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set { return set } +/** + * True when [note] can only ever arrive inside a wrapped stream (today: a Concord Chat Plane rumor), + * so an `{"ids":[…]}` REQ for it is futile *and* harmful — no relay indexes an id that only exists + * after a local decrypt, and asking leaks a private rumor id. [filterMissingConcordRumors] fetches + * these off the plane instead. + */ +private fun isFetchedByPlane(note: Note) = concordChannelToLoadFrom(note) != null + fun filterMissingEvents(keys: List): List { val eventsPerRelay = mapOfSet { keys.forEach { key -> val default = key.account.followPlusAllMineWithSearch.flow.value - if (key.note !is AddressableNote && key.note.event == null) { + if (key.note !is AddressableNote && key.note.event == null && !isFetchedByPlane(key.note)) { potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, key.note.idHex) } @@ -115,7 +127,7 @@ fun filterMissingEvents(keys: List): List - if (note !is AddressableNote && note.event == null) { + if (note !is AddressableNote && note.event == null && !isFetchedByPlane(note)) { potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt index 9ff15e6407..7826ce7f30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt @@ -32,6 +32,9 @@ class NoteEventLoaderSubAssembler( listOfNotNull( filterMissingEvents(keys), filterMissingAddressables(keys), + // Concord chat messages have no fetchable id — they only exist inside a plane wrap — so + // they need their own filter shape instead of riding the `ids` REQ above. + filterMissingConcordRumors(keys), ).flatten() override fun distinct(key: EventFinderQueryState) = key.note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index 91358cdfd0..5e2c44bd0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -58,7 +58,14 @@ class EventWatcherSubAssembler( return null } - lastNotesOnFilter = keys.map { it.note } + // Private rumors are excluded. A rumor (a Concord chat message, a gift-wrapped DM or private + // reaction) is unsigned and exists only inside its wrap, so no relay indexes its id and + // `#e=` can never return anything — while asking publishes a private rumor id to a + // relay, exactly what Note.isPrivateRumor warns against. Their replies and reactions arrive over + // the same wrapped plane the message itself did. + lastNotesOnFilter = keys.mapNotNull { key -> key.note.takeIf { !it.isPrivateRumor() } } + + if (lastNotesOnFilter.isEmpty()) return null return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumorsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumorsTest.kt new file mode 100644 index 0000000000..260b8a9a64 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingConcordRumorsTest.kt @@ -0,0 +1,252 @@ +/* + * 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.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * A reply notification from a Concord community must be able to render the message it answers. + * + * The reported failure: a kind-1111 Concord reply arrives by push, its parent kind-9 message isn't in + * the cache, and the reply-to slot stays an unresolvable `nostr:nevent1…`. The reason is that the + * parent is a *rumor* — it only ever exists inside a kind-1059 Chat Plane wrap — so the loader's + * `{"ids":[…]}` REQ can never find it (and leaks the private rumor id while trying). These tests pin + * the two halves of the fix: the id REQ is not emitted, and a plane window aimed at the community's + * relays is. + */ +class FilterMissingConcordRumorsTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.concord.example/")!! + private val otherRelay = RelayUrlNormalizer.normalizeOrNull("wss://someone-elses-relay.example/")!! + + private val communityId = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1" + private val channelIdHex = "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23" + private val planePk = "cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00" + private val priorEpochPlanePk = "aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00" + + /** The reply from the report, verbatim: a kind-1111 comment bound to the channel, unsigned (a rumor). */ + private val replyJson = + """ + { + "id": "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250", + "pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326", + "created_at": 1785382199, + "kind": 1111, + "tags": [ + ["channel", "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23"], + ["epoch", "2"], + ["K", "9"], + ["E", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"], + ["P", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"], + ["k", "9"], + ["e", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"], + ["p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"], + ["ms", "62"] + ], + "content": "I saw you fixed it in today's update.", + "sig": "" + } + """.trimIndent() + + private val parentId = "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007" + private val replyId = "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250" + + /** + * [LocalCache] is a singleton, so each test has to start from an empty pair of notes — otherwise a + * previous test's channel gatherer (a different mock) is still attached to the reply and answers + * for it. + */ + @Before + fun dropCachedNotes() { + LocalCache.notes.remove(parentId) + LocalCache.notes.remove(replyId) + } + + /** + * The channel a Concord rumor is attached to at ingest. Mocked rather than folded from a Control + * Plane: all the loaders read off it is its id and its community's relays. + */ + private fun concordChannel() = + mockk(relaxed = true).also { + every { it.channelId } returns ConcordChannelId(communityId, channelIdHex) + every { it.relays() } returns setOf(relay) + } + + /** + * Reproduces the post-ingest cache state of the reported notification: the kind-1111 reply loaded + * and attached to its Concord channel, its parent a bare id-only [Note]. Returns the parent. + */ + private fun landReplyWithUnloadedParent(channel: ConcordChannel?): Note { + val replyEvent = Event.fromJson(replyJson) + val parent = LocalCache.getOrCreateNote(parentId) + val reply = LocalCache.getOrCreateNote(replyEvent.id) + + reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent)) + // The wrap it rode in on was seen here, so this is the only relay the reply itself points at. + reply.addRelay(relay) + parent.addReply(reply) + channel?.let { reply.addGatherer(it) } + + return parent + } + + /** + * An account with no relay lists of its own, so the only relays any filter can name are the ones + * the notes/channel supply — which is what these assertions are about. + */ + private fun stubAccount(): Account { + val account = mockk(relaxed = true) + every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet()) + every { account.searchRelayList.flow } returns MutableStateFlow(emptySet()) + return account + } + + /** An account whose Control Plane has folded this channel, so its plane keys are derivable. */ + private fun accountWithFoldedChannel(): Account { + val account = stubAccount() + val session = mockk(relaxed = true) + every { account.concordSessions.sessionFor(communityId) } returns session + // Every held epoch's plane, so the window reaches across a CORD-06 Refounding. + every { session.channelPlaneAddressesAllEpochs(channelIdHex) } returns listOf(planePk, priorEpochPlanePk) + return account + } + + private fun accountWithUnfoldedChannel(): Account { + val account = stubAccount() + every { account.concordSessions.sessionFor(any()) } returns null + return account + } + + @Test + fun `the channel of an unloaded parent is borrowed from the reply that arrived over its plane`() { + val channel = concordChannel() + val parent = landReplyWithUnloadedParent(channel) + + assertSame("the parent's room comes from its consumed reply", channel, LocalCache.getChannelToLoadFrom(parent)) + assertSame(channel, concordChannelToLoadFrom(parent)) + } + + @Test + fun `a missing Concord parent is never requested by id`() { + val channel = concordChannel() + val parent = landReplyWithUnloadedParent(channel) + val account = accountWithFoldedChannel() + val keys = listOf(EventFinderQueryState(parent, account)) + + val ids = filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() } + assertTrue("a rumor id must never be sent to a relay, it cannot be served: $ids", ids.isEmpty()) + } + + @Test + fun `a missing Concord parent is fetched as a plane window on the community relays`() { + val channel = concordChannel() + val parent = landReplyWithUnloadedParent(channel) + val account = accountWithFoldedChannel() + val keys = listOf(EventFinderQueryState(parent, account)) + + val filters = filterMissingConcordRumors(keys) + assertEquals(1, filters.size) + assertEquals(relay, filters[0].relay) + + val filter = filters[0].filter + assertEquals(listOf(ConcordStreamEnvelope.KIND_WRAP), filter.kinds) + assertEquals(listOf(planePk, priorEpochPlanePk).sorted(), filter.authors) + // The window tops out at the reply — the message it answers is necessarily at or below it. + assertEquals(1785382199L, filter.until) + assertEquals(50, filter.limit) + assertNull("a plane window is never an ids REQ", filter.ids) + } + + @Test + fun `the reply's own key backfills its parent, which is how a notification card loads it`() { + // The EventFinder is mounted on the note the card renders — the reply — not on the parent. + val channel = concordChannel() + val parent = landReplyWithUnloadedParent(channel) + val reply = parent.replies.single() + val keys = listOf(EventFinderQueryState(reply, accountWithFoldedChannel())) + + assertEquals(1, filterMissingConcordRumors(keys).size) + assertTrue(filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() }.isEmpty()) + } + + @Test + fun `nothing is requested until the Control Plane folds the channel`() { + val parent = landReplyWithUnloadedParent(concordChannel()) + val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel())) + + assertTrue(filterMissingConcordRumors(keys).isEmpty()) + } + + @Test + fun `a plain missing parent is still fetched by id`() { + // Guard against over-blocking: only Concord content skips the ids REQ. + val parent = landReplyWithUnloadedParent(channel = null) + val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel())) + + val byRelay = filterMissingEvents(keys) + assertEquals(listOf(relay), byRelay.map { it.relay }) + assertEquals(listOf(listOf(parentId)), byRelay.map { it.filter.ids }) + assertTrue(filterMissingConcordRumors(keys).isEmpty()) + } + + @Test + fun `two replies to the same unloaded parent collapse into one plane window`() { + val channel = concordChannel() + val parent = landReplyWithUnloadedParent(channel) + val account = accountWithFoldedChannel() + val reply = parent.replies.single() + + val keys = + listOf( + EventFinderQueryState(parent, account), + EventFinderQueryState(reply, account), + ) + + assertEquals(1, filterMissingConcordRumors(keys).size) + } + + @Test + fun `an unrelated relay is not asked for the plane`() { + val channel = concordChannel() + every { channel.relays() } returns setOf(relay) + val parent = landReplyWithUnloadedParent(channel) + val keys = listOf(EventFinderQueryState(parent, accountWithFoldedChannel())) + + assertTrue(filterMissingConcordRumors(keys).none { it.relay == otherRelay }) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEventsChannelRelaysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEventsChannelRelaysTest.kt new file mode 100644 index 0000000000..4d2dffa196 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEventsChannelRelaysTest.kt @@ -0,0 +1,104 @@ +/* + * 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.service.relayClient.reqCommand.event.loaders + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +/** + * A NIP-29 / Buzz group's history lives on ONE relay — its host — and nowhere else, so the id REQ for + * an unloaded message in that group has to name that relay. The group is keyed by (host relay, group + * id), which is not derivable from the event's tags alone, so the loader has to resolve it through the + * channel the ingest path attached (`LocalCache.getChannelToLoadFrom`) rather than the tag-only + * `getAnyChannel`. Without that, a message whose reply reached us from a mirror — or with no relay + * provenance at all — is only ever asked for on the account's default relays, which don't carry the + * group. + */ +class FilterMissingEventsChannelRelaysTest { + private val hostRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.host.example/")!! + private val mirrorRelay = RelayUrlNormalizer.normalizeOrNull("wss://mirror.example/")!! + + private val parentId = "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" + private val replyId = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b" + + /** A NIP-29 kind-9 group message answering [parentId], scoped to group `g1` by its `h` tag. */ + private val replyJson = + """ + { + "id": "$replyId", + "pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326", + "created_at": 1785382199, + "kind": 9, + "tags": [ + ["h", "g1"], + ["e", "$parentId", "", "reply"] + ], + "content": "same question here", + "sig": "" + } + """.trimIndent() + + @Before + fun dropCachedNotes() { + LocalCache.notes.remove(parentId) + LocalCache.notes.remove(replyId) + } + + private fun stubAccount(): Account { + val account = mockk(relaxed = true) + every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet()) + every { account.searchRelayList.flow } returns MutableStateFlow(emptySet()) + every { account.concordSessions.sessionFor(any()) } returns null + return account + } + + @Test + fun `an unloaded group parent is asked for on the group's host relay, not only where the reply came from`() { + val group = RelayGroupChannel(GroupId("g1", hostRelay)) + val replyEvent = Event.fromJson(replyJson) + + val parent = LocalCache.getOrCreateNote(parentId) + val reply = LocalCache.getOrCreateNote(replyId) + reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent)) + // The reply reached us from a mirror; only the group's channel knows where the group lives. + reply.addRelay(mirrorRelay) + parent.addReply(reply) + reply.addGatherer(group) + + assertSame("the parent's group comes from its consumed reply", group, LocalCache.getChannelToLoadFrom(parent)) + + val filters = filterMissingEvents(listOf(EventFinderQueryState(parent, stubAccount()))) + assertEquals(setOf(hostRelay, mirrorRelay), filters.map { it.relay }.toSet()) + filters.forEach { assertEquals(listOf(parentId), it.filter.ids) } + } +}