From 374b6143d5e85a839f4ce6738604da403b7e0031 Mon Sep 17 00:00:00 2001 From: Ika Minami Date: Wed, 12 Aug 2026 04:38:43 +0100 Subject: [PATCH 1/2] fix(cli): keep project replacement timestamps at or after wall clock next_timestamp computed replacement created_at as head+1, which collides with the relay's +/-900s ingest drift window: updating any project whose head is older than 15 minutes is rejected with 'event timestamp too far from server time', and even fresh replacements are recorded seconds in the past, losing LWW races against wall-clock writers. Use max(now, head+1) instead - strictly after the observed head, never behind the wall clock - matching the pattern the relay itself uses when authoring replacements in side_effects.rs. Fixes #5665 Signed-off-by: Ika Minami --- crates/buzz-cli/src/commands/projects.rs | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index e6798dbfc4..fa35a6d180 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -4,8 +4,8 @@ //! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. //! 2. Mutate the tag set (strip `auth`, apply change). //! 3. Re-validate the full envelope through Layer A before submitting. -//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid -//! overwriting a concurrently advancing head. +//! 4. Set `created_at = max(now, head.created_at + 1)` so the replacement +//! dominates the observed head while remaining inside the relay window. //! //! Limitations recorded in this phase: //! - Relay hints are read-preserved but not authored (`--repo` carries @@ -120,13 +120,14 @@ async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<() // ── Build helpers ───────────────────────────────────────────────────────────── -/// Advance the `created_at` counter off an observed head. +/// Advance past the observed head without falling outside the relay window. fn next_timestamp(head: &Event) -> Result { - head.created_at + let after_head = head + .created_at .as_secs() .checked_add(1) - .map(Timestamp::from) - .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))?; + Ok(Timestamp::from(after_head.max(Timestamp::now().as_secs()))) } /// Strip `auth` from a tag list and pass the resulting envelope through @@ -1003,7 +1004,27 @@ mod tests { assert_eq!( next.as_secs(), far_future_ts.as_secs() + 1, - "tombstone must be strictly after head, even when head is far in the future" + "replacement must be strictly after head, even when head is far in the future" + ); + } + + #[test] + fn next_timestamp_uses_wall_clock_when_head_is_stale() { + let keys = nostr::Keys::generate(); + let stale_ts = Timestamp::from(100u64); + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + ]; + let builder = rebuild_project("", tags, stale_ts).expect("valid head envelope"); + let head = builder.sign_with_keys(&keys).expect("sign"); + let before = Timestamp::now().as_secs(); + + let next = next_timestamp(&head).expect("no overflow"); + + assert!( + next.as_secs() >= before, + "replacement timestamp must remain inside the relay acceptance window" ); } From 07a7b023b9b71fa024012ae7d8650cecc17d00e9 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 14 Aug 2026 13:56:32 -0400 Subject: [PATCH 2/2] test(cli): make project timestamp cases deterministic Co-authored-by: Ravneet Arora Signed-off-by: Ravneet Arora --- crates/buzz-cli/src/commands/projects.rs | 91 +++++++++++++----------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index fa35a6d180..1308535d46 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -4,8 +4,10 @@ //! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. //! 2. Mutate the tag set (strip `auth`, apply change). //! 3. Re-validate the full envelope through Layer A before submitting. -//! 4. Set `created_at = max(now, head.created_at + 1)` so the replacement -//! dominates the observed head while remaining inside the relay window. +//! 4. Set `created_at = max(client_now, head.created_at + 1)` so the +//! replacement dominates the observed head and uses wall clock for +//! ordinary stale heads. Unusually future heads may still hit the relay's +//! timestamp-drift guard until time advances. //! //! Limitations recorded in this phase: //! - Relay hints are read-preserved but not authored (`--repo` carries @@ -120,14 +122,17 @@ async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<() // ── Build helpers ───────────────────────────────────────────────────────────── -/// Advance past the observed head without falling outside the relay window. -fn next_timestamp(head: &Event) -> Result { +/// Choose the later of client wall clock and the instant after the observed head. +/// +/// The relay remains authoritative for timestamp drift: a sufficiently future +/// head can require a timestamp that the relay will temporarily reject. +fn next_timestamp(head: &Event, now: Timestamp) -> Result { let after_head = head .created_at .as_secs() .checked_add(1) .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))?; - Ok(Timestamp::from(after_head.max(Timestamp::now().as_secs()))) + Ok(Timestamp::from(after_head.max(now.as_secs()))) } /// Strip `auth` from a tag list and pass the resulting envelope through @@ -289,7 +294,7 @@ pub async fn cmd_add_repo( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Build the new tag set: keep existing tags (including hinted members), // append new members only if not already present (by coordinate). @@ -343,7 +348,7 @@ pub async fn cmd_remove_repo( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Verify all requested repos exist in the project. let existing_coords: std::collections::HashSet = head @@ -435,7 +440,7 @@ pub async fn cmd_update( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Build the new tag set. For each singleton metadata field: // - setter present: replace value (strip old, append new) @@ -492,7 +497,7 @@ pub async fn cmd_update( /// /// Head-based and verified: /// 1. Fetch own live head — `NotFound` if absent. -/// 2. Build tombstone at `head.created_at + 1`. +/// 2. Build tombstone at `max(client_now, head.created_at + 1)`. /// 3. Submit. /// 4. Re-query the coordinate; if a newer head survived → `Conflict`. pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { @@ -501,7 +506,7 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; let pubkey_hex = client.keys().public_key().to_hex(); let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) @@ -980,51 +985,51 @@ mod tests { // ── next_timestamp ordering ─────────────────────────────────────────────── - /// `next_timestamp` must return `head.created_at + 1` regardless of the wall - /// clock. NIP-MP Deletion rule: a tombstone older than the live head does - /// NOT remove it, so we must advance strictly off the observed head — never - /// use wall-clock time, which could be behind a head that was bumped - /// multiple times in the same second. - #[test] - fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { - // Build a minimal signed event with a created_at far in the future. + fn project_head_at(created_at: u64) -> Event { let keys = nostr::Keys::generate(); - let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 let tags = vec![ make_test_tag(&["d", "platform"]), make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), ]; - let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); - let head = builder.sign_with_keys(&keys).expect("sign"); - // Verify the event actually has our future timestamp. - assert_eq!(head.created_at, far_future_ts); - - // next_timestamp must return far_future + 1, not now(). - let next = next_timestamp(&head).expect("no overflow"); - assert_eq!( - next.as_secs(), - far_future_ts.as_secs() + 1, - "replacement must be strictly after head, even when head is far in the future" - ); + rebuild_project("", tags, Timestamp::from(created_at)) + .expect("valid head envelope") + .sign_with_keys(&keys) + .expect("sign") } #[test] - fn next_timestamp_uses_wall_clock_when_head_is_stale() { - let keys = nostr::Keys::generate(); - let stale_ts = Timestamp::from(100u64); - let tags = vec![ - make_test_tag(&["d", "platform"]), - make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + fn next_timestamp_uses_later_of_wall_clock_and_after_head() { + let cases = [ + ("stale head", 100, 1_000, 1_000), + ("head equal to now", 1_000, 1_000, 1_001), + ("future head", 1_500, 1_000, 1_501), + ("last timestamp inside future boundary", 1_899, 1_000, 1_900), + ( + "future boundary cannot be dominated inside the window", + 1_900, + 1_000, + 1_901, + ), ]; - let builder = rebuild_project("", tags, stale_ts).expect("valid head envelope"); - let head = builder.sign_with_keys(&keys).expect("sign"); - let before = Timestamp::now().as_secs(); - let next = next_timestamp(&head).expect("no overflow"); + for (name, head_ts, now, expected) in cases { + let head = project_head_at(head_ts); + let next = next_timestamp(&head, Timestamp::from(now)).expect("no overflow"); + + assert_eq!(next.as_secs(), expected, "case: {name}"); + } + } + + #[test] + fn next_timestamp_rejects_overflowing_head() { + let head = project_head_at(u64::MAX); + + let err = next_timestamp(&head, Timestamp::from(1_000u64)) + .expect_err("maximum timestamp cannot be advanced"); assert!( - next.as_secs() >= before, - "replacement timestamp must remain inside the relay acceptance window" + matches!(err, CliError::Other(ref message) if message == "project timestamp cannot be advanced"), + "unexpected error: {err}" ); }