From 5a042e783dae5d1cb359a4a17ab64a4f2f775020 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 9 Mar 2026 14:28:22 -0400 Subject: [PATCH 1/2] feat: update BlockTags during reorgs to prevent stale tag window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `BlockTags::rewind_to(ancestor)` that caps all three tags (latest, safe, finalized) at a given block number using `fetch_min`. Stores run in reverse order (latest → safe → finalized) compared to `update_all` so readers never observe `latest < finalized` while values decrease. Called in `on_host_revert` after `drain_above` but before `notify_reorg` so RPC consumers always see tags consistent with storage. Closes ENG-1969 Co-Authored-By: Claude Opus 4.6 --- crates/node/src/node.rs | 6 ++++ crates/rpc/src/config/resolve.rs | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index ae2c595..aabf803 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -506,6 +506,12 @@ where let drained = self.storage.drain_above(target).await?; + // Immediately cap block tags to the common ancestor so that + // `latest` never references a block that no longer exists in + // storage. This must happen before the reorg notification so + // that RPC consumers see consistent tags. + self.chain.tags().rewind_to(target); + // The early return above guards against no-op reverts, so drained // should always contain at least one block. Guard defensively. debug_assert!(!drained.is_empty(), "drain_above returned empty after host revert"); diff --git a/crates/rpc/src/config/resolve.rs b/crates/rpc/src/config/resolve.rs index 47d4a1f..aa0bc9d 100644 --- a/crates/rpc/src/config/resolve.rs +++ b/crates/rpc/src/config/resolve.rs @@ -109,6 +109,33 @@ impl BlockTags { self.latest.store(latest, Ordering::Release); } + /// Cap all three tags to at most `ancestor`. + /// + /// Used during reorgs to ensure tags never reference blocks that + /// have been removed from storage. Stores are ordered + /// latest → safe → finalized (the reverse of [`update_all`]) so + /// that readers never observe `latest < finalized` while the + /// values are being decreased. + /// + /// [`update_all`]: Self::update_all + /// + /// # Example + /// + /// ``` + /// use signet_rpc::BlockTags; + /// + /// let tags = BlockTags::new(100, 95, 90); + /// tags.rewind_to(92); + /// assert_eq!(tags.latest(), 92); + /// assert_eq!(tags.safe(), 92); + /// assert_eq!(tags.finalized(), 90); // already below ancestor + /// ``` + pub fn rewind_to(&self, ancestor: u64) { + self.latest.fetch_min(ancestor, Ordering::Release); + self.safe.fetch_min(ancestor, Ordering::Release); + self.finalized.fetch_min(ancestor, Ordering::Release); + } + /// Returns `true` if the node is currently syncing. pub fn is_syncing(&self) -> bool { self.sync_status.read().expect("sync status lock poisoned").is_some() @@ -130,6 +157,38 @@ impl BlockTags { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewind_to_caps_all_tags() { + let tags = BlockTags::new(100, 95, 90); + tags.rewind_to(92); + assert_eq!(tags.latest(), 92); + assert_eq!(tags.safe(), 92); + assert_eq!(tags.finalized(), 90); + } + + #[test] + fn rewind_to_caps_all_above_ancestor() { + let tags = BlockTags::new(100, 95, 90); + tags.rewind_to(50); + assert_eq!(tags.latest(), 50); + assert_eq!(tags.safe(), 50); + assert_eq!(tags.finalized(), 50); + } + + #[test] + fn rewind_to_noop_when_all_below() { + let tags = BlockTags::new(100, 95, 90); + tags.rewind_to(200); + assert_eq!(tags.latest(), 100); + assert_eq!(tags.safe(), 95); + assert_eq!(tags.finalized(), 90); + } +} + /// Error resolving a block identifier. #[derive(Debug, thiserror::Error)] pub enum ResolveError { From 2a0407d93290a87c61cddb1422506c9b0d69982a Mon Sep 17 00:00:00 2001 From: James Date: Tue, 10 Mar 2026 08:57:03 -0400 Subject: [PATCH 2/2] fix: correct rewind_to store ordering to match invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous order (latest → safe → finalized) allowed readers to momentarily observe latest < finalized during a rewind. Reorder to finalized → safe → latest so the invariant is maintained. Co-Authored-By: Claude Opus 4.6 --- crates/rpc/src/config/resolve.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/rpc/src/config/resolve.rs b/crates/rpc/src/config/resolve.rs index aa0bc9d..465b7fd 100644 --- a/crates/rpc/src/config/resolve.rs +++ b/crates/rpc/src/config/resolve.rs @@ -113,7 +113,7 @@ impl BlockTags { /// /// Used during reorgs to ensure tags never reference blocks that /// have been removed from storage. Stores are ordered - /// latest → safe → finalized (the reverse of [`update_all`]) so + /// finalized → safe → latest (the same as [`update_all`]) so /// that readers never observe `latest < finalized` while the /// values are being decreased. /// @@ -131,9 +131,9 @@ impl BlockTags { /// assert_eq!(tags.finalized(), 90); // already below ancestor /// ``` pub fn rewind_to(&self, ancestor: u64) { - self.latest.fetch_min(ancestor, Ordering::Release); - self.safe.fetch_min(ancestor, Ordering::Release); self.finalized.fetch_min(ancestor, Ordering::Release); + self.safe.fetch_min(ancestor, Ordering::Release); + self.latest.fetch_min(ancestor, Ordering::Release); } /// Returns `true` if the node is currently syncing.