diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md new file mode 100644 index 0000000000..a9eb400c02 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md @@ -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 `[, , , ]`. 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::"]` so consumers can find every note list for + a tag (`{"kinds":[30393],"#a":[coord]}`) and `["p", ]` 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) + } +``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..bf11cd8b2f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayBuilderExt.kt @@ -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 TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.metric(metric: String) = addUnique(MetricTag.assemble(metric)) + +fun TagArrayBuilder.observer(observer: HexKey) = addUnique(ObserverTag.assemble(observer)) + +fun TagArrayBuilder.sourceTag(source: SourceTag) = addUnique(source.toTagArray()) + +fun TagArrayBuilder.sourceTag( + eventId: HexKey, + author: HexKey? = null, + slug: String? = null, +) = addUnique(SourceTag.assemble(eventId, author, slug)) + +fun TagArrayBuilder.cutoff(cutoff: Int) = addUnique(CutoffTag.assemble(cutoff)) + +fun TagArrayBuilder.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 TagArrayBuilder.truncated(total: Int) = addUnique(TruncatedTag.assemble(total)) + +fun TagArrayBuilder.status(status: ListStatus) = addUnique(StatusTag.assemble(status)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayExt.kt new file mode 100644 index 0000000000..b1d37319e4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt new file mode 100644 index 0000000000..3237ba6e7b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListContent.kt @@ -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 = 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(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 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt new file mode 100644 index 0000000000..d2d8cbf8b4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt @@ -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 + + 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) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt new file mode 100644 index 0000000000..fd6f7df211 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/AddressableTrustedListEvent.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.trustedLists.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>, + content: String, + sig: HexKey, +) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun members(): List = 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 = emptyList(), + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(listId) + members(members) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..015ed351f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayBuilderExt.kt @@ -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.member( + address: String, + relayHint: NormalizedRelayUrl? = null, + score: Int? = null, +) = add(AddressMemberTag.assemble(address, relayHint, score)) + +fun TagArrayBuilder.member( + address: Address, + relayHint: NormalizedRelayUrl? = null, + score: Int? = null, +) = add(AddressMemberTag.assemble(address.toValue(), relayHint, score)) + +fun TagArrayBuilder.member(member: AddressMemberTag) = add(AddressMemberTag.assemble(member)) + +fun TagArrayBuilder.members(members: List) = addAll(AddressMemberTag.assemble(members)) + +fun TagArrayBuilder.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray()) + +fun TagArrayBuilder.aboutPubKey( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, +) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt new file mode 100644 index 0000000000..64649e59fe --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt new file mode 100644 index 0000000000..2324776f37 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt @@ -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", , , ]`. + * + * 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) = members.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt new file mode 100644 index 0000000000..6fadea6aab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/EventTrustedListEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun members(): List = 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 = emptyList(), + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(listId) + members(members) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..e19570311d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayBuilderExt.kt @@ -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.member( + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + score: Int? = null, +) = add(EventMemberTag.assemble(eventId, relayHint, score)) + +fun TagArrayBuilder.member(member: EventMemberTag) = add(EventMemberTag.assemble(member)) + +fun TagArrayBuilder.members(members: List) = addAll(EventMemberTag.assemble(members)) + +fun TagArrayBuilder.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray()) + +fun TagArrayBuilder.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray()) + +fun TagArrayBuilder.aboutPubKey( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, +) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt new file mode 100644 index 0000000000..14bee14bd8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt new file mode 100644 index 0000000000..27b46d5cfc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/events/tags/EventMemberTag.kt @@ -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", , , ]`. + * + * 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) = members.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt new file mode 100644 index 0000000000..92b4ce6d60 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/ExternalIdTrustedListEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun members(): List = 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 = emptyList(), + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(listId) + members(members) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..72693526b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayBuilderExt.kt @@ -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.member( + externalId: String, + hint: String? = null, + score: Int? = null, +) = add(ExternalIdMemberTag.assemble(externalId, hint, score)) + +fun TagArrayBuilder.member( + externalId: ExternalId, + score: Int? = null, +) = add(ExternalIdMemberTag.assemble(externalId, score)) + +fun TagArrayBuilder.member(member: ExternalIdMemberTag) = add(ExternalIdMemberTag.assemble(member)) + +fun TagArrayBuilder.members(members: List) = addAll(ExternalIdMemberTag.assemble(members)) + +fun TagArrayBuilder.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray()) + +fun TagArrayBuilder.aboutPubKey(pubKey: PTag) = addUniqueValueIfNew(pubKey.toTagArray()) + +fun TagArrayBuilder.aboutPubKey( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, +) = addUniqueValueIfNew(PTag.assemble(pubKey, relayHint)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt new file mode 100644 index 0000000000..b640dfa494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt new file mode 100644 index 0000000000..2ad88d6b89 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/externalIds/tags/ExternalIdMemberTag.kt @@ -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", , , ]`. + * + * 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) = members.map { assemble(it) } + + fun assemble( + externalId: ExternalId, + score: Int? = null, + ) = assemble(externalId.toScope(), externalId.hint(), score) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/CutoffTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/CutoffTag.kt new file mode 100644 index 0000000000..4b1da13122 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/CutoffTag.kt @@ -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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MetricTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MetricTag.kt new file mode 100644 index 0000000000..7bf443873d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MetricTag.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MinRankTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MinRankTag.kt new file mode 100644 index 0000000000..3ac6c73d5f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/MinRankTag.kt @@ -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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/ObserverTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/ObserverTag.kt new file mode 100644 index 0000000000..dfe79c95aa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/ObserverTag.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/SourceTag.kt new file mode 100644 index 0000000000..cfacc7b968 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/SourceTag.kt @@ -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", , , ]`. + */ +@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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/StatusTag.kt new file mode 100644 index 0000000000..09ef2ef73d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/StatusTag.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TruncatedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TruncatedTag.kt new file mode 100644 index 0000000000..794f9c50ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TruncatedTag.kt @@ -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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt new file mode 100644 index 0000000000..78a7b18821 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/tags/TrustedListMemberTag.kt @@ -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: + * `[, , , ]`. 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? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..17e3839238 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayBuilderExt.kt @@ -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.member( + pubKey: HexKey, + relayHint: NormalizedRelayUrl? = null, + score: Int? = null, +) = add(PubKeyMemberTag.assemble(pubKey, relayHint, score)) + +fun TagArrayBuilder.member(member: PubKeyMemberTag) = add(PubKeyMemberTag.assemble(member)) + +fun TagArrayBuilder.members(members: List) = addAll(PubKeyMemberTag.assemble(members)) + +fun TagArrayBuilder.aboutAddress(address: ATag) = addUniqueValueIfNew(address.toATagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt new file mode 100644 index 0000000000..ecc835b236 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt new file mode 100644 index 0000000000..24760f6cc6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/UserTrustedListEvent.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.trustedLists.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>, + content: String, + sig: HexKey, +) : TrustedListEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider, + AddressHintProvider { + override fun members(): List = 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 = emptyList(), + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + dTag(listId) + members(members) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt new file mode 100644 index 0000000000..a9884c7af5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/users/tags/PubKeyMemberTag.kt @@ -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", , , ]`. + * + * 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) = members.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index a14b0916c5..fd0f98c520 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -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), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 3955952fdd..cd74a1952e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListBuilderTest.kt new file mode 100644 index 0000000000..eac38944b6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListBuilderTest.kt @@ -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): Event = + EventFactory.create( + 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(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(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(event) + + assertTrue(event.isRetracted()) + assertEquals(emptyList(), 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)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt new file mode 100644 index 0000000000..e9ab442abe --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt @@ -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( + 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(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(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(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(event) + + assertFalse(event.isTruncated()) + assertNull(event.truncatedTotal()) + } + + @Test + fun aTruncatedTagMeansNotExhaustive() { + val event = + EventFactory.create( + 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(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( + 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(event) + + assertTrue(event.isTruncated(), "presence of the tag is what signals incompleteness") + assertNull(event.truncatedTotal()) + } + + @Test + fun readsTheRetractionMarker() { + val event = + EventFactory.create( + 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(event) + + assertEquals(ListStatus.RETRACTED, event.status()) + assertTrue(event.isRetracted()) + assertEquals(emptyList(), event.memberValues()) + } + + @Test + fun discoveryTagsOnTheNoteListAreNotMembers() { + val coordinate = "39999:$tagAuthor:podcaster" + val noteId = "f00dcafe00000000000000000000000000000000000000000000000000000000" + val event = + EventFactory.create( + 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(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( + 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(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( + 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(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(), + ) + } +}