feat: add NIP-66 relay monitor and discovery support to quartz

Implements both event kinds from NIP-66 (Relay Monitor Protocol):

- Kind 10166 (RelayMonitorEvent) - replaceable event published by
  monitoring services, declaring check frequency, timeout values, and
  supported check types (open, read, write, auth, nip11, dns, geo)

- Kind 30166 (RelayDiscoveryEvent) - addressable event (keyed by relay
  URL in d-tag) published per monitored relay, carrying RTT metrics,
  network type, relay type, supported NIPs, accepted kinds, access
  requirements, topics, and geohash

Package: nip66RelayMonitor/{monitor,discovery} following existing NIP-88
patterns with tag classes, TagArrayBuilderExt, and TagArrayExt. Both
events registered in EventFactory.

https://claude.ai/code/session_01Wtg56Vudthgfyz8ASTb4p7
This commit is contained in:
Claude
2026-03-02 21:20:43 +00:00
parent 5984292ea5
commit 49e2f2c7c9
16 changed files with 893 additions and 0 deletions
@@ -0,0 +1,115 @@
/*
* 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.nip66RelayMonitor.discovery
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.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-66 Kind 30166: Relay Discovery Event
*
* Published by relay monitors to document the state of a specific relay.
* The `d` tag holds the normalized relay URL, making each relay URL
* addressable per monitor pubkey.
*
* Carries metrics and metadata observed during monitoring:
* - Round-trip times (rtt-open, rtt-read, rtt-write)
* - Network type (n), relay type (T)
* - Supported NIPs (N), accepted kinds (k)
* - Requirements (R: auth, payment, pow, writes)
* - Topics (t) and geohash (g)
*/
@Immutable
class RelayDiscoveryEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relay(): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(dTag())
fun rtt() = tags.rtts()
fun rttOpen() = tags.rtt(RttType.OPEN)
fun rttRead() = tags.rtt(RttType.READ)
fun rttWrite() = tags.rtt(RttType.WRITE)
fun networkTypes() = tags.networkTypes()
fun relayTypes() = tags.relayTypes()
fun supportedNips() = tags.supportedNips()
fun requirements() = tags.requirements()
fun acceptedKinds() = tags.acceptedKinds()
fun topics() = tags.topics()
fun geohashes() = tags.geohashes()
companion object {
const val KIND = 30166
const val ALT_DESCRIPTION = "Relay discovery"
fun build(
relayUrl: NormalizedRelayUrl,
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayDiscoveryEvent>.() -> Unit = {},
) = eventTemplate<RelayDiscoveryEvent>(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(relayUrl.url)
initializer()
}
fun build(
relayUrl: String,
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayDiscoveryEvent>.() -> Unit = {},
) = build(
relayUrl = RelayUrlNormalizer.normalize(relayUrl),
content = content,
createdAt = createdAt,
initializer = 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.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag
fun TagArrayBuilder<RelayDiscoveryEvent>.rtt(
type: RttType,
milliseconds: Long,
) = addUnique(RttTag.assemble(type, milliseconds))
fun TagArrayBuilder<RelayDiscoveryEvent>.networkType(network: NetworkType) = addUnique(NetworkTypeTag.assemble(network))
fun TagArrayBuilder<RelayDiscoveryEvent>.networkTypes(networks: List<NetworkType>) = addAll(NetworkTypeTag.assemble(networks))
fun TagArrayBuilder<RelayDiscoveryEvent>.relayType(type: String) = addUnique(RelayTypeTag.assemble(type))
fun TagArrayBuilder<RelayDiscoveryEvent>.relayTypes(types: List<String>) = addAll(RelayTypeTag.assemble(types))
fun TagArrayBuilder<RelayDiscoveryEvent>.supportedNip(nip: Int) = add(SupportedNipTag.assemble(nip))
fun TagArrayBuilder<RelayDiscoveryEvent>.supportedNips(nips: List<Int>) = addAll(SupportedNipTag.assemble(nips))
fun TagArrayBuilder<RelayDiscoveryEvent>.requirement(
value: String,
negated: Boolean = false,
) = add(RequirementTag.assemble(value, negated))
fun TagArrayBuilder<RelayDiscoveryEvent>.requirements(reqs: List<RequirementTag>) = addAll(RequirementTag.assemble(reqs))
fun TagArrayBuilder<RelayDiscoveryEvent>.acceptedKind(
kind: Int,
negated: Boolean = false,
) = add(AcceptedKindTag.assemble(kind, negated))
fun TagArrayBuilder<RelayDiscoveryEvent>.acceptedKinds(kinds: List<AcceptedKindTag>) = addAll(AcceptedKindTag.assemble(kinds))
@@ -0,0 +1,47 @@
/*
* 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.nip66RelayMonitor.discovery
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag
fun TagArray.rtts() = mapNotNull(RttTag::parse)
fun TagArray.rtt(type: RttType) = mapNotNull(RttTag::parse).firstOrNull { it.type == type }?.milliseconds
fun TagArray.networkTypes() = mapNotNull(NetworkTypeTag::parse)
fun TagArray.relayTypes() = mapNotNull(RelayTypeTag::parse)
fun TagArray.supportedNips() = mapNotNull(SupportedNipTag::parse)
fun TagArray.requirements() = mapNotNull(RequirementTag::parse)
fun TagArray.acceptedKinds() = mapNotNull(AcceptedKindTag::parse)
fun TagArray.topics() = hashtags()
@@ -0,0 +1,67 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["k", "<kind>"] - an event kind accepted by this relay.
*
* A leading "!" negates, meaning the relay does NOT accept that kind.
* Examples: "1" (accepts kind 1), "!1" (rejects kind 1)
*/
@Stable
class AcceptedKindTag(
val kind: Int,
val negated: Boolean,
) {
companion object {
const val TAG_NAME = "k"
private const val NEGATION_PREFIX = "!"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): AcceptedKindTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val raw = tag[1]
return if (raw.startsWith(NEGATION_PREFIX)) {
val kind = raw.removePrefix(NEGATION_PREFIX).toIntOrNull() ?: return null
AcceptedKindTag(kind = kind, negated = true)
} else {
val kind = raw.toIntOrNull() ?: return null
AcceptedKindTag(kind = kind, negated = false)
}
}
fun assemble(
kind: Int,
negated: Boolean = false,
) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$kind" else kind.toString())
fun assemble(entry: AcceptedKindTag) = assemble(entry.kind, entry.negated)
fun assemble(kinds: List<AcceptedKindTag>) = kinds.map { assemble(it) }
}
}
@@ -0,0 +1,54 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Network accessibility types for relay connections */
enum class NetworkType(
val code: String,
) {
CLEARNET("clearnet"),
TOR("tor"),
I2P("i2p"),
LOKI("loki"),
}
/** ["n", "<network-type>"] - the network type the relay is accessible on */
class NetworkTypeTag {
companion object {
const val TAG_NAME = "n"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): NetworkType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return NetworkType.entries.find { it.code == tag[1] }
}
fun assemble(network: NetworkType) = arrayOf(TAG_NAME, network.code)
fun assemble(networks: List<NetworkType>) = networks.map { assemble(it) }
}
}
@@ -0,0 +1,47 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["T", "<relay-type>"] - the type of relay in PascalCase (e.g. PrivateInbox, PublicOutbox).
* Values are arbitrary strings defined by relay operators; not an enum.
*/
class RelayTypeTag {
companion object {
const val TAG_NAME = "T"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(relayType: String) = arrayOf(TAG_NAME, relayType)
fun assemble(relayTypes: List<String>) = relayTypes.map { assemble(it) }
}
}
@@ -0,0 +1,65 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["R", "<requirement>"] - access requirement enforced by the relay.
*
* A leading "!" negates the requirement, meaning the relay does NOT enforce it.
* Examples: "auth", "payment", "!payment" (no payment required), "pow", "writes"
*/
@Stable
class RequirementTag(
val value: String,
val negated: Boolean,
) {
companion object {
const val TAG_NAME = "R"
private const val NEGATION_PREFIX = "!"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): RequirementTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val raw = tag[1]
return if (raw.startsWith(NEGATION_PREFIX)) {
RequirementTag(value = raw.removePrefix(NEGATION_PREFIX), negated = true)
} else {
RequirementTag(value = raw, negated = false)
}
}
fun assemble(
value: String,
negated: Boolean = false,
) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$value" else value)
fun assemble(req: RequirementTag) = assemble(req.value, req.negated)
fun assemble(requirements: List<RequirementTag>) = requirements.map { assemble(it) }
}
}
@@ -0,0 +1,66 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Round-trip time measurement types */
enum class RttType(
val tagName: String,
) {
OPEN("rtt-open"),
READ("rtt-read"),
WRITE("rtt-write"),
}
/**
* Round-trip time tags measured in milliseconds.
*
* ["rtt-open", "<milliseconds>"]
* ["rtt-read", "<milliseconds>"]
* ["rtt-write", "<milliseconds>"]
*/
@Stable
class RttTag(
val type: RttType,
val milliseconds: Long,
) {
companion object {
fun isTag(tag: Array<String>) =
tag.has(1) && RttType.entries.any { it.tagName == tag[0] }
fun parse(tag: Array<String>): RttTag? {
ensure(tag.has(1)) { return null }
val type = RttType.entries.find { it.tagName == tag[0] } ?: return null
val ms = tag[1].toLongOrNull() ?: return null
return RttTag(type, ms)
}
fun assemble(
type: RttType,
milliseconds: Long,
) = arrayOf(type.tagName, milliseconds.toString())
fun assemble(rtt: RttTag) = assemble(rtt.type, rtt.milliseconds)
}
}
@@ -0,0 +1,44 @@
/*
* 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.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** ["N", "<nip-number>"] - a NIP number supported by this relay (repeated per supported NIP) */
class SupportedNipTag {
companion object {
const val TAG_NAME = "N"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(nip: Int) = arrayOf(TAG_NAME, nip.toString())
fun assemble(nips: List<Int>) = nips.map { assemble(it) }
}
}
@@ -0,0 +1,85 @@
/*
* 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.nip66RelayMonitor.monitor
import androidx.compose.runtime.Immutable
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.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-66 Kind 10166: Relay Monitor Announcement
*
* Published by monitoring services to advertise themselves. Declares:
* - How frequently the monitor publishes results (frequency tag, required)
* - Which types of checks it performs (c tags)
* - Timeout values used for each check type (timeout tags)
* - Geographical location of the monitor (g tags)
*
* Monitors should also publish complementary kind 0 (metadata) and
* kind 10002 (relay list) events for full discoverability.
*/
@Immutable
class RelayMonitorEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun frequency() = tags.frequency()
fun checks() = tags.checks()
fun timeouts() = tags.timeouts()
fun geohashes() = tags.geohashes()
companion object {
const val KIND = 10166
const val ALT_DESCRIPTION = "Relay monitor announcement"
fun build(
frequencySeconds: Long,
checks: List<CheckType> = emptyList(),
timeouts: List<TimeoutTag> = emptyList(),
geohash: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayMonitorEvent>.() -> Unit = {},
) = eventTemplate<RelayMonitorEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
frequency(frequencySeconds)
checks(checks)
timeouts(timeouts)
geohash?.let { geohash(it) }
initializer()
}
}
}
@@ -0,0 +1,33 @@
/*
* 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.nip66RelayMonitor.monitor
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
fun TagArrayBuilder<RelayMonitorEvent>.frequency(seconds: Long) = addUnique(FrequencyTag.assemble(seconds))
fun TagArrayBuilder<RelayMonitorEvent>.checks(checks: List<CheckType>) = addAll(CheckTag.assemble(checks))
fun TagArrayBuilder<RelayMonitorEvent>.timeouts(timeouts: List<TimeoutTag>) = addAll(TimeoutTag.assemble(timeouts))
@@ -0,0 +1,32 @@
/*
* 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.nip66RelayMonitor.monitor
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
fun TagArray.frequency() = firstNotNullOfOrNull(FrequencyTag::parse)
fun TagArray.checks() = mapNotNull(CheckTag::parse)
fun TagArray.timeouts() = mapNotNull(TimeoutTag::parse)
@@ -0,0 +1,57 @@
/*
* 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.nip66RelayMonitor.monitor.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Types of checks a relay monitor can perform */
enum class CheckType(
val code: String,
) {
OPEN("open"),
READ("read"),
WRITE("write"),
AUTH("auth"),
NIP11("nip11"),
DNS("dns"),
GEO("geo"),
}
/** ["c", "<check-type>"] - a capability check conducted by this monitor */
class CheckTag {
companion object {
const val TAG_NAME = "c"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): CheckType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return CheckType.entries.find { it.code == tag[1] }
}
fun assemble(check: CheckType) = arrayOf(TAG_NAME, check.code)
fun assemble(checks: List<CheckType>) = checks.map { assemble(it) }
}
}
@@ -0,0 +1,42 @@
/*
* 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.nip66RelayMonitor.monitor.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** ["frequency", "<seconds>"] - how often this monitor publishes check results */
class FrequencyTag {
companion object {
const val TAG_NAME = "frequency"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(seconds: Long) = arrayOf(TAG_NAME, seconds.toString())
}
}
@@ -0,0 +1,73 @@
/*
* 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.nip66RelayMonitor.monitor.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Timeout tag for relay monitors.
*
* General form: ["timeout", "<milliseconds>"]
* Per-check form: ["timeout", "<check-type>", "<milliseconds>"]
*
* Where check-type is one of: open, read, write, nip11
*/
@Stable
class TimeoutTag(
val checkType: String?,
val milliseconds: Long,
) {
companion object {
const val TAG_NAME = "timeout"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): TimeoutTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return if (tag.has(2)) {
val ms = tag[2].toLongOrNull() ?: return null
TimeoutTag(checkType = tag[1], milliseconds = ms)
} else {
val ms = tag[1].toLongOrNull() ?: return null
TimeoutTag(checkType = null, milliseconds = ms)
}
}
fun assemble(milliseconds: Long) = arrayOf(TAG_NAME, milliseconds.toString())
fun assemble(
checkType: String,
milliseconds: Long,
) = arrayOf(TAG_NAME, checkType, milliseconds.toString())
fun assemble(timeout: TimeoutTag) =
if (timeout.checkType != null) {
assemble(timeout.checkType, timeout.milliseconds)
} else {
assemble(timeout.milliseconds)
}
fun assemble(timeouts: List<TimeoutTag>) = timeouts.map { assemble(it) }
}
}
@@ -121,6 +121,8 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -281,6 +283,8 @@ class EventFactory {
ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
ContactCardEvent.KIND -> ContactCardEvent(id, pubKey, createdAt, tags, content, sig)
RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig)
RelayDiscoveryEvent.KIND -> RelayDiscoveryEvent(id, pubKey, createdAt, tags, content, sig)
RelayMonitorEvent.KIND -> RelayMonitorEvent(id, pubKey, createdAt, tags, content, sig)
RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig)