From 70baf55e1c812b93ab6ce8388fd1852d1beb186e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:23:32 +0000 Subject: [PATCH] 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 --- gradle/libs.versions.toml | 2 + quartz/build.gradle.kts | 3 + .../kotlinSerialization/CommandKSerializer.kt | 39 +++ .../kotlinSerialization/MessageKSerializer.kt | 26 ++ .../quartz/nip77Negentropy/NegCloseCmd.kt | 35 ++ .../quartz/nip77Negentropy/NegErrMessage.kt | 34 ++ .../quartz/nip77Negentropy/NegMsgCmd.kt | 36 +++ .../quartz/nip77Negentropy/NegMsgMessage.kt | 34 ++ .../quartz/nip77Negentropy/NegOpenCmd.kt | 38 +++ .../nip77Negentropy/NegentropyManager.kt | 147 +++++++++ .../NegentropyServerSession.kt | 68 ++++ .../quartz/nip77Negentropy/NegentropySync.kt | 100 ++++++ .../nip77Negentropy/NegentropySessionTest.kt | 306 ++++++++++++++++++ .../commands/toClient/MessageDeserializer.kt | 16 + .../commands/toClient/MessageSerializer.kt | 12 + .../commands/toRelay/CommandDeserializer.kt | 30 ++ .../commands/toRelay/CommandSerializer.kt | 18 ++ .../nip77Negentropy/Nip77SerializationTest.kt | 286 ++++++++++++++++ 18 files changed, 1230 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b82ca191e..52432d073 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 5b49c924f..d046fbeac 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -121,6 +121,9 @@ kotlin { // RFC3986 library(normalizes URLs) api(libs.uri.reference.kmp) + + // Negentropy set reconciliation (NIP-77) + api(libs.negentropy.kmp) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 4d3eba299..34d245752 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -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 { 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 { 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") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 34ee1b491..4edf4ee4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -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 { 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 { 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") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt new file mode 100644 index 000000000..1fe05efe9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt @@ -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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt new file mode 100644 index 000000000..a81e1ded1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt @@ -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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt new file mode 100644 index 000000000..d8eedd15d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt @@ -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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt new file mode 100644 index 000000000..dd97b5247 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt @@ -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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt new file mode 100644 index 000000000..7093e17ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt @@ -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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt new file mode 100644 index 000000000..7401a63be --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt @@ -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, + ) + + fun onNeedIds( + relay: NormalizedRelayUrl, + subId: String, + needIds: List, + ) + + 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>() + + fun startSync( + relay: IRelayClient, + subId: String, + filter: Filter, + localEvents: List, + 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") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt new file mode 100644 index 000000000..4fe221d00 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt @@ -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, + 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 + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt new file mode 100644 index 000000000..1cde06cdf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt @@ -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, + 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, + val needIds: List, +) { + fun isComplete() = nextCmd == null +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt new file mode 100644 index 000000000..655956239 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt @@ -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() + val allNeedIds = mutableListOf() + + 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()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index f3ab111d7..09e31365e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -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::class.java) { val eventDeserializer = EventDeserializer() @@ -101,6 +103,20 @@ class MessageDeserializer : StdDeserializer(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") } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 345b9d9c4..b0ca6557a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -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::class.java) { val eventSerializer = EventSerializer() @@ -75,6 +77,16 @@ class MessageSerializer : StdSerializer(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() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index f8265d069..3b5a8d622 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -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::class.java) { val eventDeserializer = EventDeserializer() @@ -97,6 +100,33 @@ class CommandDeserializer : StdDeserializer(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") } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index bacbb8bf1..97e57f072 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -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::class.java) { val eventSerializer = EventSerializer() @@ -65,6 +68,21 @@ class CommandSerializer : StdSerializer(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 } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt new file mode 100644 index 000000000..48efb19ad --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt @@ -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()) + } +}