fix(marmot): return newest KeyPackage by created_at

KeyPackageFetcher.fetchKeyPackage used client.fetchFirst, which closes
the subscription on the first event a relay sends. For kind:443
(KeyPackage) that's wrong after a rotation: the publisher does not
replace the prior event (kind:443 is not addressable), so relays may
still hold the old KP and whichever one wins the race gets returned.

Switch to fetchAll — drain every matching event until EOSE, then pick
the one with the highest created_at. Keeps freshly-rotated bundles
reachable and matches whitenoise/mdk semantics.

Unskips interop test 16 (wn rotates, amy discovers). The inverse path
(test 13) already worked because `wn keys check` looks up via the
addressable kind:10051 index instead of kind:443 directly.

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
This commit is contained in:
Claude
2026-04-23 21:24:57 +00:00
parent 310cae0a64
commit c1a818c2e2
2 changed files with 48 additions and 15 deletions
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.MarmotFilters import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** /**
@@ -60,8 +60,16 @@ object KeyPackageFetcher {
} }
/** /**
* One-shot fetch of a user's KeyPackage across [relays], returning the first * Drain every kind:443 KeyPackage event for [targetPubKey] across [relays]
* matching event or `null` if none of the relays yielded one before timeout. * and return the most recently published one (highest `created_at`), or
* `null` if nothing arrived before the timeout.
*
* Returning the newest is important after a KeyPackage rotation: MIP-00
* does not use addressable replacement for kind:443, so a relay may still
* hold the prior KP alongside the new one. `fetchFirst` would race the
* relays and pick whichever replied first, which would frequently be the
* older event. Draining to EOSE and selecting by `created_at` matches
* MDK/whitenoise semantics and keeps freshly-rotated bundles reachable.
*/ */
suspend fun fetchKeyPackage( suspend fun fetchKeyPackage(
client: INostrClient, client: INostrClient,
@@ -71,8 +79,10 @@ object KeyPackageFetcher {
): KeyPackageEvent? { ): KeyPackageEvent? {
if (relays.isEmpty()) return null if (relays.isEmpty()) return null
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey) val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs) val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
return event as? KeyPackageEvent // fetchAll returns events sorted by created_at DESC, so the first
// KeyPackageEvent is the most recent one any relay had.
return events.firstNotNullOfOrNull { it as? KeyPackageEvent }
} }
/** /**
+33 -10
View File
@@ -238,14 +238,37 @@ test_16_wn_keypackage_rotation() {
banner "Test 16 — wn rotates KeyPackage; amy discovers new KP" banner "Test 16 — wn rotates KeyPackage; amy discovers new KP"
local id="16 wn keypackage rotation" local id="16 wn keypackage rotation"
# amy's KeyPackageFetcher.fetchKeyPackage calls client.fetchFirst, # KeyPackageFetcher now drains to EOSE and returns the event with the
# which returns the first matching event a relay sends — nostr-rs-relay # highest created_at, so a freshly-rotated KP is reliably preferred
# typically serves kind:443 events in storage order, not created_at # over an older one still held on some relays. This test verifies
# order, so after a rotation amy may keep seeing the older event_id # the wn->amy direction of that behaviour.
# depending on which arrives first. Making this test deterministic
# requires a "fetch latest by created_at" KeyPackage fetcher; until # Capture the KP amy currently sees for B (the one B originally published).
# then the check flaps. The inverse direction (test 13, amy rotates local before
# and wn sees via `wn keys check` which is an addressable index) is before=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \
# the reliable one. | jq -r '.event_id // empty')
record_result "$id" skip "pending createdAt-sorted KeyPackage fetch path" if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP visible to amy for B"; return
fi
# Ask B to rotate. `wn keys publish` writes a new kind:443 with a fresh
# created_at; the old event may or may not be evicted depending on the
# relay's retention policy, so both may coexist for a while.
wn_b keys publish >/dev/null 2>&1 || {
record_result "$id" fail "wn_b keys publish failed"; return
}
local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do
after=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \
| jq -r '.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
if [[ -n "$after" && "$after" != "$before" ]]; then
info "amy saw KP rotation: ${before:0:8}… → ${after:0:8}"
record_result "$id" pass
else
record_result "$id" fail "amy kept seeing the pre-rotation KP"
fi
} }