From b465785c27d129e394d6a2464b0d708747f44827 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:33:29 +0000 Subject: [PATCH 1/2] feat: NIP-A4 compliance - add k tag to zap requests and enforce e tag prohibition - Add KindTag to LnZapRequestEvent.create() when zapping events, as required by NIP-A4 ("NIP-57 zaps MUST include the k tag to 24") - Enforce e tag prohibition in all PublicMessageEvent.build() methods by stripping any e tags from the tag builder output (NIP-A4: "e tags must not be used") - Add comprehensive tests for both changes https://claude.ai/code/session_019JsSsTTXivnNWiAtE5DE1R --- .../quartz/nip57Zaps/LnZapRequestEvent.kt | 2 + .../nipA4PublicMessages/PublicMessageEvent.kt | 3 + .../publicMessages/PublicMessageEventTest.kt | 184 ++++++++++++++++++ .../nip57Zaps/LnZapRequestKindTagTest.kt | 119 +++++++++++ 4 files changed, 308 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index efa2cec2b..c867fbd9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -101,6 +102,7 @@ class LnZapRequestEvent( arrayOf("e", zappedEvent.id), arrayOf("p", toUserPubHex ?: zappedEvent.pubKey), arrayOf("relays") + relays.map { it.url }, + KindTag.assemble(zappedEvent.kind), AltTag.assemble(ALT), ) if (zappedEvent is AddressableEvent) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt index 8e81c4a60..6f308d9b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt @@ -83,6 +83,7 @@ class PublicMessageEvent( ) = eventTemplate(KIND, "", createdAt) { alt(ALT_DESCRIPTION) initializer() + remove("e") // NIP-A4: e tags must not be used } fun build( @@ -94,6 +95,7 @@ class PublicMessageEvent( alt(ALT_DESCRIPTION) toUser(to) initializer() + remove("e") // NIP-A4: e tags must not be used } fun build( @@ -105,6 +107,7 @@ class PublicMessageEvent( alt(ALT_DESCRIPTION) toGroup(to) initializer() + remove("e") // NIP-A4: e tags must not be used } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt new file mode 100644 index 000000000..cc621967e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt @@ -0,0 +1,184 @@ +/* + * 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.publicMessages + +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PublicMessageEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val receiverRelay = "wss://relay.example.com/" + + @Test + fun `verify event kind is 24`() { + assertEquals(24, PublicMessageEvent.KIND) + } + + @Test + fun `verify alt description`() { + assertEquals("Public Message", PublicMessageEvent.ALT_DESCRIPTION) + } + + @Test + fun `build with single receiver`() { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Hello!") + val event = signer.sign(template) + + assertEquals(24, event.kind) + assertEquals("Hello!", event.content) + assertTrue(event.tags.any { it[0] == "p" && it[1] == receiverPubKey }) + assertTrue(event.tags.any { it[0] == "alt" && it[1] == "Public Message" }) + } + + @Test + fun `build with multiple receivers`() { + val receiver1 = ReceiverTag(receiverPubKey, null) + val receiver2PubKey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + val receiver2 = ReceiverTag(receiver2PubKey, null) + + val template = PublicMessageEvent.build(listOf(receiver1, receiver2), "Group message") + val event = signer.sign(template) + + assertEquals("Group message", event.content) + val pTags = event.tags.filter { it[0] == "p" } + assertEquals(2, pTags.size) + assertTrue(pTags.any { it[1] == receiverPubKey }) + assertTrue(pTags.any { it[1] == receiver2PubKey }) + } + + @Test + fun `build with relay hint`() { + val receiver = ReceiverTag.parse(arrayOf("p", receiverPubKey, "wss://relay.example.com/"))!! + val template = PublicMessageEvent.build(receiver, "With relay hint") + val event = signer.sign(template) + + val pTag = event.tags.first { it[0] == "p" && it[1] == receiverPubKey } + assertEquals("wss://relay.example.com/", pTag[2]) + } + + @Test + fun `e tags are stripped from build output`() { + val receiver = ReceiverTag(receiverPubKey, null) + val fakeEventId = "a".repeat(64) + + val template = + PublicMessageEvent.build(receiver, "Test message") { + eTag(ETag(fakeEventId, null)) + } + val event = signer.sign(template) + + assertFalse( + event.tags.any { it[0] == "e" }, + "NIP-A4 prohibits e tags in kind 24 events", + ) + } + + @Test + fun `e tags are stripped from empty build`() { + val fakeEventId = "b".repeat(64) + + val template = + PublicMessageEvent.build { + eTag(ETag(fakeEventId, null)) + } + val event = signer.sign(template) + + assertFalse( + event.tags.any { it[0] == "e" }, + "NIP-A4 prohibits e tags in kind 24 events", + ) + } + + @Test + fun `e tags are stripped from group build`() { + val receiver = ReceiverTag(receiverPubKey, null) + val fakeEventId = "c".repeat(64) + + val template = + PublicMessageEvent.build(listOf(receiver), "Group test") { + eTag(ETag(fakeEventId, null)) + } + val event = signer.sign(template) + + assertFalse( + event.tags.any { it[0] == "e" }, + "NIP-A4 prohibits e tags in kind 24 events", + ) + } + + @Test + fun `isIncluded checks receivers and author`() { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Test") + val event = signer.sign(template) + + assertTrue(event.isIncluded(receiverPubKey)) + assertTrue(event.isIncluded(event.pubKey)) + assertFalse(event.isIncluded("0".repeat(64))) + } + + @Test + fun `groupKeys includes author and receivers`() { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Test") + val event = signer.sign(template) + + val keys = event.groupKeys() + assertTrue(keys.contains(event.pubKey)) + assertTrue(keys.contains(receiverPubKey)) + } + + @Test + fun `groupKeySet returns unique keys`() { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Test") + val event = signer.sign(template) + + val keySet = event.groupKeySet() + assertEquals(2, keySet.size) + assertTrue(keySet.contains(event.pubKey)) + assertTrue(keySet.contains(receiverPubKey)) + } + + @Test + fun `chatroomKey excludes given user`() { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Test") + val event = signer.sign(template) + + val chatroomKey = event.chatroomKey(event.pubKey) + assertFalse(chatroomKey.contains(event.pubKey)) + assertTrue(chatroomKey.contains(receiverPubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt new file mode 100644 index 000000000..a174b0fb2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt @@ -0,0 +1,119 @@ +/* + * 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.nip57Zaps + +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LnZapRequestKindTagTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val nostrSigner = NostrSignerInternal(signer.key) + + private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + + @Test + fun `zap request for kind 24 event includes k tag`() = + runTest { + val receiver = ReceiverTag(receiverPubKey, null) + val template = PublicMessageEvent.build(receiver, "Test message") + val publicMsg = signer.sign(template) + + val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) + + val zapRequest = + LnZapRequestEvent.create( + zappedEvent = publicMsg, + relays = relays, + signer = nostrSigner, + pollOption = null, + message = "", + zapType = LnZapEvent.ZapType.PUBLIC, + toUserPubHex = null, + ) + + val kTag = zapRequest.tags.firstOrNull { it[0] == "k" } + assertTrue(kTag != null, "Zap request must include k tag per NIP-A4") + assertEquals("24", kTag[1], "k tag must contain the zapped event's kind") + } + + @Test + fun `zap request for kind 1 event includes k tag`() = + runTest { + val kind1Event = + Event( + id = "a".repeat(64), + pubKey = receiverPubKey, + createdAt = 1000L, + kind = 1, + tags = emptyArray(), + content = "Hello world", + sig = "b".repeat(128), + ) + + val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) + + val zapRequest = + LnZapRequestEvent.create( + zappedEvent = kind1Event, + relays = relays, + signer = nostrSigner, + pollOption = null, + message = "", + zapType = LnZapEvent.ZapType.PUBLIC, + toUserPubHex = null, + ) + + val kTag = zapRequest.tags.firstOrNull { it[0] == "k" } + assertTrue(kTag != null, "Zap request should include k tag") + assertEquals("1", kTag[1], "k tag should contain the zapped event's kind") + } + + @Test + fun `zap request for user only has no k tag`() = + runTest { + val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) + + val zapRequest = + LnZapRequestEvent.create( + userHex = receiverPubKey, + relays = relays, + signer = nostrSigner, + message = "", + zapType = LnZapEvent.ZapType.PUBLIC, + ) + + val kTag = zapRequest.tags.firstOrNull { it[0] == "k" } + assertEquals(null, kTag, "User-only zap should not have k tag") + } +} From 5db9330306a2417deaa7a0888d940418bc419843 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:52:02 +0000 Subject: [PATCH 2/2] fix: update test packages after nipA4PublicMessages rename Move test files from experimental.publicMessages to nipA4PublicMessages to match the package rename on main. https://claude.ai/code/session_019JsSsTTXivnNWiAtE5DE1R --- .../vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt | 4 ++-- .../PublicMessageEventTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/{experimental/publicMessages => nipA4PublicMessages}/PublicMessageEventTest.kt (98%) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt index a174b0fb2..0ba820241 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip57Zaps -import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent -import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlinx.coroutines.test.runTest diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEventTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEventTest.kt index cc621967e..8fd9bcbfa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/publicMessages/PublicMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEventTest.kt @@ -18,11 +18,11 @@ * 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.publicMessages +package com.vitorpamplona.quartz.nipA4PublicMessages -import com.vitorpamplona.quartz.experimental.publicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.utils.DeterministicSigner import com.vitorpamplona.quartz.utils.nsecToKeyPair import kotlin.test.Test