fix(quartz): address audit findings on the link-review fixes

Bugs:
- Address.isOfKind / AddressSerializer.isOfKind (all actuals) indexed past
  the end of a value that is exactly the kind string, so a hostile
  ["request","31872"] threw from every RequestTag reader. Bounds-checked.
- AddressMemberTag accepted an naddr (AddressSerializer decodes bech32) and
  returned the raw bech32 as the member key, and isTag / parseAsHint were
  looser than parse. All readers now gate on the allocation-free
  CoordinateShape, which also stops a WARN log per rejected member.
- Voice replies dropped an address- or identifier-rooted thread (only E was
  inherited) and rooted replies to pre-NIP-22 voice replies at the reply.
  rootScopeTags() now inherits E/A/I + K/P, and derives the root from a
  legacy reply's lowercase e/p when its parent is a voice message.
- Interactive-story reading states published by the old builder carry no
  root tag: root() falls back to the d-tag (always the root's address) and
  update() writes the missing A tag.
- Event.dTag() now defers to AddressableEvent, so a replaceable list seen
  as a plain Event gets the same address as the cache (zap a-tags, backup
  slots). EncryptionKeyListEvent (10044) ignores a stray d like the lists.
- deleteRecommendation wrote the 38000 address twice: build() adds it now
  that MintRecommendationEvent is addressable.
- The NIP-87 wallet and mint-directory backfills scanned only cache.notes;
  addressable events live in cache.addressables.

Cleanups: the new hex checks reuse HexKey.isValid(), the nip29 readers are
single-pass, and WinnerTag.isTag agrees with parse.

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:58:40 +00:00
parent 6c6aec8315
commit ab1c71a5ef
27 changed files with 305 additions and 63 deletions
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.cashu.ops
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -28,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
@@ -1094,19 +1092,11 @@ class CashuWalletOps(
* pointing at the address coordinate (kind:pubkey:dTag) drops all
* versions on compliant relays. We also include the original event id
* via DeletionRequestEvent.build so relays that only track by event id still
* remove it. MintRecommendationEvent doesn't extend AddressableEvent
* today, so we compute and add the `a` tag ourselves.
* remove it. MintRecommendationEvent is an AddressableEvent, so
* DeletionRequestEvent.build writes that `a` tag itself.
*/
suspend fun deleteRecommendation(event: MintRecommendationEvent) {
// Add the `a` tag when we have a d-tag — kind:38000 is parameterized-
// replaceable, so the address coordinate lets compliant relays drop
// all versions, not just the specific id. Recommendations without a
// d-tag still get a NIP-09 `e`-only delete (the default build path).
val dTag = event.dTag()
val template =
DeletionRequestEvent.build(listOf(event)) {
if (dTag != null) aTag(Address(event.kind, event.pubKey, dTag))
}
val template = DeletionRequestEvent.build(listOf(event))
val delEvent = signer.sign(template)
publish(delEvent)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.model.nip60Cashu
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryQueryState
@@ -201,13 +202,17 @@ class CashuMintDirectoryState(
private fun backfillFromCacheAsync() {
scope.launch(Dispatchers.Default) {
cache.notes.forEach { _, note ->
// NIP-87 kinds are addressable: the current version lives in `addressables` (its
// per-id note is weakly held and pruned once superseded). Both maps are keyed by id.
val visit = { note: Note ->
when (val e = note.event) {
is CashuMintEvent -> announcements[e.id] = e
is MintRecommendationEvent -> if (e.isCashuRecommendation()) recommendations[e.id] = e
else -> Unit
}
}
cache.notes.forEach { _, note -> visit(note) }
cache.addressables.forEach { _, note -> visit(note) }
rebuildEntries()
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.AccountSettings
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -906,12 +907,17 @@ class CashuWalletState(
}
private fun scanCacheForOwnEvents(): List<Event> {
val collected = mutableListOf<Event>()
cache.notes.forEach { _, note ->
val e = note.event ?: return@forEach
if (isRelevantEvent(e)) collected += e
// Replaceable and addressable kinds (the wallet, NIP-87 recommendations) live in
// `addressables`: their per-id note is only weakly held and pruned once superseded.
// The current version can sit in both maps, so collect by id.
val collected = LinkedHashMap<HexKey, Event>()
val visit = { note: Note ->
val e = note.event
if (e != null && isRelevantEvent(e)) collected[e.id] = e
}
return collected
cache.notes.forEach { _, note -> visit(note) }
cache.addressables.forEach { _, note -> visit(note) }
return collected.values.toList()
}
// ============================================================
@@ -74,7 +74,7 @@ actual data class Address actual constructor(
actual fun isOfKind(
addressId: String,
kind: String,
) = addressId.startsWith(kind) && addressId[kind.length] == ':'
) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':'
}
// -----------
@@ -23,7 +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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -39,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].length == 64 && Hex.isHex(tag[1])) { return null }
ensure(tag[1].isValid()) { return null }
return tag[1]
}
@@ -23,7 +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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -50,7 +50,7 @@ object ConsentTag {
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
// The actor is a pubkey.
ensure(tag[2].length == 64 && Hex.isHex(tag[2])) { return null }
ensure(tag[2].isValid()) { return null }
return Consent(tag[1], tag[2])
}
@@ -23,7 +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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -41,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].length == 64 && Hex.isHex(tag[1])) { return null }
ensure(tag[1].isValid()) { return null }
return tag[1]
}
@@ -23,7 +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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -42,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].length == 64 && Hex.isHex(tag[1])) { return null }
ensure(tag[1].isValid()) { return null }
return tag[1]
}
@@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.experimental.citations.tags.CitationTags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.isValid
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. */
@@ -51,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)?.takeIf { it.length == 64 && Hex.isHex(it) }
fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.isValid() }
override fun displayTitle(): String? = title() ?: url()
@@ -40,6 +40,10 @@ class EncryptionKeyListEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun keys() = tags.mapNotNull(KeyTag::parse)
// Kind 10044 is replaceable: NIP-01 fixes its address to `kind:pubkey:`, so a stray `d`
// tag must not split one user's key list into several addresses.
override fun dTag(): String = ""
companion object {
const val KIND = 10044
@@ -28,6 +28,7 @@ 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.core.builder
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -56,7 +57,14 @@ class InteractiveStoryReadingStateEvent(
fun status() = tags.firstNotNullOfOrNull(StatusTag::parse)
fun root() = tags.firstNotNullOfOrNull(RootSceneTag::parse)
/**
* The story this state tracks. Reading states written before `rootScene` emitted the `A`
* tag carry no root tag at all (the lowercase `a` it wrote was replaced by the current
* scene's), but `build` always set the d-tag to the root's address, so that is the fallback.
*/
fun root() =
tags.firstNotNullOfOrNull(RootSceneTag::parse)
?: Address.parse(dTag())?.let { RootSceneTag(it.kind, it.pubKeyHex, it.dTag, null) }
fun currentScene() = tags.firstNotNullOfOrNull(ATag::parseAddress)
@@ -97,6 +105,10 @@ class InteractiveStoryReadingStateEvent(
val updatedTags =
base.tags.builder {
// Heal a state written without its root tag (see [root]).
if (base.tags.none { it.has(1) && it[0] == RootSceneTag.TAG_NAME } && Address.parse(rootTag) != null) {
add(RootSceneTag.assemble(rootTag, null))
}
currentScene(sceneTag)
status(status)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape
import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields
import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -58,7 +59,11 @@ data class AddressMemberTag(
companion object {
const val TAG_NAME = "a"
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
// A member is a `kind:pubkey:d` coordinate. CoordinateShape checks that without allocating
// (a 30394 can list thousands of members) and, unlike AddressSerializer.parse, neither
// decodes an naddr (whose raw bech32 would then be used as the member key) nor logs a
// warning per rejected value.
fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1])
fun isTagged(
tag: Tag,
@@ -68,8 +73,7 @@ data class AddressMemberTag(
fun parse(tag: Tag): 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 }
ensure(CoordinateShape.matches(tag[1])) { return null }
return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag))
}
@@ -77,24 +81,21 @@ data class AddressMemberTag(
fun parseAddressId(tag: Tag): String? {
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 }
ensure(CoordinateShape.matches(tag[1])) { 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 }
ensure(CoordinateShape.matches(tag[1])) { 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 }
// only index a value that is actually a coordinate, as ATag does
ensure(tag[1].contains(':')) { return null }
ensure(CoordinateShape.matches(tag[1])) { return null }
val hint = MemberTagFields.relayHint(tag)
@@ -75,6 +75,6 @@ class AddressSerializer {
fun isOfKind(
addressId: String,
kind: String,
) = addressId.startsWith(kind) && addressId[kind.length] == ':'
) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':'
}
}
@@ -20,6 +20,12 @@
*/
package com.vitorpamplona.quartz.nip01Core.tags.dTag
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
fun Event.dTag() = tags.dTag()
/**
* The d-tag that places this event in its address. An [AddressableEvent] decides it (a
* replaceable kind's is always "", whatever `d` tags it carries), so a caller holding a plain
* [Event] gets the same answer as one holding the concrete class; anything else reads the tag.
*/
fun Event.dTag(): String = (this as? AddressableEvent)?.dTag() ?: tags.dTag()
@@ -24,10 +24,10 @@ 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.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.isValid
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
/**
@@ -49,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 }.filter { it.length == 64 && Hex.isHex(it) }
fun participants(): List<HexKey> = tags.mapValueTagged(TAG_NAME) { it.takeIf { value -> value.isValid() } }
companion object {
const val KIND = 39004
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin
@@ -35,7 +36,6 @@ 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)
@@ -57,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 }.filter { it.length == 64 && Hex.isHex(it) }
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it.takeIf { value -> value.isValid() } }
/** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */
fun TagArray.groupPins(): List<GroupPin> = mapNotNull(GroupPin::parse)
@@ -22,19 +22,19 @@ 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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
class WinnerTag {
companion object {
const val TAG_NAME = "winner"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isValid()
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
// The winner is a pubkey.
ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null }
ensure(tag[1].isValid()) { return null }
return tag[1]
}
@@ -24,8 +24,10 @@ 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.RootAddressTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag
import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
@@ -64,6 +66,29 @@ class VoiceReplyEvent(
/** The root scope's author (NIP-22 `P`). */
fun rootAuthorKey(): HexKey? = tags.firstNotNullOfOrNull(RootAuthorTag::parseKey)
/**
* The root-scope tags a reply to this event inherits, or null when this event names no root.
*
* A NIP-22 reply carries them verbatim: `E`, `A` or `I` (a thread may be rooted at an
* address or an external id, not only at an event) plus `K` and `P`. A reply published
* before voice replies followed NIP-22 has only the lowercase parent tags; when that parent
* is a voice message (`k` 1222) the parent IS the root, so its `e` / `p` are rewritten as
* `E` / `P` (identical layouts). An older reply to a reply has lost its root: null.
*/
fun rootScopeTags(): List<Array<String>>? {
val scope = tags.filter { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) }
if (scope.any { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) }) return scope
if (tags.none { ReplyKindTag.match(it) && it[1] == VoiceEvent.KIND.toString() }) return null
val parentTag = tags.lastOrNull { ReplyEventTag.parseKey(it) != null } ?: return null
val authorTag = tags.lastOrNull { ReplyAuthorTag.parseKey(it) != null }
return listOfNotNull(
parentTag.copyOf().also { it[0] = RootEventTag.TAG_NAME },
RootKindTag.assemble(VoiceEvent.KIND),
authorTag?.copyOf()?.also { it[0] = RootAuthorTag.TAG_NAME },
)
}
companion object {
const val KIND = 1244
@@ -82,17 +107,11 @@ 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.
// NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope as
// well as its parent. 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) }) {
val inherited = if (parent is VoiceReplyEvent) parent.rootScopeTags() else null
if (inherited != null) {
inherited.forEach { addUnique(it) }
} else {
rootEvent(parent.id, replyingTo.relay, parent.pubKey)
@@ -22,7 +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.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.ensure
/**
@@ -37,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].length == 64 && Hex.isHex(tag[1])) { return null }
ensure(tag[1].isValid()) { return null }
return tag[1]
}
@@ -46,4 +46,20 @@ class ReadingStateBuildTest {
assertEquals("the summary", state.summary())
assertEquals("https://img.example/cover.png", state.image())
}
@Test
fun aStateWrittenWithoutItsRootTagFallsBackToItsDTagAndHealsOnUpdate() {
val pk = "1".repeat(64)
val story = "30296:$pk:story"
val scene = "30297:$pk:scene-2"
// What the old builder published: the root's lowercase `a` was replaced by the scene's.
val old =
InteractiveStoryReadingStateEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", story), arrayOf("a", scene), arrayOf("status", "reading")), "", "0".repeat(128))
assertEquals(story, old.root()?.toTag())
val next = InteractiveStorySceneEvent("3".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-3")), "", "0".repeat(128))
val updated = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.update(old, EventHintBundle(next)))
assertEquals(listOf(story), updated.tags.filter { it[0] == "A" }.map { it[1] })
assertEquals(next.address().toValue(), updated.currentScene()?.toValue())
}
}
@@ -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.trustedLists
import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
class AddressMemberShapeTest {
private val pk = "1".repeat(64)
@Test
fun onlyCoordinatesAreMembers() {
val coordinate = "30023:$pk:post"
assertEquals(coordinate, AddressMemberTag.parseAddressId(arrayOf("a", coordinate)))
// An naddr would decode, but its raw bech32 must not become the member key.
val naddr = NAddress.create(30023, pk, "post", null)
for (value in listOf(naddr, "abc:$pk:d", "not-an-address", "30023:short:d")) {
assertNull(AddressMemberTag.parse(arrayOf("a", value)), value)
assertNull(AddressMemberTag.parseAddressId(arrayOf("a", value)), value)
assertNull(AddressMemberTag.parseAddress(arrayOf("a", value)), value)
assertFalse(AddressMemberTag.isTag(arrayOf("a", value)), value)
}
assertEquals(Address(30023, pk, "post"), AddressMemberTag.parseAddress(arrayOf("a", coordinate)))
}
}
@@ -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.nip01Core.core
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AddressIsOfKindBoundsTest {
@Test
fun aValueThatIsOnlyTheKindIsNotOfThatKind() {
assertFalse(Address.isOfKind("31872", "31872"))
assertFalse(AddressSerializer.isOfKind("31872", "31872"))
assertFalse(Address.isOfKind("318720:x:y", "31872"))
assertTrue(Address.isOfKind("31872:x:y", "31872"))
}
@Test
fun aHostileRequestTagIsRejectedNotThrown() {
assertNull(RequestTag.parse(arrayOf("request", "31872")))
assertFalse(RequestTag.isTagged(arrayOf("request", "31872")))
}
}
@@ -0,0 +1,41 @@
/*
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import kotlin.test.Test
import kotlin.test.assertEquals
class EventDTagAgreesWithAddressTest {
private val pk = "1".repeat(64)
@Test
fun aListSeenAsAPlainEventHasItsAddressesDTag() {
val list: Event = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128))
assertEquals("", list.dTag())
val set: Event = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128))
assertEquals("friends", set.dTag())
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.quartz.nip87Ecash
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
@@ -43,4 +45,11 @@ class MintEventsAreAddressableTest {
assertEquals("${event.kind}:$pk:mint-id", event.addressTag())
}
}
@Test
fun deletingARecommendationNamesItsAddressOnce() {
val rec = MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128))
val deletion = NostrSignerSync().sign(DeletionRequestEvent.build(listOf(rec)))
assertEquals(listOf("38000:$pk:mint-id"), deletion.tags.filter { it[0] == "a" }.map { it[1] })
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class VoiceReplyRootScopeTest {
private val audio = AudioMeta("https://blossom.example/a.m4a", "audio/mp4", "f".repeat(64), 3, listOf(0.1f))
@@ -43,4 +44,44 @@ class VoiceReplyRootScopeTest {
assertEquals(voice.id, nested.rootEventId())
assertEquals(reply.id, nested.replyingTo())
}
@Test
fun aReplyToAnAddressRootedReplyKeepsTheAddressRoot() {
val article = "30023:${"2".repeat(64)}:post"
val parent =
VoiceReplyEvent(
"8".repeat(64),
"3".repeat(64),
1,
arrayOf(arrayOf("A", article), arrayOf("K", "30023"), arrayOf("P", "2".repeat(64)), arrayOf("e", "7".repeat(64)), arrayOf("k", "1111")),
"",
"0".repeat(128),
)
val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle<BaseVoiceEvent>(parent)))
assertEquals(listOf(article), reply.tags.filter { it[0] == "A" }.map { it[1] })
assertEquals(listOf("30023"), reply.tags.filter { it[0] == "K" }.map { it[1] })
assertTrue(reply.tags.none { it[0] == "E" })
assertEquals(parent.id, reply.replyingTo())
}
@Test
fun aReplyToALegacyReplyFindsTheVoiceMessageRoot() {
// Published before voice replies wrote NIP-22 root tags: only the lowercase parent.
val voiceId = "9".repeat(64)
val voiceAuthor = "1".repeat(64)
val legacy =
VoiceReplyEvent(
"8".repeat(64),
"3".repeat(64),
1,
arrayOf(arrayOf("e", voiceId, "", voiceAuthor), arrayOf("k", "1222"), arrayOf("p", voiceAuthor)),
"",
"0".repeat(128),
)
val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle<BaseVoiceEvent>(legacy)))
assertEquals(voiceId, reply.rootEventId())
assertEquals(voiceAuthor, reply.rootAuthorKey())
assertEquals(listOf("1222"), reply.tags.filter { it[0] == "K" }.map { it[1] })
assertEquals(legacy.id, reply.replyingTo())
}
}
@@ -56,6 +56,6 @@ actual data class Address actual constructor(
actual fun isOfKind(
addressId: String,
kind: String,
) = addressId.startsWith(kind) && addressId[kind.length] == ':'
) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':'
}
}
@@ -61,6 +61,6 @@ actual data class Address actual constructor(
actual fun isOfKind(
addressId: String,
kind: String,
) = addressId.startsWith(kind) && addressId[kind.length] == ':'
) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':'
}
}