From 9fae1e526e125c009b208825cfedf431c7b16bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:26:10 +0000 Subject: [PATCH 1/5] feat(quartz): give the trusted-list member score a 0-100 scale The member tag has carried its score at index 3, right after the relay hint, since the family landed -- but as a bare Int with no domain. A number nobody agreed on the ceiling for cannot be compared across two publishers, or even across two metrics of one publisher, which is the whole reason a list carries scores instead of just membership. Pin it to a percentage: an integer 0..100 inclusive, named once in MemberTagFields.SCORE_RANGE and shared by `p`, `e`, `a` and `i`. Write clamps into the range, so we never emit a value we would refuse to read. Read drops anything outside it rather than clamping: a publisher counting on some other scale (0..1, 0..1000, a raw endorsement tally) is reporting a quantity this field cannot carry, and pinning 950 to 100 would rank that member above every honestly-scored peer. The member itself still stands -- it is simply unscored, the same state as a tag that carries no score at all. Both bounds are real scores, not sentinels: 0 means "scored, and the publisher has no confidence in this member", which is not the same as unscored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp --- .../experimental/trustedLists/README.md | 12 ++- .../trustedLists/TrustedListContent.kt | 5 ++ .../addressables/TagArrayBuilderExt.kt | 5 ++ .../addressables/tags/AddressMemberTag.kt | 5 +- .../trustedLists/events/TagArrayBuilderExt.kt | 5 ++ .../events/tags/EventMemberTag.kt | 7 +- .../externalIds/TagArrayBuilderExt.kt | 5 ++ .../externalIds/tags/ExternalIdMemberTag.kt | 5 +- .../trustedLists/tags/MemberTagFields.kt | 29 ++++++- .../trustedLists/tags/TrustedListMemberTag.kt | 6 +- .../trustedLists/users/TagArrayBuilderExt.kt | 5 ++ .../users/tags/PubKeyMemberTag.kt | 7 +- .../trustedLists/TrustedListEventTest.kt | 85 +++++++++++++++++++ 13 files changed, 168 insertions(+), 13 deletions(-) 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 efc0ad1af5..3bc6e164e2 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 @@ -62,8 +62,16 @@ build the member objects — these lists run to thousands of entries. recomputed (`listId()`). - `title` / `metric` are the label and the name of the computation. - Member tags carry `[, , , ]`. The score sits at - 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. + index 3 for every kind in the family — right after the relay hint — so a + publisher with a score but no relay hint pads index 2 with an empty string, + as in the `p` tag above. It is a **percentage: an integer 0–100 inclusive** + (`MemberTagFields.SCORE_RANGE`). The fixed scale is the point — it is what + lets a consumer compare members across two publishers, or across two metrics + of one publisher, without knowing either computation. `assemble` clamps into + the range, so we never emit what we would refuse to read; a parsed value + outside it is dropped rather than clamped, because a 950 pinned to 100 would + rank a member from some other scale above every honestly-scored peer. Such a + member is simply unscored, exactly as if the tag carried no score at all. On `e` members index 3 is the score, **not** a NIP-10 marker: these lists enumerate membership, they do not thread. Index 2 is only read as a relay hint when it looks like one (`MemberTagFields.relayHint`): the normalizer 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 cebf986c9b..df92cdce41 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 @@ -66,6 +66,11 @@ data class TrustedListContentMember( val i: String? = null, val endorsements: Int? = null, val disputes: Int? = null, + /** + * The same 0..100 percentage the member tag carries at index 3. The echo is + * the publisher's own JSON, so it is taken as written -- the tag is the + * authoritative copy, and the one whose range this library enforces. + */ val score: Int? = null, ) { /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt index 015ed351f2..8b4c584ead 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt @@ -27,6 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +/** + * Adds one member. [score] is the publisher's 0..100 confidence percentage and + * goes after the hint; it is clamped into range, and left off the tag entirely + * when null. See `MemberTagFields.SCORE_RANGE`. + */ fun TagArrayBuilder.member( address: String, relayHint: NormalizedRelayUrl? = null, 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 b153ff1ac9..8257f672cb 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 @@ -36,6 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure * An addressable-event member of a kind-30394 Trusted List: * `["a", , , ]`. * + * The score is a 0..100 percentage at index 3, after the relay hint; see + * [MemberTagFields.SCORE_RANGE]. + * * A-coordinate members belong on 30394 and never on 30393: publishing them on * the event-id kind would tell a conformant reader "these are event ids" when * they are coordinates, breaking kind-keyed dispatch. @@ -102,7 +105,7 @@ data class AddressMemberTag( address: String, relayHint: NormalizedRelayUrl?, score: Int?, - ) = arrayOfNotNull(TAG_NAME, address, relayHint?.url, score?.toString()) + ) = arrayOfNotNull(TAG_NAME, address, relayHint?.url, MemberTagFields.encodeScore(score)) fun assemble(member: AddressMemberTag) = assemble(member.address, member.relayHint, member.score) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt index e19570311d..703809a769 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt @@ -27,6 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +/** + * Adds one member. [score] is the publisher's 0..100 confidence percentage and + * goes after the hint; it is clamped into range, and left off the tag entirely + * when null. See `MemberTagFields.SCORE_RANGE`. + */ fun TagArrayBuilder.member( eventId: HexKey, relayHint: NormalizedRelayUrl? = null, 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 41f2ac30ed..dbe0028fde 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 @@ -35,8 +35,9 @@ import com.vitorpamplona.quartz.utils.ensure * An event member of a kind-30393 Trusted List: * `["e", , , ]`. * - * Index 3 is the score across the whole family, not a NIP-10 marker: these - * lists enumerate membership, they do not thread. + * Index 3 is the score across the whole family -- a 0..100 percentage, see + * [MemberTagFields.SCORE_RANGE] -- and not a NIP-10 marker: these lists + * enumerate membership, they do not thread. */ @Immutable data class EventMemberTag( @@ -89,7 +90,7 @@ data class EventMemberTag( eventId: HexKey, relayHint: NormalizedRelayUrl?, score: Int?, - ) = arrayOfNotNull(TAG_NAME, eventId, relayHint?.url, score?.toString()) + ) = arrayOfNotNull(TAG_NAME, eventId, relayHint?.url, MemberTagFields.encodeScore(score)) fun assemble(member: EventMemberTag) = assemble(member.eventId, member.relayHint, member.score) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt index 72693526b3..3633ee1ac1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt @@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +/** + * Adds one member. [score] is the publisher's 0..100 confidence percentage and + * goes after the hint; it is clamped into range, and left off the tag entirely + * when null. See `MemberTagFields.SCORE_RANGE`. + */ fun TagArrayBuilder.member( externalId: String, hint: String? = null, 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 0083ecc88b..58bf0a11c3 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 @@ -34,7 +34,8 @@ import com.vitorpamplona.quartz.utils.ensure * `["i", , , ]`. * * Index 2 is NIP-73's URL hint rather than a relay hint, so these members - * carry no relay information for the hint indexer. + * carry no relay information for the hint indexer. Index 3 is the same 0..100 + * score the rest of the family carries; see [MemberTagFields.SCORE_RANGE]. */ @Immutable data class ExternalIdMemberTag( @@ -75,7 +76,7 @@ data class ExternalIdMemberTag( externalId: String, hint: String?, score: Int?, - ) = arrayOfNotNull(TAG_NAME, externalId, hint, score?.toString()) + ) = arrayOfNotNull(TAG_NAME, externalId, hint, MemberTagFields.encodeScore(score)) fun assemble(member: ExternalIdMemberTag) = assemble(member.externalId, member.hint, member.score) 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 index 64a09c6047..ea1b2de1aa 100644 --- 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 @@ -32,6 +32,16 @@ object MemberTagFields { const val HINT_INDEX = 2 const val SCORE_INDEX = 3 + /** + * The score is a percentage of the publisher's confidence in the member, + * so it is only meaningful on a fixed scale: **0 to 100 inclusive**. The + * range is what lets a consumer compare members across two publishers, or + * across two metrics of the same publisher, without knowing either + * computation -- a bare number with a publisher-chosen ceiling could not + * be compared at all. + */ + val SCORE_RANGE = 0..100 + /** * 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 @@ -49,5 +59,22 @@ object MemberTagFields { /** 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() + /** + * The score at index 3, when it is one this scale can express. + * + * A number outside [SCORE_RANGE] is dropped rather than clamped: it is a + * publisher counting on some other scale (0..1, 0..1000, a raw endorsement + * tally), and pinning it to the nearest bound would turn an unknown + * quantity into a confident one -- a 950 read as 100 ranks that member + * above every honestly-scored peer. The member itself still stands; it is + * simply unscored, which is the same state as a tag that carries no score. + */ + fun score(tag: Tag): Int? = tag.getOrNull(SCORE_INDEX)?.toIntOrNull()?.takeIf { it in SCORE_RANGE } + + /** + * The score as it goes on the wire, clamped into [SCORE_RANGE] so that we + * never emit a value our own [score] would refuse to read. Null stays null: + * an unscored member pads nothing and the tag simply ends at the hint. + */ + fun encodeScore(score: Int?): String? = score?.coerceIn(SCORE_RANGE)?.toString() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt index 78a7b18821..207b035b80 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt @@ -35,6 +35,10 @@ interface TrustedListMemberTag { /** The pubkey, event id, a-coordinate or external id of this member. */ val memberValue: String - /** The computed score the publisher assigned to this member, if any. */ + /** + * The computed score the publisher assigned to this member, if any, as a + * percentage in [MemberTagFields.SCORE_RANGE] (0..100). Null both when the + * tag carries no score and when it carries one this scale cannot express. + */ val score: Int? } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt index 17e3839238..e9bc2f4f4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt @@ -26,6 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +/** + * Adds one member. [score] is the publisher's 0..100 confidence percentage and + * goes after the hint; it is clamped into range, and left off the tag entirely + * when null. See `MemberTagFields.SCORE_RANGE`. + */ fun TagArrayBuilder.member( pubKey: HexKey, relayHint: NormalizedRelayUrl? = null, 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 2630ffbf03..42efba2600 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 @@ -36,8 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure * A pubkey member of a kind-30392 Trusted List: * `["p", , , ]`. * - * The score sits at index 3 across the whole family, so publishers that have a - * score but no relay hint pad index 2 with an empty string. + * The score sits at index 3 across the whole family -- after the relay hint -- + * so publishers that have a score but no relay hint pad index 2 with an empty + * string. It is a 0..100 percentage; see [MemberTagFields.SCORE_RANGE]. */ @Immutable data class PubKeyMemberTag( @@ -91,7 +92,7 @@ data class PubKeyMemberTag( pubKey: HexKey, relayHint: NormalizedRelayUrl?, score: Int?, - ) = arrayOfNotNull(TAG_NAME, pubKey, relayHint?.url, score?.toString()) + ) = arrayOfNotNull(TAG_NAME, pubKey, relayHint?.url, MemberTagFields.encodeScore(score)) fun assemble(member: PubKeyMemberTag) = assemble(member.pubKey, member.relayHint, member.score) 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 e218745170..f413559884 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.Addr import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag @@ -451,6 +452,75 @@ class TrustedListEventTest { assertEquals("", event.indexableContent()) } + @Test + fun theScoreScaleRunsFromZeroToOneHundredInclusive() { + // both bounds are real scores, not sentinels: 0 is "scored, and the + // publisher has no confidence in this member", which is not the same + // state as an unscored member + 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, "", "0"), + arrayOf("p", "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a", "", "100"), + arrayOf("p", "19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f", "wss://nos.lol/"), + ), + content = "", + sig = dummySig, + ) + assertIs(event) + + assertEquals(listOf(0, 100, null), event.members().map { it.score }) + } + + @Test + fun aScoreOffTheZeroToOneHundredScaleIsDroppedRatherThanClamped() { + // a publisher counting on some other scale (0..1, 0..1000, a raw + // endorsement tally) is telling us a quantity this field cannot carry. + // Pinning it to the nearest bound would turn that unknown into a + // confident one -- 950 read as 100 outranks every honestly-scored peer + 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, "", "950"), + arrayOf("p", "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a", "", "-1"), + arrayOf("p", "19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f", "", "0.87"), + ), + content = "", + sig = dummySig, + ) + assertIs(event) + + assertEquals(listOf(null, null, null), event.members().map { it.score }, "an unreadable score leaves the member unscored") + // the membership itself is untouched -- an unreadable score is not an + // unreadable member + assertEquals(3, event.memberCount()) + assertEquals(member, event.memberValues().first()) + } + + @Test + fun theScoreRangeHoldsOnEveryMemberType() { + assertNull(EventMemberTag.parse(arrayOf("e", "f00dcafe" + "0".repeat(56), "", "101"))?.score) + assertEquals(100, EventMemberTag.parse(arrayOf("e", "f00dcafe" + "0".repeat(56), "", "100"))?.score) + assertNull(AddressMemberTag.parse(arrayOf("a", "39999:$tagAuthor:podcaster", "", "101"))?.score) + assertEquals(100, AddressMemberTag.parse(arrayOf("a", "39999:$tagAuthor:podcaster", "", "100"))?.score) + assertNull(ExternalIdMemberTag.parse(arrayOf("i", "podcast:guid:c90e609a", "", "101"))?.score) + assertEquals(100, ExternalIdMemberTag.parse(arrayOf("i", "podcast:guid:c90e609a", "", "100"))?.score) + assertNull(PubKeyMemberTag.parse(arrayOf("p", member, "", "101"))?.score) + assertEquals(100, PubKeyMemberTag.parse(arrayOf("p", member, "", "100"))?.score) + } + @Test fun memberTagsRoundTripThroughTheirWireShape() { assertEquals( @@ -470,4 +540,19 @@ class TrustedListEventTest { AddressMemberTag("39999:$tagAuthor:podcaster").toTagArray().toList(), ) } + + @Test + fun assemblingClampsSoWeNeverEmitAScoreWeWouldRefuseToRead() { + assertEquals( + listOf("p", member, "", "100"), + PubKeyMemberTag(member, score = 950).toTagArray().toList(), + ) + assertEquals( + listOf("p", member, "", "0"), + PubKeyMemberTag(member, score = -1).toTagArray().toList(), + ) + // and the round trip closes: what we write, we read back unchanged + assertEquals(100, PubKeyMemberTag.parse(PubKeyMemberTag(member, score = 950).toTagArray())?.score) + assertEquals(0, PubKeyMemberTag.parse(PubKeyMemberTag(member, score = -1).toTagArray())?.score) + } } From 2ffc5ff49dcbbec7b24ca8182ddc3df6da83ccb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:05:20 +0000 Subject: [PATCH 2/5] feat(quartz): read the Trusted List entry in a NIP-85 Treasure Map A 10040 delegates each assertion kind+metric with ["30382:rank", , ]. Trusted Lists extend the Map with a generic bare-kind entry, ["30392", , ] (Tapestry ADR tl-treasure-map/0001), where one entry delegates every list of that kind and names are never enumerated. Quartz could not see it at all: parsing went through ServiceType, which requires a `:`, so the entry fell out as unparseable and the delegation was invisible. Two further gaps came out of probing the same path: An entry whose relay hint is the empty string -- what a publisher writes when it has no relay configured, keeping the three-element shape -- was dropped whole, taking the pubkey with it. The pubkey is the part a consumer cannot do without, so relayUrl is nullable here and the delegation stands without a hint. A reserved named entry, ["30392:podcaster", ...], splits into two segments exactly like "30382:rank" and was being handed to NIP-85 consumers as a live provider -- the one thing the spec says readers must not do with them. ServiceProviderTag.parse is now bounded to NIP-85's own assertion kinds (30382-30385), so those entries route to the Trusted List parser instead of the rank/follower-count lookups. Nothing is lost, only sorted: named entries parse, carry isGeneric = false, and drive nothing. Readers resolve duplicate generic entries first-occurrence-wins, so two readers of one Map pick the same publisher. Writers go through replaceTrustedListProvider, which swaps the entry in place, collapses duplicates for that kind, and preserves every other tag verbatim -- 10040 is replaceable, so anything dropped on an update is gone from the Map for good. Content is carried across untouched, so the write needs no decryption permission. Kept in experimental/trustedLists/treasureMap rather than the NIP-85 package: this is a pre-NIP extension riding on that kind, and a NIP-85 consumer should stay unaware of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp --- .../experimental/trustedLists/README.md | 57 ++++ .../trustedLists/treasureMap/TagArrayExt.kt | 86 ++++++ .../treasureMap/TrustProviderListEventExt.kt | 80 ++++++ .../treasureMap/TrustedListProviderTag.kt | 162 +++++++++++ .../list/tags/ServiceProviderTag.kt | 23 ++ .../trustedLists/TreasureMapEntryTest.kt | 254 ++++++++++++++++++ 6 files changed, 662 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.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 3bc6e164e2..79c12c4713 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 @@ -88,6 +88,63 @@ build the member objects — these lists run to thousands of entries. - `content` is an optional JSON echo of the members with their computed values (`contentEcho()` → `TrustedListContent`). +## Treasure Map advertisement (kind 10040) + +A NIP-85 Treasure Map delegates each assertion kind+metric to a publisher with +`["30382:rank", , ]`. Trusted Lists extend it with a **generic +bare-kind entry** (Tapestry ADR `tl-treasure-map/0001`): + +```json +["30392", "", "wss://nip85.brainstorm.world"] +``` + +One entry delegates *all* lists of that kind — the ones computed under the Map +owner's point of view, discoverable at the relay hint. List names are never +enumerated, which is the point of the bare-kind form: the Map stays a fixed +size however many lists the publisher computes. + +Parse rule — split the first element on `:`. A single all-digits segment is a +generic entry; two segments are either NIP-85's `3038x:` or a **named** +TL entry (`3039x:`, reserved). Named entries parse so a reader can +display them as Trusted List entries, but drive no behavior until the spec +defines them — `isGeneric` is the guard, and `trustedListProvider(kind)` +returns only the generic one. + +This lives in `treasureMap/`, outside `nip85TrustedAssertions/`, even though it +rides on that kind: `ServiceProviderTag` models NIP-85's own delegation, and a +NIP-85 consumer should stay unaware of this family. That separation is load +bearing in both directions — `ServiceProviderTag.parse` is bounded to NIP-85's +own assertion kinds (30382–30385), so a `30392:podcaster` entry, which splits +into two segments exactly like `30382:rank`, is never handed to code looking +for a rank or follower-count service. + +Two things the reader must not do: + +- **Drop an entry with an empty relay hint.** A publisher with no relay + configured still writes the three-element shape with `""` in the slot. The + pubkey is the part a consumer cannot do without, so `relayUrl` is nullable + and the delegation stands without it. +- **Resolve duplicates arbitrarily.** At most one generic entry per kind is the + *writer's* invariant; where duplicates appear in the wild the **first + occurrence wins**, so two readers of one Map resolve the same publisher. + +Writing goes through `replaceTrustedListProvider`, which swaps the generic +entry for its kind **in place** and preserves every other tag verbatim — 10040 +is replaceable, so the update republishes the whole tag set and anything +dropped is lost from the Map for good. `content` (the NIP-44 envelope holding +private entries) is carried across untouched, so the write needs no decryption +permission. + +```kotlin +val updated = + treasureMap.replaceTrustedListProvider( + kind = UserTrustedListEvent.KIND, + pubkey = publisherHex, + relayUrl = RelayUrlNormalizer.normalizeOrNull("wss://nip85.brainstorm.world"), + signer = signer, + ) +``` + ## Completeness and retraction A list an integrator relies on must be complete, or say that it isn't. The diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt new file mode 100644 index 0000000000..068a61e7b5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt @@ -0,0 +1,86 @@ +/* + * 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.treasureMap + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull + +/** + * Every Trusted List entry in a Treasure Map, generic and named alike. Named + * entries are reserved, so a caller that intends to *act* on a delegation + * wants [trustedListProvider] instead -- this is the display-everything view. + */ +fun TagArray.trustedListProviders() = mapNotNull(TrustedListProviderTag::parse) + +/** + * The generic entry delegating [kind], or null when the Map does not delegate + * that kind. + * + * There is meant to be at most one generic entry per kind. Where duplicates + * turn up in the wild the **first occurrence wins**, which is what + * [fastFirstNotNullOfOrNull] gives us -- a fixed rule so that two readers of + * the same Map resolve the same publisher. + */ +fun TagArray.trustedListProvider(kind: Int) = + fastFirstNotNullOfOrNull { tag -> + TrustedListProviderTag.parseGeneric(tag)?.takeIf { it.kind == kind } + } + +/** + * Replaces the generic entry for [provider]'s kind, preserving **every other + * tag verbatim** -- 10040 is replaceable, so an update republishes the whole + * tag set and anything dropped here is lost from the Map for good. + * + * The replacement keeps the old entry's position rather than moving it to the + * end, so a Map does not reshuffle on every publisher switch. Redundant + * generic entries for the same kind are collapsed onto that one: the invariant + * is at most one per kind, and a writer that has to touch the kind anyway is + * the right place to settle a Map that arrived violating it. Named entries for + * the same kind are left alone -- they are a different delegation, reserved to + * override this one per list. + */ +fun TagArray.replaceTrustedListProvider(provider: TrustedListProviderTag): TagArray { + val replacement = provider.toTagArray() + var replaced = false + + val out = ArrayList>(size + 1) + forEach { tag -> + val existing = TrustedListProviderTag.parseGeneric(tag) + if (existing != null && existing.kind == provider.kind) { + if (!replaced) { + out.add(replacement) + replaced = true + } + } else { + out.add(tag) + } + } + + if (!replaced) out.add(replacement) + + return out.toTypedArray() +} + +/** Drops the generic entry for [kind], leaving every other tag verbatim. */ +fun TagArray.removeTrustedListProvider(kind: Int): TagArray = + filterNot { tag -> + TrustedListProviderTag.parseGeneric(tag)?.kind == kind + }.toTypedArray() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt new file mode 100644 index 0000000000..ae6d57dc41 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt @@ -0,0 +1,80 @@ +/* + * 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.treasureMap + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The Trusted List entries this Map carries, generic and named alike. + * + * These are extensions rather than members of [TrustProviderListEvent] on + * purpose: the entry is a pre-NIP Tapestry extension riding on NIP-85's kind, + * so the NIP-85 event class stays unaware of it and a consumer opts in by + * importing this package. + */ +fun TrustProviderListEvent.trustedListProviders() = tags.trustedListProviders() + +/** The publisher this Map delegates all its [kind] lists to, if any. */ +fun TrustProviderListEvent.trustedListProvider(kind: Int) = tags.trustedListProvider(kind) + +/** + * Republishes the Map with [provider] as the generic entry for its kind. + * + * Every other tag survives verbatim, and `content` -- the NIP-44 envelope + * holding the private entries -- is carried across untouched rather than + * re-encrypted, so this needs no decryption permission from the signer. + */ +suspend fun TrustProviderListEvent.replaceTrustedListProvider( + provider: TrustedListProviderTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), +): TrustProviderListEvent = + TrustProviderListEvent.resign( + content = content, + tags = tags.replaceTrustedListProvider(provider), + signer = signer, + createdAt = createdAt, + ) + +suspend fun TrustProviderListEvent.replaceTrustedListProvider( + kind: Int, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), +): TrustProviderListEvent = replaceTrustedListProvider(TrustedListProviderTag(kind, null, pubkey, relayUrl), signer, createdAt) + +/** Republishes the Map without its generic entry for [kind]. */ +suspend fun TrustProviderListEvent.removeTrustedListProvider( + kind: Int, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), +): TrustProviderListEvent = + TrustProviderListEvent.resign( + content = content, + tags = tags.removeTrustedListProvider(kind), + signer = signer, + createdAt = createdAt, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt new file mode 100644 index 0000000000..489dc1b40d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt @@ -0,0 +1,162 @@ +/* + * 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.treasureMap + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedLists.addressables.AddressableTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent +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.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +/** + * A Trusted List entry in a NIP-85 Treasure Map (kind 10040). + * + * The Map delegates each Trusted-Assertion kind+metric to a publisher with + * `["30382:rank", , ]`. Trusted Lists extend it with a + * **generic bare-kind entry** (Tapestry ADR `tl-treasure-map/0001`): + * + * ```json + * ["30392", "", "wss://nip85.brainstorm.world"] + * ``` + * + * One entry delegates *all* lists of that kind -- the lists computed under the + * Map owner's point of view, discoverable at the relay hint. List names are + * never enumerated, which is the whole point of the bare-kind form: the Map + * stays a fixed size no matter how many lists the publisher computes. + * + * This deliberately lives outside `nip85TrustedAssertions` even though it + * rides on that kind: the entry is a pre-NIP Tapestry extension, and + * [com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag] + * models NIP-85's own `3038x:` delegation. Keeping them apart is what + * lets a NIP-85 consumer stay unaware of this family -- and is why + * `serviceProviders()` does not hand a `3039x` entry to code looking for a + * rank provider. + */ +@Immutable +data class TrustedListProviderTag( + /** The Trusted List kind delegated: one of [KINDS]. */ + val kind: Int, + /** + * The list name on a **named** entry, or null on the generic one. + * + * Named entries are *reserved*: once specified they will override the + * generic entry for that one list. Until then they are parsed so a reader + * can display them as Trusted List entries, and must drive no behavior -- + * which is why [isGeneric] is the guard every consumer should ask before + * acting on an entry. + */ + val name: String? = null, + val pubkey: HexKey, + /** + * Where the publisher's lists of this kind can be found, or null when the + * publisher had no relay configured. The spec keeps the entry at its + * three-element shape with an empty string in that slot, so an absent hint + * must not take the delegation down with it -- the pubkey is the part a + * consumer cannot do without. + */ + val relayUrl: NormalizedRelayUrl? = null, +) { + /** + * True for the bare-kind entry, the only form that currently drives + * behavior. Named entries parse but stay inert until the spec defines them. + */ + val isGeneric: Boolean get() = name == null + + fun toTagArray() = assemble(kind, name, pubkey, relayUrl) + + companion object { + /** The Trusted List kinds a Map entry may delegate. */ + val KINDS = + setOf( + UserTrustedListEvent.KIND, + EventTrustedListEvent.KIND, + AddressableTrustedListEvent.KIND, + ExternalIdTrustedListEvent.KIND, + ) + + /** + * Splits the first element on `:`, per the ADR's parse rule: a single + * all-digits segment is a generic entry, two segments are a named one. + * + * A 10040 carries foreign tags -- `["client", "nostria"]` and the like + * -- so everything that is not a Trusted List entry must fall out as + * null rather than throw. The kind is checked against [KINDS] for the + * same reason NIP-85's own parser checks its range: `30382:rank` splits + * into two segments too, and it is not ours. + */ + fun parse(tag: Tag): TrustedListProviderTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0].isNotEmpty()) { return null } + ensure(tag[1].length == 64) { return null } + + val divider = tag[0].indexOf(':') + + val kind: Int + val name: String? + if (divider < 0) { + kind = tag[0].toIntOrNull() ?: return null + name = null + } else { + kind = tag[0].substring(0, divider).toIntOrNull() ?: return null + // "30392:" is neither generic nor named -- a name was intended + // and lost, so it is not something to display or act on + name = tag[0].substring(divider + 1).takeIf { it.isNotEmpty() } ?: return null + } + + ensure(kind in KINDS) { return null } + + return TrustedListProviderTag(kind, name, tag[1], relayHint(tag)) + } + + /** The generic entry alone, for callers that must not act on a reserved named one. */ + fun parseGeneric(tag: Tag): TrustedListProviderTag? = parse(tag)?.takeIf { it.isGeneric } + + private fun relayHint(tag: Tag): NormalizedRelayUrl? { + val raw = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return null + return RelayUrlNormalizer.normalizeOrNull(raw) + } + + fun assembleServiceType( + kind: Int, + name: String? = null, + ) = if (name == null) kind.toString() else "$kind:$name" + + /** + * Always three elements, with an empty relay slot when there is no hint: + * the shape is what a reader indexes by, so it does not vary with what + * the publisher happened to have configured. + */ + fun assemble( + kind: Int, + name: String?, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl?, + ) = arrayOf(assembleServiceType(kind, name), pubkey, relayUrl?.url ?: "") + + fun assemble(provider: TrustedListProviderTag) = provider.toTagArray() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt index a4649cd72b..d12986f9c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt @@ -26,6 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure +/** + * One NIP-85 delegation in a Treasure Map (kind 10040): + * `[":", , ]`. + */ data class ServiceProviderTag( val service: ServiceType, val pubkey: HexKey, @@ -34,6 +38,22 @@ data class ServiceProviderTag( fun toTagArray() = assemble(service, pubkey, relayUrl) companion object { + /** + * The assertion kinds NIP-85 defines, and so the only ones a + * `:` entry can be delegating. + * + * A Treasure Map is an open tag set -- it carries `["client", ...]`, + * and neighbouring specs hang their own delegations off the same event + * with the same two-segment shape (Tapestry's Trusted Lists reserve + * `3039x:`). Without this bound every one of those would parse as + * a NIP-85 provider and be handed to code looking for a rank or + * follower-count service, which is exactly the behavior those specs + * tell readers not to drive. They are not lost, only routed: the + * `experimental/trustedLists/treasureMap` parser reads the `3039x` + * ones. + */ + val ASSERTION_KINDS = 30382..30385 + fun parse(tag: Array): ServiceProviderTag? { ensure(tag.has(2)) { return null } ensure(tag[0].isNotEmpty()) { return null } @@ -41,6 +61,9 @@ data class ServiceProviderTag( ensure(tag[2].isNotEmpty()) { return null } val service = ServiceType.parse(tag[0]) ?: return null + + ensure(service.kind in ASSERTION_KINDS) { return null } + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null return ServiceProviderTag(service, tag[1], relay) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt new file mode 100644 index 0000000000..f95dec62e7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt @@ -0,0 +1,254 @@ +/* + * 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 + +import com.vitorpamplona.quartz.experimental.trustedLists.addressables.AddressableTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.TrustedListProviderTag +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.removeTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.replaceTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProviders +import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Trusted List entry in a NIP-85 Treasure Map (kind 10040): + * `["30392", , ]`, per Tapestry ADR `tl-treasure-map/0001`. + */ +class TreasureMapEntryTest { + private val publisher = "7d7ffd720b907fe597a7f454afe02f2dc1eca440baa029e9117b1c3209839377" + private val otherPublisher = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val scores = "wss://scores.brainstorm.world" + private val nip85 = "wss://nip85.brainstorm.world" + + private fun map(vararg tags: Array): TrustProviderListEvent { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599", + createdAt = 1_787_253_028L, + kind = TrustProviderListEvent.KIND, + tags = arrayOf(*tags), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + return event + } + + @Test + fun readsTheGenericBareKindEntry() { + val entry = map(arrayOf("30392", publisher, nip85)).trustedListProvider(UserTrustedListEvent.KIND) + + assertEquals(UserTrustedListEvent.KIND, entry?.kind) + assertEquals(publisher, entry?.pubkey) + assertEquals("$nip85/", entry?.relayUrl?.url) + assertTrue(entry?.isGeneric == true) + assertNull(entry?.name) + } + + @Test + fun oneEntryDelegatesEveryListOfItsKindAndOnlyThatKind() { + // list names are never enumerated -- the entry is per kind, which is + // what keeps the Map a fixed size however many lists get computed + val treasureMap = map(arrayOf("30392", publisher, nip85)) + + assertEquals(publisher, treasureMap.trustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + assertNull(treasureMap.trustedListProvider(AddressableTrustedListEvent.KIND)) + } + + @Test + fun anUnconfiguredRelayHintDoesNotTakeTheDelegationWithIt() { + // the spec keeps the three-element shape with an empty relay slot. The + // pubkey is the part a consumer cannot do without, so the entry stands + val entry = map(arrayOf("30392", publisher, "")).trustedListProvider(UserTrustedListEvent.KIND) + + assertEquals(publisher, entry?.pubkey) + assertNull(entry?.relayUrl) + + // and a missing third element is the same story + val short = map(arrayOf("30392", publisher)).trustedListProvider(UserTrustedListEvent.KIND) + assertEquals(publisher, short?.pubkey) + assertNull(short?.relayUrl) + } + + @Test + fun theFirstOfDuplicateGenericEntriesWins() { + // at most one per kind is the writer's invariant; readers still need a + // fixed rule so two of them resolve the same publisher + val entry = + map( + arrayOf("30392", publisher, nip85), + arrayOf("30392", otherPublisher, scores), + ).trustedListProvider(UserTrustedListEvent.KIND) + + assertEquals(publisher, entry?.pubkey) + } + + @Test + fun aReservedNamedEntryIsReadableButNeverDrivesTheDelegation() { + val treasureMap = map(arrayOf("30392:podcaster", publisher, nip85)) + + // display it as a Trusted List entry... + val named = treasureMap.trustedListProviders().single() + assertEquals(UserTrustedListEvent.KIND, named.kind) + assertEquals("podcaster", named.name) + assertFalse(named.isGeneric) + + // ...but drive nothing from it: it is not the kind's delegation + assertNull(treasureMap.trustedListProvider(UserTrustedListEvent.KIND)) + } + + @Test + fun aNamedEntryIsNotHandedToNip85Consumers() { + // it splits on `:` into two segments exactly like `30382:rank` does. + // Without a kind bound it would land in the NIP-85 provider set and be + // offered to code looking for a rank or follower-count service + val treasureMap = + map( + arrayOf("30382:rank", publisher, scores), + arrayOf("30392:podcaster", publisher, nip85), + arrayOf("30392", publisher, nip85), + ) + + assertEquals(listOf(ProviderTypes.rank), treasureMap.serviceProviders().map { it.service }) + } + + @Test + fun foreignTagsInTheMapFallOutRatherThanParse() { + // a 10040 is an open tag set: `["client", "nostria"]` lives there too + val treasureMap = + map( + arrayOf("client", "nostria"), + arrayOf("30382:rank", publisher, scores), + arrayOf("30392"), + arrayOf("30392", "not-a-pubkey", nip85), + arrayOf("30392:", publisher, nip85), + arrayOf("30396", publisher, nip85), + arrayOf("alt", "a trust provider list"), + ) + + assertEquals(emptyList(), treasureMap.trustedListProviders()) + } + + @Test + fun switchingPublishersReplacesInPlaceAndPreservesEveryOtherTag() { + // 10040 is replaceable: the update republishes the whole tag set, so a + // tag dropped here is gone from the Map for good + val before = + map( + arrayOf("30382:rank", publisher, scores), + arrayOf("30392", publisher, nip85), + arrayOf("client", "nostria"), + ).tags + + val after = + before.replaceTrustedListProvider( + TrustedListProviderTag(UserTrustedListEvent.KIND, null, otherPublisher, RelayUrlNormalizer.normalizeOrNull(scores)), + ) + + assertEquals( + listOf( + listOf("30382:rank", publisher, scores), + listOf("30392", otherPublisher, "$scores/"), + listOf("client", "nostria"), + ), + after.map { it.toList() }, + "the entry keeps its position and its neighbours survive verbatim", + ) + } + + @Test + fun replacingCollapsesDuplicateGenericEntriesButLeavesNamedOnesAlone() { + val before = + map( + arrayOf("30392", publisher, nip85), + arrayOf("30392:podcaster", publisher, nip85), + arrayOf("30392", otherPublisher, scores), + arrayOf("30393", publisher, nip85), + ).tags + + val after = before.replaceTrustedListProvider(TrustedListProviderTag(UserTrustedListEvent.KIND, null, otherPublisher, null)) + + assertEquals( + listOf( + listOf("30392", otherPublisher, ""), + listOf("30392:podcaster", publisher, nip85), + listOf("30393", publisher, nip85), + ), + after.map { it.toList() }, + ) + } + + @Test + fun replacingAddsTheEntryWhenTheMapHasNoneForThatKind() { + val before = map(arrayOf("30382:rank", publisher, scores)).tags + + val after = before.replaceTrustedListProvider(TrustedListProviderTag(UserTrustedListEvent.KIND, null, publisher, null)) + + assertEquals(publisher, after.trustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + assertEquals(2, after.size) + } + + @Test + fun removingDropsOnlyTheGenericEntryForThatKind() { + val before = + map( + arrayOf("30382:rank", publisher, scores), + arrayOf("30392", publisher, nip85), + arrayOf("30393", publisher, nip85), + ).tags + + val after: TagArray = before.removeTrustedListProvider(UserTrustedListEvent.KIND) + + assertNull(after.trustedListProvider(UserTrustedListEvent.KIND)) + assertEquals(2, after.size) + } + + @Test + fun entriesRoundTripThroughTheirWireShape() { + assertEquals( + listOf("30392", publisher, "$nip85/"), + TrustedListProviderTag(UserTrustedListEvent.KIND, null, publisher, RelayUrlNormalizer.normalizeOrNull(nip85)).toTagArray().toList(), + ) + // three elements even with no hint, so readers index a stable shape + assertEquals( + listOf("30392", publisher, ""), + TrustedListProviderTag(UserTrustedListEvent.KIND, null, publisher, null).toTagArray().toList(), + ) + assertEquals( + listOf("30392:podcaster", publisher, ""), + TrustedListProviderTag(UserTrustedListEvent.KIND, "podcaster", publisher, null).toTagArray().toList(), + ) + } +} From dfddf35e4031c6fd6d3c3a99bdfcd621399494af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:37:47 +0000 Subject: [PATCH 3/5] feat(quartz): carry Trusted List Map entries in both halves of the 10040 A 10040 keeps half its delegations NIP-44 encrypted in content -- who you trust to rank the network is itself sensitive -- and the previous commit only reached the public tags. The parsing was never the gap: it is TagArray-level, so a caller merging the halves (commons' PrivateTagArrayEventCache, which is how the app reads NIP-85 providers) already got private entries out of trustedListProviders(). What was missing was the event-level surface. Reading now splits explicitly. publicTrustedListProvider(kind) is the public tags alone; trustedListProvider(kind, signer) merges both halves and falls back to the public half with anyone else's signer rather than failing, matching TrustProviderListEvent.privateTags. Public tags are searched first, so a Map that violates the invariant across halves resolves to its public entry. Writing takes isPrivate and maintains the invariant ACROSS halves: at most one generic entry per kind is a property of the Map, not of one half, so the write also drops the entry from the other side. Moving a delegation between public and private is one call instead of a two-step that strands a twin -- shadowed on read, republished forever after. That costs the property the earlier version had of never needing decryption: a public write on a Map with a private half must open it, because we cannot drop a twin we cannot read. It throws UnauthorizedDecryptionException rather than publish a Map that breaks the invariant. A Map with no private half needs no decryption either way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp --- .../experimental/trustedLists/README.md | 42 +++- .../treasureMap/TrustProviderListEventExt.kt | 132 +++++++++--- .../trustedLists/TreasureMapEntryTest.kt | 21 +- .../TreasureMapPrivateEntryTest.kt | 195 ++++++++++++++++++ 4 files changed, 349 insertions(+), 41 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapPrivateEntryTest.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 79c12c4713..185f34c578 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 @@ -128,12 +128,41 @@ Two things the reader must not do: *writer's* invariant; where duplicates appear in the wild the **first occurrence wins**, so two readers of one Map resolve the same publisher. -Writing goes through `replaceTrustedListProvider`, which swaps the generic -entry for its kind **in place** and preserves every other tag verbatim — 10040 -is replaceable, so the update republishes the whole tag set and anything -dropped is lost from the Map for good. `content` (the NIP-44 envelope holding -private entries) is carried across untouched, so the write needs no decryption -permission. +### Both halves of the Map + +A 10040 keeps half its delegations NIP-44 encrypted in `content` — who you +trust to rank the network is itself sensitive — so a Trusted List entry has to +work in both halves, and the one-entry-per-kind invariant spans them. + +The parsing is `TagArray`-level and half-agnostic: hand +`trustedListProviders()` an already-merged array (commons' +`PrivateTagArrayEventCache`, which caches the decryption, is how the app reads +NIP-85 providers) and private entries come out with no extra work. The +event-level accessors are the convenience layer on top: + +| Accessor | Sees | +|---|---| +| `publicTrustedListProviders()` / `publicTrustedListProvider(kind)` | the public tags alone, no signer | +| `trustedListProviders(signer)` / `trustedListProvider(kind, signer)` | both halves, merged | + +With anyone else's signer, or a private half that will not decrypt, the merged +accessors fall back to the public half rather than failing — the same contract +as `TrustProviderListEvent.privateTags`. Public tags are searched first, so +where a Map violates the invariant *across* halves the public entry wins. + +Writing goes through `replaceTrustedListProvider(provider, isPrivate, signer)`, +which swaps the generic entry for its kind **in place** in the half `isPrivate` +selects, and drops it from the other one — moving a delegation between public +and private is a single call rather than a two-step that strands a twin, +shadowed on read and republished forever after. Every other tag in both halves +survives verbatim: 10040 is replaceable, so the update republishes the whole +event and anything dropped is gone from the Map for good. + +The cost of the cross-half invariant is that a Map *with* a private half must +be decryptable even for a public write — we cannot drop a private twin we +cannot read, so that write throws `UnauthorizedDecryptionException` rather than +publishing a Map that breaks the invariant. A Map with no private half (blank +`content`) needs no decryption either way. ```kotlin val updated = @@ -141,6 +170,7 @@ val updated = kind = UserTrustedListEvent.KIND, pubkey = publisherHex, relayUrl = RelayUrlNormalizer.normalizeOrNull("wss://nip85.brainstorm.world"), + isPrivate = false, signer = signer, ) ``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt index ae6d57dc41..a628e05ade 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt @@ -21,60 +21,142 @@ package com.vitorpamplona.quartz.experimental.trustedLists.treasureMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.utils.TimeUtils /** - * The Trusted List entries this Map carries, generic and named alike. + * Trusted List entries in a Treasure Map, public and private alike. * - * These are extensions rather than members of [TrustProviderListEvent] on - * purpose: the entry is a pre-NIP Tapestry extension riding on NIP-85's kind, - * so the NIP-85 event class stays unaware of it and a consumer opts in by - * importing this package. + * A 10040 keeps half its delegations in `content`, NIP-44 encrypted to the + * owner -- who you trust to rank the network is itself sensitive -- so the + * public tags are only half the Map. Reading both takes the owner's signer; + * with anyone else's, or a Map whose private half will not decrypt, this sees + * the public half alone rather than failing, matching + * [TrustProviderListEvent.privateTags]. + * + * The parsing itself is [TagArray]-level and half-agnostic: a caller holding + * an already-merged array (commons' `PrivateTagArrayEventCache`, which caches + * the decryption) gets private entries out of [TagArray.trustedListProviders] + * with no extra work. These are the convenience accessors for callers that + * hold the event and a signer instead. + * + * Named entries are reserved, so a caller that intends to *act* on a + * delegation wants [trustedListProvider] -- this is the display-everything + * view. */ -fun TrustProviderListEvent.trustedListProviders() = tags.trustedListProviders() - -/** The publisher this Map delegates all its [kind] lists to, if any. */ -fun TrustProviderListEvent.trustedListProvider(kind: Int) = tags.trustedListProvider(kind) +suspend fun TrustProviderListEvent.trustedListProviders(signer: NostrSigner) = mergedTags(signer).trustedListProviders() /** - * Republishes the Map with [provider] as the generic entry for its kind. + * The publisher this Map delegates all its [kind] lists to, if any, across + * both halves. * - * Every other tag survives verbatim, and `content` -- the NIP-44 envelope - * holding the private entries -- is carried across untouched rather than - * re-encrypted, so this needs no decryption permission from the signer. + * Public tags are searched before private ones, so where a Map violates the + * one-entry-per-kind invariant across the two halves, the public entry is the + * one that wins. + */ +suspend fun TrustProviderListEvent.trustedListProvider( + kind: Int, + signer: NostrSigner, +) = mergedTags(signer).trustedListProvider(kind) + +/** The public half alone -- no signer, so no private entries. */ +fun TrustProviderListEvent.publicTrustedListProviders() = tags.trustedListProviders() + +/** The public half alone -- no signer, so no private entries. */ +fun TrustProviderListEvent.publicTrustedListProvider(kind: Int) = tags.trustedListProvider(kind) + +private suspend fun TrustProviderListEvent.mergedTags(signer: NostrSigner): TagArray = tags + (privateTags(signer) ?: emptyArray()) + +/** + * Republishes the Map with [provider] as the generic entry for its kind, in + * the half [isPrivate] selects. + * + * At most one generic entry per kind is the invariant, and it spans **both** + * halves -- so this also drops the entry from the other half. That is what + * makes moving a delegation between public and private a single call rather + * than a two-step that leaves a stale twin behind, shadowed on read and + * republished forever after. + * + * The cost is that a Map with a private half must be decryptable even for a + * public write: we cannot drop a private twin we cannot read. + * [SignerExceptions.UnauthorizedDecryptionException] is thrown rather than + * silently writing a Map that breaks the invariant. A Map with no private half + * (blank `content`) needs no decryption either way. + * + * Every other tag survives verbatim in both halves. 10040 is replaceable, so + * the update republishes the whole event and anything dropped here is gone + * from the Map for good. */ suspend fun TrustProviderListEvent.replaceTrustedListProvider( provider: TrustedListProviderTag, + isPrivate: Boolean = false, signer: NostrSigner, createdAt: Long = TimeUtils.now(), -): TrustProviderListEvent = - TrustProviderListEvent.resign( - content = content, - tags = tags.replaceTrustedListProvider(provider), - signer = signer, - createdAt = createdAt, - ) +): TrustProviderListEvent { + val privateTags = readPrivateTagsForWrite(signer) + + return if (isPrivate) { + resignBothHalves( + publicTags = tags.removeTrustedListProvider(provider.kind), + privateTags = privateTags.replaceTrustedListProvider(provider), + signer = signer, + createdAt = createdAt, + ) + } else { + resignBothHalves( + publicTags = tags.replaceTrustedListProvider(provider), + privateTags = privateTags.removeTrustedListProvider(provider.kind), + signer = signer, + createdAt = createdAt, + ) + } +} suspend fun TrustProviderListEvent.replaceTrustedListProvider( kind: Int, pubkey: HexKey, relayUrl: NormalizedRelayUrl? = null, + isPrivate: Boolean = false, signer: NostrSigner, createdAt: Long = TimeUtils.now(), -): TrustProviderListEvent = replaceTrustedListProvider(TrustedListProviderTag(kind, null, pubkey, relayUrl), signer, createdAt) +): TrustProviderListEvent = replaceTrustedListProvider(TrustedListProviderTag(kind, null, pubkey, relayUrl), isPrivate, signer, createdAt) -/** Republishes the Map without its generic entry for [kind]. */ +/** Republishes the Map without its generic entry for [kind], in either half. */ suspend fun TrustProviderListEvent.removeTrustedListProvider( kind: Int, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): TrustProviderListEvent = - TrustProviderListEvent.resign( - content = content, - tags = tags.removeTrustedListProvider(kind), + resignBothHalves( + publicTags = tags.removeTrustedListProvider(kind), + privateTags = readPrivateTagsForWrite(signer).removeTrustedListProvider(kind), + signer = signer, + createdAt = createdAt, + ) + +/** + * The private half as a writer must see it: empty when the Map has none, and + * an error rather than a guess when it has one we cannot open. + */ +private suspend fun TrustProviderListEvent.readPrivateTagsForWrite(signer: NostrSigner): TagArray { + if (content.isBlank()) return emptyArray() + return privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() +} + +private suspend fun resignBothHalves( + publicTags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long, +): TrustProviderListEvent = + TrustProviderListEvent.resign( + content = if (privateTags.isEmpty()) "" else PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = publicTags, signer = signer, createdAt = createdAt, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt index f95dec62e7..6924d8d2eb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.experimental.trustedLists import com.vitorpamplona.quartz.experimental.trustedLists.addressables.AddressableTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.TrustedListProviderTag +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.publicTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.publicTrustedListProviders import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.removeTrustedListProvider import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.replaceTrustedListProvider import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProvider -import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProviders import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArray @@ -68,7 +69,7 @@ class TreasureMapEntryTest { @Test fun readsTheGenericBareKindEntry() { - val entry = map(arrayOf("30392", publisher, nip85)).trustedListProvider(UserTrustedListEvent.KIND) + val entry = map(arrayOf("30392", publisher, nip85)).publicTrustedListProvider(UserTrustedListEvent.KIND) assertEquals(UserTrustedListEvent.KIND, entry?.kind) assertEquals(publisher, entry?.pubkey) @@ -83,21 +84,21 @@ class TreasureMapEntryTest { // what keeps the Map a fixed size however many lists get computed val treasureMap = map(arrayOf("30392", publisher, nip85)) - assertEquals(publisher, treasureMap.trustedListProvider(UserTrustedListEvent.KIND)?.pubkey) - assertNull(treasureMap.trustedListProvider(AddressableTrustedListEvent.KIND)) + assertEquals(publisher, treasureMap.publicTrustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + assertNull(treasureMap.publicTrustedListProvider(AddressableTrustedListEvent.KIND)) } @Test fun anUnconfiguredRelayHintDoesNotTakeTheDelegationWithIt() { // the spec keeps the three-element shape with an empty relay slot. The // pubkey is the part a consumer cannot do without, so the entry stands - val entry = map(arrayOf("30392", publisher, "")).trustedListProvider(UserTrustedListEvent.KIND) + val entry = map(arrayOf("30392", publisher, "")).publicTrustedListProvider(UserTrustedListEvent.KIND) assertEquals(publisher, entry?.pubkey) assertNull(entry?.relayUrl) // and a missing third element is the same story - val short = map(arrayOf("30392", publisher)).trustedListProvider(UserTrustedListEvent.KIND) + val short = map(arrayOf("30392", publisher)).publicTrustedListProvider(UserTrustedListEvent.KIND) assertEquals(publisher, short?.pubkey) assertNull(short?.relayUrl) } @@ -110,7 +111,7 @@ class TreasureMapEntryTest { map( arrayOf("30392", publisher, nip85), arrayOf("30392", otherPublisher, scores), - ).trustedListProvider(UserTrustedListEvent.KIND) + ).publicTrustedListProvider(UserTrustedListEvent.KIND) assertEquals(publisher, entry?.pubkey) } @@ -120,13 +121,13 @@ class TreasureMapEntryTest { val treasureMap = map(arrayOf("30392:podcaster", publisher, nip85)) // display it as a Trusted List entry... - val named = treasureMap.trustedListProviders().single() + val named = treasureMap.publicTrustedListProviders().single() assertEquals(UserTrustedListEvent.KIND, named.kind) assertEquals("podcaster", named.name) assertFalse(named.isGeneric) // ...but drive nothing from it: it is not the kind's delegation - assertNull(treasureMap.trustedListProvider(UserTrustedListEvent.KIND)) + assertNull(treasureMap.publicTrustedListProvider(UserTrustedListEvent.KIND)) } @Test @@ -158,7 +159,7 @@ class TreasureMapEntryTest { arrayOf("alt", "a trust provider list"), ) - assertEquals(emptyList(), treasureMap.trustedListProviders()) + assertEquals(emptyList(), treasureMap.publicTrustedListProviders()) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapPrivateEntryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapPrivateEntryTest.kt new file mode 100644 index 0000000000..0defb99703 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapPrivateEntryTest.kt @@ -0,0 +1,195 @@ +/* + * 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 + +import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.TrustedListProviderTag +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.publicTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.removeTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.replaceTrustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProvider +import com.vitorpamplona.quartz.experimental.trustedLists.treasureMap.trustedListProviders +import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A 10040 keeps half its delegations NIP-44 encrypted in `content` -- who you + * trust to rank the network is itself sensitive -- so a Trusted List entry has + * to work in both halves, and the one-entry-per-kind invariant has to hold + * across them. + */ +class TreasureMapPrivateEntryTest { + private val publisher = "7d7ffd720b907fe597a7f454afe02f2dc1eca440baa029e9117b1c3209839377" + private val otherPublisher = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val nip85 = RelayUrlNormalizer.normalizeOrNull("wss://nip85.brainstorm.world")!! + private val scores = RelayUrlNormalizer.normalizeOrNull("wss://scores.brainstorm.world")!! + + private val userList = TrustedListProviderTag(UserTrustedListEvent.KIND, null, publisher, nip85) + + private suspend fun emptyMap(signer: NostrSignerInternal) = + TrustProviderListEvent.create( + publicProviders = listOf(ServiceProviderTag(ProviderTypes.rank, publisher, scores)), + privateProviders = listOf(ServiceProviderTag(ProviderTypes.followerCount, publisher, scores)), + signer = signer, + ) + + @Test + fun aPrivateEntryIsWrittenAndReadBack() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + + val map = emptyMap(signer).replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + + assertEquals(publisher, map.trustedListProvider(UserTrustedListEvent.KIND, signer)?.pubkey) + // and it is genuinely private: the public half does not carry it + assertNull(map.publicTrustedListProvider(UserTrustedListEvent.KIND)) + } + + @Test + fun aPrivateEntryIsInvisibleWithoutTheOwnersSigner() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val stranger = NostrSignerInternal(KeyPair()) + + val map = emptyMap(signer).replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + + // the public half alone, rather than a failure -- same contract as + // TrustProviderListEvent.privateTags + assertNull(map.trustedListProvider(UserTrustedListEvent.KIND, stranger)) + assertEquals(emptyList(), map.trustedListProviders(stranger)) + } + + @Test + fun movingADelegationBetweenHalvesLeavesNoStaleTwin() = + runTest { + // the invariant is one generic entry per kind across the WHOLE Map. + // A twin left in the other half would be shadowed on read and + // republished forever after + val signer = NostrSignerInternal(KeyPair()) + + val public = emptyMap(signer).replaceTrustedListProvider(userList, isPrivate = false, signer = signer) + assertEquals(publisher, public.publicTrustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + + val moved = public.replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + assertNull(moved.publicTrustedListProvider(UserTrustedListEvent.KIND), "the public twin must be gone") + assertEquals(publisher, moved.trustedListProvider(UserTrustedListEvent.KIND, signer)?.pubkey) + assertEquals(1, moved.trustedListProviders(signer).size) + + val back = moved.replaceTrustedListProvider(userList, isPrivate = false, signer = signer) + assertEquals(publisher, back.publicTrustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + assertEquals(1, back.trustedListProviders(signer).size, "the private twin must be gone") + } + + @Test + fun switchingThePrivatePublisherReplacesRatherThanAccumulates() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + + val map = + emptyMap(signer) + .replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + .replaceTrustedListProvider(userList.copy(pubkey = otherPublisher), isPrivate = true, signer = signer) + + assertEquals(listOf(otherPublisher), map.trustedListProviders(signer).map { it.pubkey }) + } + + @Test + fun theOtherHalfSurvivesTheWriteVerbatim() = + runTest { + // 10040 is replaceable: whatever this write drops is gone for good + val signer = NostrSignerInternal(KeyPair()) + + val map = emptyMap(signer).replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + + val providers = map.tags.toList().map { it.toList() } + (map.privateTags(signer) ?: emptyArray()).toList().map { it.toList() } + assertTrue(providers.contains(listOf("30382:rank", publisher, scores.url)), "the public NIP-85 entry survived") + assertTrue(providers.contains(listOf("30382:followers", publisher, scores.url)), "the private NIP-85 entry survived") + } + + @Test + fun removingClearsTheEntryFromEitherHalf() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + + val map = + emptyMap(signer) + .replaceTrustedListProvider(userList, isPrivate = true, signer = signer) + .replaceTrustedListProvider( + TrustedListProviderTag(EventTrustedListEvent.KIND, null, publisher, nip85), + isPrivate = false, + signer = signer, + ) + + val cleared = + map + .removeTrustedListProvider(UserTrustedListEvent.KIND, signer) + .removeTrustedListProvider(EventTrustedListEvent.KIND, signer) + + assertEquals(emptyList(), cleared.trustedListProviders(signer)) + // the NIP-85 delegations in both halves are untouched + assertEquals(1, cleared.serviceProviders().size) + assertEquals(1, (cleared.privateTags(signer) ?: emptyArray()).size) + } + + @Test + fun aPublicWriteRefusesRatherThanStrandAPrivateTwinItCannotRead() = + runTest { + // we cannot drop a private twin we cannot open, and writing anyway + // would republish a Map that breaks the invariant + val signer = NostrSignerInternal(KeyPair()) + val stranger = NostrSignerInternal(KeyPair()) + + val map = emptyMap(signer) + + assertFailsWith { + map.replaceTrustedListProvider(userList, isPrivate = false, signer = stranger) + } + } + + @Test + fun aMapWithNoPrivateHalfNeedsNoDecryption() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + + val map = + TrustProviderListEvent.create( + publicProviders = listOf(ServiceProviderTag(ProviderTypes.rank, publisher, scores)), + privateProviders = emptyList(), + signer = signer, + ) + + val updated = map.replaceTrustedListProvider(userList, isPrivate = false, signer = signer) + + assertEquals(publisher, updated.publicTrustedListProvider(UserTrustedListEvent.KIND)?.pubkey) + } +} From 42c297104c26190a1c4cad54f164e03e21b8512f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:39:03 +0000 Subject: [PATCH 4/5] fix(quartz): address the audit of the Trusted List work Three bugs, each one where a write could produce a tag the matching read refuses -- so the entry can never be found again, and every later write appends instead of replacing. replaceTrustedListProvider matched only generic entries but wrote whatever it was handed. A named write therefore deleted the kind's generic delegation -- a live delegation, gone irrecoverably, since 10040 is replaceable -- while never finding its own entry, so it duplicated on every call. Replace and remove now address an entry by kind AND name, the pair the first element encodes. TrustedListProviderTag and ServiceProviderTag both let a constructor write a kind their own parse rejects: outside 30392-30395 for the first, outside NIP-85's 30382-30385 for the second. Both now require it, making the unreadable state unrepresentable rather than silently accumulating. That second bound, added in the previous commit on the read side only, had regressed `amy graperank register --service 30392:podcaster`: the dedup probe reads through the parser, so it appended a fresh duplicate per run, and unregister could never match one. The CLI now rejects a non-assertion kind with bad_args instead of writing a 10040 that grows a tag per invocation. Performance: the member scans that return one entry per tag -- members(), memberValues(), linkedPubKeys/EventIds/AddressIds -- go through a presizing fastMapNotNullDense instead of the stdlib mapNotNull, whose capacity-10 start costs ~20 array copies on a 5k-member list. Deliberately NOT applied to the sparse scans beside them: picking two discovery tags out of thousands would allocate a thousands-wide array to hold two, which is worse than the growth it avoids. The operator's KDoc says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp --- .../commands/graperank/GrapeRankOperator.kt | 20 ++++++- .../trustedLists/TrustedListEvent.kt | 3 +- .../AddressableTrustedListEvent.kt | 3 +- .../trustedLists/addressables/TagArrayExt.kt | 3 +- .../events/EventTrustedListEvent.kt | 3 +- .../trustedLists/events/TagArrayExt.kt | 3 +- .../trustedLists/externalIds/TagArrayExt.kt | 3 +- .../trustedLists/treasureMap/TagArrayExt.kt | 52 ++++++++++++----- .../treasureMap/TrustedListProviderTag.kt | 9 +++ .../trustedLists/users/TagArrayExt.kt | 3 +- .../users/UserTrustedListEvent.kt | 3 +- .../quartz/nip01Core/core/TagArray.kt | 20 +++++++ .../list/tags/ServiceProviderTag.kt | 10 ++++ .../trustedLists/TreasureMapEntryTest.kt | 58 +++++++++++++++++++ 14 files changed, 168 insertions(+), 25 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt index fe389882b3..c380072c06 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt @@ -309,6 +309,22 @@ object GrapeRankOperator { } } + /** + * `--service KIND:TAG`, accepted only for a kind NIP-85 actually defines. + * [ServiceProviderTag] reads back nothing else, and both the dedup check + * before appending and `unregister`'s match go through that read -- so a + * `30392:podcaster` would append a fresh duplicate on every register and + * could never be unregistered. Better a bad_args than a 10040 that grows a + * tag per run. + */ + private fun parseAssertionService(raw: String): ServiceType? = ServiceType.parse(raw)?.takeIf { it.kind in ServiceProviderTag.ASSERTION_KINDS } + + private fun badServiceArg() = + Output.error( + "bad_args", + "--service must be KIND:TAG with KIND in ${ServiceProviderTag.ASSERTION_KINDS.first}-${ServiceProviderTag.ASSERTION_KINDS.last}, e.g. 30382:rank", + ) + /** * `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] [--private]` * @@ -337,7 +353,7 @@ object GrapeRankOperator { val service = serviceArg?.let { - ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + parseAssertionService(it) ?: return badServiceArg() } ?: ProviderTypes.rank Context.open(dataDir).use { ctx -> @@ -424,7 +440,7 @@ object GrapeRankOperator { val service = serviceArg?.let { - ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + parseAssertionService(it) ?: return badServiceArg() } val relay = relayArg?.let { 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 019d71b7cf..74524a3aeb 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 @@ -27,6 +27,7 @@ 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 +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip50Search.SearchableEvent /** @@ -102,7 +103,7 @@ abstract class TrustedListEvent( * 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 memberValues(): List = tags.fastMapNotNullDense { memberValueOf(it) } fun memberCount(): Int = tags.count { isMemberTag(it) } 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 ced4f702b5..1009553d19 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.Addr 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.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -58,7 +59,7 @@ class AddressableTrustedListEvent( override fun addressHints() = tags.mapNotNull(AddressMemberTag::parseAsHint) - override fun linkedAddressIds() = tags.mapNotNull(AddressMemberTag::parseAddressId) + override fun linkedAddressIds() = tags.fastMapNotNullDense(AddressMemberTag::parseAddressId) override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt index 64649e59fe..910817e2f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt @@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.experimental.trustedLists.addressables import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -fun TagArray.members() = mapNotNull(AddressMemberTag::parse) +fun TagArray.members() = fastMapNotNullDense(AddressMemberTag::parse) /** * The optional relay-filterable discovery tags: what this list is *about*, 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 e2c8b4956d..f91449e431 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMembe 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.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -63,7 +64,7 @@ class EventTrustedListEvent( override fun eventHints() = tags.mapNotNull(EventMemberTag::parseAsHint) - override fun linkedEventIds() = tags.mapNotNull(EventMemberTag::parseId) + override fun linkedEventIds() = tags.fastMapNotNullDense(EventMemberTag::parseId) override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt index 14bee14bd8..550ca2b86e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.experimental.trustedLists.events import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -fun TagArray.members() = mapNotNull(EventMemberTag::parse) +fun TagArray.members() = fastMapNotNullDense(EventMemberTag::parse) /** * The optional relay-filterable discovery tags: what this list is *about*, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt index b640dfa494..4e5ef9a3e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.experimental.trustedLists.externalIds import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -fun TagArray.members() = mapNotNull(ExternalIdMemberTag::parse) +fun TagArray.members() = fastMapNotNullDense(ExternalIdMemberTag::parse) /** * The optional relay-filterable discovery tags: what this list is *about*, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt index 068a61e7b5..8bb7c665f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.trustedLists.treasureMap +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull @@ -45,17 +46,22 @@ fun TagArray.trustedListProvider(kind: Int) = } /** - * Replaces the generic entry for [provider]'s kind, preserving **every other - * tag verbatim** -- 10040 is replaceable, so an update republishes the whole - * tag set and anything dropped here is lost from the Map for good. + * Replaces the entry [provider] would occupy, preserving **every other tag + * verbatim** -- 10040 is replaceable, so an update republishes the whole tag + * set and anything dropped here is lost from the Map for good. + * + * What counts as "the same entry" is kind **and** name, the pair the first + * element encodes: a generic write replaces the generic entry, a named write + * replaces that name. Matching on kind alone would let a named write delete + * the kind's generic delegation -- a different, live delegation, gone + * irrecoverably -- while never finding its own entry to replace, so it would + * also duplicate on every later call. * * The replacement keeps the old entry's position rather than moving it to the * end, so a Map does not reshuffle on every publisher switch. Redundant - * generic entries for the same kind are collapsed onto that one: the invariant - * is at most one per kind, and a writer that has to touch the kind anyway is - * the right place to settle a Map that arrived violating it. Named entries for - * the same kind are left alone -- they are a different delegation, reserved to - * override this one per list. + * entries for the same kind and name are collapsed onto that one: at most one + * is the invariant, and a writer that has to touch the entry anyway is the + * right place to settle a Map that arrived violating it. */ fun TagArray.replaceTrustedListProvider(provider: TrustedListProviderTag): TagArray { val replacement = provider.toTagArray() @@ -63,8 +69,7 @@ fun TagArray.replaceTrustedListProvider(provider: TrustedListProviderTag): TagAr val out = ArrayList>(size + 1) forEach { tag -> - val existing = TrustedListProviderTag.parseGeneric(tag) - if (existing != null && existing.kind == provider.kind) { + if (isSameEntry(tag, provider.kind, provider.name)) { if (!replaced) { out.add(replacement) replaced = true @@ -79,8 +84,25 @@ fun TagArray.replaceTrustedListProvider(provider: TrustedListProviderTag): TagAr return out.toTypedArray() } -/** Drops the generic entry for [kind], leaving every other tag verbatim. */ -fun TagArray.removeTrustedListProvider(kind: Int): TagArray = - filterNot { tag -> - TrustedListProviderTag.parseGeneric(tag)?.kind == kind - }.toTypedArray() +/** + * Drops the entry for [kind] and [name] -- the generic one by default -- + * leaving every other tag verbatim. + */ +fun TagArray.removeTrustedListProvider( + kind: Int, + name: String? = null, +): TagArray = filterNot { isSameEntry(it, kind, name) }.toTypedArray() + +/** + * Whether [tag] is the Trusted List entry addressed by [kind] and [name]. The + * comparison goes through the parser rather than string-matching the first + * element so that a tag we would not read is never one we silently delete. + */ +private fun isSameEntry( + tag: Tag, + kind: Int, + name: String?, +): Boolean { + val entry = TrustedListProviderTag.parse(tag) ?: return false + return entry.kind == kind && entry.name == name +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt index 489dc1b40d..120018b250 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt @@ -80,6 +80,15 @@ data class TrustedListProviderTag( */ val relayUrl: NormalizedRelayUrl? = null, ) { + init { + // [parse] refuses a kind outside the family, so a constructed one would + // write a tag that can never be read back -- and therefore never + // replaced or removed either, since both address an entry through the + // parser. It would accumulate on every write. Rejecting it here keeps + // the two directions in step. + require(kind in KINDS) { "Not a Trusted List kind: $kind" } + } + /** * True for the bare-kind entry, the only form that currently drives * behavior. Named entries parse but stay inert until the spec defines them. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt index ecc835b236..76d051363c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt @@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.experimental.trustedLists.users import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -fun TagArray.members() = mapNotNull(PubKeyMemberTag::parse) +fun TagArray.members() = fastMapNotNullDense(PubKeyMemberTag::parse) /** * The optional relay-filterable discovery tags: what this list is *about*, 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 7ce5fec5d4..b3036a7f7b 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMembe 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.core.fastMapNotNullDense import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -58,7 +59,7 @@ class UserTrustedListEvent( override fun pubKeyHints() = tags.mapNotNull(PubKeyMemberTag::parseAsHint) - override fun linkedPubKeys() = tags.mapNotNull(PubKeyMemberTag::parseKey) + override fun linkedPubKeys() = tags.fastMapNotNullDense(PubKeyMemberTag::parseKey) override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt index 0190139377..fa3c17b0d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArray.kt @@ -40,6 +40,26 @@ inline fun Array.fastFirstOrNull(predicate: (T) -> Boolean): T? { return null } +/** + * [mapNotNull] with the destination sized up front, for scans where **most + * tags match** -- a Trusted List's thousands of member tags, say. The stdlib + * version starts at capacity 10 and grows by halves, so a 5k-member scan pays + * ~20 array copies on the way up; this pays none. + * + * Use it only for the dense case. On a sparse scan -- picking the two + * discovery tags out of those same thousands -- presizing allocates a + * thousands-wide array to hold two entries, which is worse than the growth it + * avoids. Reach for the stdlib [mapNotNull] there. + */ +inline fun Array.fastMapNotNullDense(transform: (T) -> R?): List { + val destination = ArrayList(size) + for (index in indices) { + val mapped = transform(this[index]) + if (mapped != null) destination.add(mapped) + } + return destination +} + inline fun Array.fastFirstNotNullOfOrNull(transform: (T) -> R?): R? { for (index in indices) { val result = transform(get(index)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt index d12986f9c2..d7d682df3e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceProviderTag.kt @@ -35,6 +35,16 @@ data class ServiceProviderTag( val pubkey: HexKey, val relayUrl: NormalizedRelayUrl, ) { + init { + // [parse] refuses a kind outside NIP-85's own, so a constructed one + // would write a tag this class can never read back. Callers address an + // existing entry by parsing -- `removeParsing`, the CLI's dedup check + // before appending -- so such a tag can be neither found nor removed, + // and re-registering appends another. Rejecting it here keeps the write + // side inside what the read side admits. + require(service.kind in ASSERTION_KINDS) { "Not a NIP-85 assertion kind: ${service.kind}" } + } + fun toTagArray() = assemble(service, pubkey, relayUrl) companion object { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt index 6924d8d2eb..65707d8b99 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull @@ -236,6 +237,63 @@ class TreasureMapEntryTest { assertEquals(2, after.size) } + @Test + fun aNamedWriteNeverDeletesTheKindsGenericDelegation() { + // the generic entry is a live delegation and 10040 is replaceable, so + // a write that matched on kind alone would drop it irrecoverably -- + // and, never having found its own entry, duplicate on every later call + val before = + map( + arrayOf("30392", publisher, nip85), + arrayOf("30392:podcaster", publisher, nip85), + ).tags + + val named = TrustedListProviderTag(UserTrustedListEvent.KIND, "podcaster", otherPublisher, null) + val after = before.replaceTrustedListProvider(named) + + assertEquals( + listOf( + listOf("30392", publisher, nip85), + listOf("30392:podcaster", otherPublisher, ""), + ), + after.map { it.toList() }, + ) + + // and it is idempotent: a second write finds its own entry + assertEquals(after.map { it.toList() }, after.replaceTrustedListProvider(named).map { it.toList() }) + } + + @Test + fun removingTheGenericEntryLeavesTheNamedOneAndViceVersa() { + val before = + map( + arrayOf("30392", publisher, nip85), + arrayOf("30392:podcaster", publisher, nip85), + ).tags + + assertEquals( + listOf(listOf("30392:podcaster", publisher, nip85)), + before.removeTrustedListProvider(UserTrustedListEvent.KIND).map { it.toList() }, + ) + assertEquals( + listOf(listOf("30392", publisher, nip85)), + before.removeTrustedListProvider(UserTrustedListEvent.KIND, "podcaster").map { it.toList() }, + ) + } + + @Test + fun aKindOutsideTheFamilyCannotBeConstructed() { + // parse refuses it, so a constructed one would write a tag that can + // never be read back -- and therefore never replaced or removed, since + // both address an entry through the parser. It would accumulate + assertFailsWith { + TrustedListProviderTag(30382, null, publisher, null) + } + assertFailsWith { + TrustedListProviderTag(30396, null, publisher, null) + } + } + @Test fun entriesRoundTripThroughTheirWireShape() { assertEquals( From cdfc7ddb1743a936ee942e905c3a6a7e6276a089 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:17:18 +0000 Subject: [PATCH 5/5] test(quartz): drop a redundant safe call the compiler flagged assertTrue(entry?.isGeneric == true) smart-casts entry to non-null, so the next line's ?. was dead and the build warned on it. Assert non-nullness once up front and read the fields plainly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp --- .../trustedLists/TreasureMapEntryTest.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt index 65707d8b99..b3336be932 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt @@ -40,6 +40,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -70,13 +71,13 @@ class TreasureMapEntryTest { @Test fun readsTheGenericBareKindEntry() { - val entry = map(arrayOf("30392", publisher, nip85)).publicTrustedListProvider(UserTrustedListEvent.KIND) + val entry = assertNotNull(map(arrayOf("30392", publisher, nip85)).publicTrustedListProvider(UserTrustedListEvent.KIND)) - assertEquals(UserTrustedListEvent.KIND, entry?.kind) - assertEquals(publisher, entry?.pubkey) - assertEquals("$nip85/", entry?.relayUrl?.url) - assertTrue(entry?.isGeneric == true) - assertNull(entry?.name) + assertEquals(UserTrustedListEvent.KIND, entry.kind) + assertEquals(publisher, entry.pubkey) + assertEquals("$nip85/", entry.relayUrl?.url) + assertTrue(entry.isGeneric) + assertNull(entry.name) } @Test