diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index b79f81bf51..ea1a1352bd 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -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:)") } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 9520dd9848..33a01e0e92 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -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 -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt index 4e14cb2550..90e416f230 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 1b3643543c..080d26f4c4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -62,6 +62,8 @@ class SQLiteEventStore( val extraPragmas: List = 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:") || diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 7171a9e4a0..39d27b15fc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -120,8 +120,13 @@ class NostrServerTest { server.close() } + /** + * NIP-01: `["OK", , 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() }