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
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
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.any
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
@@ -30,10 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
@@ -46,6 +50,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Immutable
class MeetingRoomEvent(
@@ -157,5 +163,24 @@ class MeetingRoomEvent(
val tags = arrayOf(AltTag.assemble(ALT))
return signer.sign(createdAt, KIND, tags, "")
}
@OptIn(ExperimentalUuidApi::class)
fun build(
meetingSpace: MeetingSpaceTag,
title: String,
starts: Long,
status: StatusTag.STATUS,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MeetingRoomEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(dTag)
meetingSpace(meetingSpace)
title(title)
starts(starts)
status(status)
alt(ALT)
initializer()
}
}
}
@@ -0,0 +1,68 @@
/*
* 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.
*/
@file:JvmName("MeetingRoomTagArrayBuilderExt")
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
import kotlin.jvm.JvmName
fun TagArrayBuilder<MeetingRoomEvent>.meetingSpace(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
fun TagArrayBuilder<MeetingRoomEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<MeetingRoomEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
fun TagArrayBuilder<MeetingRoomEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
fun TagArrayBuilder<MeetingRoomEvent>.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp))
fun TagArrayBuilder<MeetingRoomEvent>.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp))
fun TagArrayBuilder<MeetingRoomEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
fun TagArrayBuilder<MeetingRoomEvent>.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count))
fun TagArrayBuilder<MeetingRoomEvent>.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count))
fun TagArrayBuilder<MeetingRoomEvent>.participant(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
role: String? = null,
proof: HexKey? = null,
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
fun TagArrayBuilder<MeetingRoomEvent>.participants(participants: List<ParticipantTag>) =
participants.forEach {
participant(it.pubKey, it.relayHint, it.role, it.proof)
}
@@ -23,12 +23,16 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
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.any
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag
@@ -36,6 +40,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUr
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Immutable
class MeetingSpaceEvent(
@@ -93,5 +99,24 @@ class MeetingSpaceEvent(
val tags = arrayOf(AltTag.assemble(ALT))
return signer.sign(createdAt, KIND, tags, "")
}
@OptIn(ExperimentalUuidApi::class)
fun build(
room: String,
status: StatusTag.STATUS,
service: String,
host: ParticipantTag,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MeetingSpaceEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(dTag)
room(room)
status(status)
service(service)
participant(host.pubKey, host.relayHint, host.role, host.proof)
alt(ALT)
initializer()
}
}
}
@@ -0,0 +1,62 @@
/*
* 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.
*/
@file:JvmName("MeetingSpaceTagArrayBuilderExt")
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUrlTag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlin.jvm.JvmName
fun TagArrayBuilder<MeetingSpaceEvent>.room(name: String) = addUnique(RoomNameTag.assemble(name))
fun TagArrayBuilder<MeetingSpaceEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
fun TagArrayBuilder<MeetingSpaceEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
fun TagArrayBuilder<MeetingSpaceEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
fun TagArrayBuilder<MeetingSpaceEvent>.service(url: String) = addUnique(ServiceUrlTag.assemble(url))
fun TagArrayBuilder<MeetingSpaceEvent>.endpoint(url: String) = addUnique(EndpointUrlTag.assemble(url))
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
fun TagArrayBuilder<MeetingSpaceEvent>.participant(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
role: String? = null,
proof: HexKey? = null,
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
fun TagArrayBuilder<MeetingSpaceEvent>.participants(participants: List<ParticipantTag>) =
participants.forEach {
participant(it.pubKey, it.relayHint, it.role, it.proof)
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
@@ -43,7 +43,7 @@ class MeetingRoomPresenceEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(MeetingSpaceTag::parseAsHint)
@@ -57,20 +57,11 @@ class MeetingRoomPresenceEvent(
const val KIND = 10312
const val ALT = "Room Presence tag"
fun createAddress(
pubKey: HexKey,
dtag: String,
): Address = Address(KIND, pubKey, dtag)
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(
pubKey: HexKey,
dtag: String,
): ATag = ATag(KIND, pubKey, dtag, null)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(
pubKey: HexKey,
dtag: String,
): String = Address.assemble(KIND, pubKey, dtag)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun build(
root: MeetingRoomEvent,
@@ -23,15 +23,19 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming
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.any
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
@@ -43,6 +47,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Immutable
class LiveActivitiesEvent(
@@ -154,5 +160,16 @@ class LiveActivitiesEvent(
val tags = arrayOf(AltTag.assemble(ALT))
return signer.sign(createdAt, KIND, tags, "")
}
@OptIn(ExperimentalUuidApi::class)
fun build(
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag(dTag)
alt(ALT)
initializer()
}
}
}
@@ -0,0 +1,114 @@
/*
* 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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* NIP-53 Proof of Agreement to Participate.
*
* The proof is a schnorr signature by the participant's private key over the
* SHA256 of the event's addressable `a` tag value (`kind:pubkey:dTag`), encoded as hex.
*
* See: https://github.com/nostr-protocol/nips/blob/master/53.md#proof-of-agreement-to-participate
*/
object ProofOfAgreement {
/**
* Computes the SHA256 digest over the canonical `kind:pubkey:dTag` string.
* This is the message that the participant's private key signs.
*/
fun digest(
eventKind: Int,
eventAuthor: HexKey,
eventDTag: String,
): ByteArray = sha256(Address.assemble(eventKind, eventAuthor, eventDTag).encodeToByteArray())
fun digest(address: Address): ByteArray = digest(address.kind, address.pubKeyHex, address.dTag)
/**
* Signs the proof using the participant's private key (schnorr over the SHA256
* digest of the event's `a` tag value). Returns the hex-encoded signature.
*
* Only usable by the participant — requires access to their raw private key
* (e.g. from a local `NostrSignerInternal`). External signers (NIP-46, NIP-55)
* do not expose a raw-bytes sign API so they cannot produce this proof.
*/
fun sign(
participantPrivKey: ByteArray,
eventKind: Int,
eventAuthor: HexKey,
eventDTag: String,
): HexKey = Nip01Crypto.sign(digest(eventKind, eventAuthor, eventDTag), participantPrivKey).toHexKey()
/**
* Verifies that [proof] is a valid schnorr signature by [participantPubKey] over
* SHA256(`kind:pubkey:dTag`). Returns `false` if the proof is malformed, the
* pubkey is invalid, or the signature doesn't match.
*/
fun verify(
proof: HexKey,
participantPubKey: HexKey,
eventKind: Int,
eventAuthor: HexKey,
eventDTag: String,
): Boolean {
val signature = proof.hexToByteArrayOrNull() ?: return false
if (signature.size != 64) return false
val pubKey = participantPubKey.hexToByteArrayOrNull() ?: return false
if (pubKey.size != 32) return false
return runCatching {
Nip01Crypto.verify(signature, digest(eventKind, eventAuthor, eventDTag), pubKey)
}.getOrDefault(false)
}
fun verify(
proof: HexKey,
participantPubKey: HexKey,
address: Address,
): Boolean = verify(proof, participantPubKey, address.kind, address.pubKeyHex, address.dTag)
/**
* Verifies a participant tag's proof against its owning [LiveActivitiesEvent].
* Returns `false` if the tag has no proof or the proof is invalid.
*/
fun verify(
tag: ParticipantTag,
event: LiveActivitiesEvent,
): Boolean {
val proof = tag.proof ?: return false
return verify(proof, tag.pubKey, event.address())
}
}
/**
* Convenience: check whether a participant's proof of agreement is valid for this event.
* Useful for UI that wants to distinguish "verified" participants from "invited" ones
* (per NIP-53: "Clients MAY only display participants if the proof is available or
* MAY display participants as 'invited' if the proof is not available").
*/
fun LiveActivitiesEvent.hasValidProof(participant: ParticipantTag): Boolean = ProofOfAgreement.verify(participant, this)
@@ -0,0 +1,74 @@
/*
* 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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.PinnedEventTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RecordingTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
fun TagArrayBuilder<LiveActivitiesEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<LiveActivitiesEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
fun TagArrayBuilder<LiveActivitiesEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
fun TagArrayBuilder<LiveActivitiesEvent>.streaming(url: String) = addUnique(StreamingTag.assemble(url))
fun TagArrayBuilder<LiveActivitiesEvent>.recording(url: String) = addUnique(RecordingTag.assemble(url))
fun TagArrayBuilder<LiveActivitiesEvent>.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp))
fun TagArrayBuilder<LiveActivitiesEvent>.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp))
fun TagArrayBuilder<LiveActivitiesEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
fun TagArrayBuilder<LiveActivitiesEvent>.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count))
fun TagArrayBuilder<LiveActivitiesEvent>.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count))
fun TagArrayBuilder<LiveActivitiesEvent>.participant(
pubKey: HexKey,
relayHint: NormalizedRelayUrl? = null,
role: String? = null,
proof: HexKey? = null,
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
fun TagArrayBuilder<LiveActivitiesEvent>.participants(participants: List<ParticipantTag>) =
participants.forEach {
participant(it.pubKey, it.relayHint, it.role, it.proof)
}
fun TagArrayBuilder<LiveActivitiesEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
fun TagArrayBuilder<LiveActivitiesEvent>.pinned(eventId: HexKey) = add(PinnedEventTag.assemble(eventId))
@@ -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,
),
)
}
}