From 1d3da803cf1652becf7898680bb33a9fa66b158f Mon Sep 17 00:00:00 2001 From: AI-OWEN Date: Thu, 6 Aug 2026 23:08:58 +0100 Subject: [PATCH] fix(relay): force monotonic created_at on the NIP-IA archive snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapid archive/unarchive mutations publish several kind:13535 snapshots inside one wall-clock second. created_at is second-resolution, so NIP-16 breaks the tie on the lowest event id — a content hash, and therefore an arbitrary winner. An intermediate snapshot can stay authoritative while every kind:8002 delta and every archived_identities row is accepted, and nothing surfaces the mismatch: no error, no warning, no drift check. Observed in the field twice. Six archive requests ~330ms apart grew the table 7 -> 12 while the published snapshot stayed at 7 and remained there for six days across relay restarts. Because Desktop filters mention autocomplete, the DM recipient picker, search and the add-member dialog on this snapshot, the five lost identities stayed fully selectable, and the archive looked like it had silently failed. Force the snapshot's created_at strictly past the previous snapshot's, the same guard emit_addressable_discovery_event and publish_dm_visibility_snapshot already apply. The shared arithmetic is extracted into monotonic_snapshot_created_at so it can be unit-tested without a database; the two existing call sites are left inline and unchanged to keep this diff to the reported bug. Regression coverage simulates six snapshot updates in one wall-clock second and asserts strictly increasing timestamps from T through T+5, plus first-publish and clock-overtakes-previous cases. Refs: #3848 Signed-off-by: AI-OWEN --- .../buzz-relay/src/handlers/side_effects.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98f8a9aa84..c48105cb25 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3107,10 +3107,30 @@ pub async fn reconcile_channel_events( Ok(()) } +/// `created_at` for a replaceable snapshot that must supersede `previous`. +/// +/// `created_at` is second-resolution, and NIP-16 breaks a same-second tie on the +/// lowest event id — which, being a content hash, is effectively random. Two +/// snapshots written inside one wall-clock second therefore leave an arbitrary +/// one of them authoritative, and the loser is discarded with no error. Forcing +/// the next snapshot strictly past the previous one keeps replacement +/// deterministic no matter how fast the mutations arrive. +fn monotonic_snapshot_created_at(now: u64, previous: Option) -> u64 { + match previous { + Some(previous) => now.max(previous + 1), + None => now, + } +} + /// Publish a kind:13535 archived identities list event (NIP-IA). /// /// Queries all current archived identities and emits a relay-signed, /// NIP-70-protected replaceable-by-convention snapshot with bare `p` tags. +/// +/// The snapshot's `created_at` is forced strictly past the previous snapshot's, +/// so a burst of archive requests inside one second cannot strand an +/// intermediate list as authoritative. Same guard as +/// `emit_addressable_discovery_event` and `publish_dm_visibility_snapshot`. pub async fn publish_nipia_archival_list( tenant: &TenantContext, state: &Arc, @@ -3128,8 +3148,26 @@ pub async fn publish_nipia_archival_list( ); } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let previous = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + limit: Some(1), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await + .unwrap_or_default(); + let ts = + monotonic_snapshot_created_at(now, previous.first().map(|e| e.event.created_at.as_secs())); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") .tags(tags) + .custom_created_at(nostr::Timestamp::from(ts)) .sign_with_keys(&state.relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; @@ -3450,4 +3488,55 @@ mod tests { assert!(actor_is_channel_owner_or_admin(&members, &actor)); } + + #[test] + fn first_snapshot_uses_wall_clock() { + assert_eq!( + monotonic_snapshot_created_at(1_700_000_000, None), + 1_700_000_000 + ); + } + + #[test] + fn snapshots_within_one_second_get_strictly_increasing_timestamps() { + // Six archive requests landing inside one wall-clock second: without the + // guard all six snapshots share `created_at`, the NIP-16 lowest-event-id + // tiebreak picks an arbitrary winner, and the rest are silently dropped. + let now = 1_700_000_000; + let mut previous = None; + let mut published = Vec::new(); + + for _ in 0..6 { + let ts = monotonic_snapshot_created_at(now, previous); + published.push(ts); + previous = Some(ts); + } + + assert_eq!( + published, + vec![now, now + 1, now + 2, now + 3, now + 4, now + 5] + ); + assert!(published.windows(2).all(|w| w[1] > w[0])); + } + + #[test] + fn snapshot_follows_wall_clock_once_it_overtakes_the_previous() { + // A burst can push `created_at` ahead of the clock; once real time passes + // it, the snapshot must go back to using the wall clock rather than + // drifting further into the future. + let previous = 1_700_000_005; + + assert_eq!( + monotonic_snapshot_created_at(1_700_000_000, Some(previous)), + 1_700_000_006 + ); + assert_eq!( + monotonic_snapshot_created_at(1_700_000_006, Some(previous)), + 1_700_000_006 + ); + assert_eq!( + monotonic_snapshot_created_at(1_700_000_099, Some(previous)), + 1_700_000_099 + ); + } }