From 638486ea1f0aabe6e6283684f3fbfa5e2b8476bc Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 15:01:09 +0200 Subject: [PATCH 1/3] Compute legacy NIP-71 video addresses with their d tag: Extend BaseAddressableEvent instead so dTag() reads the real `d` tag. --- .../nip71Video/ReplaceableVideoEvent.kt | 4 +- .../ReplaceableVideoEventAddressTest.kt | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt index dca8125f51..a129863164 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -45,7 +45,7 @@ abstract class ReplaceableVideoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, RootScope { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt new file mode 100644 index 0000000000..9ee3c8dcf2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt @@ -0,0 +1,99 @@ +/* + * 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.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Kinds 34235/34236 (legacy NIP-71 videos) are parameterized replaceable + * events: their address MUST include the `d` tag. A wrong (empty-dTag) + * address makes LocalCache consume the event into a different + * AddressableNote than the one `a` tags point to, so quotes/reposts of + * these videos never resolve on screen. + */ +class ReplaceableVideoEventAddressTest { + // Fixture from a real kind-34236 event published by the Divine client + // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the + // same data synthetically. + private val pubkey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2" + private val dTag = "7af7cae314483a84dcc204824cef10aace246a69c819734412330e2a25f459a1" + + private fun assertAddressUsesDTag( + kind: Int, + event: ReplaceableVideoEvent, + ) { + assertEquals(dTag, event.dTag()) + assertEquals(Address(kind, pubkey, dTag), event.address()) + // The kind:pubkey:dTag wire format is fixed by NIP-01, so it is + // asserted literally instead of via Address.assemble (which is + // what addressTag() calls internally). + assertEquals("$kind:$pubkey:$dTag", event.addressTag()) + } + + @Test + fun verticalVideoAddressUsesDTag() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag), arrayOf("title", "Lunchtime for our Koi")), + content = "Lunchtime for our Koi", + sig = "", + ) + + assertAddressUsesDTag(VideoVerticalEvent.KIND, event) + } + + @Test + fun horizontalVideoAddressUsesDTag() { + val event = + VideoHorizontalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "", + ) + + assertAddressUsesDTag(VideoHorizontalEvent.KIND, event) + } + + @Test + fun videoWithoutDTagFallsBackToEmptyAddress() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("title", "No d tag")), + content = "", + sig = "", + ) + + assertEquals("", event.dTag()) + assertEquals(Address(VideoVerticalEvent.KIND, pubkey, ""), event.address()) + assertEquals("${VideoVerticalEvent.KIND}:$pubkey:", event.addressTag()) + } +} From d90574c4e9a00f5bc1ea47ab8cccc6cd2d59b81e Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 19:45:09 +0200 Subject: [PATCH 2/3] refactor: rename ReplaceableVideoEvent to AddressableVideoEvent --- .../profile/gallery/dal/UserProfileGalleryFeedFilter.kt | 4 ++-- .../{ReplaceableVideoEvent.kt => AddressableVideoEvent.kt} | 2 +- .../vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt | 2 +- .../com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt | 2 +- ...ventAddressTest.kt => AddressableVideoEventAddressTest.kt} | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/{ReplaceableVideoEvent.kt => AddressableVideoEvent.kt} (98%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/{ReplaceableVideoEventAddressTest.kt => AddressableVideoEventAddressTest.kt} (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index bcb315079c..579f6c07a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent -import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( @@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter( ( noteEvent is PictureEvent || noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is AddressableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt index a129863164..486753cef2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag @Immutable -abstract class ReplaceableVideoEvent( +abstract class AddressableVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 2facb76e93..dbd5434a59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -40,7 +40,7 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34235 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index e293129f49..0f46b0433e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -40,7 +40,7 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34236 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt index 9ee3c8dcf2..0f878d0ee0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -31,7 +31,7 @@ import kotlin.test.assertEquals * AddressableNote than the one `a` tags point to, so quotes/reposts of * these videos never resolve on screen. */ -class ReplaceableVideoEventAddressTest { +class AddressableVideoEventAddressTest { // Fixture from a real kind-34236 event published by the Divine client // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the // same data synthetically. @@ -40,7 +40,7 @@ class ReplaceableVideoEventAddressTest { private fun assertAddressUsesDTag( kind: Int, - event: ReplaceableVideoEvent, + event: AddressableVideoEvent, ) { assertEquals(dTag, event.dTag()) assertEquals(Address(kind, pubkey, dTag), event.address()) From 572f4005e10c5524412478c0a2c820d321e87934 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 20:16:24 +0200 Subject: [PATCH 3/3] test: guard kind-range vs class-hierarchy invariant in EventFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps every typed kind: addressable kinds (30000..39999) must read their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray ones — the invariant the kind-34235/34236 fix restores. --- .../quartz/utils/EventFactoryKindRangeTest.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt new file mode 100644 index 0000000000..f6dd98567b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt @@ -0,0 +1,114 @@ +/* + * 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.utils + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the kind-range vs class-hierarchy invariant across the whole + * EventFactory: parameterized replaceables (30000..39999) must derive their + * address from the `d` tag, while plain replaceables (10000..19999, plus + * kinds 0 and 3) must ignore stray `d` tags. A typed class extending the + * wrong base (the kind-34235/34236 bug, fixed in this branch) splits the + * cache between the event's own address and the address `a` tags reference. + * + * Known offenders that predate this guard are allowlisted below so the test + * catches NEW mismatches; shrink the lists as they get fixed. + */ +class EventFactoryKindRangeTest { + /** + * NIP-87 ecash kinds that are addressable per spec but currently extend + * plain Event, so they have no address at all and never replace older + * versions in the cache. + */ + private val knownNonAddressable = setOf(38000, 38172, 38173) + + /** + * NIP-51-style list kinds whose shared PrivateTagArrayEvent hierarchy + * reads `d` tags even though plain replaceables must ignore them; a + * stray `d` tag on a malformed event fragments their cache address. + */ + private val knownDTagReaders = + setOf( + 10004, + 10005, + 10006, + 10007, + 10009, + 10012, + 10013, + 10015, + 10017, + 10018, + 10020, + 10023, + 10040, + 10054, + 10081, + 10086, + 10087, + 10088, + 10089, + 10090, + 10101, + 10102, + ) + + private val probeDTag = "probe-d-tag" + + private fun probe(kind: Int) = EventFactory.create("", "", 0L, kind, arrayOf(arrayOf("d", probeDTag)), "", "") + + @Test + fun addressableKindsReadTheirDTag() { + val violations = + (30000 until 40000).mapNotNull { kind -> + val event = probe(kind) + when { + // Unknown kinds parse as a bare Event; only typed classes are checked. + event::class == Event::class -> null + kind in knownNonAddressable -> null + event !is AddressableEvent -> "kind $kind (${event::class.simpleName}) does not implement AddressableEvent" + event.dTag() != probeDTag -> "kind $kind (${event::class.simpleName}) ignores its d tag" + else -> null + } + } + + assertEquals(emptyList(), violations) + } + + @Test + fun plainReplaceableKindsIgnoreStrayDTags() { + val violations = + (listOf(0, 3) + (10000 until 20000)).mapNotNull { kind -> + val event = probe(kind) + if (kind !in knownDTagReaders && event::class != Event::class && event is AddressableEvent && event.dTag() != "") { + "kind $kind (${event::class.simpleName}) addresses itself by a stray d tag" + } else { + null + } + } + + assertEquals(emptyList(), violations) + } +}