fix(quartz): correct tag readers found by the graph link review

Reviewing every kind's links for the graph vocabulary turned up readers
that return the wrong target, or accept values of the wrong shape:

- nip19: pubKeys() no longer returns the key of a pasted nsec
- nip18: QTag address parsers accepted event ids and rejected addresses
- nip28: a hide-message no longer counts its channel as a hidden message
- nip53: live chat unmarkedReplyTos drops the activity; presence events
  mark their room `root`, as the spec requires
- nip51: GeohashListEvent.create no longer swaps public and private
  entries; a replaceable list ignores a stray d tag in its address
- forks: forkFromAddress reads the fork-marked `a`, not the first `a`;
  ForkTag checks the NIP-text kind, not the community kind
- attestations: RequestTag parses as RequestTag and requires kind 31872;
  attestorPubKeys() names the p tags, assertionPubkey() is deprecated
- nip58: profile badges read only kind 30009 definitions
- interactive stories: rootScene writes the root-scene tag; reading-state
  build no longer swaps summary and image
- nip87: mint and recommendation events are addressable (EventCache now
  routes them through consumeBaseReplaceable)
- nipA0: voice replies carry the thread's root event, kind and author
- id-shaped tags (replaced-by, agent, report, winner, edit, consent,
  group participants and deletions, citation timestamp, address members)
  reject values that are not 64-char hex or valid addresses

