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:
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -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)
|
||||
}
|
||||
+5
-14
@@ -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,
|
||||
|
||||
+17
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -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)
|
||||
+74
@@ -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))
|
||||
+102
@@ -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])
|
||||
}
|
||||
}
|
||||
+69
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+97
@@ -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")
|
||||
}
|
||||
}
|
||||
+204
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user