feat(nip56): declare pointer hints on report, chat, classifieds and channel events

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", <pubkey>, "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.
This commit is contained in:
Claude
2026-08-11 20:15:58 +00:00
parent c43339e92b
commit db6b81eb04
14 changed files with 462 additions and 46 deletions
@@ -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)
}
@@ -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)
}
@@ -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
@@ -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)
}
}
@@ -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 {
@@ -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
@@ -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<Array<String>>,
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
@@ -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<ReportEvent>.event(
eventId: HexKey,
reportType: ReportType,
) = addUnique(ReportedEventTag.assemble(eventId, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedEventTag.assemble(eventId, relay, reportType))
fun TagArrayBuilder<ReportEvent>.address(
address: Address,
reportType: ReportType,
) = addUnique(ReportedAddressTag.assemble(address, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedAddressTag.assemble(address, relay, reportType))
fun TagArrayBuilder<ReportEvent>.user(
pubkey: HexKey,
reportType: ReportType,
) = addUnique(ReportedAuthorTag.assemble(pubkey, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedAuthorTag.assemble(pubkey, relay, reportType))
fun TagArrayBuilder<ReportEvent>.hash(
x: String,
@@ -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", "<pubkey>", "nudity"] // legacy: type at 2
* ["p", "<pubkey>", "wss://relay/", "nudity"] // modern: hint at 2, type at 3
* ["p", "<pubkey>", "wss://relay/"] // modern, no per-tag type
* ["p", "<pubkey>", "", "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<String>): 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<String>,
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
}
}
@@ -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>): 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<String>): 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)
}
}
@@ -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<String>): 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<String>): 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)
}
}
@@ -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<String>): 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<String>): 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)
}
}
@@ -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)
@@ -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<String>) =
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())
}
}