feat: add NIP-77 negentropy sync support
Add support for NIP-77 (Negentropy Syncing) protocol messages using the negentropy-kmp library for efficient set reconciliation between client and relay. New files: - NegOpenCmd, NegMsgCmd, NegCloseCmd: Client-to-relay commands - NegMsgMessage, NegErrMessage: Relay-to-client messages - NegentropySession: Client-side reconciliation orchestrator - NegentropyServerSession: Server-side reconciliation handler - NegentropyManager: High-level sync manager with listener callbacks Updated serializers: - Jackson MessageSerializer/Deserializer for NEG-MSG, NEG-ERR - Jackson CommandSerializer/Deserializer for NEG-OPEN, NEG-MSG, NEG-CLOSE - Kotlin MessageKSerializer/CommandKSerializer for all NIP-77 types Compatible with strfry relay's negentropy implementation via the negentropy-kmp library which implements the same Protocol V1 spec. https://claude.ai/code/session_01Dc6W1G1jURAAR9kzyVvk6g
This commit is contained in:
@@ -41,6 +41,7 @@ materialIconsExtended = "1.7.3"
|
||||
media3 = "1.9.3"
|
||||
mockk = "1.14.9"
|
||||
kotlinx-coroutines-test = "1.10.2"
|
||||
negentropyKmp = "1.0.1"
|
||||
netUrlencoderLibVersion = "1.6.0"
|
||||
navigationCompose = "2.9.7"
|
||||
okhttp = "5.3.2"
|
||||
@@ -156,6 +157,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||
negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" }
|
||||
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
||||
|
||||
@@ -121,6 +121,9 @@ kotlin {
|
||||
|
||||
// RFC3986 library(normalizes URLs)
|
||||
api(libs.uri.reference.kmp)
|
||||
|
||||
// Negentropy set reconciliation (NIP-77)
|
||||
api(libs.negentropy.kmp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -27,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
@@ -78,6 +81,21 @@ object CommandKSerializer : KSerializer<Command> {
|
||||
add(FilterKSerializer.serializeToElement(filter))
|
||||
}
|
||||
}
|
||||
|
||||
is NegOpenCmd -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
add(FilterKSerializer.serializeToElement(value.filter))
|
||||
add(JsonPrimitive(value.initialMessage))
|
||||
}
|
||||
|
||||
is NegMsgCmd -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
add(JsonPrimitive(value.message))
|
||||
}
|
||||
|
||||
is NegCloseCmd -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonEncoder.encodeJsonElement(element)
|
||||
@@ -119,6 +137,27 @@ object CommandKSerializer : KSerializer<Command> {
|
||||
AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent)
|
||||
}
|
||||
|
||||
NegOpenCmd.LABEL -> {
|
||||
NegOpenCmd(
|
||||
subId = array[1].jsonPrimitive.content,
|
||||
filter = FilterKSerializer.deserializeFromElement(array[2].jsonObject),
|
||||
initialMessage = array[3].jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
|
||||
NegMsgCmd.LABEL -> {
|
||||
NegMsgCmd(
|
||||
subId = array[1].jsonPrimitive.content,
|
||||
message = array[2].jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
|
||||
NegCloseCmd.LABEL -> {
|
||||
NegCloseCmd(
|
||||
subId = array[1].jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException("Message $type is not supported")
|
||||
}
|
||||
|
||||
+26
@@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
@@ -94,6 +96,16 @@ object MessageKSerializer : KSerializer<Message> {
|
||||
is EoseMessage -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
}
|
||||
|
||||
is NegMsgMessage -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
add(JsonPrimitive(value.message))
|
||||
}
|
||||
|
||||
is NegErrMessage -> {
|
||||
add(JsonPrimitive(value.subId))
|
||||
add(JsonPrimitive(value.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonEncoder.encodeJsonElement(element)
|
||||
@@ -148,6 +160,20 @@ object MessageKSerializer : KSerializer<Message> {
|
||||
CountMessage(queryId, result)
|
||||
}
|
||||
|
||||
NegMsgMessage.LABEL -> {
|
||||
NegMsgMessage(
|
||||
subId = array[1].jsonPrimitive.content,
|
||||
message = array[2].jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
|
||||
NegErrMessage.LABEL -> {
|
||||
NegErrMessage(
|
||||
subId = array[1].jsonPrimitive.content,
|
||||
reason = if (array.size > 2) array[2].jsonPrimitive.content else "",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException("Message $type is not supported")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
|
||||
class NegCloseCmd(
|
||||
val subId: String,
|
||||
) : Command {
|
||||
override fun label(): String = LABEL
|
||||
|
||||
override fun isValid() = subId.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
const val LABEL = "NEG-CLOSE"
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
|
||||
class NegErrMessage(
|
||||
val subId: String,
|
||||
val reason: String,
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
companion object {
|
||||
const val LABEL = "NEG-ERR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
|
||||
class NegMsgCmd(
|
||||
val subId: String,
|
||||
val message: String,
|
||||
) : Command {
|
||||
override fun label(): String = LABEL
|
||||
|
||||
override fun isValid() = subId.isNotEmpty() && message.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
const val LABEL = "NEG-MSG"
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
|
||||
class NegMsgMessage(
|
||||
val subId: String,
|
||||
val message: String,
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
companion object {
|
||||
const val LABEL = "NEG-MSG"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
class NegOpenCmd(
|
||||
val subId: String,
|
||||
val filter: Filter,
|
||||
val initialMessage: String,
|
||||
) : Command {
|
||||
override fun label(): String = LABEL
|
||||
|
||||
override fun isValid() = subId.isNotEmpty() && initialMessage.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
const val LABEL = "NEG-OPEN"
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Callback interface for negentropy reconciliation results.
|
||||
*/
|
||||
interface INegentropyListener {
|
||||
fun onHaveIds(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
haveIds: List<String>,
|
||||
)
|
||||
|
||||
fun onNeedIds(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
needIds: List<String>,
|
||||
)
|
||||
|
||||
fun onComplete(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
)
|
||||
|
||||
fun onError(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
reason: String,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages NIP-77 negentropy sync sessions across multiple relays.
|
||||
*
|
||||
* This class acts as a [IRelayClientListener] that intercepts NEG-MSG and NEG-ERR
|
||||
* messages and drives the reconciliation protocol. It maintains active sessions
|
||||
* per relay and subscription ID.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Register this as a listener on the NostrClient
|
||||
* 2. Call [startSync] with a filter and local events to begin reconciliation
|
||||
* 3. Receive results via [INegentropyListener] callbacks
|
||||
*/
|
||||
class NegentropyManager(
|
||||
private val listener: INegentropyListener,
|
||||
) : IRelayClientListener {
|
||||
private val activeSessions = mutableMapOf<String, Pair<NormalizedRelayUrl, NegentropySession>>()
|
||||
|
||||
fun startSync(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
filter: Filter,
|
||||
localEvents: List<Event>,
|
||||
frameSizeLimit: Long = 0,
|
||||
) {
|
||||
val session = NegentropySession(subId, filter, localEvents, frameSizeLimit)
|
||||
activeSessions[subId] = Pair(relay.url, session)
|
||||
|
||||
val openCmd = session.open()
|
||||
relay.sendOrConnectAndSync(openCmd)
|
||||
}
|
||||
|
||||
fun closeSync(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
) {
|
||||
val (_, session) = activeSessions.remove(subId) ?: return
|
||||
relay.sendIfConnected(session.close())
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is NegMsgMessage -> handleNegMsg(relay, msg)
|
||||
is NegErrMessage -> handleNegErr(relay, msg)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNegMsg(
|
||||
relay: IRelayClient,
|
||||
msg: NegMsgMessage,
|
||||
) {
|
||||
val (relayUrl, session) = activeSessions[msg.subId] ?: return
|
||||
|
||||
val result = session.processMessage(msg.message)
|
||||
|
||||
if (result.haveIds.isNotEmpty()) {
|
||||
listener.onHaveIds(relayUrl, msg.subId, result.haveIds)
|
||||
}
|
||||
if (result.needIds.isNotEmpty()) {
|
||||
listener.onNeedIds(relayUrl, msg.subId, result.needIds)
|
||||
}
|
||||
|
||||
if (result.nextCmd != null) {
|
||||
relay.sendIfConnected(result.nextCmd)
|
||||
} else {
|
||||
activeSessions.remove(msg.subId)
|
||||
relay.sendIfConnected(session.close())
|
||||
listener.onComplete(relayUrl, msg.subId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNegErr(
|
||||
relay: IRelayClient,
|
||||
msg: NegErrMessage,
|
||||
) {
|
||||
val (relayUrl, _) = activeSessions.remove(msg.subId) ?: return
|
||||
listener.onError(relayUrl, msg.subId, msg.reason)
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
val toRemove = activeSessions.filter { it.value.first == relay.url }
|
||||
toRemove.forEach { (subId, pair) ->
|
||||
activeSessions.remove(subId)
|
||||
listener.onError(pair.first, subId, "closed: relay disconnected")
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.negentropy.Negentropy
|
||||
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* Server-side handler for NIP-77 negentropy reconciliation.
|
||||
*
|
||||
* Used when acting as a relay (or relay-relay sync) to respond to
|
||||
* incoming NEG-OPEN and NEG-MSG from a client.
|
||||
*
|
||||
* Usage:
|
||||
* 1. On NEG-OPEN: create a [NegentropyServerSession] with the matching local events
|
||||
* 2. Call [processMessage] with the initial hex message from NEG-OPEN
|
||||
* 3. Send back the resulting [NegMsgMessage]
|
||||
* 4. On subsequent NEG-MSG: call [processMessage] again and send the response
|
||||
*/
|
||||
class NegentropyServerSession(
|
||||
val subId: String,
|
||||
localEvents: List<Event>,
|
||||
frameSizeLimit: Long = 0,
|
||||
) {
|
||||
private val storage = StorageVector()
|
||||
private val negentropy: Negentropy
|
||||
|
||||
init {
|
||||
for (event in localEvents) {
|
||||
storage.insert(event.createdAt, event.id)
|
||||
}
|
||||
storage.seal()
|
||||
negentropy = Negentropy(storage, frameSizeLimit)
|
||||
}
|
||||
|
||||
fun processMessage(hexMessage: String): NegMsgMessage? {
|
||||
val msgBytes = Hex.decode(hexMessage)
|
||||
val result = negentropy.reconcile(msgBytes)
|
||||
return if (result.msg != null) {
|
||||
NegMsgMessage(
|
||||
subId = subId,
|
||||
message = Hex.encode(result.msg!!),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.negentropy.Negentropy
|
||||
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* Manages a single NIP-77 negentropy reconciliation session with a relay.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Create a NegentropySession with local events and a filter
|
||||
* 2. Call [open] to get the NEG-OPEN command
|
||||
* 3. Feed relay NEG-MSG responses via [processMessage]
|
||||
* 4. If [processMessage] returns a non-null [NegMsgCmd], send it back
|
||||
* 5. Repeat until [processMessage] returns a result with a null command (reconciliation complete)
|
||||
* 6. Use [haveIds] and [needIds] from the result to send/request events
|
||||
* 7. Call [close] to produce a NEG-CLOSE command when done
|
||||
*/
|
||||
class NegentropySession(
|
||||
val subId: String,
|
||||
val filter: Filter,
|
||||
localEvents: List<Event>,
|
||||
frameSizeLimit: Long = 0,
|
||||
) {
|
||||
private val storage = StorageVector()
|
||||
private val negentropy: Negentropy
|
||||
|
||||
init {
|
||||
for (event in localEvents) {
|
||||
storage.insert(event.createdAt, event.id)
|
||||
}
|
||||
storage.seal()
|
||||
negentropy = Negentropy(storage, frameSizeLimit)
|
||||
}
|
||||
|
||||
fun open(): NegOpenCmd {
|
||||
val initialMessage = negentropy.initiate()
|
||||
return NegOpenCmd(
|
||||
subId = subId,
|
||||
filter = filter,
|
||||
initialMessage = Hex.encode(initialMessage),
|
||||
)
|
||||
}
|
||||
|
||||
fun processMessage(hexMessage: String): ReconcileResult {
|
||||
val msgBytes = Hex.decode(hexMessage)
|
||||
val result = negentropy.reconcile(msgBytes)
|
||||
|
||||
val haveIds = result.sendIds.map { it.toHexString() }
|
||||
val needIds = result.needIds.map { it.toHexString() }
|
||||
|
||||
val nextCmd =
|
||||
if (result.msg != null) {
|
||||
NegMsgCmd(
|
||||
subId = subId,
|
||||
message = Hex.encode(result.msg!!),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return ReconcileResult(
|
||||
nextCmd = nextCmd,
|
||||
haveIds = haveIds,
|
||||
needIds = needIds,
|
||||
)
|
||||
}
|
||||
|
||||
fun close(): NegCloseCmd = NegCloseCmd(subId)
|
||||
}
|
||||
|
||||
class ReconcileResult(
|
||||
val nextCmd: NegMsgCmd?,
|
||||
val haveIds: List<String>,
|
||||
val needIds: List<String>,
|
||||
) {
|
||||
fun isComplete() = nextCmd == null
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.negentropy.Negentropy
|
||||
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NegentropySessionTest {
|
||||
private fun makeEvent(
|
||||
id: String,
|
||||
createdAt: Long,
|
||||
): Event =
|
||||
Event(
|
||||
id = id,
|
||||
pubKey = "a".repeat(64),
|
||||
createdAt = createdAt,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "b".repeat(64),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun clientServerSync_identicalSets_noIdsExchanged() {
|
||||
val events =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
makeEvent("b".repeat(64), 2000),
|
||||
makeEvent("c".repeat(64), 3000),
|
||||
)
|
||||
|
||||
val clientSession = NegentropySession("sub1", Filter(), events)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
assertEquals("NEG-OPEN", openCmd.label())
|
||||
assertEquals("sub1", openCmd.subId)
|
||||
assertTrue(openCmd.initialMessage.isNotEmpty())
|
||||
|
||||
// Server side: same events
|
||||
val serverStorage = StorageVector()
|
||||
for (e in events) {
|
||||
serverStorage.insert(e.createdAt, e.id)
|
||||
}
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
// Identical sets: no haves or needs
|
||||
assertEquals(0, result.haveIds.size)
|
||||
assertEquals(0, result.needIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientServerSync_clientHasExtra_detectsHaveIds() {
|
||||
val sharedEvents =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
makeEvent("b".repeat(64), 2000),
|
||||
)
|
||||
|
||||
val clientOnlyEvent = makeEvent("c".repeat(64), 3000)
|
||||
val clientEvents = sharedEvents + clientOnlyEvent
|
||||
|
||||
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server side: only shared events
|
||||
val serverStorage = StorageVector()
|
||||
for (e in sharedEvents) {
|
||||
serverStorage.insert(e.createdAt, e.id)
|
||||
}
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
// Client has extra event that server needs
|
||||
assertTrue(result.haveIds.contains("c".repeat(64)))
|
||||
assertEquals(0, result.needIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientServerSync_serverHasExtra_detectsNeedIds() {
|
||||
val sharedEvents =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
makeEvent("b".repeat(64), 2000),
|
||||
)
|
||||
|
||||
val clientSession = NegentropySession("sub1", Filter(), sharedEvents)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server side: shared + extra
|
||||
val serverStorage = StorageVector()
|
||||
for (e in sharedEvents) {
|
||||
serverStorage.insert(e.createdAt, e.id)
|
||||
}
|
||||
serverStorage.insert(3000, "d".repeat(64))
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
// Server has an event client needs
|
||||
assertTrue(result.needIds.contains("d".repeat(64)))
|
||||
assertEquals(0, result.haveIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientServerSync_bothHaveUnique_detectsBothDirections() {
|
||||
val sharedEvents =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
)
|
||||
|
||||
val clientOnly = makeEvent("b".repeat(64), 2000)
|
||||
val clientEvents = sharedEvents + clientOnly
|
||||
|
||||
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server side: shared + different extra
|
||||
val serverStorage = StorageVector()
|
||||
for (e in sharedEvents) {
|
||||
serverStorage.insert(e.createdAt, e.id)
|
||||
}
|
||||
serverStorage.insert(3000, "c".repeat(64))
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
assertTrue(result.haveIds.contains("b".repeat(64)))
|
||||
assertTrue(result.needIds.contains("c".repeat(64)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientServerSync_emptyClient_needsAll() {
|
||||
val clientSession = NegentropySession("sub1", Filter(), emptyList())
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server has events
|
||||
val serverStorage = StorageVector()
|
||||
serverStorage.insert(1000, "a".repeat(64))
|
||||
serverStorage.insert(2000, "b".repeat(64))
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
assertEquals(0, result.haveIds.size)
|
||||
assertEquals(2, result.needIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientServerSync_emptyServer_hasAll() {
|
||||
val clientEvents =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
makeEvent("b".repeat(64), 2000),
|
||||
)
|
||||
|
||||
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server is empty
|
||||
val serverStorage = StorageVector()
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage)
|
||||
|
||||
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverResponse.msg)
|
||||
|
||||
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||
|
||||
assertEquals(2, result.haveIds.size)
|
||||
assertEquals(0, result.needIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiRoundSync_withFrameLimit() {
|
||||
// Create enough events to require multiple rounds with a small frame limit
|
||||
val clientEvents =
|
||||
(1..500).map { i ->
|
||||
makeEvent(
|
||||
i.toString().padStart(64, '0'),
|
||||
i.toLong() * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
val serverEvents =
|
||||
(251..750).map { i ->
|
||||
Pair(i.toLong() * 1000, i.toString().padStart(64, '0'))
|
||||
}
|
||||
|
||||
// Small frame limit to force multiple rounds
|
||||
val clientSession = NegentropySession("sub1", Filter(), clientEvents, frameSizeLimit = 4096)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
val serverStorage = StorageVector()
|
||||
for ((ts, id) in serverEvents) {
|
||||
serverStorage.insert(ts, id)
|
||||
}
|
||||
serverStorage.seal()
|
||||
val serverNeg = Negentropy(serverStorage, 4096)
|
||||
|
||||
var serverMsg = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||
assertNotNull(serverMsg.msg)
|
||||
|
||||
val allHaveIds = mutableListOf<String>()
|
||||
val allNeedIds = mutableListOf<String>()
|
||||
|
||||
var result = clientSession.processMessage(Hex.encode(serverMsg.msg!!))
|
||||
allHaveIds.addAll(result.haveIds)
|
||||
allNeedIds.addAll(result.needIds)
|
||||
|
||||
var rounds = 1
|
||||
while (!result.isComplete()) {
|
||||
rounds++
|
||||
serverMsg = serverNeg.reconcile(Hex.decode(result.nextCmd!!.message))
|
||||
assertNotNull(serverMsg.msg)
|
||||
result = clientSession.processMessage(Hex.encode(serverMsg.msg!!))
|
||||
allHaveIds.addAll(result.haveIds)
|
||||
allNeedIds.addAll(result.needIds)
|
||||
}
|
||||
|
||||
// Client has 1-250 exclusively, server has 501-750 exclusively
|
||||
assertEquals(250, allHaveIds.size)
|
||||
assertEquals(250, allNeedIds.size)
|
||||
assertTrue(rounds > 1, "Should require multiple rounds with frame limit")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverSession_processesMessages() {
|
||||
val sharedEvents =
|
||||
listOf(
|
||||
makeEvent("a".repeat(64), 1000),
|
||||
)
|
||||
val serverOnlyEvent = makeEvent("b".repeat(64), 2000)
|
||||
val serverEvents = sharedEvents + serverOnlyEvent
|
||||
|
||||
// Client initiates
|
||||
val clientSession = NegentropySession("sub1", Filter(), sharedEvents)
|
||||
val openCmd = clientSession.open()
|
||||
|
||||
// Server processes via NegentropyServerSession
|
||||
val serverSession = NegentropyServerSession("sub1", serverEvents)
|
||||
val response = serverSession.processMessage(openCmd.initialMessage)
|
||||
|
||||
assertNotNull(response)
|
||||
assertEquals("NEG-MSG", response.label())
|
||||
assertEquals("sub1", response.subId)
|
||||
assertTrue(response.message.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun close_returnsCorrectCmd() {
|
||||
val session = NegentropySession("sub1", Filter(), emptyList())
|
||||
val closeCmd = session.close()
|
||||
|
||||
assertEquals("NEG-CLOSE", closeCmd.label())
|
||||
assertEquals("sub1", closeCmd.subId)
|
||||
assertTrue(closeCmd.isValid())
|
||||
}
|
||||
}
|
||||
+16
@@ -26,6 +26,8 @@ import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||
|
||||
class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
|
||||
val eventDeserializer = EventDeserializer()
|
||||
@@ -101,6 +103,20 @@ class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
|
||||
)
|
||||
}
|
||||
|
||||
NegMsgMessage.LABEL -> {
|
||||
NegMsgMessage(
|
||||
subId = jp.nextTextValue(),
|
||||
message = jp.nextTextValue(),
|
||||
)
|
||||
}
|
||||
|
||||
NegErrMessage.LABEL -> {
|
||||
NegErrMessage(
|
||||
subId = jp.nextTextValue(),
|
||||
reason = jp.nextTextValue() ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException("Message $type is not supported")
|
||||
}
|
||||
|
||||
+12
@@ -24,6 +24,8 @@ import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||
|
||||
class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
||||
val eventSerializer = EventSerializer()
|
||||
@@ -75,6 +77,16 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
||||
is EoseMessage -> {
|
||||
gen.writeString(msg.subId)
|
||||
}
|
||||
|
||||
is NegMsgMessage -> {
|
||||
gen.writeString(msg.subId)
|
||||
gen.writeString(msg.message)
|
||||
}
|
||||
|
||||
is NegErrMessage -> {
|
||||
gen.writeString(msg.subId)
|
||||
gen.writeString(msg.reason)
|
||||
}
|
||||
}
|
||||
|
||||
gen.writeEndArray()
|
||||
|
||||
+30
@@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
|
||||
class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
val eventDeserializer = EventDeserializer()
|
||||
@@ -97,6 +100,33 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
)
|
||||
}
|
||||
|
||||
NegOpenCmd.LABEL -> {
|
||||
val subId = jp.nextTextValue()
|
||||
jp.nextToken()
|
||||
val filterObj: ObjectNode = jp.codec.readTree(jp)
|
||||
val filter = ManualFilterDeserializer.fromJson(filterObj)
|
||||
val initialMessage = jp.nextTextValue()
|
||||
|
||||
NegOpenCmd(
|
||||
subId = subId,
|
||||
filter = filter,
|
||||
initialMessage = initialMessage,
|
||||
)
|
||||
}
|
||||
|
||||
NegMsgCmd.LABEL -> {
|
||||
NegMsgCmd(
|
||||
subId = jp.nextTextValue(),
|
||||
message = jp.nextTextValue(),
|
||||
)
|
||||
}
|
||||
|
||||
NegCloseCmd.LABEL -> {
|
||||
NegCloseCmd(
|
||||
subId = jp.nextTextValue(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException("Message $type is not supported")
|
||||
}
|
||||
|
||||
+18
@@ -25,6 +25,9 @@ import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
|
||||
class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
||||
val eventSerializer = EventSerializer()
|
||||
@@ -65,6 +68,21 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
||||
}
|
||||
}
|
||||
|
||||
is NegOpenCmd -> {
|
||||
gen.writeString(cmd.subId)
|
||||
filterSerializer.serialize(cmd.filter, gen, provider)
|
||||
gen.writeString(cmd.initialMessage)
|
||||
}
|
||||
|
||||
is NegMsgCmd -> {
|
||||
gen.writeString(cmd.subId)
|
||||
gen.writeString(cmd.message)
|
||||
}
|
||||
|
||||
is NegCloseCmd -> {
|
||||
gen.writeString(cmd.subId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* 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.nip77Negentropy
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Nip77SerializationTest {
|
||||
// =========================================================================
|
||||
// NEG-MSG Message (relay-to-client) Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun serializeNegMsgMessage_matchesJackson() {
|
||||
val msg = NegMsgMessage("neg-sub1", "abcdef0123456789")
|
||||
val jacksonJson = JacksonMapper.toJson(msg)
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||
assertEquals(jacksonJson, kotlinJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegMsgMessage_jackson() {
|
||||
val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]"""
|
||||
val deserialized = JacksonMapper.fromJsonToMessage(json)
|
||||
|
||||
assertTrue(deserialized is NegMsgMessage)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("abcdef0123456789", deserialized.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegMsgMessage_kotlin() {
|
||||
val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]"""
|
||||
val deserialized = KotlinSerializationMapper.fromJsonToMessage(json)
|
||||
|
||||
assertTrue(deserialized is NegMsgMessage)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("abcdef0123456789", deserialized.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negMsgMessage_crossDeserialization() {
|
||||
val msg = NegMsgMessage("neg-sub1", "abcdef0123456789")
|
||||
|
||||
val jacksonJson = JacksonMapper.toJson(msg)
|
||||
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson)
|
||||
assertTrue(kotlinDeserialized is NegMsgMessage)
|
||||
assertEquals(msg.subId, kotlinDeserialized.subId)
|
||||
assertEquals(msg.message, kotlinDeserialized.message)
|
||||
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||
val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson)
|
||||
assertTrue(jacksonDeserialized is NegMsgMessage)
|
||||
assertEquals(msg.subId, (jacksonDeserialized as NegMsgMessage).subId)
|
||||
assertEquals(msg.message, jacksonDeserialized.message)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NEG-ERR Message (relay-to-client) Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun serializeNegErrMessage_matchesJackson() {
|
||||
val msg = NegErrMessage("neg-sub1", "blocked: query too large")
|
||||
val jacksonJson = JacksonMapper.toJson(msg)
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||
assertEquals(jacksonJson, kotlinJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegErrMessage_jackson() {
|
||||
val json = """["NEG-ERR","neg-sub1","blocked: query too large"]"""
|
||||
val deserialized = JacksonMapper.fromJsonToMessage(json)
|
||||
|
||||
assertTrue(deserialized is NegErrMessage)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("blocked: query too large", deserialized.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegErrMessage_kotlin() {
|
||||
val json = """["NEG-ERR","neg-sub1","closed: timeout"]"""
|
||||
val deserialized = KotlinSerializationMapper.fromJsonToMessage(json)
|
||||
|
||||
assertTrue(deserialized is NegErrMessage)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("closed: timeout", deserialized.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negErrMessage_crossDeserialization() {
|
||||
val msg = NegErrMessage("neg-sub1", "blocked: too many records")
|
||||
|
||||
val jacksonJson = JacksonMapper.toJson(msg)
|
||||
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson)
|
||||
assertTrue(kotlinDeserialized is NegErrMessage)
|
||||
assertEquals(msg.reason, (kotlinDeserialized as NegErrMessage).reason)
|
||||
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||
val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson)
|
||||
assertTrue(jacksonDeserialized is NegErrMessage)
|
||||
assertEquals(msg.reason, (jacksonDeserialized as NegErrMessage).reason)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NEG-OPEN Command (client-to-relay) Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun serializeNegOpenCmd_matchesJackson() {
|
||||
val filter = Filter(kinds = listOf(1), since = 1000L)
|
||||
val cmd = NegOpenCmd("neg-sub1", filter, "61abcdef")
|
||||
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||
assertEquals(jacksonJson, kotlinJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegOpenCmd_jackson() {
|
||||
val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]"""
|
||||
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegOpenCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals(listOf(1), deserialized.filter.kinds)
|
||||
assertEquals(1000L, deserialized.filter.since)
|
||||
assertEquals("61abcdef", deserialized.initialMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegOpenCmd_kotlin() {
|
||||
val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]"""
|
||||
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegOpenCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals(listOf(1), deserialized.filter.kinds)
|
||||
assertEquals("61abcdef", deserialized.initialMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negOpenCmd_crossDeserialization() {
|
||||
val filter = Filter(kinds = listOf(1, 7), limit = 100)
|
||||
val cmd = NegOpenCmd("neg-sub1", filter, "61aabbccdd")
|
||||
|
||||
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson)
|
||||
assertTrue(kotlinDeserialized is NegOpenCmd)
|
||||
assertEquals(cmd.subId, kotlinDeserialized.subId)
|
||||
assertEquals(cmd.initialMessage, kotlinDeserialized.initialMessage)
|
||||
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||
val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson)
|
||||
assertTrue(jacksonDeserialized is NegOpenCmd)
|
||||
assertEquals(cmd.subId, (jacksonDeserialized as NegOpenCmd).subId)
|
||||
assertEquals(cmd.initialMessage, jacksonDeserialized.initialMessage)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NEG-MSG Command (client-to-relay) Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun serializeNegMsgCmd_matchesJackson() {
|
||||
val cmd = NegMsgCmd("neg-sub1", "aabbccdd")
|
||||
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||
assertEquals(jacksonJson, kotlinJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegMsgCmd_jackson() {
|
||||
val json = """["NEG-MSG","neg-sub1","aabbccdd"]"""
|
||||
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegMsgCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("aabbccdd", deserialized.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegMsgCmd_kotlin() {
|
||||
val json = """["NEG-MSG","neg-sub1","aabbccdd"]"""
|
||||
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegMsgCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
assertEquals("aabbccdd", deserialized.message)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NEG-CLOSE Command (client-to-relay) Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun serializeNegCloseCmd_matchesJackson() {
|
||||
val cmd = NegCloseCmd("neg-sub1")
|
||||
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||
assertEquals(jacksonJson, kotlinJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegCloseCmd_jackson() {
|
||||
val json = """["NEG-CLOSE","neg-sub1"]"""
|
||||
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegCloseCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeNegCloseCmd_kotlin() {
|
||||
val json = """["NEG-CLOSE","neg-sub1"]"""
|
||||
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||
|
||||
assertTrue(deserialized is NegCloseCmd)
|
||||
assertEquals("neg-sub1", deserialized.subId)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Validation Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
fun negOpenCmd_isValid() {
|
||||
val valid = NegOpenCmd("sub1", Filter(), "61abcd")
|
||||
assertTrue(valid.isValid())
|
||||
|
||||
val invalidSubId = NegOpenCmd("", Filter(), "61abcd")
|
||||
assertTrue(!invalidSubId.isValid())
|
||||
|
||||
val invalidMsg = NegOpenCmd("sub1", Filter(), "")
|
||||
assertTrue(!invalidMsg.isValid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negMsgCmd_isValid() {
|
||||
val valid = NegMsgCmd("sub1", "abcd")
|
||||
assertTrue(valid.isValid())
|
||||
|
||||
val invalidSubId = NegMsgCmd("", "abcd")
|
||||
assertTrue(!invalidSubId.isValid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negCloseCmd_isValid() {
|
||||
val valid = NegCloseCmd("sub1")
|
||||
assertTrue(valid.isValid())
|
||||
|
||||
val invalid = NegCloseCmd("")
|
||||
assertTrue(!invalid.isValid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negMsgMessage_label() {
|
||||
assertEquals("NEG-MSG", NegMsgMessage("sub1", "msg").label())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negErrMessage_label() {
|
||||
assertEquals("NEG-ERR", NegErrMessage("sub1", "error").label())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user