feat(quartz): implement the Tapestry Trusted List family (30392-30395)

Adds the pre-NIP Trusted List kinds as a companion to NIP-85 Trusted
Assertions. A Trusted List is an addressable event that publishes a curated
set of members computed under a point of view -- the aggregate analog of an
assertion, which states a computed result about a single subject.

The family binds to NIP-85's subject-type convention by the +10 rule, so the
kind's last digit denotes the member type and a reader never has to inspect
the tags to know what a list contains:

  30392 (= 30382 + 10) pubkeys            `p`  UserTrustedListEvent
  30393 (= 30383 + 10) events             `e`  EventTrustedListEvent
  30394 (= 30384 + 10) addressable events `a`  AddressableTrustedListEvent
  30395 (= 30385 + 10) external ids       `i`  ExternalIdTrustedListEvent

All four extend TrustedListEvent, which carries what the family shares: the
list identity in `d`, the title/metric labels, the observer / source-tag /
cutoff / min-rank provenance, the completeness signal, the retraction marker
and the optional JSON echo of the membership in `content`. members() is
narrowed per kind but always satisfies TrustedListMemberTag, so kind-agnostic
readers can take memberValue and score without branching.

Notable semantics:

- Member tags are [<tag>, <value>, <hint>, <score>] for every kind, so a
  publisher with a score but no relay hint pads index 2 with an empty string
  (as the reference publisher does). On `e` members index 3 is the score, not
  a NIP-10 marker.
- isTruncated() keys off the *presence* of the `truncated` tag, since its
  absence is what promises the list is exhaustive; a tag with a missing or
  unparseable total still reads as incomplete.
- Single-letter tags that are not the kind's member tag are relay-filterable
  discovery metadata, read through aboutAddresses()/aboutPubKeys() and never
  through members().

