fix(relay-server): answer a duplicate EVENT with OK true, per NIP-01

Running the Marmot headless harness against the embedded geode relay
failed 10 of 29 scenarios, every one on the same reply: the relay
answered a resent EVENT with

  ["OK", <id>, false, "Error code: 2067, message: UNIQUE constraint
   failed: event_headers.id"]

NIP-01 says a relay that already holds the event answers
["OK", <id>, true, "duplicate: already have this event"], and every
client here depends on that: amethyst's outbox writes an event as soon
as the socket is ready and resends it when the connection finishes
syncing, so one of the two copies is always a duplicate; MDK's wn
counts a `duplicate:` prefix as idempotent success but files an
unclassified OK false as "publish acknowledgement unknown" and keeps
retrying. Both amy's group commits and wn's KeyPackage publish were
failing on it, while nostr-rs-relay had answered the resend correctly.

SQLiteEventStore now recognises the unique-index violation on
event_headers.id and reports RejectionReason.DUPLICATE, the constant
that already carried NIP-01's exact wording but was never produced;
RelaySession sends `OK true` for a `duplicate:` reason and keeps
`OK false` for every other rejection. The store outcome stays Rejected,
so a duplicate is still not fanned out to live subscriptions or counted
as a new write by the mirror worker and importer. The filesystem store
already treated a duplicate insert as a no-op.

Two tests pinned the old OK false behaviour (NostrServerTest,
KtorRelayTest) and now assert the NIP-01 reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
This commit is contained in:
Claude
2026-09-12 22:11:23 +00:00
parent 9e7ffe6854
commit 5b846d64d6
5 changed files with 32 additions and 6 deletions
@@ -311,14 +311,14 @@ class KtorRelayTest {
)
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
// Duplicate insert returns OK false; this also exercises the
// "non-empty message" branch of the serializer.
// Duplicate insert returns OK true with a `duplicate:` message (NIP-01);
// this also exercises the "non-empty message" branch of the serializer.
val ok2 =
client.publishAndConfirm(
event = event,
relayList = setOf(server.url.normalizeRelayUrl()),
)
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
assertEquals(true, ok2, "duplicate insert must round-trip OK true (NIP-01 duplicate:)")
}
/**
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip01Core.store.RejectionReason
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
@@ -212,7 +213,13 @@ class RelaySession(
}
is IEventStore.InsertOutcome.Rejected -> {
send(OkMessage(cmd.event.id, false, outcome.reason))
// NIP-01: an event the relay already holds is acknowledged with
// `OK true` and the `duplicate:` prefix. Every real client
// (amethyst's outbox included) resends an event whose OK has not
// landed yet, and treats OK false as a rejection to surface —
// so answering false here turns a routine resend into an error.
val duplicate = outcome.reason.startsWith(RejectionReason.PREFIX_DUPLICATE)
send(OkMessage(cmd.event.id, duplicate, outcome.reason))
}
is IEventStore.InsertOutcome.Failed -> {
@@ -44,6 +44,9 @@ object RejectionReason {
*/
const val PREFIX_REPLACED = "replaced:"
/** NIP-01 prefix for "already have this event" — answered with `OK true`, not false. */
const val PREFIX_DUPLICATE = "duplicate:"
// The standard store reasons.
const val DUPLICATE = "duplicate: already have this event"
const val EXPIRED = "blocked: Cannot insert an expired event"
@@ -62,6 +62,8 @@ class SQLiteEventStore(
val extraPragmas: List<String> = emptyList(),
) {
companion object {
/** SQLite's message for the unique index on `event_headers (id)`. */
private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id"
const val DATABASE_VERSION = 5
}
@@ -526,6 +528,14 @@ class SQLiteEventStore(
*/
private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome {
val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED
// A second copy of an event the store already holds trips the unique index on
// event_headers.id. That is not a refusal of the event but a statement that it
// is already here, and NIP-01 has a dedicated answer for it (`OK true` with the
// `duplicate:` prefix) — so name it, instead of leaking SQLite's constraint text
// for the session to turn into a rejection the client then retries or reports.
if (message.contains(DUPLICATE_ID_CONSTRAINT)) {
return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE)
}
val refusal =
message.contains("blocked:") ||
message.contains("duplicate:") ||
@@ -120,8 +120,13 @@ class NostrServerTest {
server.close()
}
/**
* NIP-01: `["OK", <id>, true, "duplicate: already have this event"]`. A client
* resends any event whose OK has not landed, so a duplicate must read as
* success — OK false would make every such resend look like a rejection.
*/
@Test
fun duplicateEventReturnsOkFalse() =
fun duplicateEventReturnsOkTrueWithDuplicatePrefix() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val store = EventStore(null)
@@ -138,7 +143,8 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",false,"))
assertTrue(okMessages[1].contains(",true,"))
assertTrue(okMessages[1].contains("duplicate:"))
server.close()
}