fix(quartz): align NIP-53 Live Activities with spec

- MeetingRoomPresenceEvent (kind 10312) now extends BaseReplaceableEvent
  instead of BaseAddressableEvent. NIP-53 states this kind is a regular
  replaceable event ("presence can only be indicated in one room at a
  time"), and LocalCache already routes it through consumeBaseReplaceable.
  createAddress/createAddressATag/createAddressTag now take only pubKey.

- Add TagArrayBuilder extensions for streaming (30311), meeting space
  (30312) and meeting room (30313) tags, plus build() helpers that assemble
  the required tags per NIP-53.

- Add ProofOfAgreement utility to sign and verify the schnorr proof that
  NIP-53 places in the 5th element of a participant p-tag
  (SHA256 of kind:pubkey:dTag signed by the participant's private key).
  Includes LiveActivitiesEvent.hasValidProof() convenience.
This commit is contained in:
Claude
2026-04-21 23:48:11 +00:00
parent 39f9cc350a
commit 2f78849845
12 changed files with 862 additions and 14 deletions
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag as StreamStatusTag
class MeetingSpaceEventBuildTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
@Test
fun meetingSpaceBuildAssemblesRequiredTags() {
val template =
MeetingSpaceEvent.build(
room = "Main Hall",
status = StatusTag.STATUS.OPEN,
service = "https://meet.example.com/hall",
host = ParticipantTag(host, null, "Host", null),
dTag = "main-hall",
createdAt = 1_700_000_000L,
) {
summary("The big room")
image("https://example.com/hall.png")
endpoint("https://api.example.com/hall")
}
assertEquals(MeetingSpaceEvent.KIND, template.kind)
val byName = template.tags.groupBy { it[0] }
assertEquals("main-hall", byName["d"]?.single()?.get(1))
assertEquals("Main Hall", byName["room"]?.single()?.get(1))
assertEquals("open", byName["status"]?.single()?.get(1))
assertEquals("https://meet.example.com/hall", byName["service"]?.single()?.get(1))
assertEquals("https://api.example.com/hall", byName["endpoint"]?.single()?.get(1))
assertEquals("The big room", byName["summary"]?.single()?.get(1))
assertEquals("https://example.com/hall.png", byName["image"]?.single()?.get(1))
assertEquals(MeetingSpaceEvent.ALT, byName["alt"]?.single()?.get(1))
val hostTag = byName["p"]?.single()
assertNotNull(hostTag)
assertEquals(host, hostTag[1])
assertEquals("Host", hostTag[3])
}
@Test
fun meetingRoomBuildReferencesParentSpace() {
val spaceAddress = Address(MeetingSpaceEvent.KIND, host, "main-hall")
val template =
MeetingRoomEvent.build(
meetingSpace = MeetingSpaceTag(spaceAddress),
title = "Annual Meeting",
starts = 1_700_000_000L,
status = StreamStatusTag.STATUS.PLANNED,
dTag = "annual-2026",
createdAt = 1_700_000_000L,
) {
ends(1_700_003_600L)
currentParticipants(10)
totalParticipants(100)
}
assertEquals(MeetingRoomEvent.KIND, template.kind)
val byName = template.tags.groupBy { it[0] }
assertEquals("annual-2026", byName["d"]?.single()?.get(1))
assertEquals("Annual Meeting", byName["title"]?.single()?.get(1))
assertEquals("1700000000", byName["starts"]?.single()?.get(1))
assertEquals("1700003600", byName["ends"]?.single()?.get(1))
assertEquals("planned", byName["status"]?.single()?.get(1))
assertEquals("10", byName["current_participants"]?.single()?.get(1))
assertEquals("100", byName["total_participants"]?.single()?.get(1))
assertEquals(MeetingRoomEvent.ALT, byName["alt"]?.single()?.get(1))
val aTag = byName["a"]?.single()
assertNotNull(aTag)
assertEquals("${MeetingSpaceEvent.KIND}:$host:main-hall", aTag[1])
}
}
@@ -0,0 +1,69 @@
/*
* 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.presence
import kotlin.test.Test
import kotlin.test.assertEquals
class MeetingRoomPresenceEventTest {
private val participant = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
@Test
fun kind10312IsReplaceable() {
val event =
MeetingRoomPresenceEvent(
id = "0".repeat(64),
pubKey = participant,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "30313:${"b".repeat(64)}:annual-2026"),
arrayOf("hand", "1"),
),
content = "",
sig = "0".repeat(128),
)
// Per NIP-53, kind 10312 is a regular replaceable event: "presence can only
// be indicated in one room at a time". Replaceable events have a fixed empty
// d-tag regardless of what's in the event tags.
assertEquals("", event.dTag())
assertEquals("${MeetingRoomPresenceEvent.KIND}:$participant:", event.addressTag())
}
@Test
fun createAddressKeysByPubKeyOnly() {
val a = MeetingRoomPresenceEvent.createAddress(participant)
assertEquals(MeetingRoomPresenceEvent.KIND, a.kind)
assertEquals(participant, a.pubKeyHex)
assertEquals("", a.dTag)
val aTag = MeetingRoomPresenceEvent.createAddressATag(participant)
assertEquals(MeetingRoomPresenceEvent.KIND, aTag.kind)
assertEquals(participant, aTag.pubKeyHex)
assertEquals("", aTag.dTag)
assertEquals(
"${MeetingRoomPresenceEvent.KIND}:$participant:",
MeetingRoomPresenceEvent.createAddressTag(participant),
)
}
}
@@ -0,0 +1,97 @@
/*
* 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.streaming
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class LiveActivitiesEventBuildTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val guest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
@Test
fun buildAssemblesAllRequiredAndOptionalTags() {
val template =
LiveActivitiesEvent.build(dTag = "my-stream", createdAt = 1_700_000_000L) {
title("My Stream")
summary("A live event")
image("https://example.com/img.png")
streaming("https://example.com/stream.m3u8")
starts(1_700_000_000L)
status(StatusTag.STATUS.LIVE)
currentParticipants(42)
totalParticipants(100)
participant(host, role = "Host")
participant(guest, role = "Speaker")
}
assertEquals(LiveActivitiesEvent.KIND, template.kind)
assertEquals("", template.content)
val tagsByName = template.tags.groupBy { it[0] }
assertEquals("my-stream", tagsByName["d"]?.single()?.get(1))
assertEquals("My Stream", tagsByName["title"]?.single()?.get(1))
assertEquals("A live event", tagsByName["summary"]?.single()?.get(1))
assertEquals("https://example.com/img.png", tagsByName["image"]?.single()?.get(1))
assertEquals("https://example.com/stream.m3u8", tagsByName["streaming"]?.single()?.get(1))
assertEquals("1700000000", tagsByName["starts"]?.single()?.get(1))
assertEquals("live", tagsByName["status"]?.single()?.get(1))
assertEquals("42", tagsByName["current_participants"]?.single()?.get(1))
assertEquals("100", tagsByName["total_participants"]?.single()?.get(1))
assertEquals(LiveActivitiesEvent.ALT, tagsByName["alt"]?.single()?.get(1))
// Per NIP-53, role lives at index 3 of the p-tag; a missing relay must be
// represented as an empty string placeholder, not dropped.
val pTags = tagsByName["p"] ?: emptyList()
assertEquals(2, pTags.size)
assertContentEquals(arrayOf("p", host, "", "Host"), pTags[0])
assertContentEquals(arrayOf("p", guest, "", "Speaker"), pTags[1])
}
@Test
fun buildAllowsMultiplePinnedAndHashtagsWithoutClobbering() {
val template =
LiveActivitiesEvent.build(dTag = "my-stream") {
title("title")
pinned("1".repeat(64))
pinned("2".repeat(64))
}
val pinned = template.tags.filter { it[0] == "pinned" }
assertEquals(2, pinned.size)
}
@Test
fun buildUsesRandomDTagWhenNotProvided() {
val a = LiveActivitiesEvent.build { title("a") }
val b = LiveActivitiesEvent.build { title("b") }
val aDTag = a.tags.first { it[0] == "d" }[1]
val bDTag = b.tags.first { it[0] == "d" }[1]
assertNotNull(aDTag)
assertTrue(aDTag.isNotEmpty())
assertTrue(aDTag != bDTag, "random dTag should differ between builds")
}
}
@@ -0,0 +1,204 @@
/*
* 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.streaming
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ProofOfAgreementTest {
private val dummySig = "0".repeat(128)
@Test
fun roundTripSignAndVerify() {
val host = KeyPair()
val participant = KeyPair()
val hostHex = host.pubKey.toHexKey()
val participantHex = participant.pubKey.toHexKey()
val dTag = "my-stream"
val proof =
ProofOfAgreement.sign(
participantPrivKey = participant.privKey!!,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = dTag,
)
assertTrue(
ProofOfAgreement.verify(
proof = proof,
participantPubKey = participantHex,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = dTag,
),
)
}
@Test
fun verifyFailsForDifferentAddress() {
val host = KeyPair()
val participant = KeyPair()
val hostHex = host.pubKey.toHexKey()
val participantHex = participant.pubKey.toHexKey()
val proof =
ProofOfAgreement.sign(
participantPrivKey = participant.privKey!!,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = "one",
)
assertFalse(
ProofOfAgreement.verify(
proof = proof,
participantPubKey = participantHex,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = "another",
),
)
}
@Test
fun verifyFailsForWrongParticipantKey() {
val host = KeyPair()
val participant = KeyPair()
val imposter = KeyPair()
val hostHex = host.pubKey.toHexKey()
val proof =
ProofOfAgreement.sign(
participantPrivKey = participant.privKey!!,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = "stream",
)
assertFalse(
ProofOfAgreement.verify(
proof = proof,
participantPubKey = imposter.pubKey.toHexKey(),
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = "stream",
),
)
}
@Test
fun verifyFailsForMalformedProof() {
assertFalse(
ProofOfAgreement.verify(
proof = "not-hex",
participantPubKey = "a".repeat(64),
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = "a".repeat(64),
eventDTag = "x",
),
)
assertFalse(
ProofOfAgreement.verify(
proof = "ab",
participantPubKey = "a".repeat(64),
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = "a".repeat(64),
eventDTag = "x",
),
)
}
@Test
fun verifyAgainstLiveActivitiesEvent() {
val host = KeyPair()
val participant = KeyPair()
val hostHex = host.pubKey.toHexKey()
val participantHex = participant.pubKey.toHexKey()
val dTag = "event-d"
val proof =
ProofOfAgreement.sign(
participantPrivKey = participant.privKey!!,
eventKind = LiveActivitiesEvent.KIND,
eventAuthor = hostHex,
eventDTag = dTag,
)
val event =
LiveActivitiesEvent(
id = "0".repeat(64),
pubKey = hostHex,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", dTag),
arrayOf("p", participantHex, "", "Speaker", proof),
arrayOf("p", "c".repeat(64), "", "Speaker"),
),
content = "",
sig = dummySig,
)
val verifiedTag = event.participants().first { it.pubKey == participantHex }
val unverifiedTag = event.participants().first { it.pubKey == "c".repeat(64) }
assertTrue(event.hasValidProof(verifiedTag))
assertFalse(event.hasValidProof(unverifiedTag), "missing proof must not be treated as valid")
}
@Test
fun digestMatchesAddressForm() {
// digest(kind, pubkey, dtag) must agree with digest(Address)
val hostHex = "a".repeat(64)
val d1 = ProofOfAgreement.digest(LiveActivitiesEvent.KIND, hostHex, "abc")
val d2 = ProofOfAgreement.digest(Address(LiveActivitiesEvent.KIND, hostHex, "abc"))
assertTrue(d1.contentEquals(d2))
}
@Test
fun tagWithoutProofFailsVerification() {
val host = KeyPair()
val participant = KeyPair()
val event =
LiveActivitiesEvent(
id = "0".repeat(64),
pubKey = host.pubKey.toHexKey(),
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "d"), arrayOf("p", participant.pubKey.toHexKey())),
content = "",
sig = dummySig,
)
assertFalse(
ProofOfAgreement.verify(
tag = ParticipantTag(participant.pubKey.toHexKey(), null, null, null),
event = event,
),
)
}
}