From 509075abde0ca228440ce203ea97ed550c234df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:48:30 +0000 Subject: [PATCH] fix(quartz): guard trusted-list member hints and drop member-scan allocations Audit of the Trusted List family turned up one real bug, one indexing inconsistency and two allocation problems. Fabricated relay hints. RelayUrlNormalizer.normalizeOrNull("alice") returns wss://alice/, so parsing index 2 of a member tag unconditionally turned any non-url there -- a petname, a label, the empty-string padding's non-empty cousins -- into a relay hint that then reached pubKeyHints()/eventHints() and the hint indexer. Member tags now apply the same length + isRelayUrl gate PTag uses on that slot. The trailing-field parsing that all four member types share moves into MemberTagFields, which also removes the three copies of the score parser. AddressMemberTag.parseAsHint accepted any non-empty value, so a malformed `a` tag produced an AddressHint keyed on a non-coordinate. It now requires the value to look like a coordinate, matching ATag.parseAsHint. memberValues() and memberCount() ran through members(), building one member object per tag just to read a value or a length -- on lists that the spec expects to carry thousands of entries. Both now read the tags directly via two protected hooks each kind implements with its own isTag/parse-value pair, so the objects are only built when a caller actually wants the hints and scores. A test pins memberCount() == members().size, including over malformed tags, since the two predicates have to stay in step. Also aligns TrustedListContentMember.memberValue with the property form used by TrustedListMemberTag, and emits list metadata ahead of the membership in build() so a large list does not bury its own header tags. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD --- .../experimental/trustedLists/README.md | 8 +- .../trustedLists/TrustedListContent.kt | 7 +- .../trustedLists/TrustedListEvent.kt | 16 +++- .../AddressableTrustedListEvent.kt | 9 +- .../addressables/tags/AddressMemberTag.kt | 12 ++- .../events/EventTrustedListEvent.kt | 9 +- .../events/tags/EventMemberTag.kt | 10 +-- .../externalIds/ExternalIdTrustedListEvent.kt | 9 +- .../externalIds/tags/ExternalIdMemberTag.kt | 7 +- .../trustedLists/tags/MemberTagFields.kt | 53 ++++++++++++ .../users/UserTrustedListEvent.kt | 9 +- .../users/tags/PubKeyMemberTag.kt | 10 +-- .../trustedLists/TrustedListEventTest.kt | 83 ++++++++++++++++++- 13 files changed, 206 insertions(+), 36 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MemberTagFields.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md index a9eb400c02..fd24570c20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md @@ -35,6 +35,9 @@ breaks kind-keyed dispatch and federation. All four extend `TrustedListEvent`, which carries everything the family shares. `members()` is narrowed per kind but always satisfies `TrustedListMemberTag`, so a kind-agnostic reader can take `memberValue` and `score` without branching. +`memberValues()` and `memberCount()` read the tags directly rather than going +through `members()`, so callers that only need the membership never pay to +build the member objects — these lists run to thousands of entries. ## Wire shape @@ -62,7 +65,10 @@ so a kind-agnostic reader can take `memberValue` and `score` without branching. index 3 for every kind in the family, so a publisher with a score but no relay hint pads index 2 with an empty string — as in the `p` tag above. On `e` members index 3 is the score, **not** a NIP-10 marker: these lists - enumerate membership, they do not thread. + enumerate membership, they do not thread. Index 2 is only read as a relay + hint when it looks like one (`MemberTagFields.relayHint`): the normalizer + turns any bare word into `wss:///`, so an unguarded parse would index a + petname as a relay. On `i` members that slot is NIP-73's URL hint instead. - `observer`, `source-tag`, `cutoff`, `min-rank` are provenance. - Single-letter tags that are *not* the kind's member tag are relay-filterable **discovery** metadata — what the list is about — and are diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt index 3237ba6e7b..cebf986c9b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.trustedLists import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlinx.serialization.Serializable @@ -67,5 +68,9 @@ data class TrustedListContentMember( val disputes: Int? = null, val score: Int? = null, ) { - fun memberValue() = pubkey ?: id ?: address ?: i + /** + * Matches [TrustedListMemberTag.memberValue] on the tag side. Computed, so + * it never round-trips into the JSON as a key of its own. + */ + val memberValue: String? get() = pubkey ?: id ?: address ?: i } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt index d2d8cbf8b4..a49e416bc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArray /** @@ -79,9 +80,20 @@ abstract class TrustedListEvent( */ abstract fun members(): List - fun memberValues() = members().map { it.memberValue } + /** True when [tag] is one of this kind's member tags. Must accept exactly what [members] parses. */ + protected abstract fun isMemberTag(tag: Tag): Boolean - fun memberCount() = members().size + /** The member value in [tag], or null when it is not one of this kind's member tags. */ + protected abstract fun memberValueOf(tag: Tag): String? + + /** + * The member values alone. Goes straight from the tags so that callers who + * do not need the hints and scores never pay to build the member objects -- + * these lists run to thousands of entries. + */ + fun memberValues(): List = tags.mapNotNull { memberValueOf(it) } + + fun memberCount(): Int = tags.count { isMemberTag(it) } /** * True when the publisher signalled that it could not carry the full diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt index fd6f7df211..ced4f702b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -51,6 +52,10 @@ class AddressableTrustedListEvent( PubKeyHintProvider { override fun members(): List = tags.members() + override fun isMemberTag(tag: Tag) = AddressMemberTag.isTag(tag) + + override fun memberValueOf(tag: Tag) = AddressMemberTag.parseAddressId(tag) + override fun addressHints() = tags.mapNotNull(AddressMemberTag::parseAsHint) override fun linkedAddressIds() = tags.mapNotNull(AddressMemberTag::parseAddressId) @@ -73,8 +78,10 @@ class AddressableTrustedListEvent( initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { dTag(listId) - members(members) + // metadata first: it keeps the header tags ahead of a membership + // that can run to thousands of entries initializer() + members(members) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt index 2324776f37..b153ff1ac9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer @@ -28,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag 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.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -67,7 +67,7 @@ data class AddressMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return AddressMemberTag(tag[1], parseHint(tag), parseScore(tag)) + return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } fun parseAddressId(tag: Tag): String? { @@ -88,18 +88,16 @@ data class AddressMemberTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } + // only index a value that is actually a coordinate, as ATag does + ensure(tag[1].contains(':')) { return null } - val hint = parseHint(tag) + val hint = MemberTagFields.relayHint(tag) ensure(hint != null) { return null } return AddressHint(tag[1], hint) } - private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) } - - private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull() - fun assemble( address: String, relayHint: NormalizedRelayUrl?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt index 6fadea6aab..e2c8b4956d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -56,6 +57,10 @@ class EventTrustedListEvent( PubKeyHintProvider { override fun members(): List = tags.members() + override fun isMemberTag(tag: Tag) = EventMemberTag.isTag(tag) + + override fun memberValueOf(tag: Tag) = EventMemberTag.parseId(tag) + override fun eventHints() = tags.mapNotNull(EventMemberTag::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventMemberTag::parseId) @@ -84,8 +89,10 @@ class EventTrustedListEvent( initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { dTag(listId) - members(members) + // metadata first: it keeps the header tags ahead of a membership + // that can run to thousands of entries initializer() + members(members) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt index 27b46d5cfc..41f2ac30ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.quartz.experimental.trustedLists.events.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag 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.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -63,7 +63,7 @@ data class EventMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return EventMemberTag(tag[1], parseHint(tag), parseScore(tag)) + return EventMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } fun parseId(tag: Tag): HexKey? { @@ -78,17 +78,13 @@ data class EventMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val hint = parseHint(tag) + val hint = MemberTagFields.relayHint(tag) ensure(hint != null) { return null } return EventIdHint(tag[1], hint) } - private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) } - - private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull() - fun assemble( eventId: HexKey, relayHint: NormalizedRelayUrl?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt index 92b4ce6d60..c87e44bb40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -50,6 +51,10 @@ class ExternalIdTrustedListEvent( PubKeyHintProvider { override fun members(): List = tags.members() + override fun isMemberTag(tag: Tag) = ExternalIdMemberTag.isTag(tag) + + override fun memberValueOf(tag: Tag) = ExternalIdMemberTag.parseId(tag) + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) @@ -74,8 +79,10 @@ class ExternalIdTrustedListEvent( initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { dTag(listId) - members(members) + // metadata first: it keeps the header tags ahead of a membership + // that can run to thousands of entries initializer() + members(members) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt index 2ad88d6b89..0083ecc88b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has @@ -60,11 +61,7 @@ data class ExternalIdMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return ExternalIdMemberTag( - tag[1], - tag.getOrNull(2)?.takeIf { it.isNotEmpty() }, - tag.getOrNull(3)?.toIntOrNull(), - ) + return ExternalIdMemberTag(tag[1], MemberTagFields.hint(tag), MemberTagFields.score(tag)) } fun parseId(tag: Tag): String? { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MemberTagFields.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MemberTagFields.kt new file mode 100644 index 0000000000..64a09c6047 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MemberTagFields.kt @@ -0,0 +1,53 @@ +/* + * 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.experimental.trustedLists.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * The trailing fields every member tag in the family shares: index 2 is the + * hint and index 3 the score, on `p`, `e`, `a` and `i` alike. + */ +object MemberTagFields { + const val HINT_INDEX = 2 + const val SCORE_INDEX = 3 + + /** + * Index 2 only counts as a relay hint when it actually looks like one. + * Publishers pad it with an empty string when they carry a score but no + * hint, and neighbouring conventions put a petname there -- while the + * normalizer turns any bare word into `wss:///`. Without this guard + * a petname would be indexed as a relay nobody can connect to, so this + * mirrors the check `PTag` applies to the same slot. + */ + fun relayHint(tag: Tag): NormalizedRelayUrl? { + val raw = tag.getOrNull(HINT_INDEX) ?: return null + if (raw.length < 8 || !RelayUrlNormalizer.isRelayUrl(raw)) return null + return RelayUrlNormalizer.normalizeOrNull(raw) + } + + /** The raw hint, for member types whose hint is not a relay url (NIP-73). */ + fun hint(tag: Tag): String? = tag.getOrNull(HINT_INDEX)?.takeIf { it.isNotEmpty() } + + fun score(tag: Tag): Int? = tag.getOrNull(SCORE_INDEX)?.toIntOrNull() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt index 24760f6cc6..7ce5fec5d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -51,6 +52,10 @@ class UserTrustedListEvent( AddressHintProvider { override fun members(): List = tags.members() + override fun isMemberTag(tag: Tag) = PubKeyMemberTag.isTag(tag) + + override fun memberValueOf(tag: Tag) = PubKeyMemberTag.parseKey(tag) + override fun pubKeyHints() = tags.mapNotNull(PubKeyMemberTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PubKeyMemberTag::parseKey) @@ -73,8 +78,10 @@ class UserTrustedListEvent( initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { dTag(listId) - members(members) + // metadata first: it keeps the header tags ahead of a membership + // that can run to thousands of entries initializer() + members(members) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt index a9884c7af5..2630ffbf03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.quartz.experimental.trustedLists.users.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag 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.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -65,7 +65,7 @@ data class PubKeyMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return PubKeyMemberTag(tag[1], parseHint(tag), parseScore(tag)) + return PubKeyMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } fun parseKey(tag: Tag): HexKey? { @@ -80,17 +80,13 @@ data class PubKeyMemberTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val hint = parseHint(tag) + val hint = MemberTagFields.relayHint(tag) ensure(hint != null) { return null } return PubKeyHint(tag[1], hint) } - private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) } - - private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull() - fun assemble( pubKey: HexKey, relayHint: NormalizedRelayUrl?, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt index e9ab442abe..75a2935eaa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt @@ -39,6 +39,7 @@ import kotlin.test.assertTrue class TrustedListEventTest { private val observer = "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331" + private val member = "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450" private val tagEventId = "2f6a8652bde6fb5a974d6e06e4eae3b4f130140fd170b2686a291463f47a7451" private val tagAuthor = "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f" private val dummySig = "00".repeat(64) @@ -135,12 +136,12 @@ class TrustedListEventTest { val echo = event.contentEcho() assertEquals(3, echo?.members?.size) - assertEquals("b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", echo?.members?.first()?.memberValue()) + assertEquals("b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", echo?.members?.first()?.memberValue) assertEquals(4, echo?.members?.first()?.endorsements) assertEquals(0, echo?.members?.first()?.disputes) assertEquals(99, echo?.members?.first()?.score) assertNull(echo?.partial, "a complete list carries no partial marker") - assertEquals(event.memberValues(), echo?.members?.mapNotNull { it.memberValue() }) + assertEquals(event.memberValues(), echo?.members?.mapNotNull { it.memberValue }) } @Test @@ -318,6 +319,84 @@ class TrustedListEventTest { assertEquals(91, member.score) } + @Test + fun aNonUrlAtTheHintSlotIsNotReadAsARelay() { + // the normalizer happily turns a bare word into wss:///, so an + // unguarded parse would fabricate a relay hint out of a petname + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599", + createdAt = 1_787_253_028L, + kind = UserTrustedListEvent.KIND, + tags = + arrayOf( + arrayOf("d", "tl"), + arrayOf("p", member, "alice", "99"), + arrayOf("p", "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a", "wss://nos.lol/", "97"), + ), + content = "", + sig = dummySig, + ) + assertIs(event) + + assertNull(event.members().first().relayHint, "a petname must not become a relay hint") + assertEquals(99, event.members().first().score, "the score must still be read") + assertEquals("wss://nos.lol/", event.members()[1].relayHint?.url) + assertEquals(listOf("wss://nos.lol/"), event.pubKeyHints().map { it.relay.url }) + } + + @Test + fun aNonCoordinateAddressIsNotIndexedAsAHint() { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599", + createdAt = 1_787_253_028L, + kind = AddressableTrustedListEvent.KIND, + tags = + arrayOf( + arrayOf("d", "tl"), + arrayOf("a", "not-a-coordinate", "wss://nos.lol/"), + arrayOf("a", "39999:$tagAuthor:podcaster", "wss://nos.lol/"), + ), + content = "", + sig = dummySig, + ) + assertIs(event) + + assertEquals(listOf("39999:$tagAuthor:podcaster"), event.addressHints().map { it.addressId }) + } + + @Test + fun memberCountMatchesTheParsedMembers() { + // memberCount() counts tags without building the member objects, so it + // has to accept exactly what members() parses -- malformed tags included + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599", + createdAt = 1_787_253_028L, + kind = UserTrustedListEvent.KIND, + tags = + arrayOf( + arrayOf("d", "tl"), + arrayOf("observer", observer), + arrayOf("p", member, "", "99"), + arrayOf("p", "too-short"), + arrayOf("p"), + arrayOf("e", "f00dcafe00000000000000000000000000000000000000000000000000000000"), + ), + content = "", + sig = dummySig, + ) + assertIs(event) + + assertEquals(event.members().size, event.memberCount()) + assertEquals(event.members().map { it.memberValue }, event.memberValues()) + assertEquals(1, event.memberCount(), "only the well-formed p tag is a member") + } + @Test fun memberTagsRoundTripThroughTheirWireShape() { assertEquals(