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/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md index efc0ad1af5..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 @@ -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 @@ -80,6 +88,93 @@ 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. + +### 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 = + treasureMap.replaceTrustedListProvider( + kind = UserTrustedListEvent.KIND, + pubkey = publisherHex, + relayUrl = RelayUrlNormalizer.normalizeOrNull("wss://nip85.brainstorm.world"), + isPrivate = false, + 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/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/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/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/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/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/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/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/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/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/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/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/treasureMap/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt new file mode 100644 index 0000000000..8bb7c665f9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TagArrayExt.kt @@ -0,0 +1,108 @@ +/* + * 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.Tag +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 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 + * 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() + var replaced = false + + val out = ArrayList>(size + 1) + forEach { tag -> + if (isSameEntry(tag, provider.kind, provider.name)) { + if (!replaced) { + out.add(replacement) + replaced = true + } + } else { + out.add(tag) + } + } + + if (!replaced) out.add(replacement) + + return out.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/TrustProviderListEventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.kt new file mode 100644 index 0000000000..a628e05ade --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustProviderListEventExt.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 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 + +/** + * Trusted List entries in a Treasure Map, public and private alike. + * + * 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. + */ +suspend fun TrustProviderListEvent.trustedListProviders(signer: NostrSigner) = mergedTags(signer).trustedListProviders() + +/** + * The publisher this Map delegates all its [kind] lists to, if any, across + * both halves. + * + * 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 { + 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), isPrivate, signer, createdAt) + +/** 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 = + 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/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..120018b250 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/treasureMap/TrustedListProviderTag.kt @@ -0,0 +1,171 @@ +/* + * 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, +) { + 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. + */ + 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/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/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/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/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 a4649cd72b..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 @@ -26,14 +26,44 @@ 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, 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 { + /** + * 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 +71,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..b3336be932 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TreasureMapEntryTest.kt @@ -0,0 +1,314 @@ +/* + * 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.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.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.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +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 = 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) + 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.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, "")).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)).publicTrustedListProvider(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), + ).publicTrustedListProvider(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.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.publicTrustedListProvider(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.publicTrustedListProviders()) + } + + @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 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( + 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(), + ) + } +} 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) + } +} 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) + } }