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
@@ -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
}