Merge pull request #3166 from davotoula/fix/nip71-legacy-video-addressable-dtag

Compute legacy NIP-71 video addresses with their d tag
This commit is contained in:
Vitor Pamplona
2026-06-10 19:28:27 -04:00
committed by GitHub
6 changed files with 220 additions and 7 deletions
@@ -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())
)
@@ -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
@@ -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,
@@ -45,7 +45,7 @@ abstract class ReplaceableVideoEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig),
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
PublishedAtProvider,
VideoEvent,
RootScope {
@@ -40,7 +40,7 @@ class VideoHorizontalEvent(
tags: Array<Array<String>>,
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
@@ -40,7 +40,7 @@ class VideoVerticalEvent(
tags: Array<Array<String>>,
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
@@ -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 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.
private val pubkey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2"
private val dTag = "7af7cae314483a84dcc204824cef10aace246a69c819734412330e2a25f459a1"
private fun assertAddressUsesDTag(
kind: Int,
event: AddressableVideoEvent,
) {
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())
}
}
@@ -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<Event>("", "", 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)
}
}