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:
Claude
2026-03-26 04:23:32 +00:00
parent 46b75abc81
commit 70baf55e1c
18 changed files with 1230 additions and 0 deletions
+2
View File
@@ -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" }
+3
View File
@@ -121,6 +121,9 @@ kotlin {
// RFC3986 library(normalizes URLs)
api(libs.uri.reference.kmp)
// Negentropy set reconciliation (NIP-77)
api(libs.negentropy.kmp)
}
}
@@ -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")
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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")
}
}
}
@@ -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
}
}
}
@@ -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
}
@@ -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())
}
}
@@ -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")
}
@@ -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()
@@ -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")
}
@@ -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
}
@@ -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())
}
}