mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
fix(relay-server): acknowledge a superseded replaceable with OK true duplicate:
Second finding from the Marmot headless harness on geode. wn's `keys publish` mints a KeyPackage (kind 30443, same `d` tag) in the same second as the one its bootstrap already published; NIP-01's lowest-id-wins tie keeps the stored one, and the insert of the loser trips the addressable unique index. The store classified that as a rejection carrying SQLite's text — "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey, event_headers.d_tag" — so the relay answered OK false with a reason no client can classify, and MDK filed it as "publish acknowledgement unknown" and retried forever. nostr-rs-relay, which this harness was validated against, does not even attempt the insert when a newer version exists and acknowledges the event as `OK true "duplicate: ..."` (its Duplicate status maps to true). Match that: the replaceable and addressable unique-index failures now classify as RejectionReason.SUPERSEDED, a `duplicate:`-prefixed reason the session already answers with OK true. The stored version is untouched, nothing is fanned out, and the STORE-W01/W02 contract in the event-store-semantics skill is updated to say so. Tests: NostrServerTest covers an older kind-0 re-insert and the same-second kind-30443 tie, asserting the OK true duplicate: reply and that the winner remains the only stored version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
This commit is contained in:
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
|
||||
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
|
||||
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
|
||||
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
|
||||
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
|
||||
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
|
||||
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
|
||||
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
|
||||
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
|
||||
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
|
||||
by lowest id; older re-inserts blocked but acknowledged as already covered.
|
||||
|
||||
**STORE-W02 — addressable supersession.** Same as W01 with unique index
|
||||
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
|
||||
|
||||
+10
@@ -49,6 +49,16 @@ object RejectionReason {
|
||||
|
||||
// The standard store reasons.
|
||||
const val DUPLICATE = "duplicate: already have this event"
|
||||
|
||||
/**
|
||||
* A replaceable or addressable event that a stored version already supersedes
|
||||
* (newer `created_at`, or the same `created_at` and a lower id). Nothing is
|
||||
* written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps
|
||||
* `duplicate:` as the machine-readable prefix for "already covered", and that
|
||||
* is what nostr-rs-relay sends here too, so clients that retry on anything
|
||||
* else (MDK's `wn`) settle instead of re-offering the same event forever.
|
||||
*/
|
||||
const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored"
|
||||
const val EXPIRED = "blocked: Cannot insert an expired event"
|
||||
const val DELETED = "blocked: a deletion event exists"
|
||||
const val VANISHED = "blocked: a request to vanish event exists"
|
||||
|
||||
+12
@@ -64,6 +64,12 @@ class SQLiteEventStore(
|
||||
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"
|
||||
|
||||
/**
|
||||
* Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and
|
||||
* `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns.
|
||||
*/
|
||||
private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey"
|
||||
const val DATABASE_VERSION = 5
|
||||
}
|
||||
|
||||
@@ -536,6 +542,12 @@ class SQLiteEventStore(
|
||||
if (message.contains(DUPLICATE_ID_CONSTRAINT)) {
|
||||
return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE)
|
||||
}
|
||||
// The replaceable / addressable unique indexes fire only when the supersession
|
||||
// trigger found nothing older to delete, i.e. the stored version already wins
|
||||
// (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`.
|
||||
if (message.contains(SUPERSEDED_CONSTRAINT)) {
|
||||
return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED)
|
||||
}
|
||||
val refusal =
|
||||
message.contains("blocked:") ||
|
||||
message.contains("duplicate:") ||
|
||||
|
||||
+64
@@ -149,6 +149,70 @@ class NostrServerTest {
|
||||
server.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE-W01: a replaceable event older than the stored version is not written,
|
||||
* and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the
|
||||
* NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a
|
||||
* rejection the client would keep retrying.
|
||||
*/
|
||||
@Test
|
||||
fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() =
|
||||
runTest {
|
||||
val dispatcher = UnconfinedTestDispatcher(testScheduler)
|
||||
val store = EventStore(null)
|
||||
val server = createServer(dispatcher, store)
|
||||
val collector = MessageCollector()
|
||||
val c1 = server.connect(collector.sendCallback)
|
||||
|
||||
val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L)
|
||||
val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L)
|
||||
c1.insert(newer)
|
||||
c1.insert(older)
|
||||
|
||||
val okMessages = collector.rawMessagesContaining("OK")
|
||||
assertEquals(2, okMessages.size)
|
||||
assertTrue(okMessages[0].contains(",true,"))
|
||||
assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}")
|
||||
assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix")
|
||||
|
||||
val stored = store.query<Event>(Filter(kinds = listOf(0)))
|
||||
assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one")
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE-W02 tie: two addressable events with the same `d` tag and the same
|
||||
* `created_at` — the lower id wins, the other is acknowledged as superseded.
|
||||
* This is the exact shape MDK's `wn keys publish` produces when it mints a
|
||||
* second KeyPackage within the same second as the first.
|
||||
*/
|
||||
@Test
|
||||
fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() =
|
||||
runTest {
|
||||
val dispatcher = UnconfinedTestDispatcher(testScheduler)
|
||||
val store = EventStore(null)
|
||||
val server = createServer(dispatcher, store)
|
||||
val collector = MessageCollector()
|
||||
val c1 = server.connect(collector.sendCallback)
|
||||
|
||||
val dTag = arrayOf(arrayOf("d", "kp"))
|
||||
val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag)
|
||||
val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag)
|
||||
c1.insert(lowerId)
|
||||
c1.insert(higherId)
|
||||
|
||||
val okMessages = collector.rawMessagesContaining("OK")
|
||||
assertEquals(2, okMessages.size)
|
||||
assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}")
|
||||
assertTrue(okMessages[1].contains("duplicate:"))
|
||||
|
||||
val stored = store.query<Event>(Filter(kinds = listOf(30443)))
|
||||
assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie")
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
// -- REQ command -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user