Package layout and tag classes follow the nip88Polls structure. Kinds are
registered in EventFactory and KindNames; the package README documents the
wire format. Covered by tests over the reference pinned-tag list event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
This commit is contained in:
Claude
2026-08-20 20:45:59 +00:00
parent 2b86db485c
commit c0f812e468
33 changed files with 2359 additions and 0 deletions
@@ -0,0 +1,107 @@
# Trusted Lists (kinds 30392-30395)
A **Trusted List** is an addressable event that publishes a curated set of
**members** computed under a point of view — "the pubkeys trusted-tagged with
tag X under observer O", "the notes trusted-tagged with X", "the tags that
apply to events". It is the *aggregate* analog of a
[NIP-85](https://nips.nostr.com/85) Trusted Assertion: where an assertion
states a computed result about **one** subject, a list enumerates **many**
members of one type.
This is a pre-NIP wire format drafted by Tapestry
([spec](https://github.com/nous-clawds4/tapestry/blob/main/protocols/drafts/trusted-lists.md)).
It does not modify NIP-85; it defines the list analog and binds its kinds to
NIP-85's subject-type convention.
## Kinds — the `+10` rule
NIP-85 encodes the *subject* type in the last digit of `3038x`. Trusted Lists
mirror that with `TL kind = TA kind + 10`, so the last digit denotes the
**member** type and a reader can tell what a list contains from the kind alone,
without inspecting the tags.
| Kind | = TA + 10 | Members | Member tag | Quartz class |
|---|---|---|---|---|
| 30392 | 30382 | pubkeys | `p` | `users.UserTrustedListEvent` |
| 30393 | 30383 | events | `e` | `events.EventTrustedListEvent` |
| 30394 | 30384 | addressable events | `a` | `addressables.AddressableTrustedListEvent` |
| 30395 | 30385 | external identifiers (NIP-73) | `i` | `externalIds.ExternalIdTrustedListEvent` |
A list of addressable events therefore **must** be 30394. Publishing
a-coordinate members on 30393 (the event-id kind) would tell a conformant
reader "these are event ids" when they are coordinates — a category error that
breaks kind-keyed dispatch and federation.
All four extend `TrustedListEvent`, which carries everything the family shares.
`members()` is narrowed per kind but always satisfies `TrustedListMemberTag`,
so a kind-agnostic reader can take `memberValue` and `score` without branching.
## Wire shape
```json
{
"kind": 30392,
"tags": [
["d", "tl-pin-2efaa715-e5272de9-podcaster"],
["title", "Podcaster"],
["metric", "pinned-tag-membership"],
["observer", "2efaa715…"],
["source-tag", "2f6a8652…", "e5272de9…", "podcaster"],
["cutoff", "1"],
["min-rank", "2"],
["p", "b83a28b7…", "", "99"]
],
"content": "{\"members\":[{\"pubkey\":\"b83a28b7…\",\"endorsements\":4,\"disputes\":0,\"score\":99}]}"
}
```
- `d` identifies the **list**, not a subject, so it replaces in place as it is
recomputed (`listId()`).
- `title` / `metric` are the label and the name of the computation.
- Member tags carry `[<tag>, <value>, <hint>, <score>]`. The score sits at
index 3 for every kind in the family, so a publisher with a score but no
relay hint pads index 2 with an empty string — as in the `p` tag above.
On `e` members index 3 is the score, **not** a NIP-10 marker: these lists
enumerate membership, they do not thread.
- `observer`, `source-tag`, `cutoff`, `min-rank` are provenance.
- Single-letter tags that are *not* the kind's member tag are
relay-filterable **discovery** metadata — what the list is about — and are
read through `aboutAddresses()` / `aboutPubKeys()`, never through
`members()`. The pinned-tag note list (30393) carries
`["a", "39999:<tagAuthor>:<slug>"]` so consumers can find every note list for
a tag (`{"kinds":[30393],"#a":[coord]}`) and `["p", <observer>]` to find
every note list for an observer.
- `content` is an optional JSON echo of the members with their computed values
(`contentEcho()` → `TrustedListContent`).
## Completeness and retraction
A list an integrator relies on must be complete, or say that it isn't. The
**absence** of a `truncated` tag means the membership is
authoritative-complete; its **presence** means the list is not exhaustive and
its value is the true total. `isTruncated()` keys off presence, so a
`truncated` tag with a missing or unparseable total still reads as incomplete;
`truncatedTotal()` returns the total when it parses. The content echo mirrors
this with `partial` + `total`.
A list that no longer has members, or that is migrating off a kind, is replaced
in place by an empty-membership event carrying `["status", "retracted"]`
(`isRetracted()`) rather than being deleted.
## Building
```kotlin
val template =
UserTrustedListEvent.build(
listId = "tl-pin-2efaa715-e5272de9-podcaster",
members = listOf(PubKeyMemberTag(authorHex, score = 99)),
content = TrustedListContent(listOf(TrustedListContentMember(pubkey = authorHex, score = 99))).toContent(),
) {
title("Podcaster")
metric("pinned-tag-membership")
observer(observerHex)
sourceTag(tagEventId, tagAuthorHex, "podcaster")
cutoff(1)
minRank(2)
}
```
@@ -0,0 +1,60 @@
/*
* 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.tags.CutoffTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus
import com.vitorpamplona.quartz.experimental.trustedLists.tags.MetricTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.MinRankTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.ObserverTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.SourceTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.StatusTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TruncatedTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
fun <T : TrustedListEvent> TagArrayBuilder<T>.title(title: String) = addUnique(TitleTag.assemble(title))
fun <T : TrustedListEvent> TagArrayBuilder<T>.metric(metric: String) = addUnique(MetricTag.assemble(metric))
fun <T : TrustedListEvent> TagArrayBuilder<T>.observer(observer: HexKey) = addUnique(ObserverTag.assemble(observer))
fun <T : TrustedListEvent> TagArrayBuilder<T>.sourceTag(source: SourceTag) = addUnique(source.toTagArray())
fun <T : TrustedListEvent> TagArrayBuilder<T>.sourceTag(
eventId: HexKey,
author: HexKey? = null,
slug: String? = null,
) = addUnique(SourceTag.assemble(eventId, author, slug))
fun <T : TrustedListEvent> TagArrayBuilder<T>.cutoff(cutoff: Int) = addUnique(CutoffTag.assemble(cutoff))
fun <T : TrustedListEvent> TagArrayBuilder<T>.minRank(minRank: Int) = addUnique(MinRankTag.assemble(minRank))
/**
* Marks the membership as *not* exhaustive, carrying the true [total]. Only
* add it when the list had to be cut: its absence is what tells consumers the
* list is complete.
*/
fun <T : TrustedListEvent> TagArrayBuilder<T>.truncated(total: Int) = addUnique(TruncatedTag.assemble(total))
fun <T : TrustedListEvent> TagArrayBuilder<T>.status(status: ListStatus) = addUnique(StatusTag.assemble(status))
@@ -0,0 +1,51 @@
/*
* 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.tags.CutoffTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.MetricTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.MinRankTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.ObserverTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.SourceTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.StatusTag
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TruncatedTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
fun TagArray.title() = fastFirstNotNullOfOrNull(TitleTag::parse)
fun TagArray.metric() = fastFirstNotNullOfOrNull(MetricTag::parse)
fun TagArray.observer() = fastFirstNotNullOfOrNull(ObserverTag::parse)
fun TagArray.sourceTag() = fastFirstNotNullOfOrNull(SourceTag::parse)
fun TagArray.cutoff() = fastFirstNotNullOfOrNull(CutoffTag::parse)
fun TagArray.minRank() = fastFirstNotNullOfOrNull(MinRankTag::parse)
fun TagArray.truncatedTotal() = fastFirstNotNullOfOrNull(TruncatedTag::parse)
fun TagArray.isTruncated() = fastAny(TruncatedTag::isTag)
fun TagArray.status() = fastFirstNotNullOfOrNull(StatusTag::parse)
@@ -0,0 +1,71 @@
/*
* 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 androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
/**
* The optional JSON echo of the membership carried in `content`, with the
* values the publisher computed for each member.
*
* [partial] mirrors the `truncated` tag: it is only present (with [total])
* when the list is not exhaustive.
*/
@Immutable
@Serializable
data class TrustedListContent(
val members: List<TrustedListContentMember> = emptyList(),
val partial: Boolean? = null,
val total: Int? = null,
) {
fun toContent() = assemble(this)
companion object {
fun parse(content: String): TrustedListContent? {
if (content.isBlank()) return null
return runCatching { JsonMapper.fromJson<TrustedListContent>(content) }.getOrNull()
}
fun assemble(content: TrustedListContent) = JsonMapper.toJson(content)
}
}
/**
* One member of the content echo. The member value is carried under the key
* that matches the list's member type -- `pubkey` on 30392, `id` on 30393,
* `address` on 30394 and `i` on 30395 -- so exactly one of them is set.
* Use [memberValue] to read it without branching on the kind.
*/
@Immutable
@Serializable
data class TrustedListContentMember(
val pubkey: String? = null,
val id: String? = null,
val address: String? = null,
val i: String? = null,
val endorsements: Int? = null,
val disputes: Int? = null,
val score: Int? = null,
) {
fun memberValue() = pubkey ?: id ?: address ?: i
}
@@ -0,0 +1,106 @@
/*
* 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 androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
/**
* Base of the Tapestry Trusted List family: an addressable event that
* publishes a curated set of members computed under a point of view.
*
* Where a NIP-85 Trusted Assertion states a computed result about **one**
* subject, a Trusted List enumerates **many** members of one type. The family
* binds to NIP-85's subject-type convention by the `+10` rule -- a list kind
* is its assertion kind plus ten -- so the last digit denotes the member type
* on both sides and a reader can tell what a list contains from the kind
* alone:
*
* | List kind | Assertion kind | Member type | Member tag |
* |---|---|---|---|
* | 30392 | 30382 | pubkeys | `p` |
* | 30393 | 30383 | events | `e` |
* | 30394 | 30384 | addressable events | `a` |
* | 30395 | 30385 | external identifiers (NIP-73) | `i` |
*
* The `d` tag identifies the *list*, not a single subject, so the list
* replaces in place as it is recomputed.
*/
@Immutable
abstract class TrustedListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: TagArray,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
/** The addressable identity of this list. Deterministic per list. */
fun listId() = dTag()
fun title() = tags.title()
fun metric() = tags.metric()
fun observer() = tags.observer()
fun sourceTag() = tags.sourceTag()
fun cutoff() = tags.cutoff()
fun minRank() = tags.minRank()
/**
* The members of this list, in the tag this kind's last digit denotes.
* Subclasses narrow the return type to their own member tag.
*/
abstract fun members(): List<TrustedListMemberTag>
fun memberValues() = members().map { it.memberValue }
fun memberCount() = members().size
/**
* True when the publisher signalled that it could not carry the full
* membership. A list without the `truncated` tag is authoritative-complete;
* a truncated one is a cue to reconcile from the raw source events.
*/
fun isTruncated() = tags.isTruncated()
/** The true total member count, when the list is truncated. */
fun truncatedTotal() = tags.truncatedTotal()
fun status() = tags.status()
/**
* Retracted lists are empty-membership replacements published in place of
* a list that no longer has members or was migrated to another kind.
*/
fun isRetracted() = status() == ListStatus.RETRACTED
/** The optional JSON echo of the membership carried in `content`. */
fun contentEcho() = TrustedListContent.parse(content)
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.addressables
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Trusted List whose members are **addressable events**, by a-coordinate --
* the list analog of the NIP-85 kind-30384 addressable assertion (30384 + 10).
*
* Example: the tag-applicability lists, whose members are tag coordinates.
*/
@Immutable
class AddressableTrustedListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun members(): List<AddressMemberTag> = tags.members()
override fun addressHints() = tags.mapNotNull(AddressMemberTag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(AddressMemberTag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
/** What this list is about, for relay-side discovery. Never its members. */
fun aboutPubKeys() = tags.aboutPubKeys()
companion object {
const val KIND = 30394
fun build(
listId: String,
members: List<AddressMemberTag> = emptyList(),
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AddressableTrustedListEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
dTag(listId)
members(members)
initializer()
}
}
}
@@ -0,0 +1,51 @@
/*
* 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.addressables
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<AddressableTrustedListEvent>.member(
address: String,
relayHint: NormalizedRelayUrl? = null,
score: Int? = null,
) = add(AddressMemberTag.assemble(address, relayHint, score))
fun TagArrayBuilder<AddressableTrustedListEvent>.member(
address: Address,
relayHint: NormalizedRelayUrl? = null,
score: Int? = null,
) = add(AddressMemberTag.assemble(address.toValue(), relayHint, score))
fun TagArrayBuilder<AddressableTrustedListEvent>.member(member: AddressMemberTag) = add(AddressMemberTag.assemble(member))
fun TagArrayBuilder<AddressableTrustedListEvent>.members(members: List<AddressMemberTag>) = addAll(AddressMemberTag.assemble(members))
fun TagArrayBuilder<AddressableTrustedListEvent>.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray())
fun TagArrayBuilder<AddressableTrustedListEvent>.aboutPubKey(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint))
@@ -0,0 +1,34 @@
/*
* 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.addressables
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArray.members() = mapNotNull(AddressMemberTag::parse)
/**
* The optional relay-filterable discovery tags: what this list is *about*,
* as opposed to its members. On kind 30394 the members are the `a` tags, so
* only `p` is free to carry discovery metadata.
*/
fun TagArray.aboutPubKeys() = mapNotNull(PTag::parse)
@@ -0,0 +1,113 @@
/*
* 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.addressables.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* An addressable-event member of a kind-30394 Trusted List:
* `["a", <kind:pubkey:d>, <relay-hint>, <score>]`.
*
* A-coordinate members belong on 30394 and never on 30393: publishing them on
* the event-id kind would tell a conformant reader "these are event ids" when
* they are coordinates, breaking kind-keyed dispatch.
*/
@Immutable
data class AddressMemberTag(
val address: String,
val relayHint: NormalizedRelayUrl? = null,
override val score: Int? = null,
) : TrustedListMemberTag {
override val memberValue: String get() = address
fun toAddress(): Address? = AddressSerializer.parse(address)
fun toTagArray() = assemble(address, relayHint, score)
companion object {
const val TAG_NAME = "a"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(
tag: Tag,
address: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address
fun parse(tag: Tag): AddressMemberTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return AddressMemberTag(tag[1], parseHint(tag), parseScore(tag))
}
fun parseAddressId(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun parseAddress(tag: Tag): Address? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return AddressSerializer.parse(tag[1])
}
fun parseAsHint(tag: Tag): AddressHint? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val hint = parseHint(tag)
ensure(hint != null) { return null }
return AddressHint(tag[1], hint)
}
private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) }
private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull()
fun assemble(
address: String,
relayHint: NormalizedRelayUrl?,
score: Int?,
) = arrayOfNotNull(TAG_NAME, address, relayHint?.url, score?.toString())
fun assemble(member: AddressMemberTag) = assemble(member.address, member.relayHint, member.score)
fun assemble(members: List<AddressMemberTag>) = members.map { assemble(it) }
}
}
@@ -0,0 +1,91 @@
/*
* 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Trusted List whose members are **events**, by id -- the list analog of the
* NIP-85 kind-30383 event assertion (30383 + 10).
*
* Example: the notes trusted-tagged with a given tag under one observer. Its
* `a` and `p` tags are discovery metadata (the tag coordinate and the
* observer), not members.
*/
@Immutable
class EventTrustedListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun members(): List<EventMemberTag> = tags.members()
override fun eventHints() = tags.mapNotNull(EventMemberTag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(EventMemberTag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
/** What this list is about, for relay-side discovery. Never its members. */
fun aboutAddresses() = tags.aboutAddresses()
fun aboutPubKeys() = tags.aboutPubKeys()
companion object {
const val KIND = 30393
fun build(
listId: String,
members: List<EventMemberTag> = emptyList(),
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EventTrustedListEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
dTag(listId)
members(members)
initializer()
}
}
}
@@ -0,0 +1,47 @@
/*
* 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.events
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<EventTrustedListEvent>.member(
eventId: HexKey,
relayHint: NormalizedRelayUrl? = null,
score: Int? = null,
) = add(EventMemberTag.assemble(eventId, relayHint, score))
fun TagArrayBuilder<EventTrustedListEvent>.member(member: EventMemberTag) = add(EventMemberTag.assemble(member))
fun TagArrayBuilder<EventTrustedListEvent>.members(members: List<EventMemberTag>) = addAll(EventMemberTag.assemble(members))
fun TagArrayBuilder<EventTrustedListEvent>.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray())
fun TagArrayBuilder<EventTrustedListEvent>.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray())
fun TagArrayBuilder<EventTrustedListEvent>.aboutPubKey(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint))
@@ -0,0 +1,39 @@
/*
* 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.events
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArray.members() = mapNotNull(EventMemberTag::parse)
/**
* The optional relay-filterable discovery tags: what this list is *about*,
* as opposed to its members. On kind 30393 the members are the `e` tags, so
* both `a` and `p` are free to carry discovery metadata -- the pinned-tag note
* list uses `a` for the tag coordinate and `p` for the observer, so consumers
* can find every note list for a tag (`#a`) or for an observer (`#p`).
*/
fun TagArray.aboutAddresses() = mapNotNull(ATag::parse)
fun TagArray.aboutPubKeys() = mapNotNull(PTag::parse)
@@ -0,0 +1,102 @@
/*
* 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.events.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* An event member of a kind-30393 Trusted List:
* `["e", <event-id>, <relay-hint>, <score>]`.
*
* Index 3 is the score across the whole family, not a NIP-10 marker: these
* lists enumerate membership, they do not thread.
*/
@Immutable
data class EventMemberTag(
val eventId: HexKey,
val relayHint: NormalizedRelayUrl? = null,
override val score: Int? = null,
) : TrustedListMemberTag {
override val memberValue: String get() = eventId
fun toTagArray() = assemble(eventId, relayHint, score)
companion object {
const val TAG_NAME = "e"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun isTagged(
tag: Tag,
eventId: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
fun parse(tag: Tag): EventMemberTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return EventMemberTag(tag[1], parseHint(tag), parseScore(tag))
}
fun parseId(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun parseAsHint(tag: Tag): EventIdHint? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = parseHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
}
private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) }
private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull()
fun assemble(
eventId: HexKey,
relayHint: NormalizedRelayUrl?,
score: Int?,
) = arrayOfNotNull(TAG_NAME, eventId, relayHint?.url, score?.toString())
fun assemble(member: EventMemberTag) = assemble(member.eventId, member.relayHint, member.score)
fun assemble(members: List<EventMemberTag>) = members.map { assemble(it) }
}
}
@@ -0,0 +1,81 @@
/*
* 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.externalIds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Trusted List whose members are **external identifiers** (NIP-73) -- the
* list analog of the NIP-85 kind-30385 external-id assertion (30385 + 10).
*/
@Immutable
class ExternalIdTrustedListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun members(): List<ExternalIdMemberTag> = tags.members()
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
/** What this list is about, for relay-side discovery. Never its members. */
fun aboutAddresses() = tags.aboutAddresses()
fun aboutPubKeys() = tags.aboutPubKeys()
companion object {
const val KIND = 30395
fun build(
listId: String,
members: List<ExternalIdMemberTag> = emptyList(),
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ExternalIdTrustedListEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
dTag(listId)
members(members)
initializer()
}
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.externalIds
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
fun TagArrayBuilder<ExternalIdTrustedListEvent>.member(
externalId: String,
hint: String? = null,
score: Int? = null,
) = add(ExternalIdMemberTag.assemble(externalId, hint, score))
fun TagArrayBuilder<ExternalIdTrustedListEvent>.member(
externalId: ExternalId,
score: Int? = null,
) = add(ExternalIdMemberTag.assemble(externalId, score))
fun TagArrayBuilder<ExternalIdTrustedListEvent>.member(member: ExternalIdMemberTag) = add(ExternalIdMemberTag.assemble(member))
fun TagArrayBuilder<ExternalIdTrustedListEvent>.members(members: List<ExternalIdMemberTag>) = addAll(ExternalIdMemberTag.assemble(members))
fun TagArrayBuilder<ExternalIdTrustedListEvent>.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray())
fun TagArrayBuilder<ExternalIdTrustedListEvent>.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray())
fun TagArrayBuilder<ExternalIdTrustedListEvent>.aboutPubKey(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint))
@@ -0,0 +1,37 @@
/*
* 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.externalIds
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.tags.ExternalIdMemberTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArray.members() = mapNotNull(ExternalIdMemberTag::parse)
/**
* The optional relay-filterable discovery tags: what this list is *about*,
* as opposed to its members. On kind 30395 the members are the `i` tags, so
* both `a` and `p` are free to carry discovery metadata.
*/
fun TagArray.aboutAddresses() = mapNotNull(ATag::parse)
fun TagArray.aboutPubKeys() = mapNotNull(PTag::parse)
@@ -0,0 +1,92 @@
/*
* 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.externalIds.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* An external-identifier member of a kind-30395 Trusted List:
* `["i", <i-tag>, <url-hint>, <score>]`.
*
* Index 2 is NIP-73's URL hint rather than a relay hint, so these members
* carry no relay information for the hint indexer.
*/
@Immutable
data class ExternalIdMemberTag(
val externalId: String,
val hint: String? = null,
override val score: Int? = null,
) : TrustedListMemberTag {
override val memberValue: String get() = externalId
fun toTagArray() = assemble(externalId, hint, score)
companion object {
const val TAG_NAME = "i"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(
tag: Tag,
externalId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == externalId
fun parse(tag: Tag): ExternalIdMemberTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return ExternalIdMemberTag(
tag[1],
tag.getOrNull(2)?.takeIf { it.isNotEmpty() },
tag.getOrNull(3)?.toIntOrNull(),
)
}
fun parseId(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(
externalId: String,
hint: String?,
score: Int?,
) = arrayOfNotNull(TAG_NAME, externalId, hint, score?.toString())
fun assemble(member: ExternalIdMemberTag) = assemble(member.externalId, member.hint, member.score)
fun assemble(members: List<ExternalIdMemberTag>) = members.map { assemble(it) }
fun assemble(
externalId: ExternalId,
score: Int? = null,
) = assemble(externalId.toScope(), externalId.hint(), score)
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Minimum number of endorsements a member needed to make the list. */
class CutoffTag {
companion object {
const val TAG_NAME = "cutoff"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Tag): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(cutoff: Int) = arrayOf(TAG_NAME, cutoff.toString())
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Names the computation that produced the list, e.g. `pinned-tag-membership`,
* `pinned-tag-notes` or `tag-applicability`.
*/
class MetricTag {
companion object {
const val TAG_NAME = "metric"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(metric: String) = arrayOf(TAG_NAME, metric)
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Minimum rank an endorser needed for their endorsement to count. */
class MinRankTag {
companion object {
const val TAG_NAME = "min-rank"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Tag): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(minRank: Int) = arrayOf(TAG_NAME, minRank.toString())
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The point of view the list was computed under. This is provenance, not a
* member and not a notification: it never uses the `p` tag name, which on
* kind 30392 carries the members themselves.
*/
class ObserverTag {
companion object {
const val TAG_NAME = "observer"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun assemble(observer: HexKey) = arrayOf(TAG_NAME, observer)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import androidx.compose.runtime.Immutable
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.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* The tag definition the membership was computed from:
* `["source-tag", <eventId>, <author>, <slug>]`.
*/
@Immutable
data class SourceTag(
val eventId: HexKey,
val author: HexKey? = null,
val slug: String? = null,
) {
fun toTagArray() = assemble(eventId, author, slug)
companion object {
const val TAG_NAME = "source-tag"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Tag): SourceTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return SourceTag(
tag[1],
tag.getOrNull(2)?.ifEmpty { null },
tag.getOrNull(3)?.ifEmpty { null },
)
}
fun assemble(
eventId: HexKey,
author: HexKey?,
slug: String?,
) = arrayOfNotNull(TAG_NAME, eventId, author, slug)
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
enum class ListStatus(
val code: String,
) {
RETRACTED("retracted"),
}
/**
* Marks the retraction convention: a list that no longer has members, or that
* is being migrated off a kind, is replaced in place by an empty-membership
* event carrying `["status", "retracted"]` rather than being deleted.
*/
class StatusTag {
companion object {
const val TAG_NAME = "status"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Tag): ListStatus? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return when (tag[1]) {
ListStatus.RETRACTED.code -> ListStatus.RETRACTED
else -> null
}
}
fun assemble(status: ListStatus) = arrayOf(TAG_NAME, status.code)
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The partial signal. Its **presence** means the list is not exhaustive; its
* absence means the publisher considers the membership complete. The value is
* the true total member count.
*
* Readers must key completeness off [isTag], not off [parse]: a `truncated`
* tag with a missing or unparseable total still means "not exhaustive".
*/
class TruncatedTag {
companion object {
const val TAG_NAME = "truncated"
fun isTag(tag: Tag) = tag.isNotEmpty() && tag[0] == TAG_NAME
fun parse(tag: Tag): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(total: Int) = arrayOf(TAG_NAME, total.toString())
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.tags
import androidx.compose.runtime.Stable
/**
* Kind-agnostic view of a Trusted List member.
*
* Every kind in the family carries its members in the tag its last digit
* denotes (`p`/`e`/`a`/`i`), all with the same layout:
* `[<tagName>, <memberValue>, <hint>, <score>]`. A reader that only needs the
* membership can dispatch on this interface instead of on the kind.
*/
@Stable
interface TrustedListMemberTag {
/** The pubkey, event id, a-coordinate or external id of this member. */
val memberValue: String
/** The computed score the publisher assigned to this member, if any. */
val score: Int?
}
@@ -0,0 +1,39 @@
/*
* 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.users
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
fun TagArrayBuilder<UserTrustedListEvent>.member(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
score: Int? = null,
) = add(PubKeyMemberTag.assemble(pubKey, relayHint, score))
fun TagArrayBuilder<UserTrustedListEvent>.member(member: PubKeyMemberTag) = add(PubKeyMemberTag.assemble(member))
fun TagArrayBuilder<UserTrustedListEvent>.members(members: List<PubKeyMemberTag>) = addAll(PubKeyMemberTag.assemble(members))
fun TagArrayBuilder<UserTrustedListEvent>.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray())
@@ -0,0 +1,34 @@
/*
* 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.users
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
fun TagArray.members() = mapNotNull(PubKeyMemberTag::parse)
/**
* The optional relay-filterable discovery tags: what this list is *about*,
* as opposed to its members. On kind 30392 the members are the `p` tags, so
* only `a` is free to carry discovery metadata.
*/
fun TagArray.aboutAddresses() = mapNotNull(ATag::parse)
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.trustedLists.users
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.TrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Trusted List whose members are **pubkeys** -- the list analog of the
* NIP-85 kind-30382 contact card (30382 + 10).
*
* Example: the pubkeys trusted-tagged with a given tag under one observer.
*/
@Immutable
class UserTrustedListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider,
AddressHintProvider {
override fun members(): List<PubKeyMemberTag> = tags.members()
override fun pubKeyHints() = tags.mapNotNull(PubKeyMemberTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PubKeyMemberTag::parseKey)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
/** What this list is about, for relay-side discovery. Never its members. */
fun aboutAddresses() = tags.aboutAddresses()
companion object {
const val KIND = 30392
fun build(
listId: String,
members: List<PubKeyMemberTag> = emptyList(),
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<UserTrustedListEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
dTag(listId)
members(members)
initializer()
}
}
}
@@ -0,0 +1,104 @@
/*
* 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.users.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* A pubkey member of a kind-30392 Trusted List:
* `["p", <pubkey>, <relay-hint>, <score>]`.
*
* The score sits at index 3 across the whole family, so publishers that have a
* score but no relay hint pad index 2 with an empty string.
*/
@Immutable
data class PubKeyMemberTag(
override val pubKey: HexKey,
override val relayHint: NormalizedRelayUrl? = null,
override val score: Int? = null,
) : PubKeyReferenceTag,
TrustedListMemberTag {
override val memberValue: String get() = pubKey
fun toTagArray() = assemble(pubKey, relayHint, score)
companion object {
const val TAG_NAME = "p"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun isTagged(
tag: Tag,
pubKey: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pubKey
fun parse(tag: Tag): PubKeyMemberTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return PubKeyMemberTag(tag[1], parseHint(tag), parseScore(tag))
}
fun parseKey(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun parseAsHint(tag: Tag): PubKeyHint? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = parseHint(tag)
ensure(hint != null) { return null }
return PubKeyHint(tag[1], hint)
}
private fun parseHint(tag: Tag) = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) }
private fun parseScore(tag: Tag) = tag.getOrNull(3)?.toIntOrNull()
fun assemble(
pubKey: HexKey,
relayHint: NormalizedRelayUrl?,
score: Int?,
) = arrayOfNotNull(TAG_NAME, pubKey, relayHint?.url, score?.toString())
fun assemble(member: PubKeyMemberTag) = assemble(member.pubKey, member.relayHint, member.score)
fun assemble(members: List<PubKeyMemberTag>) = members.map { assemble(it) }
}
}
@@ -57,6 +57,10 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
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.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
@@ -600,6 +604,10 @@ object KindNames {
EventAssertionEvent.KIND to KindName("Event Assertion", "85"),
AddressableAssertionEvent.KIND to KindName("Addressable Assertion", "85"),
ExternalIdAssertionEvent.KIND to KindName("External ID Assertion", "85"),
UserTrustedListEvent.KIND to KindName("Trusted List of Pubkeys", null),
EventTrustedListEvent.KIND to KindName("Trusted List of Events", null),
AddressableTrustedListEvent.KIND to KindName("Trusted List of Addressables", null),
ExternalIdTrustedListEvent.KIND to KindName("Trusted List of External IDs", null),
KeyPackageEvent.KIND to KindName("MLS KeyPackage", null),
GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"),
FeedDefinitionEvent.KIND to KindName("Feed Definition", null),
@@ -141,6 +141,10 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
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.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -786,6 +790,10 @@ class EventFactory {
EventAssertionEvent.KIND -> EventAssertionEvent(id, pubKey, createdAt, tags, content, sig)
AddressableAssertionEvent.KIND -> AddressableAssertionEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdAssertionEvent.KIND -> ExternalIdAssertionEvent(id, pubKey, createdAt, tags, content, sig)
UserTrustedListEvent.KIND -> UserTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
EventTrustedListEvent.KIND -> EventTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
AddressableTrustedListEvent.KIND -> AddressableTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdTrustedListEvent.KIND -> ExternalIdTrustedListEvent(id, pubKey, createdAt, tags, content, sig)
RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,140 @@
/*
* 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.tags.ListStatus
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class TrustedListBuilderTest {
private val observer = "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331"
private val tagEventId = "2f6a8652bde6fb5a974d6e06e4eae3b4f130140fd170b2686a291463f47a7451"
private val tagAuthor = "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f"
private val member = "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
private fun sign(template: EventTemplate<UserTrustedListEvent>): Event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "00".repeat(64),
)
@Test
fun buildsAPinnedTagPubKeyList() {
val content =
TrustedListContent(
members = listOf(TrustedListContentMember(pubkey = member, endorsements = 4, disputes = 0, score = 99)),
)
val template =
UserTrustedListEvent.build(
listId = "tl-pin-2efaa715-e5272de9-podcaster",
members = listOf(PubKeyMemberTag(member, score = 99)),
content = content.toContent(),
createdAt = 1_787_253_028L,
) {
title("Podcaster")
metric("pinned-tag-membership")
observer(observer)
sourceTag(tagEventId, tagAuthor, "podcaster")
cutoff(1)
minRank(2)
}
val event = sign(template)
assertIs<UserTrustedListEvent>(event)
assertEquals(UserTrustedListEvent.KIND, event.kind)
assertEquals("tl-pin-2efaa715-e5272de9-podcaster", event.listId())
assertEquals("Podcaster", event.title())
assertEquals("pinned-tag-membership", event.metric())
assertEquals(observer, event.observer())
assertEquals(1, event.cutoff())
assertEquals(2, event.minRank())
assertEquals("podcaster", event.sourceTag()?.slug)
assertEquals(listOf(member), event.memberValues())
assertEquals(listOf(99), event.members().map { it.score })
assertFalse(event.isTruncated(), "a list with no truncated tag is complete")
assertEquals(content, event.contentEcho())
}
@Test
fun buildsATruncatedList() {
val template =
UserTrustedListEvent.build(
listId = "tl-pin-2efaa715-e5272de9-podcaster",
members = listOf(PubKeyMemberTag(member, score = 99)),
content = TrustedListContent(partial = true, total = 4211).toContent(),
createdAt = 1_787_253_028L,
) {
truncated(4211)
}
val event = sign(template)
assertIs<UserTrustedListEvent>(event)
assertTrue(event.isTruncated())
assertEquals(4211, event.truncatedTotal())
assertEquals(true, event.contentEcho()?.partial)
assertEquals(4211, event.contentEcho()?.total)
}
@Test
fun buildsAnEmptyRetraction() {
val template =
UserTrustedListEvent.build(
listId = "tl-pin-2efaa715-e5272de9-podcaster",
createdAt = 1_787_253_028L,
) {
status(ListStatus.RETRACTED)
}
val event = sign(template)
assertIs<UserTrustedListEvent>(event)
assertTrue(event.isRetracted())
assertEquals(emptyList<String>(), event.memberValues())
}
@Test
fun aCompleteListSerializesNoPartialMarker() {
val content = TrustedListContent(members = listOf(TrustedListContentMember(pubkey = member, score = 99)))
val json = content.toContent()
assertFalse(json.contains("partial"), "encoded content was: $json")
assertFalse(json.contains("total"), "encoded content was: $json")
assertFalse(json.contains("\"id\""), "unused member keys must not be encoded: $json")
assertEquals(content, TrustedListContent.parse(json))
}
}
@@ -0,0 +1,340 @@
/*
* 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.addressables.tags.AddressMemberTag
import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.events.tags.EventMemberTag
import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TrustedListEventTest {
private val observer = "2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331"
private val tagEventId = "2f6a8652bde6fb5a974d6e06e4eae3b4f130140fd170b2686a291463f47a7451"
private val tagAuthor = "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f"
private val dummySig = "00".repeat(64)
/** The pinned-tag pubkey list published by Tapestry's `refreshPinnedTags.runOnePin`. */
private fun podcasterList(): Event =
EventFactory.create<Event>(
id = "2175c15e58ab5ac8ab05e06f3f56a6c44f7bc36b6a9de70bc125c48c21e1254f",
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = UserTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tl-pin-2efaa715-e5272de9-podcaster"),
arrayOf("title", "Podcaster"),
arrayOf("metric", "pinned-tag-membership"),
arrayOf("observer", observer),
arrayOf("source-tag", tagEventId, tagAuthor, "podcaster"),
arrayOf("cutoff", "1"),
arrayOf("min-rank", "2"),
arrayOf("p", "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", "", "99"),
arrayOf("p", "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a", "", "97"),
arrayOf("p", "19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f", "", "100"),
),
content =
"{\"members\":[" +
"{\"pubkey\":\"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450\",\"endorsements\":4,\"disputes\":0,\"score\":99}," +
"{\"pubkey\":\"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a\",\"endorsements\":2,\"disputes\":0,\"score\":97}," +
"{\"pubkey\":\"19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f\",\"endorsements\":1,\"disputes\":0,\"score\":100}" +
"]}",
sig = dummySig,
)
@Test
fun factoryBuildsEachKindInTheFamily() {
assertTrue(EventFactory.isKnownKind(UserTrustedListEvent.KIND), "kind 30392 should be a known kind")
assertTrue(EventFactory.isKnownKind(EventTrustedListEvent.KIND), "kind 30393 should be a known kind")
assertTrue(EventFactory.isKnownKind(AddressableTrustedListEvent.KIND), "kind 30394 should be a known kind")
assertTrue(EventFactory.isKnownKind(ExternalIdTrustedListEvent.KIND), "kind 30395 should be a known kind")
}
@Test
fun kindsFollowThePlusTenRuleOverNip85() {
assertEquals(30382 + 10, UserTrustedListEvent.KIND)
assertEquals(30383 + 10, EventTrustedListEvent.KIND)
assertEquals(30384 + 10, AddressableTrustedListEvent.KIND)
assertEquals(30385 + 10, ExternalIdTrustedListEvent.KIND)
}
@Test
fun parsesThePinnedTagPubKeyList() {
val event = podcasterList()
assertIs<UserTrustedListEvent>(event)
assertEquals("tl-pin-2efaa715-e5272de9-podcaster", event.listId())
assertEquals("Podcaster", event.title())
assertEquals("pinned-tag-membership", event.metric())
assertEquals(observer, event.observer())
assertEquals(1, event.cutoff())
assertEquals(2, event.minRank())
val source = event.sourceTag()
assertEquals(tagEventId, source?.eventId)
assertEquals(tagAuthor, source?.author)
assertEquals("podcaster", source?.slug)
assertEquals(3, event.memberCount())
assertEquals(
listOf(
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450",
"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
"19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f",
),
event.memberValues(),
)
assertEquals(listOf(99, 97, 100), event.members().map { it.score })
// the empty string at index 2 is a placeholder for the missing relay hint
assertTrue(event.members().all { it.relayHint == null })
}
@Test
fun theObserverIsProvenanceNotAMember() {
val event = podcasterList()
assertIs<UserTrustedListEvent>(event)
assertFalse(observer in event.memberValues(), "the observer must not be read back as a member")
assertEquals(observer, event.observer())
}
@Test
fun readsTheContentEcho() {
val event = podcasterList()
assertIs<UserTrustedListEvent>(event)
val echo = event.contentEcho()
assertEquals(3, echo?.members?.size)
assertEquals("b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", echo?.members?.first()?.memberValue())
assertEquals(4, echo?.members?.first()?.endorsements)
assertEquals(0, echo?.members?.first()?.disputes)
assertEquals(99, echo?.members?.first()?.score)
assertNull(echo?.partial, "a complete list carries no partial marker")
assertEquals(event.memberValues(), echo?.members?.mapNotNull { it.memberValue() })
}
@Test
fun aMissingTruncatedTagMeansComplete() {
val event = podcasterList()
assertIs<UserTrustedListEvent>(event)
assertFalse(event.isTruncated())
assertNull(event.truncatedTotal())
}
@Test
fun aTruncatedTagMeansNotExhaustive() {
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = EventTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tl-pin-notes-2efaa715-e5272de9-podcaster"),
arrayOf("metric", "pinned-tag-notes"),
arrayOf("truncated", "4211"),
arrayOf("e", "f00dcafe00000000000000000000000000000000000000000000000000000000", "wss://nos.lol/", "88"),
),
content = "",
sig = dummySig,
)
assertIs<EventTrustedListEvent>(event)
assertTrue(event.isTruncated())
assertEquals(4211, event.truncatedTotal())
assertNull(event.contentEcho(), "an empty content is not an echo")
}
@Test
fun aTruncatedTagWithoutATotalStillMeansNotExhaustive() {
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = EventTrustedListEvent.KIND,
tags = arrayOf(arrayOf("d", "tl"), arrayOf("truncated")),
content = "",
sig = dummySig,
)
assertIs<EventTrustedListEvent>(event)
assertTrue(event.isTruncated(), "presence of the tag is what signals incompleteness")
assertNull(event.truncatedTotal())
}
@Test
fun readsTheRetractionMarker() {
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = AddressableTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tag-applicability-nostr-event"),
arrayOf("status", "retracted"),
),
content = "",
sig = dummySig,
)
assertIs<AddressableTrustedListEvent>(event)
assertEquals(ListStatus.RETRACTED, event.status())
assertTrue(event.isRetracted())
assertEquals(emptyList<String>(), event.memberValues())
}
@Test
fun discoveryTagsOnTheNoteListAreNotMembers() {
val coordinate = "39999:$tagAuthor:podcaster"
val noteId = "f00dcafe00000000000000000000000000000000000000000000000000000000"
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = EventTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tl-pin-notes-2efaa715-e5272de9-podcaster"),
arrayOf("metric", "pinned-tag-notes"),
arrayOf("observer", observer),
arrayOf("a", coordinate),
arrayOf("p", observer),
arrayOf("e", noteId, "", "88"),
),
content = "",
sig = dummySig,
)
assertIs<EventTrustedListEvent>(event)
assertEquals(listOf(noteId), event.memberValues())
assertEquals(listOf(coordinate), event.aboutAddresses().map { it.toTag() })
assertEquals(listOf(observer), event.aboutPubKeys().map { it.pubKey })
}
@Test
fun addressableMembersAreACoordinates() {
val coordinate = "39999:$tagAuthor:podcaster"
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = AddressableTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tag-applicability-nostr-event"),
arrayOf("metric", "tag-applicability"),
arrayOf("a", coordinate),
),
content = "",
sig = dummySig,
)
assertIs<AddressableTrustedListEvent>(event)
assertEquals(listOf(coordinate), event.memberValues())
assertEquals(
39999,
event
.members()
.first()
.toAddress()
?.kind,
)
assertEquals(
tagAuthor,
event
.members()
.first()
.toAddress()
?.pubKeyHex,
)
assertEquals(
"podcaster",
event
.members()
.first()
.toAddress()
?.dTag,
)
}
@Test
fun externalIdMembersKeepTheirNip73Hint() {
val event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = ExternalIdTrustedListEvent.KIND,
tags =
arrayOf(
arrayOf("d", "tl-pin-ext-podcaster"),
arrayOf("i", "podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc", "https://fountain.fm/show/abc", "91"),
),
content = "",
sig = dummySig,
)
assertIs<ExternalIdTrustedListEvent>(event)
val member = event.members().first()
assertEquals("podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc", member.memberValue)
assertEquals("https://fountain.fm/show/abc", member.hint)
assertEquals(91, member.score)
}
@Test
fun memberTagsRoundTripThroughTheirWireShape() {
assertEquals(
listOf("p", "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", "", "99"),
PubKeyMemberTag("b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450", score = 99).toTagArray().toList(),
)
assertEquals(
listOf("p", "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"),
PubKeyMemberTag("b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450").toTagArray().toList(),
)
assertEquals(
listOf("e", "f00dcafe00000000000000000000000000000000000000000000000000000000", "", "88"),
EventMemberTag("f00dcafe00000000000000000000000000000000000000000000000000000000", score = 88).toTagArray().toList(),
)
assertEquals(
listOf("a", "39999:$tagAuthor:podcaster"),
AddressMemberTag("39999:$tagAuthor:podcaster").toTagArray().toList(),
)
}
}