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(