Each fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm
This commit is contained in:
Claude
2026-09-29 15:19:14 +00:00
parent c72c8dd2ed
commit 6c6aec8315
47 changed files with 777 additions and 66 deletions
@@ -3921,6 +3921,10 @@ open class EventCache :
is PublicationContentEvent,
is RelayReviewEvent,
is EntityRatingEvent,
// NIP-87 mint announcements and recommendations (kind 38172 / 38173 / 38000).
is CashuMintEvent,
is FedimintEvent,
is MintRecommendationEvent,
-> consumeBaseReplaceable(event, relay, wasVerified)
// ============================================================
@@ -3937,16 +3941,6 @@ open class EventCache :
is CashuTokenEvent,
is CashuSpendingHistoryEvent,
is CashuMintQuoteEvent,
// NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx
// (parameterized-replaceable per the spec) but neither CashuMintEvent /
// FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz
// today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would
// crash. Route them as regular events — downstream consumers
// (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag)
// and keep the newest.
is CashuMintEvent,
is FedimintEvent,
is MintRecommendationEvent,
is ChatMessageEncryptedFileHeaderEvent,
is ChatMessageEvent,
is BirdDetectionEvent,
@@ -292,7 +292,7 @@ fun RenderAttestationRequest(
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
val aboutPubkey = remember(noteEvent) { noteEvent.attestorPubKeys().firstOrNull() }
Column(
modifier =
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.amTurnMetrics.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.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -38,7 +39,7 @@ object AgentTag {
fun parse(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
return tag[1]
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.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.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -48,7 +49,8 @@ object ConsentTag {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
// The actor is a pubkey.
ensure(tag[2].length == 64 && Hex.isHex(tag[2])) { return null }
return Consent(tag[1], tag[2])
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.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.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -40,7 +41,7 @@ object ReplacedByTag {
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
return tag[1]
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.moderation.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.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -41,7 +42,7 @@ object ReportTag {
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
return tag[1]
}
@@ -20,14 +20,13 @@
*/
package com.vitorpamplona.quartz.experimental.attestations.attestation.tags
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.nip72ModCommunities.approval.tags.ApprovedAddressTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@@ -43,8 +42,10 @@ class RequestTag(
companion object {
const val TAG_NAME = "request"
private val REQUEST_KIND_STR = AttestationRequestEvent.KIND.toString()
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)
// The request an attestation answers is always a kind 31872 attestation request.
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], REQUEST_KIND_STR)
fun isTagged(
tag: Array<String>,
@@ -53,7 +54,7 @@ class RequestTag(
fun isTagged(
tag: Array<String>,
address: ApprovedAddressTag,
address: RequestTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
fun isIn(
@@ -61,20 +62,20 @@ class RequestTag(
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
fun parse(tag: Array<String>): ApprovedAddressTag? {
fun parse(tag: Array<String>): RequestTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null }
val address = Address.parse(tag[1]) ?: return null
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return ApprovedAddressTag(address, relayHint)
return RequestTag(address, relayHint)
}
fun parseValidAddress(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null }
return Address.parse(tag[1])?.toValue()
}
@@ -83,21 +84,21 @@ class RequestTag(
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val address = Address.parse(tag[1]) ?: return null
ensure(address.kind != CommunityDefinitionEvent.KIND) { return null }
ensure(address.kind == AttestationRequestEvent.KIND) { return null }
return address
}
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null }
return tag[1]
}
fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null }
ensure(tag[1].contains(':')) { return null }
ensure(tag[2].isNotEmpty()) { return null }
@@ -86,8 +86,13 @@ class AttestationRequestEvent(
fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse)
/** The attestors this request asks (its `p` tags, written by [buildEvent]'s `attestorPubKeys`). */
fun attestorPubKeys() = tags.mapNotNull(PTag::parseKey)
@Deprecated("Returns the first ATTESTOR, not the assertion's author", ReplaceWith("attestorPubKeys().firstOrNull()"))
fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey)
@Deprecated("Returns the first ATTESTOR's tag, not the assertion's author")
fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse)
companion object {
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
/** A citation of something on the web (kind 31): a URL, optionally timestamped. */
@@ -50,7 +51,7 @@ class ExternalCitationEvent(
fun url() = value(CitationTags.URL) ?: value("url")
/** The id of a NIP-03 kind-1040 timestamp attesting when the page was seen. */
fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)
fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.length == 64 && Hex.isHex(it) }
override fun displayTitle(): String? = title() ?: url()
@@ -0,0 +1,36 @@
/*
* 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.forks
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
/**
* The address an `a` tag marked `fork` points at: `["a", <address>, <relay>, "fork"]`, the
* version a note, a NIP text or a wiki article (NIP-54 "Forks") was forked from — of any kind.
* Only the marked tag counts: an event also carries unmarked `a` tags (a community, a mention),
* and those are not its origin.
*/
fun parseForkedAddress(tag: Array<String>): Address? {
if (tag.size < 4 || tag[0] != "a") return null
if (tag[3] != MarkedETag.MARKER.FORK.code) return null
return Address.parse(tag[1])
}
@@ -128,8 +128,8 @@ class InteractiveStoryReadingStateEvent(
status(status)
root.event.title()?.let { storyTitle(it) }
root.event.summary()?.let { storyImage(it) }
root.event.image()?.let { storySummary(it) }
root.event.summary()?.let { storySummary(it) }
root.event.image()?.let { storyImage(it) }
initializer()
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.experimental.interactiveStories
import com.vitorpamplona.quartz.experimental.interactiveStories.tags.ReadStatusTag
import com.vitorpamplona.quartz.experimental.interactiveStories.tags.RootSceneTag
import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -51,7 +52,9 @@ fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.storyImage(imageUrl: Stri
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.storyImages(imageUrls: List<String>) = addAll(imageUrls.map { ImageTag.assemble(it) })
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.rootScene(scene: ATag) = addUnique(scene.toATagArray())
// The root is the uppercase `A` (RootSceneTag): written as a lowercase `a`, the current scene
// (also an `a`) replaced it and root() found nothing.
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.rootScene(scene: ATag) = addUnique(RootSceneTag.assemble(scene.toTag(), scene.relay))
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.currentScene(scene: ATag) = addUnique(scene.toATagArray())
@@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.experimental.nipsOnNostr
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress
import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId
import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -113,7 +113,7 @@ class NipTextEvent(
override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" }
override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress)
override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress)
override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId)
@@ -26,7 +26,6 @@ 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.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@@ -66,7 +65,7 @@ class ForkTag(
ensure(
Address.Companion.isOfKind(
tag[1],
CommunityDefinitionEvent.Companion.KIND_STR,
NipTextEvent.KIND_STR,
),
) { return null }
@@ -81,7 +80,7 @@ class ForkTag(
ensure(
Address.Companion.isOfKind(
tag[1],
CommunityDefinitionEvent.Companion.KIND_STR,
NipTextEvent.KIND_STR,
),
) { return null }
return Address.Companion.parse(tag[1])?.toValue()
@@ -69,6 +69,7 @@ data class AddressMemberTag(
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(AddressSerializer.parse(tag[1]) != null) { return null }
return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag))
}
@@ -77,6 +78,7 @@ data class AddressMemberTag(
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(AddressSerializer.parse(tag[1]) != null) { return null }
return tag[1]
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress
import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -124,7 +125,7 @@ class TextNoteEvent(
override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" }
override fun forkFromAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress)
override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress)
override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId)
@@ -90,7 +90,7 @@ interface QTag {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length != 64) { return null }
ensure(!tag[1].contains(':')) { return null }
ensure(tag[1].contains(':')) { return null }
return tag[1]
}
@@ -99,7 +99,7 @@ interface QTag {
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length != 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
ensure(!tag[1].contains(':')) { return null }
ensure(tag[1].contains(':')) { return null }
val relayHint = pickRelayHint(tag)
ensure(relayHint != null) { return null }
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
fun NEvent.toEventHint() = relay.map { EventIdHint(hex, it) }
@@ -86,12 +85,16 @@ fun List<Entity>.pubKeyHints(): List<PubKeyHint> =
}
}.flatten()
/**
* The pubkeys cited as `npub` / `nprofile`. An `nsec` is deliberately NOT here: its hex is a
* PRIVATE key, so reporting a pasted one as a "linked pubkey" would publish the secret to every
* index, hint store and relay filter that consumes this list.
*/
fun List<Entity>.pubKeys(): List<HexKey> =
mapNotNull { entity ->
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
is NSec -> entity.hex
else -> null
}
}
@@ -47,7 +47,20 @@ class ChannelHideMessageEvent(
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
fun eventsToHide() = tags.taggedEventIds()
/**
* NIP-28 names the channel only through a MARKED root (Quartz writes one; the spec's own 43
* has none): the unmarked-root fallback of [BasePublicChatEvent] would read the first hidden
* message as the channel.
*/
override fun channel() = markedRoot()
override fun channelId() = channel()?.eventId
/** The hidden messages: every `e` except the channel it is posted in. */
fun eventsToHide(): List<HexKey> {
val channel = channelId()
return tags.taggedEventIds().filter { it != channel }
}
companion object {
const val KIND = 43
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -48,7 +49,7 @@ class GroupParticipantsEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun groupId() = dTag()
fun participants(): List<HexKey> = tags.mapValueTagged(TAG_NAME) { it }
fun participants(): List<HexKey> = tags.mapValueTagged(TAG_NAME) { it }.filter { it.length == 64 && Hex.isHex(it) }
companion object {
const val KIND = 39004
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag
import com.vitorpamplona.quartz.utils.Hex
fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME)
@@ -56,7 +57,7 @@ fun TagArray.childGroupIds(): List<String> = mapNotNull(ChildTag::parse)
fun TagArray.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }.filter { it.length == 64 && Hex.isHex(it) }
/** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */
fun TagArray.groupPins(): List<GroupPin> = mapNotNull(GroupPin::parse)
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -42,6 +43,13 @@ abstract class PrivateTagArrayEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
override fun isContentEncoded() = true
/**
* A NIP-51 LIST (10000–19999) is replaceable: NIP-01 fixes its address to `kind:pubkey:`
* whatever tags it carries. Read from the tags, a stray `d` would split one user's list
* into several addresses. SETS (30000–39999) are addressed by their `d`.
*/
override fun dTag(): String = if (kind.isReplaceable()) "" else super.dTag()
/**
* How the NIP-44 encrypted private items changed since [older]. They can't be compared
* item by item without decrypting, so only as a whole.
@@ -198,8 +198,8 @@ class GeohashListEvent(
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): GeohashListEvent {
val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
val publicTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
val privateTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray)
}
@@ -129,7 +129,7 @@ class LiveActivitiesChatMessageEvent(
override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "")
override fun unmarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "")
override fun unmarkedReplyTos() = super.unmarkedReplyTos().minus(activityHex() ?: "")
override fun exposeInDraft() =
tagArray<LiveActivitiesChatMessageEvent> {
@@ -37,10 +37,18 @@ class MeetingSpaceTag(
fun toTagArray() = assemble(address, relayHint)
/**
* The form a kind 10312 presence uses: NIP-53 marks the room it points at as its `root`
* (`["a", <room>, <relay>, "root"]`). A meeting room (30313) references its space WITHOUT a
* marker, so this is not [toTagArray].
*/
fun toRootTagArray() = arrayOf(TAG_NAME, address.toValue(), relayHint?.url ?: "", ROOT_MARKER)
fun toTagIdOnly() = assemble(address, null)
companion object Companion {
const val TAG_NAME = "a"
const val ROOT_MARKER = "root"
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag
@@ -30,9 +29,10 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.OnstageTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.PublishingTag
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
// NIP-53: a presence points at its room with the `root` marker.
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toRootTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(MeetingSpaceTag(rep.event.address(), rep.relay).toRootTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised))
@@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip54Wiki
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress
import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId
import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -150,7 +150,7 @@ class WikiArticleEvent(
override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" }
override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress)
override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress)
override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId)
@@ -24,9 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
fun TagArray.acceptedBadges() = AcceptedBadge.parseAll(this)
fun TagArray.badgeAwardEvents() = taggedEvents()
fun TagArray.badgeAwardDefinitions() = taggedAddresses()
/**
* The badge DEFINITIONS (kind 30009) a profile displays. NIP-58 profiles also carry `a` tags
* pointing at badge SETS (kind 30008), which are not definitions.
*/
fun TagArray.badgeAwardDefinitions() = taggedAddresses().filter { it.kind == BadgeDefinitionEvent.KIND }
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip64Chess.end.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
class WinnerTag {
@@ -32,7 +33,8 @@ class WinnerTag {
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
// The winner is a pubkey.
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
return tag[1]
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip87Ecash.cashu
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -36,15 +36,13 @@ class CashuMintEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun mintUrl() = tags.mintUrl()
fun nuts() = tags.nuts()
fun network() = tags.network()
fun dTag() = tags.dTag()
companion object {
const val KIND = 38172
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip87Ecash.fedimint
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -36,15 +36,13 @@ class FedimintEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun inviteCodes() = tags.inviteCodes()
fun modules() = tags.modules()
fun network() = tags.network()
fun dTag() = tags.dTag()
companion object {
const val KIND = 38173
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip87Ecash.recommendation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -39,7 +39,7 @@ class MintRecommendationEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
SearchableEvent {
override fun indexableContent() = content
@@ -53,8 +53,6 @@ class MintRecommendationEvent(
fun mintEventKind() = tags.mintEventKind()
fun dTag() = tags.dTag()
fun mintEventAddresses() = tags.mintEventAddresses()
fun isCashuRecommendation() = mintEventKind() == CashuMintEvent.KIND
@@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
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.nip22Comments.tags.RootAuthorTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
@@ -47,6 +50,22 @@ fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(audioUrls: List<AudioMeta
return this
}
// NIP-22 root scope (uppercase E / K / P): NIP-A0 says a voice reply MUST follow NIP-22.
fun TagArrayBuilder<VoiceReplyEvent>.rootEvent(
eventId: String,
relayHint: NormalizedRelayUrl?,
pubkey: String?,
) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey))
fun TagArrayBuilder<VoiceReplyEvent>.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.rootKind(kind: Int) = addUnique(RootKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.rootAuthor(
pubKey: HexKey,
relay: NormalizedRelayUrl?,
) = addUnique(RootAuthorTag.assemble(pubKey, relay))
fun TagArrayBuilder<VoiceReplyEvent>.replyEvent(
eventId: String,
relayHint: NormalizedRelayUrl?,
@@ -24,6 +24,9 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag
@@ -55,6 +58,12 @@ class VoiceReplyEvent(
fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
/** The thread's root scope (NIP-22 `E`): the voice message the conversation started from. */
fun rootEventId(): HexKey? = tags.firstNotNullOfOrNull(RootEventTag::parseKey)
/** The root scope's author (NIP-22 `P`). */
fun rootAuthorKey(): HexKey? = tags.firstNotNullOfOrNull(RootAuthorTag::parseKey)
companion object {
const val KIND = 1244
@@ -73,6 +82,23 @@ class VoiceReplyEvent(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
) = build(voiceMessage, KIND, createdAt) {
// NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope
// (E / K / P) as well as its parent. Replying to a reply inherits that reply's root;
// replying to the voice message itself makes it the root.
val parent = replyingTo.event
val inherited =
if (parent is VoiceReplyEvent) {
parent.tags.filter { RootEventTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) }
} else {
emptyList()
}
if (inherited.any { RootEventTag.match(it) }) {
inherited.forEach { addUnique(it) }
} else {
rootEvent(parent.id, replyingTo.relay, parent.pubKey)
rootKind(parent.kind)
rootAuthor(parent.pubKey, replyingTo.authorHomeRelay)
}
replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)
replyKind(replyingTo.event.kind)
replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay)
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -36,7 +37,7 @@ class EditTag {
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
return tag[1]
}
@@ -0,0 +1,52 @@
/*
* 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.attestations
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class RequestTagTest {
private val pk = "1".repeat(64)
@Test
fun aRequestTagPointsAtAnAttestationRequest() {
val request = "${AttestationRequestEvent.KIND}:$pk:claim"
val parsed: RequestTag? = RequestTag.parse(arrayOf("request", request))
assertEquals(request, parsed?.toTag())
assertEquals(request, RequestTag.parseAddressId(arrayOf("request", request)))
}
@Test
fun anythingElseIsNotARequest() {
assertNull(RequestTag.parse(arrayOf("request", "30023:$pk:post")))
}
@Test
fun theRequestsPTagsAreItsAttestors() {
val a = "a".repeat(64)
val b = "b".repeat(64)
val event = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "x"), arrayOf("p", a), arrayOf("p", b)), "", "0".repeat(128))
assertEquals(listOf(a, b), event.attestorPubKeys())
}
}
@@ -0,0 +1,55 @@
/*
* 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.forks
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class ForkedAddressTest {
private val pk = "1".repeat(64)
private val community = "34550:$pk:group"
@Test
fun aNoteForksFromItsMarkedAddressNotItsCommunity() {
val origin = "30023:$pk:post"
val note = TextNoteEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("a", community), arrayOf("a", origin, "", "fork")), "", "0".repeat(128))
assertEquals(origin, note.forkFromAddress()?.toValue())
}
@Test
fun aWikiArticleFindsTheArticleItWasForkedFrom() {
val origin = "30818:$pk:bitcoin"
val article = WikiArticleEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "bitcoin"), arrayOf("a", origin, "", "fork")), "", "0".repeat(128))
assertEquals(origin, article.forkFromAddress()?.toValue())
}
@Test
fun aNipTextForkTagIsItsOwnKind() {
val origin = "${NipTextEvent.KIND}:$pk:nip-01"
assertNotNull(ForkTag.parse(arrayOf("a", origin, "", "fork")))
assertEquals(origin, ForkTag.parseValidAddress(arrayOf("a", origin, "", "fork")))
}
}
@@ -0,0 +1,49 @@
/*
* 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.interactiveStories
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlin.test.Test
import kotlin.test.assertEquals
class ReadingStateBuildTest {
@Test
fun theReadingStateKeepsItsRootAndItsSummaryAndImage() {
val pk = "1".repeat(64)
val prologue =
InteractiveStoryPrologueEvent(
"0".repeat(64),
pk,
1,
arrayOf(arrayOf("d", "story"), arrayOf("title", "A Story"), arrayOf("summary", "the summary"), arrayOf("image", "https://img.example/cover.png")),
"",
"0".repeat(128),
)
val scene = InteractiveStorySceneEvent("2".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-2")), "", "0".repeat(128))
val state = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.build(EventHintBundle(prologue), EventHintBundle(scene)))
assertEquals(prologue.address().toValue(), state.root()?.toTag())
assertEquals(scene.address().toValue(), state.currentScene()?.toValue())
assertEquals("the summary", state.summary())
assertEquals("https://img.example/cover.png", state.image())
}
}
@@ -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.nip18Reposts.quotes
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class QTagAddressTest {
private val address = "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:my-article"
@Test
fun anAddressQuoteParsesAsAnAddress() {
assertEquals(address, QTag.parseAddressId(arrayOf("q", address)))
assertEquals(address, QTag.parseAddressAsHint(arrayOf("q", address, "wss://relay.example.com"))?.addressId)
}
@Test
fun anEventQuoteIsNotAnAddress() {
assertNull(QTag.parseAddressId(arrayOf("q", "a".repeat(64))))
}
}
@@ -0,0 +1,38 @@
/*
* 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.nip19Bech32
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
class PubKeysNeverLeakNsecTest {
private val secret = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
private val npubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
@Test
fun aPastedNsecIsNotALinkedPubkey() {
val nsec = secret.hexToByteArray().toNsec()
val npub = npubKey.hexToByteArray().toNpub()
val cited = Nip19Parser.parseAll("leaked nostr:$nsec and hi nostr:$npub")
assertEquals(listOf(npubKey), cited.pubKeys())
}
}
@@ -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.nip28PublicChat
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ChannelHideMessageEventTest {
private val channel = "c".repeat(64)
private val message = "d".repeat(64)
private fun hide(vararg tags: Array<String>) = ChannelHideMessageEvent("0".repeat(64), "1".repeat(64), 1, arrayOf(*tags), "spam", "0".repeat(128))
@Test
fun theChannelIsNotAmongTheHiddenMessages() {
val event = hide(arrayOf("e", channel, "", "root"), arrayOf("e", message))
assertEquals(channel, event.channelId())
assertEquals(listOf(message), event.eventsToHide())
}
@Test
fun aSpecShapedHideDoesNotReadTheHiddenMessageAsTheChannel() {
// NIP-28's own kind 43 carries only the message to hide.
val event = hide(arrayOf("e", message))
assertNull(event.channelId())
assertEquals(listOf(message), event.eventsToHide())
}
}
@@ -0,0 +1,55 @@
/*
* 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.nip51Lists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ListAddressAndGeohashTest {
private val pk = "1".repeat(64)
@Test
fun aStrayDTagDoesNotSplitAReplaceableListsAddress() {
val list = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128))
assertEquals("", list.dTag())
assertEquals("${MediaFollowListEvent.KIND}:$pk:", list.addressTag())
}
@Test
fun aSetIsStillAddressedByItsD() {
val set = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128))
assertEquals("friends", set.dTag())
}
@Test
fun privateGeohashesAreNeverPublishedInClear() {
val event = GeohashListEvent.create(listOf("u4pr"), listOf("9q8y"), NostrSignerSync())
val clear = event.tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] }
assertTrue("u4pr" in clear, "$clear")
assertFalse("9q8y" in clear, "a private geohash leaked into the public tags: $clear")
}
}
@@ -0,0 +1,58 @@
/*
* 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.nip53LiveActivities
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.tagArray
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.roomMeeting
import kotlin.test.Test
import kotlin.test.assertEquals
class LiveActivitiesLinkFixesTest {
private val pk = "1".repeat(64)
@Test
fun unmarkedReplyTosAreTheUnmarkedOnes() {
val marked = "a".repeat(64)
val unmarked = "b".repeat(64)
val chat =
LiveActivitiesChatMessageEvent(
"0".repeat(64),
pk,
1,
arrayOf(arrayOf("a", "30311:$pk:stream", "", "root"), arrayOf("e", marked, "", "reply"), arrayOf("e", unmarked)),
"hi",
"0".repeat(128),
)
assertEquals(listOf(marked), chat.markedReplyTos())
assertEquals(listOf(unmarked), chat.unmarkedReplyTos())
}
@Test
fun aPresencePointsAtItsRoomWithTheRootMarker() {
val room = Address(30313, pk, "room")
val tags = tagArray<MeetingRoomPresenceEvent> { roomMeeting(MeetingSpaceTag(room, null)) }
assertEquals(listOf("a", room.toValue(), "", "root"), tags.single().toList())
}
}
@@ -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.nip58Badges
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
class ProfileBadgeDefinitionsTest {
@Test
fun badgeSetPointersAreNotBadgeDefinitions() {
val pk = "1".repeat(64)
val definition = "30009:$pk:early-adopter"
val profile =
ProfileBadgesEvent(
"0".repeat(64),
pk,
1,
arrayOf(arrayOf("d", "profile_badges"), arrayOf("a", definition), arrayOf("e", "e".repeat(64)), arrayOf("a", "30008:$pk:favorites")),
"",
"0".repeat(128),
)
assertEquals(listOf(definition), profile.badgeAwardDefinitions().map { it.toValue() })
}
}
@@ -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.nip87Ecash
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class MintEventsAreAddressableTest {
private val pk = "1".repeat(64)
private val tags = arrayOf(arrayOf("d", "mint-id"))
@Test
fun theNip87KindsAreAddressable() {
for (event in listOf(
CashuMintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)),
FedimintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)),
MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)),
)) {
assertIs<AddressableEvent>(event)
assertEquals("${event.kind}:$pk:mint-id", event.addressTag())
}
}
}
@@ -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.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlin.test.Test
import kotlin.test.assertEquals
class VoiceReplyRootScopeTest {
private val audio = AudioMeta("https://blossom.example/a.m4a", "audio/mp4", "f".repeat(64), 3, listOf(0.1f))
@Test
fun aVoiceReplyNamesItsThreadsRootScope() {
val signer = NostrSignerSync()
val voice = VoiceEvent("9".repeat(64), "1".repeat(64), 1, arrayOf(arrayOf("imeta", "url https://blossom.example/v.m4a")), "", "0".repeat(128))
val reply = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle(voice)))
assertEquals(voice.id, reply.rootEventId())
assertEquals(voice.pubKey, reply.rootAuthorKey())
assertEquals(voice.id, reply.replyingTo())
// A reply to that reply keeps the thread's root and names its new parent.
val nested = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle<BaseVoiceEvent>(reply)))
assertEquals(voice.id, nested.rootEventId())
assertEquals(reply.id, nested.replyingTo())
}
}
@@ -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.utils
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ConsentTag
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ReplacedByTag
import com.vitorpamplona.quartz.buzz.moderation.tags.ReportTag
import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/** Tags whose value IS a pubkey or event id must not hand back anything else as one. */
class IdShapedTagParsersTest {
private val key = "a".repeat(64)
@Test
fun onlyHexIdsParse() {
for ((name, parse) in listOf<Pair<String, (Array<String>) -> String?>>(
ReplacedByTag.TAG_NAME to ReplacedByTag::parse,
AgentTag.TAG_NAME to AgentTag::parse,
ReportTag.TAG_NAME to ReportTag::parse,
WinnerTag.TAG_NAME to WinnerTag::parse,
EditTag.TAG_NAME to EditTag::parse,
)) {
assertEquals(key, parse(arrayOf(name, key)), name)
assertNull(parse(arrayOf(name, "not-a-key")), name)
assertNull(parse(arrayOf(name, "g".repeat(64))), name)
}
assertNull(ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", "not-a-key")))
assertEquals(key, ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", key))?.actorPubKey)
}
}