Merge pull request #4005 from vitorpamplona/claude/trusted-list-ptag-score-qprze4

Trusted Lists: a 0–100 member score and the kind-10040 Treasure Map entry
This commit is contained in:
Vitor Pamplona
2026-08-28 19:15:51 -04:00
committed by GitHub
29 changed files with 1292 additions and 23 deletions
@@ -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 {
@@ -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 `[<tag>, <value>, <hint>, <score>]`. 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", <pubkey>, <relay>]`. Trusted Lists extend it with a **generic
bare-kind entry** (Tapestry ADR `tl-treasure-map/0001`):
```json
["30392", "<publisher-pubkey>", "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:<metric>` or a **named**
TL entry (`3039x:<name>`, 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
@@ -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,
) {
/**
@@ -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<String> = tags.mapNotNull { memberValueOf(it) }
fun memberValues(): List<String> = tags.fastMapNotNullDense { memberValueOf(it) }
fun memberCount(): Int = tags.count { isMemberTag(it) }
@@ -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)
@@ -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<AddressableTrustedListEvent>.member(
address: String,
relayHint: NormalizedRelayUrl? = null,
@@ -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*,
@@ -36,6 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure
* An addressable-event member of a kind-30394 Trusted List:
* `["a", <kind:pubkey:d>, <relay-hint>, <score>]`.
*
* 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)
@@ -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)
@@ -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<EventTrustedListEvent>.member(
eventId: HexKey,
relayHint: NormalizedRelayUrl? = null,
@@ -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*,
@@ -35,8 +35,9 @@ import com.vitorpamplona.quartz.utils.ensure
* An event member of a kind-30393 Trusted List:
* `["e", <event-id>, <relay-hint>, <score>]`.
*
* 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)
@@ -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<ExternalIdTrustedListEvent>.member(
externalId: String,
hint: String? = null,
@@ -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*,
@@ -34,7 +34,8 @@ import com.vitorpamplona.quartz.utils.ensure
* `["i", <i-tag>, <url-hint>, <score>]`.
*
* 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)
@@ -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()
}
@@ -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?
}
@@ -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<Array<String>>(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
}
@@ -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,
)
@@ -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", <pubkey>, <relay>]`. Trusted Lists extend it with a
* **generic bare-kind entry** (Tapestry ADR `tl-treasure-map/0001`):
*
* ```json
* ["30392", "<publisher-pubkey>", "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:<metric>` 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()
}
}
@@ -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<UserTrustedListEvent>.member(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
@@ -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*,
@@ -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)
@@ -36,8 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure
* A pubkey member of a kind-30392 Trusted List:
* `["p", <pubkey>, <relay-hint>, <score>]`.
*
* 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)
@@ -40,6 +40,26 @@ inline fun <T> Array<out T>.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 <T, R : Any> Array<out T>.fastMapNotNullDense(transform: (T) -> R?): List<R> {
val destination = ArrayList<R>(size)
for (index in indices) {
val mapped = transform(this[index])
if (mapped != null) destination.add(mapped)
}
return destination
}
inline fun <T, R : Any> Array<out T>.fastFirstNotNullOfOrNull(transform: (T) -> R?): R? {
for (index in indices) {
val result = transform(get(index))
@@ -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):
* `["<assertion-kind>:<metric>", <pubkey>, <relay>]`.
*/
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
* `<kind>:<metric>` 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:<name>`). 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<String>): 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)
@@ -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", <publisher>, <relay>]`, 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<String>): TrustProviderListEvent {
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = TrustProviderListEvent.KIND,
tags = arrayOf(*tags),
content = "",
sig = "00".repeat(64),
)
assertIs<TrustProviderListEvent>(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<IllegalArgumentException> {
TrustedListProviderTag(30382, null, publisher, null)
}
assertFailsWith<IllegalArgumentException> {
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(),
)
}
}
@@ -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<SignerExceptions.UnauthorizedDecryptionException> {
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)
}
}
@@ -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<Event>(
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<UserTrustedListEvent>(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<Event>(
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<UserTrustedListEvent>(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)
}
}