mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+18
-2
@@ -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]`
|
* `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] [--private]`
|
||||||
*
|
*
|
||||||
@@ -337,7 +353,7 @@ object GrapeRankOperator {
|
|||||||
|
|
||||||
val service =
|
val service =
|
||||||
serviceArg?.let {
|
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
|
} ?: ProviderTypes.rank
|
||||||
|
|
||||||
Context.open(dataDir).use { ctx ->
|
Context.open(dataDir).use { ctx ->
|
||||||
@@ -424,7 +440,7 @@ object GrapeRankOperator {
|
|||||||
|
|
||||||
val service =
|
val service =
|
||||||
serviceArg?.let {
|
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 =
|
val relay =
|
||||||
relayArg?.let {
|
relayArg?.let {
|
||||||
|
|||||||
+97
-2
@@ -62,8 +62,16 @@ build the member objects — these lists run to thousands of entries.
|
|||||||
recomputed (`listId()`).
|
recomputed (`listId()`).
|
||||||
- `title` / `metric` are the label and the name of the computation.
|
- `title` / `metric` are the label and the name of the computation.
|
||||||
- Member tags carry `[<tag>, <value>, <hint>, <score>]`. The score sits at
|
- 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
|
index 3 for every kind in the family — right after the relay hint — so a
|
||||||
relay hint pads index 2 with an empty string — as in the `p` tag above.
|
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
|
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
|
enumerate membership, they do not thread. Index 2 is only read as a relay
|
||||||
hint when it looks like one (`MemberTagFields.relayHint`): the normalizer
|
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
|
- `content` is an optional JSON echo of the members with their computed values
|
||||||
(`contentEcho()` → `TrustedListContent`).
|
(`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
|
## Completeness and retraction
|
||||||
|
|
||||||
A list an integrator relies on must be complete, or say that it isn't. The
|
A list an integrator relies on must be complete, or say that it isn't. The
|
||||||
|
|||||||
+5
@@ -66,6 +66,11 @@ data class TrustedListContentMember(
|
|||||||
val i: String? = null,
|
val i: String? = null,
|
||||||
val endorsements: Int? = null,
|
val endorsements: Int? = null,
|
||||||
val disputes: 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,
|
val score: Int? = null,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-1
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense
|
||||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
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 --
|
* do not need the hints and scores never pay to build the member objects --
|
||||||
* these lists run to thousands of entries.
|
* 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) }
|
fun memberCount(): Int = tags.count { isMemberTag(it) }
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
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.AddressHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
@@ -58,7 +59,7 @@ class AddressableTrustedListEvent(
|
|||||||
|
|
||||||
override fun addressHints() = tags.mapNotNull(AddressMemberTag::parseAsHint)
|
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)
|
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
|
||||||
|
|
||||||
|
|||||||
+5
@@ -27,6 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
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(
|
fun TagArrayBuilder<AddressableTrustedListEvent>.member(
|
||||||
address: String,
|
address: String,
|
||||||
relayHint: NormalizedRelayUrl? = null,
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
|||||||
+2
-1
@@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.experimental.trustedLists.addressables
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
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*,
|
* The optional relay-filterable discovery tags: what this list is *about*,
|
||||||
|
|||||||
+4
-1
@@ -36,6 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure
|
|||||||
* An addressable-event member of a kind-30394 Trusted List:
|
* An addressable-event member of a kind-30394 Trusted List:
|
||||||
* `["a", <kind:pubkey:d>, <relay-hint>, <score>]`.
|
* `["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
|
* 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
|
* the event-id kind would tell a conformant reader "these are event ids" when
|
||||||
* they are coordinates, breaking kind-keyed dispatch.
|
* they are coordinates, breaking kind-keyed dispatch.
|
||||||
@@ -102,7 +105,7 @@ data class AddressMemberTag(
|
|||||||
address: String,
|
address: String,
|
||||||
relayHint: NormalizedRelayUrl?,
|
relayHint: NormalizedRelayUrl?,
|
||||||
score: Int?,
|
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)
|
fun assemble(member: AddressMemberTag) = assemble(member.address, member.relayHint, member.score)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
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.AddressHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||||
@@ -63,7 +64,7 @@ class EventTrustedListEvent(
|
|||||||
|
|
||||||
override fun eventHints() = tags.mapNotNull(EventMemberTag::parseAsHint)
|
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)
|
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||||
|
|
||||||
|
|||||||
+5
@@ -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.aTag.ATag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
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(
|
fun TagArrayBuilder<EventTrustedListEvent>.member(
|
||||||
eventId: HexKey,
|
eventId: HexKey,
|
||||||
relayHint: NormalizedRelayUrl? = null,
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
|||||||
+2
-1
@@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.experimental.trustedLists.events
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
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.aTag.ATag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
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*,
|
* The optional relay-filterable discovery tags: what this list is *about*,
|
||||||
|
|||||||
+4
-3
@@ -35,8 +35,9 @@ import com.vitorpamplona.quartz.utils.ensure
|
|||||||
* An event member of a kind-30393 Trusted List:
|
* An event member of a kind-30393 Trusted List:
|
||||||
* `["e", <event-id>, <relay-hint>, <score>]`.
|
* `["e", <event-id>, <relay-hint>, <score>]`.
|
||||||
*
|
*
|
||||||
* Index 3 is the score across the whole family, not a NIP-10 marker: these
|
* Index 3 is the score across the whole family -- a 0..100 percentage, see
|
||||||
* lists enumerate membership, they do not thread.
|
* [MemberTagFields.SCORE_RANGE] -- and not a NIP-10 marker: these lists
|
||||||
|
* enumerate membership, they do not thread.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class EventMemberTag(
|
data class EventMemberTag(
|
||||||
@@ -89,7 +90,7 @@ data class EventMemberTag(
|
|||||||
eventId: HexKey,
|
eventId: HexKey,
|
||||||
relayHint: NormalizedRelayUrl?,
|
relayHint: NormalizedRelayUrl?,
|
||||||
score: Int?,
|
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)
|
fun assemble(member: EventMemberTag) = assemble(member.eventId, member.relayHint, member.score)
|
||||||
|
|
||||||
|
|||||||
+5
@@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
|||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
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(
|
fun TagArrayBuilder<ExternalIdTrustedListEvent>.member(
|
||||||
externalId: String,
|
externalId: String,
|
||||||
hint: String? = null,
|
hint: String? = null,
|
||||||
|
|||||||
+2
-1
@@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.experimental.trustedLists.externalIds
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
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.aTag.ATag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
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*,
|
* The optional relay-filterable discovery tags: what this list is *about*,
|
||||||
|
|||||||
+3
-2
@@ -34,7 +34,8 @@ import com.vitorpamplona.quartz.utils.ensure
|
|||||||
* `["i", <i-tag>, <url-hint>, <score>]`.
|
* `["i", <i-tag>, <url-hint>, <score>]`.
|
||||||
*
|
*
|
||||||
* Index 2 is NIP-73's URL hint rather than a relay hint, so these members
|
* 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
|
@Immutable
|
||||||
data class ExternalIdMemberTag(
|
data class ExternalIdMemberTag(
|
||||||
@@ -75,7 +76,7 @@ data class ExternalIdMemberTag(
|
|||||||
externalId: String,
|
externalId: String,
|
||||||
hint: String?,
|
hint: String?,
|
||||||
score: Int?,
|
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)
|
fun assemble(member: ExternalIdMemberTag) = assemble(member.externalId, member.hint, member.score)
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -32,6 +32,16 @@ object MemberTagFields {
|
|||||||
const val HINT_INDEX = 2
|
const val HINT_INDEX = 2
|
||||||
const val SCORE_INDEX = 3
|
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.
|
* 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
|
* 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). */
|
/** 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 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()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -35,6 +35,10 @@ interface TrustedListMemberTag {
|
|||||||
/** The pubkey, event id, a-coordinate or external id of this member. */
|
/** The pubkey, event id, a-coordinate or external id of this member. */
|
||||||
val memberValue: String
|
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?
|
val score: Int?
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -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
|
||||||
|
}
|
||||||
+162
@@ -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,
|
||||||
|
)
|
||||||
+171
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -26,6 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
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(
|
fun TagArrayBuilder<UserTrustedListEvent>.member(
|
||||||
pubKey: HexKey,
|
pubKey: HexKey,
|
||||||
relayHint: NormalizedRelayUrl? = null,
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
|||||||
+2
-1
@@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.experimental.trustedLists.users
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
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.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*,
|
* The optional relay-filterable discovery tags: what this list is *about*,
|
||||||
|
|||||||
+2
-1
@@ -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.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
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.AddressHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
@@ -58,7 +59,7 @@ class UserTrustedListEvent(
|
|||||||
|
|
||||||
override fun pubKeyHints() = tags.mapNotNull(PubKeyMemberTag::parseAsHint)
|
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)
|
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -36,8 +36,9 @@ import com.vitorpamplona.quartz.utils.ensure
|
|||||||
* A pubkey member of a kind-30392 Trusted List:
|
* A pubkey member of a kind-30392 Trusted List:
|
||||||
* `["p", <pubkey>, <relay-hint>, <score>]`.
|
* `["p", <pubkey>, <relay-hint>, <score>]`.
|
||||||
*
|
*
|
||||||
* The score sits at index 3 across the whole family, so publishers that have a
|
* The score sits at index 3 across the whole family -- after the relay hint --
|
||||||
* score but no relay hint pad index 2 with an empty string.
|
* 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
|
@Immutable
|
||||||
data class PubKeyMemberTag(
|
data class PubKeyMemberTag(
|
||||||
@@ -91,7 +92,7 @@ data class PubKeyMemberTag(
|
|||||||
pubKey: HexKey,
|
pubKey: HexKey,
|
||||||
relayHint: NormalizedRelayUrl?,
|
relayHint: NormalizedRelayUrl?,
|
||||||
score: Int?,
|
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)
|
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
|
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? {
|
inline fun <T, R : Any> Array<out T>.fastFirstNotNullOfOrNull(transform: (T) -> R?): R? {
|
||||||
for (index in indices) {
|
for (index in indices) {
|
||||||
val result = transform(get(index))
|
val result = transform(get(index))
|
||||||
|
|||||||
+33
@@ -26,14 +26,44 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.utils.ensure
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One NIP-85 delegation in a Treasure Map (kind 10040):
|
||||||
|
* `["<assertion-kind>:<metric>", <pubkey>, <relay>]`.
|
||||||
|
*/
|
||||||
data class ServiceProviderTag(
|
data class ServiceProviderTag(
|
||||||
val service: ServiceType,
|
val service: ServiceType,
|
||||||
val pubkey: HexKey,
|
val pubkey: HexKey,
|
||||||
val relayUrl: NormalizedRelayUrl,
|
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)
|
fun toTagArray() = assemble(service, pubkey, relayUrl)
|
||||||
|
|
||||||
companion object {
|
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? {
|
fun parse(tag: Array<String>): ServiceProviderTag? {
|
||||||
ensure(tag.has(2)) { return null }
|
ensure(tag.has(2)) { return null }
|
||||||
ensure(tag[0].isNotEmpty()) { return null }
|
ensure(tag[0].isNotEmpty()) { return null }
|
||||||
@@ -41,6 +71,9 @@ data class ServiceProviderTag(
|
|||||||
ensure(tag[2].isNotEmpty()) { return null }
|
ensure(tag[2].isNotEmpty()) { return null }
|
||||||
|
|
||||||
val service = ServiceType.parse(tag[0]) ?: 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
|
val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null
|
||||||
|
|
||||||
return ServiceProviderTag(service, tag[1], relay)
|
return ServiceProviderTag(service, tag[1], relay)
|
||||||
|
|||||||
+314
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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.EventTrustedListEvent
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent
|
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.tags.ListStatus
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
|
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
|
||||||
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
|
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
|
||||||
@@ -451,6 +452,75 @@ class TrustedListEventTest {
|
|||||||
assertEquals("", event.indexableContent())
|
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
|
@Test
|
||||||
fun memberTagsRoundTripThroughTheirWireShape() {
|
fun memberTagsRoundTripThroughTheirWireShape() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -470,4 +540,19 @@ class TrustedListEventTest {
|
|||||||
AddressMemberTag("39999:$tagAuthor:podcaster").toTagArray().toList(),
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user