From ebf2746dd1e568dd21a3f64d322d8c06cdb2e1f4 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Thu, 30 Jul 2026 22:33:52 -0500 Subject: [PATCH 1/3] fix(relay): serialize NIP-IA snapshot timestamps Co-authored-by: Aaron K. Clark (CryptoJones) Signed-off-by: Aaron K. Clark (CryptoJones) --- .../buzz-relay/src/handlers/side_effects.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 65d04ef0ba..2b5071cec3 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3119,8 +3119,37 @@ pub async fn publish_nipia_archival_list( ); } + // Force created_at strictly past the prior snapshot. Nostr timestamps have + // one-second resolution, and replaceable-event ties retain the lowest event + // id. Without this guard, rapid archive requests can leave a random + // intermediate snapshot authoritative even though every archive delta and + // canonical database row was accepted. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts = { + let existing = 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()), + global_only: true, + limit: Some(1), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + next_replaceable_snapshot_timestamp( + now, + existing + .first() + .map(|event| event.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}"))?; @@ -3147,6 +3176,12 @@ pub async fn publish_nipia_archival_list( Ok(()) } +fn next_replaceable_snapshot_timestamp(now: u64, previous: Option) -> u64 { + previous + .map(|timestamp| timestamp.saturating_add(1).max(now)) + .unwrap_or(now) +} + /// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for /// `viewer`. The event is parameterized-replaceable (`d` = viewer pubkey) and /// carries one `h` tag per DM the viewer currently has hidden. Called after any @@ -3364,6 +3399,29 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + #[test] + fn rapid_replaceable_snapshots_receive_strictly_increasing_timestamps() { + let now = 1_000; + let mut previous = None; + let mut timestamps = Vec::new(); + + for _ in 0..6 { + let timestamp = next_replaceable_snapshot_timestamp(now, previous); + timestamps.push(timestamp); + previous = Some(timestamp); + } + + assert_eq!(timestamps, vec![1_000, 1_001, 1_002, 1_003, 1_004, 1_005]); + } + + #[test] + fn replaceable_snapshot_timestamp_does_not_regress_behind_wall_clock() { + assert_eq!( + next_replaceable_snapshot_timestamp(2_000, Some(1_000)), + 2_000 + ); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = From df9394932da6f82e12278648d06dbe0e49d02ba5 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Fri, 31 Jul 2026 23:59:52 -0500 Subject: [PATCH 2/3] Rename wall-clock catch-up test for maintainer clarity (#3849) Co-authored-by: Aaron K. Clark (CryptoJones) Signed-off-by: Aaron K. Clark (CryptoJones) --- crates/buzz-relay/src/handlers/side_effects.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 2b5071cec3..36ff7026a8 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3415,7 +3415,7 @@ mod tests { } #[test] - fn replaceable_snapshot_timestamp_does_not_regress_behind_wall_clock() { + fn wall_clock_catch_up_advances_snapshot_timestamp() { assert_eq!( next_replaceable_snapshot_timestamp(2_000, Some(1_000)), 2_000 From 46465fe43b1540839b013b3968fe883ad92bed8b Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Sat, 1 Aug 2026 01:11:59 -0500 Subject: [PATCH 3/3] fix(relay): retry concurrent NIP-IA snapshots Co-authored-by: Aaron K. Clark (CryptoJones) Signed-off-by: Aaron K. Clark (CryptoJones) --- .../buzz-relay/src/handlers/side_effects.rs | 173 ++++++++++++------ docs/nips/NIP-IA.md | 2 + 2 files changed, 119 insertions(+), 56 deletions(-) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 518636034d..d8f266a167 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3115,58 +3115,81 @@ pub async fn publish_nipia_archival_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let archived = state.db.list_archived(tenant.community()).await?; let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + let mut attempt = 0_u8; + + loop { + attempt = attempt.saturating_add(1); + // Re-read the canonical archive set on every retry. A concurrent + // publisher may have won with a newer snapshot after this call began; + // retrying stale tags with a later timestamp would roll that state back. + let archived = state.db.list_archived(tenant.community()).await?; + let mut tags: Vec = Vec::with_capacity(archived.len() + 1); + tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + for identity in &archived { + tags.push( + Tag::parse(["p", &identity.pubkey]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ); + } - let mut tags: Vec = Vec::with_capacity(archived.len() + 1); - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let previous = state + .db + .get_latest_global_replaceable( + tenant.community(), + KIND_IA_ARCHIVED_LIST as i32, + state.relay_keypair.public_key().to_bytes().as_slice(), + ) + .await? + .map(|event| event.event.created_at.as_secs()); + + let Some(ts) = next_replaceable_snapshot_timestamp(now, previous) else { + // Keep relay-authored snapshots within the documented client skew + // window. Once the wall clock catches up, retry from canonical DB + // state instead of publishing an ever-further-future event. + tracing::warn!( + attempt, + previous_snapshot_timestamp = previous, + wall_clock_timestamp = now, + "NIP-IA archived identities snapshot reached the future-skew limit" + ); + if !nipia_snapshot_retry_allowed(attempt) { + anyhow::bail!( + "NIP-IA archived identities snapshot remained above the future-skew limit after {attempt} attempts" + ); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + }; - for identity in &archived { - tags.push( - Tag::parse(["p", &identity.pubkey]) - .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, - ); - } + 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}"))?; - // Force created_at strictly past the prior snapshot. Nostr timestamps have - // one-second resolution, and replaceable-event ties retain the lowest event - // id. Without this guard, rapid archive requests can leave a random - // intermediate snapshot authoritative even though every archive delta and - // canonical database row was accepted. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let ts = { - let existing = state + let (stored, was_inserted) = 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()), - global_only: true, - limit: Some(1), - ..buzz_db::event::EventQuery::for_community(tenant.community()) - }) + .replace_addressable_event(tenant.community(), &event, None) .await?; - next_replaceable_snapshot_timestamp( - now, - existing - .first() - .map(|event| event.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}"))?; + if !was_inserted { + tracing::warn!( + attempt, + snapshot_timestamp = ts, + "NIP-IA archived identities snapshot lost a concurrent replacement race" + ); + if !nipia_snapshot_retry_allowed(attempt) { + anyhow::bail!( + "NIP-IA archived identities snapshot lost {attempt} consecutive replacement races" + ); + } + continue; + } - let (stored, was_inserted) = state - .db - .replace_addressable_event(tenant.community(), &event, None) - .await?; - if was_inserted { dispatch_persistent_event( tenant, state, @@ -3176,19 +3199,26 @@ pub async fn publish_nipia_archival_list( None, ) .await; + info!( + archived_count = archived.len(), + "NIP-IA archived identities list published" + ); + return Ok(()); } - - info!( - archived_count = archived.len(), - "NIP-IA archived identities list published" - ); - Ok(()) } -fn next_replaceable_snapshot_timestamp(now: u64, previous: Option) -> u64 { - previous +const MAX_NIPIA_SNAPSHOT_FUTURE_SKEW_SECS: u64 = 60; +const MAX_NIPIA_SNAPSHOT_PUBLISH_ATTEMPTS: u8 = 8; + +fn next_replaceable_snapshot_timestamp(now: u64, previous: Option) -> Option { + let timestamp = previous .map(|timestamp| timestamp.saturating_add(1).max(now)) - .unwrap_or(now) + .unwrap_or(now); + (timestamp <= now.saturating_add(MAX_NIPIA_SNAPSHOT_FUTURE_SKEW_SECS)).then_some(timestamp) +} + +fn nipia_snapshot_retry_allowed(attempt: u8) -> bool { + attempt < MAX_NIPIA_SNAPSHOT_PUBLISH_ATTEMPTS } /// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for @@ -3415,7 +3445,8 @@ mod tests { let mut timestamps = Vec::new(); for _ in 0..6 { - let timestamp = next_replaceable_snapshot_timestamp(now, previous); + let timestamp = next_replaceable_snapshot_timestamp(now, previous) + .expect("six updates remain within the future-skew window"); timestamps.push(timestamp); previous = Some(timestamp); } @@ -3427,8 +3458,38 @@ mod tests { fn wall_clock_catch_up_advances_snapshot_timestamp() { assert_eq!( next_replaceable_snapshot_timestamp(2_000, Some(1_000)), - 2_000 + Some(2_000) + ); + } + + #[test] + fn snapshot_timestamp_pauses_at_future_skew_limit() { + let now = 1_000; + assert_eq!( + next_replaceable_snapshot_timestamp( + now, + Some(now + MAX_NIPIA_SNAPSHOT_FUTURE_SKEW_SECS - 1) + ), + Some(now + MAX_NIPIA_SNAPSHOT_FUTURE_SKEW_SECS) ); + assert_eq!( + next_replaceable_snapshot_timestamp( + now, + Some(now + MAX_NIPIA_SNAPSHOT_FUTURE_SKEW_SECS) + ), + None + ); + } + + #[test] + fn snapshot_publication_retry_budget_is_bounded() { + assert!(nipia_snapshot_retry_allowed(1)); + assert!(nipia_snapshot_retry_allowed( + MAX_NIPIA_SNAPSHOT_PUBLISH_ATTEMPTS - 1 + )); + assert!(!nipia_snapshot_retry_allowed( + MAX_NIPIA_SNAPSHOT_PUBLISH_ATTEMPTS + )); } #[test] diff --git a/docs/nips/NIP-IA.md b/docs/nips/NIP-IA.md index 28b2cfa52d..7caa8ed358 100644 --- a/docs/nips/NIP-IA.md +++ b/docs/nips/NIP-IA.md @@ -71,6 +71,8 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d `kind:13535` is replaceable per NIP-01 (`10000 <= n < 20000`). Clients use the latest valid `kind:13535` signed by the relay identity as current state. The snapshot is relay-scoped: it is signed by the relay identity advertised in NIP-11 `self`, mirroring NIP-43's relay-membership snapshot shape. Relays without a stable NIP-11 `self` pubkey MUST NOT publish NIP-IA relay-signed state, because clients would have no stable key against which to verify it. +To preserve strict replacement order when several archive changes occur within one wall-clock second, a relay MAY timestamp a `kind:13535` snapshot slightly in the future. Relays SHOULD bound that lead; this implementation allows at most 60 seconds, retries briefly while the wall clock catches up, then fails publication rather than occupying an ingest handler indefinitely. Clients MUST accept otherwise-valid relay-signed `kind:13535` snapshots within 60 seconds of their local clock and SHOULD reject snapshots further in the future. + ## Event Formats ### `kind:9035` Archive Request