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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
This commit is contained in:
Claude
2026-08-20 21:48:30 +00:00
parent c0f812e468
commit 509075abde
13 changed files with 206 additions and 36 deletions
@@ -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://<word>/`, 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
@@ -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
}
@@ -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<TrustedListMemberTag>
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<String> = tags.mapNotNull { memberValueOf(it) }
fun memberCount(): Int = tags.count { isMemberTag(it) }
/**
* True when the publisher signalled that it could not carry the full
@@ -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<AddressMemberTag> = 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<AddressableTrustedListEvent>.() -> 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)
}
}
}
@@ -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?,
@@ -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<EventMemberTag> = 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<EventTrustedListEvent>.() -> 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)
}
}
}
@@ -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?,
@@ -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<ExternalIdMemberTag> = 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<ExternalIdTrustedListEvent>.() -> 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)
}
}
}
@@ -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? {
@@ -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://<word>/`. 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()
}
@@ -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<PubKeyMemberTag> = 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<UserTrustedListEvent>.() -> 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)
}
}
}
@@ -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?,
@@ -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://<word>/, so an
// unguarded parse would fabricate a relay hint out of a petname
val event =
EventFactory.create<Event>(
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<UserTrustedListEvent>(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<Event>(
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<AddressableTrustedListEvent>(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<Event>(
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<UserTrustedListEvent>(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(