mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+3
-13
@@ -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)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -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] == ':'
|
||||
}
|
||||
|
||||
// -----------
|
||||
|
||||
+2
-2
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+2
-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
|
||||
|
||||
/**
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
+2
-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]
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
|
||||
+13
-1
@@ -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)
|
||||
}
|
||||
|
||||
+10
-9
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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] == ':'
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+29
-10
@@ -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)
|
||||
|
||||
+2
-2
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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)))
|
||||
}
|
||||
}
|
||||
+43
@@ -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")))
|
||||
}
|
||||
}
|
||||
+41
@@ -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())
|
||||
}
|
||||
}
|
||||
+9
@@ -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] })
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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] == ':'
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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] == ':'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user