From db6b81eb0447c3ebff3a6348bc2825a29d891e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:15:58 +0000 Subject: [PATCH] feat(nip56): declare pointer hints on report, chat, classifieds and channel events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quartz's PubKeyHintProvider / EventHintProvider / AddressHintProvider are the kind-agnostic answer to "what does this event point at" — they let a caller walk an event's references without knowing which tag name a given NIP chose (`p` vs `P` vs `member` vs `moderator`). Measured against the 248k-event corpus in commonTest, 84 of 403 event classes implement one, covering ~95% of all pointer edges. This closes the four largest remaining gaps. ReportEvent (1984) carried the most undeclared edges of any kind — 14,244 — and they are the negative trust signal that a web-of-trust projection most needs. Its tag classes also predate the modern layout, so they are brought up to the structure used by e.g. NIP-88 polls: - ReportedAuthorTag now implements PubKeyReferenceTag, ReportedEventTag implements GenericETag, and all three tags carry a relay hint. - Adds parseKey / parseId / parseAddressId / parseAsHint companions. Fixes a latent bug while doing so. NIP-56 predates the convention that slot 2 of a pointer tag is a relay hint — it put the report type there — so both layouts are in the wild. The old reader passed slot 2 straight to ReportType.parseOrNull, which despite its name never returns null and maps anything unrecognized to OTHER. A modern `["p", , "wss://relay/"]` tag therefore became an OTHER report and masked the event-level default. The new shared ReportTagLayout disambiguates by shape (a slot that parses as a relay URL is a hint, never a type) and falls back to the event-level default when a tag names no type of its own. Emitted tags are unchanged: assemble() still writes the legacy `[name, id, type]` form unless a relay hint is supplied, since many clients still read the report type out of slot 2. Also renames ReportedAuthorTag.pubkey to pubKey to satisfy PubKeyReferenceTag, updating the four call sites. Coverage over the corpus goes from ~95.3% to ~98.5% of pointer edges. What remains is GiftWrapEvent's recipient p-tag (deliberate — it is the store owner key and handled separately) and PrivateDmEvent's e-tags. --- .../amethyst/model/CachePruner.kt | 2 +- .../amethyst/model/LocalCache.kt | 4 +- .../cli/commands/graperank/GrapeRankScore.kt | 2 +- .../nip56Reports/UserReportWarningState.kt | 2 +- .../nip17Dm/messages/ChatMessageEvent.kt | 6 + .../admin/ChannelCreateEvent.kt | 7 + .../quartz/nip56Reports/ReportEvent.kt | 20 +- .../quartz/nip56Reports/TagArrayBuilderExt.kt | 10 +- .../nip56Reports/tags/ReportTagLayout.kt | 75 +++++++ .../nip56Reports/tags/ReportedAddressTag.kt | 52 +++-- .../nip56Reports/tags/ReportedAuthorTag.kt | 61 ++++-- .../nip56Reports/tags/ReportedEventTag.kt | 59 ++++-- .../nip99Classifieds/ClassifiedsEvent.kt | 21 ++ .../nip56Reports/ReportHintProviderTest.kt | 187 ++++++++++++++++++ 14 files changed, 462 insertions(+), 46 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt index c906079d84..1f3f356995 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -395,7 +395,7 @@ class CachePruner( if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { - cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> + cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports -> reports.removeReport(note) reports.removeReportNamingUser(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 4029469535..31afef4830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1864,7 +1864,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { val new = consumeRegularEvent(event, relay, wasVerified) if (new) { - val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) } + val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) } val eventsReported = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } + event.reportedAddresses().map { getOrCreateAddressableNote(it.address) } @@ -1885,7 +1885,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // report can `p`-tag an incidentally-mentioned third party with no type of its own, // and there is no threshold here to absorb that noise the way // `receivedReportsByAuthor`'s hide path does. - val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey } + val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey } authorsReported.forEach { author -> if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt index 4a147dc115..34efcf4f86 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt @@ -303,7 +303,7 @@ object GrapeRankScore { dropped++ continue } - builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubkey }) + builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubKey }) } if (dropped > 0) System.err.println("[graperank] dropped $dropped retracted reports (NIP-09 deletions)") return dropped diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt index 18f3657b29..0b7fe44d04 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportWarningState.kt @@ -85,7 +85,7 @@ fun dmReportWarningFor( (report.event as? ReportEvent)?.reportedAuthor()?.forEach { // A single report can name several people; only this counterpart's reason belongs here. - if (it.pubkey == counterpart.pubkeyHex) it.type?.let(types::add) + if (it.pubKey == counterpart.pubkeyHex) it.type?.let(types::add) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index 1dbbc919a2..8cc2be58a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -41,10 +42,15 @@ class ChatMessageEvent( content: String, sig: HexKey, ) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, SearchableEvent { // content is the decrypted (plaintext) direct-message body. override fun indexableContent() = content + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + fun replyTo() = tags.mapNotNull(ETag::parseId) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index ed61fa5d9e..88a0db285f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -25,10 +25,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -46,9 +48,14 @@ class ChannelCreateEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") } + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + @kotlinx.serialization.Transient @kotlin.jvm.Transient var cache: ChannelDataNorm? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt index 7a64e5d275..6a94d60a1f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -26,6 +26,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag @@ -42,7 +45,22 @@ class ReportEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider { + override fun pubKeyHints() = tags.mapNotNull(ReportedAuthorTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ReportedAuthorTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ReportedEventTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ReportedEventTag::parseId) + + override fun addressHints() = tags.mapNotNull(ReportedAddressTag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ReportedAddressTag::parseAddressId) + @kotlinx.serialization.Transient @kotlin.jvm.Transient private var defaultType: ReportType? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt index 5912ae66a5..fa05fff641 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/TagArrayBuilderExt.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip56Reports import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.tags.HashSha256Tag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag @@ -32,17 +33,20 @@ import com.vitorpamplona.quartz.nip56Reports.tags.ServerTag fun TagArrayBuilder.event( eventId: HexKey, reportType: ReportType, -) = addUnique(ReportedEventTag.assemble(eventId, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedEventTag.assemble(eventId, relay, reportType)) fun TagArrayBuilder.address( address: Address, reportType: ReportType, -) = addUnique(ReportedAddressTag.assemble(address, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedAddressTag.assemble(address, relay, reportType)) fun TagArrayBuilder.user( pubkey: HexKey, reportType: ReportType, -) = addUnique(ReportedAuthorTag.assemble(pubkey, reportType)) + relay: NormalizedRelayUrl? = null, +) = addUnique(ReportedAuthorTag.assemble(pubkey, relay, reportType)) fun TagArrayBuilder.hash( x: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt new file mode 100644 index 0000000000..80cfbd1d1e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportTagLayout.kt @@ -0,0 +1,75 @@ +/* + * 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.nip56Reports.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip56Reports.ReportType + +/** + * Positional layout shared by the NIP-56 pointer tags (`p`, `e`, `a`). + * + * NIP-56 predates the convention that slot 2 of a pointer tag is a relay + * hint: it put the report type there instead. Both layouts are in the wild, + * so every reader has to disambiguate: + * + * ``` + * ["p", "", "nudity"] // legacy: type at 2 + * ["p", "", "wss://relay/", "nudity"] // modern: hint at 2, type at 3 + * ["p", "", "wss://relay/"] // modern, no per-tag type + * ["p", "", "", "nudity"] // empty hint slot + * ``` + * + * Disambiguation is by *shape*, not by tag length: a slot that parses as a + * relay URL is a hint, never a type. No report-type code is a relay URL, so + * the two spaces cannot collide. + */ +internal object ReportTagLayout { + /** The relay hint at slot 2, or null under the legacy layout. */ + fun relayHint(tag: Array): NormalizedRelayUrl? { + if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) { + return RelayUrlNormalizer.normalizeOrNull(tag[2]) + } + return null + } + + /** + * The report type this tag carries, falling back to [default] (the + * event-level type) when the tag does not name one of its own. + * + * Only a slot that is neither blank nor a relay URL is offered to + * [ReportType.parseOrNull] — which despite its name never returns null + * and maps anything unrecognized to [ReportType.OTHER]. Feeding it a + * relay URL would turn every hint-carrying tag into an `OTHER` report + * and mask the event-level default. + */ + fun reportType( + tag: Array, + default: ReportType?, + ): ReportType? { + val slot = if (tag.has(2) && (tag[2].isBlank() || relayHint(tag) != null)) 3 else 2 + + if (!tag.has(slot) || tag[slot].isBlank()) return default + + return ReportType.parseOrNull(tag[slot], tag) ?: default + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt index ef40dcf78d..46d7241bcd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAddressTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -30,9 +32,10 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedAddressTag( val address: Address, + val relay: NormalizedRelayUrl? = null, override val type: ReportType? = null, ) : BaseReportTag { - fun toTagArray() = assemble(address, type) + fun toTagArray() = assemble(address, relay, type) companion object { const val TAG_NAME = "a" @@ -49,21 +52,48 @@ class ReportedAddressTag( ensure(address != null) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedAddressTag( + address, + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedAddressTag(address, type) + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val address = Address.parse(tag[1]) + ensure(address != null) { return null } + + val hint = ReportTagLayout.relayHint(tag) + ensure(hint != null) { return null } + + return AddressHint(address.toValue(), hint) + } + + /** See [ReportedAuthorTag.assemble] for why the layout is conditional. */ + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relay != null) { + arrayOfNotNull(TAG_NAME, address.toValue(), relay.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, address.toValue(), type?.code) } fun assemble( address: Address, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), type?.code) + ) = assemble(address, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt index 8fc7e76791..8885c1cafd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedAuthorTag.kt @@ -23,16 +23,21 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedAuthorTag( - val pubkey: HexKey, + override val pubKey: HexKey, + override val relayHint: NormalizedRelayUrl? = null, override val type: ReportType? = null, -) : BaseReportTag { - fun toTagArray() = assemble(pubkey, type) +) : BaseReportTag, + PubKeyReferenceTag { + fun toTagArray() = assemble(pubKey, relayHint, type) companion object { const val TAG_NAME = "p" @@ -45,21 +50,51 @@ class ReportedAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedAuthorTag( + tag[1], + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedAuthorTag(tag[1], type) + fun parseKey(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = ReportTagLayout.relayHint(tag) + + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + + /** + * Emits the legacy `[name, id, type]` layout unless a relay hint is + * supplied. Padding slot 2 with `""` to always reach the modern + * layout would be well-formed here but unreadable to the many + * clients that still read the report type out of slot 2. + */ + fun assemble( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relayHint != null) { + arrayOfNotNull(TAG_NAME, pubkey, relayHint.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, pubkey, type?.code) } fun assemble( pubkey: HexKey, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, type?.code) + ) = assemble(pubkey, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt index f749c49d64..fcc92b9b3a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip56Reports/tags/ReportedEventTag.kt @@ -23,16 +23,24 @@ package com.vitorpamplona.quartz.nip56Reports.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable class ReportedEventTag( - val eventId: HexKey, + override val eventId: HexKey, + override val relay: NormalizedRelayUrl? = null, override val type: ReportType? = null, -) : BaseReportTag { - fun toTagArray() = assemble(eventId, type) +) : BaseReportTag, + GenericETag { + /** NIP-56 `e` tags carry no author slot — slot 3 is the report type. */ + override val author: HexKey? = null + + override fun toTagArray() = assemble(eventId, relay, type) companion object { const val TAG_NAME = "e" @@ -45,21 +53,46 @@ class ReportedEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val type = - if (tag.size == 2) { - defaultReportType - } else if (tag.size == 3) { - ReportType.parseOrNull(tag[2], tag) ?: defaultReportType - } else { - ReportType.parseOrNull(tag[3], tag) ?: defaultReportType - } + return ReportedEventTag( + tag[1], + ReportTagLayout.relayHint(tag), + ReportTagLayout.reportType(tag, defaultReportType), + ) + } - return ReportedEventTag(tag[1], type) + fun parseId(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = ReportTagLayout.relayHint(tag) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + /** See [ReportedAuthorTag.assemble] for why the layout is conditional. */ + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + type: ReportType?, + ) = if (relay != null) { + arrayOfNotNull(TAG_NAME, eventId, relay.url, type?.code) + } else { + arrayOfNotNull(TAG_NAME, eventId, type?.code) } fun assemble( eventId: HexKey, type: ReportType? = null, - ) = arrayOfNotNull(TAG_NAME, eventId, type?.code) + ) = assemble(eventId, null, type) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 1f890a4d07..dba1ba9905 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -25,9 +25,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag @@ -53,9 +59,24 @@ class ClassifiedsEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), PublishedAtProvider, + PubKeyHintProvider, + EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt new file mode 100644 index 0000000000..6842c27753 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip56Reports/ReportHintProviderTest.kt @@ -0,0 +1,187 @@ +/* + * 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.nip56Reports + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag +import com.vitorpamplona.quartz.nip56Reports.tags.ReportedEventTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReportHintProviderTest { + private val pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val other = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64" + private val eventId = "43575072239da152afe3d7b5c70ed2beb48db2b10e60c60da45229c09c877d2a" + private val addressId = "30402:$other:7e5fec50-9add-48c6-985c-eb593e6c14cd" + private val relay = "wss://relay.damus.io/" + + private fun report(vararg tags: Array) = + ReportEvent( + id = "00".repeat(32), + pubKey = pubkey, + createdAt = 1700000000, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + + // --------------------------------------------------------------- + // Layout disambiguation: legacy [name, id, type] vs modern + // [name, id, relay, type]. See ReportTagLayout. + // --------------------------------------------------------------- + + @Test + fun legacyLayoutReadsTypeFromSlotTwo() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, "nudity"))!! + assertEquals(other, tag.pubKey) + assertEquals(ReportType.NUDITY, tag.type) + assertNull(tag.relayHint) + } + + @Test + fun modernLayoutReadsHintFromTwoAndTypeFromThree() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay, "impersonation"))!! + assertEquals(other, tag.pubKey) + assertEquals(ReportType.IMPERSONATION, tag.type) + assertEquals(relay, tag.relayHint?.url) + } + + /** + * Regression: a relay URL used to be fed to ReportType.parseOrNull, + * which maps anything unrecognized to OTHER — so a hint-carrying tag + * with no per-tag type silently became an `OTHER` report and masked + * the event-level default. + */ + @Test + fun relayHintWithoutTypeFallsBackToDefaultNotOther() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay), ReportType.SPAM)!! + assertEquals(relay, tag.relayHint?.url) + assertEquals(ReportType.SPAM, tag.type) + } + + @Test + fun emptyHintSlotStillReadsTypeFromSlotThree() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, "", "nudity"))!! + assertEquals(ReportType.NUDITY, tag.type) + assertNull(tag.relayHint) + } + + @Test + fun blankTypeSlotFallsBackToDefault() { + val tag = ReportedAuthorTag.parse(arrayOf("p", other, ""), ReportType.MALWARE)!! + assertEquals(ReportType.MALWARE, tag.type) + } + + @Test + fun eventAndAddressTagsShareTheSameLayoutRules() { + val e = ReportedEventTag.parse(arrayOf("e", eventId, relay, "illegal"))!! + assertEquals(eventId, e.eventId) + assertEquals(relay, e.relay?.url) + assertEquals(ReportType.ILLEGAL, e.type) + assertNull(e.author) + + val a = ReportedAddressTag.parse(arrayOf("a", addressId, relay, "spam"))!! + assertEquals(addressId, a.address.toValue()) + assertEquals(relay, a.relay?.url) + assertEquals(ReportType.SPAM, a.type) + } + + // --------------------------------------------------------------- + // Round trip + // --------------------------------------------------------------- + + @Test + fun assembleRoundTripsBothLayouts() { + val legacy = ReportedAuthorTag.assemble(other, ReportType.SPAM) + assertEquals(listOf("p", other, "spam"), legacy.toList()) + assertEquals(ReportType.SPAM, ReportedAuthorTag.parse(legacy)!!.type) + + val modern = ReportedAuthorTag(other, RelayUrlNormalizer.normalizeOrNull(relay), ReportType.SPAM).toTagArray() + assertEquals(listOf("p", other, relay, "spam"), modern.toList()) + + val reparsed = ReportedAuthorTag.parse(modern)!! + assertEquals(ReportType.SPAM, reparsed.type) + assertEquals(relay, reparsed.relayHint?.url) + } + + // --------------------------------------------------------------- + // Hint providers + // --------------------------------------------------------------- + + @Test + fun reportEventExposesEveryPointerItCarries() { + val event = + report( + arrayOf("p", other, relay, "impersonation"), + arrayOf("e", eventId, "nudity"), + arrayOf("a", addressId, relay, "spam"), + ) + + assertEquals(listOf(other), event.linkedPubKeys()) + assertEquals(listOf(eventId), event.linkedEventIds()) + assertEquals(listOf(addressId), event.linkedAddressIds()) + + // hints only where a relay is actually present + assertEquals(listOf(other), event.pubKeyHints().map { it.pubkey }) + assertEquals(listOf(relay), event.pubKeyHints().map { it.relay.url }) + assertTrue(event.eventHints().isEmpty()) + assertEquals(listOf(addressId), event.addressHints().map { it.addressId }) + } + + @Test + fun linkedPubKeysMatchesTypedAccessorsSoTheGraphCannotDrift() { + val event = + report( + arrayOf("p", other, "nudity"), + arrayOf("p", pubkey, relay, "spam"), + arrayOf("e", eventId, "nudity"), + ) + + // The completeness invariant: every pubkey the typed accessor + // reports is also reported by the generic hint provider. + assertEquals( + event.reportedAuthor().map { it.pubKey }.toSet(), + event.linkedPubKeys().toSet(), + ) + assertEquals( + event.reportedPost().map { it.eventId }.toSet(), + event.linkedEventIds().toSet(), + ) + } + + @Test + fun malformedPointersAreDropped() { + val event = + report( + arrayOf("p", "tooshort", "nudity"), + arrayOf("p"), + arrayOf("e", eventId), + arrayOf("a", "not-an-address"), + ) + + assertTrue(event.linkedPubKeys().isEmpty()) + assertEquals(listOf(eventId), event.linkedEventIds()) + assertTrue(event.linkedAddressIds().isEmpty()) + } +}