Repository: stacks-network/stacks-core
Pull request #6020: state machine updates for tx replay
Author: login: hstove
Labels: name: locked
Created: 2025-04-16T17:35:48Z | Merged: 2025-05-12T13:25:51Z

- Closes https://github.com/stacks-network/stacks-core/issues/5968

This PR implements:

- Detecting forks in the signer state machine
- Updating the tenure fork info API to include dropped blocks, which can be used to construct the tx replay set
- Include a `Vec<StacksTransaction>` in state machine updates
  - For now, this vec is cleared as soon as a new Stacks block comes in (regardless of that block's contents). This allows us to monitor state machine updates

This builds on top of #6048 (which we should merge first)


Diff:
diff --git a/libsigner/src/events.rs b/libsigner/src/events.rs
index bba3df86d8d..bb236e5c9e9 100644
--- a/libsigner/src/events.rs
+++ b/libsigner/src/events.rs
@@ -214,6 +214,8 @@ pub enum SignerEvent<T: SignerEventTrait> {
         consensus_hash: ConsensusHash,
         /// the time at which this event was received by the signer's event processor
         received_time: SystemTime,
+        /// the parent burn block hash for the newly processed burn block
+        parent_burn_block_hash: BurnchainHeaderHash,
     },
     /// A new processed Stacks block was received from the node with the given block hash
     NewBlock {
@@ -585,6 +587,8 @@ struct BurnBlockEvent {
     burn_amount: u64,
     #[serde(with = "prefix_hex")]
     consensus_hash: ConsensusHash,
+    #[serde(with = "prefix_hex")]
+    parent_burn_block_hash: BurnchainHeaderHash,
 }
 
 impl<T: SignerEventTrait> TryFrom<BurnBlockEvent> for SignerEvent<T> {
@@ -596,6 +600,7 @@ impl<T: SignerEventTrait> TryFrom<BurnBlockEvent> for SignerEvent<T> {
             received_time: SystemTime::now(),
             burn_header_hash: burn_block_event.burn_block_hash,
             consensus_hash: burn_block_event.consensus_hash,
+            parent_burn_block_hash: burn_block_event.parent_burn_block_hash,
         })
     }
 }
diff --git a/stacks-signer/src/client/stacks_client.rs b/stacks-signer/src/client/stacks_client.rs
index 47b538679c7..2e3712a5436 100644
--- a/stacks-signer/src/client/stacks_client.rs
+++ b/stacks-signer/src/client/stacks_client.rs
@@ -452,6 +452,22 @@ impl StacksClient {
         })
     }
 
+    /// Get the sortition info for a given consensus hash
+    pub fn get_sortition_by_consensus_hash(
+        &self,
+        consensus_hash: &ConsensusHash,
+    ) -> Result<SortitionInfo, ClientError> {
+        let path = self.sortition_by_consensus_hash_path(consensus_hash);
+        let response = self.stacks_node_client.get(&path).send()?;
+        if !response.status().is_success() {
+            return Err(ClientError::RequestFailure(response.status()));
+        }
+        let sortition_info = response.json::<Vec<SortitionInfo>>()?;
+        sortition_info.first().cloned().ok_or_else(|| {
+            ClientError::InvalidResponse("No sortition info found for given consensus hash".into())
+        })
+    }
+
     /// Get the current peer info data from the stacks node
     pub fn get_peer_info(&self) -> Result<PeerInfo, ClientError> {
         debug!("StacksClient: Getting peer info");
@@ -725,6 +741,14 @@ impl StacksClient {
         format!("{}{RPC_SORTITION_INFO_PATH}", self.http_origin)
     }
 
+    fn sortition_by_consensus_hash_path(&self, consensus_hash: &ConsensusHash) -> String {
+        format!(
+            "{}{RPC_SORTITION_INFO_PATH}/consensus/{}",
+            self.http_origin,
+            consensus_hash.to_hex()
+        )
+    }
+
     fn tenure_forking_info_path(&self, start: &ConsensusHash, stop: &ConsensusHash) -> String {
         format!(
             "{}{RPC_TENURE_FORKING_INFO_PATH}/{}/{}",
diff --git a/stacks-signer/src/signerdb.rs b/stacks-signer/src/signerdb.rs
index 08f4679cbee..1d0014a8e67 100644
--- a/stacks-signer/src/signerdb.rs
+++ b/stacks-signer/src/signerdb.rs
@@ -21,12 +21,12 @@ use std::time::{Duration, SystemTime};
 
 use blockstack_lib::chainstate::nakamoto::NakamotoBlock;
 use blockstack_lib::chainstate::stacks::TransactionPayload;
+#[cfg(any(test, feature = "testing"))]
+use blockstack_lib::util_lib::db::FromColumn;
 use blockstack_lib::util_lib::db::{
     query_row, query_rows, sqlite_open, table_exists, tx_begin_immediate, u64_to_sql,
-    Error as DBError,
+    Error as DBError, FromRow,
 };
-#[cfg(any(test, feature = "testing"))]
-use blockstack_lib::util_lib::db::{FromColumn, FromRow};
 use clarity::types::chainstate::{BurnchainHeaderHash, StacksAddress};
 use clarity::types::Address;
 use libsigner::v0::messages::{RejectReason, RejectReasonPrefix, StateMachineUpdate};
@@ -72,6 +72,34 @@ impl StacksMessageCodec for NakamotoBlockVote {
     }
 }
 
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
+/// Struct for storing information about a burn block
+pub struct BurnBlockInfo {
+    /// The hash of the burn block
+    pub block_hash: BurnchainHeaderHash,
+    /// The height of the burn block
+    pub block_height: u64,
+    /// The consensus hash of the burn block
+    pub consensus_hash: ConsensusHash,
+    /// The hash of the parent burn block
+    pub parent_burn_block_hash: BurnchainHeaderHash,
+}
+
+impl FromRow<BurnBlockInfo> for BurnBlockInfo {
+    fn from_row(row: &rusqlite::Row) -> Result<Self, DBError> {
+        let block_hash: BurnchainHeaderHash = row.get(0)?;
+        let block_height: u64 = row.get(1)?;
+        let consensus_hash: ConsensusHash = row.get(2)?;
+        let parent_burn_block_hash: BurnchainHeaderHash = row.get(3)?;
+        Ok(BurnBlockInfo {
+            block_hash,
+            block_height,
+            consensus_hash,
+            parent_burn_block_hash,
+        })
+    }
+}
+
 #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
 /// Store extra version-specific info in `BlockInfo`
 pub enum ExtraBlockInfo {
@@ -566,6 +594,15 @@ CREATE TABLE IF NOT EXISTS signer_state_machine_updates (
     PRIMARY KEY (signer_addr, reward_cycle)
 ) STRICT;"#;
 
+static ADD_PARENT_BURN_BLOCK_HASH: &str = r#"
+ ALTER TABLE burn_blocks
+    ADD COLUMN parent_burn_block_hash TEXT;
+"#;
+
+static ADD_PARENT_BURN_BLOCK_HASH_INDEX: &str = r#"
+CREATE INDEX IF NOT EXISTS burn_blocks_parent_burn_block_hash_idx on burn_blocks (parent_burn_block_hash);
+"#;
+
 static SCHEMA_1: &[&str] = &[
     DROP_SCHEMA_0,
     CREATE_DB_CONFIG,
@@ -652,6 +689,12 @@ static SCHEMA_12: &[&str] = &[
     "INSERT OR REPLACE INTO db_config (version) VALUES (12);",
 ];
 
+static SCHEMA_13: &[&str] = &[
+    ADD_PARENT_BURN_BLOCK_HASH,
+    ADD_PARENT_BURN_BLOCK_HASH_INDEX,
+    "INSERT INTO db_config (version) VALUES (13);",
+];
+
 impl SignerDb {
     /// The current schema version used in this build of the signer binary.
     pub const SCHEMA_VERSION: u32 = 12;
@@ -852,6 +895,20 @@ impl SignerDb {
         Ok(())
     }
 
+    /// Migrate from schema 12 to schema 13
+    fn schema_13_migration(tx: &Transaction) -> Result<(), DBError> {
+        if Self::get_schema_version(tx)? >= 13 {
+            // no migration necessary
+            return Ok(());
+        }
+
+        for statement in SCHEMA_13.iter() {
+            tx.execute_batch(statement)?;
+        }
+
+        Ok(())
+    }
+
     /// Register custom scalar functions used by the database
     fn register_scalar_functions(&self) -> Result<(), DBError> {
         // Register helper function for determining if a block is a tenure change transaction
@@ -897,7 +954,8 @@ impl SignerDb {
                 9 => Self::schema_10_migration(&sql_tx)?,
                 10 => Self::schema_11_migration(&sql_tx)?,
                 11 => Self::schema_12_migration(&sql_tx)?,
-                12 => break,
+                12 => Self::schema_13_migration(&sql_tx)?,
+                13 => break,
                 x => return Err(DBError::Other(format!(
                     "Database schema is newer than supported by this binary. Expected version = {}, Database version = {x}",
                     Self::SCHEMA_VERSION,
@@ -1032,19 +1090,27 @@ impl SignerDb {
         consensus_hash: &ConsensusHash,
         burn_height: u64,
         received_time: &SystemTime,
+        parent_burn_block_hash: &BurnchainHeaderHash,
     ) -> Result<(), DBError> {
         let received_ts = received_time
             .duration_since(std::time::UNIX_EPOCH)
             .map_err(|e| DBError::Other(format!("Bad system time: {e}")))?
             .as_secs();
-        debug!("Inserting burn block info"; "burn_block_height" => burn_height, "burn_hash" => %burn_hash, "received" => received_ts, "ch" => %consensus_hash);
+        debug!("Inserting burn block info";
+            "burn_block_height" => burn_height,
+            "burn_hash" => %burn_hash,
+            "received" => received_ts,
+            "ch" => %consensus_hash,
+            "parent_burn_block_hash" => %parent_burn_block_hash
+        );
         self.db.execute(
-            "INSERT OR REPLACE INTO burn_blocks (block_hash, consensus_hash, block_height, received_time) VALUES (?1, ?2, ?3, ?4)",
+            "INSERT OR REPLACE INTO burn_blocks (block_hash, consensus_hash, block_height, received_time, parent_burn_block_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
             params![
                 burn_hash,
                 consensus_hash,
                 u64_to_sql(burn_height)?,
                 u64_to_sql(received_ts)?,
+                parent_burn_block_hash,
             ],
         )?;
         Ok(())
@@ -1084,6 +1150,26 @@ impl SignerDb {
         Ok(Some(receive_time))
     }
 
+    /// Lookup the burn block for a given burn block hash.
+    pub fn get_burn_block_by_hash(
+        &self,
+        burn_block_hash: &BurnchainHeaderHash,
+    ) -> Result<BurnBlockInfo, DBError> {
+        let query =
+            "SELECT block_hash, block_height, consensus_hash, parent_burn_block_hash FROM burn_blocks WHERE block_hash = ?";
+        let args = params![burn_block_hash];
+
+        query_row(&self.db, query, args)?.ok_or(DBError::NotFoundError)
+    }
+
+    /// Lookup the burn block for a given consensus hash.
+    pub fn get_burn_block_by_ch(&self, ch: &ConsensusHash) -> Result<BurnBlockInfo, DBError> {
+        let query = "SELECT block_hash, block_height, consensus_hash, parent_burn_block_hash FROM burn_blocks WHERE consensus_hash = ?";
+        let args = params![ch];
+
+        query_row(&self.db, query, args)?.ok_or(DBError::NotFoundError)
+    }
+
     /// Insert or replace a block into the database.
     /// Preserves the `broadcast` column if replacing an existing block.
     pub fn insert_block(&mut self, block_info: &BlockInfo) -> Result<(), DBError> {
@@ -1717,8 +1803,14 @@ pub mod tests {
             .duration_since(SystemTime::UNIX_EPOCH)
             .unwrap()
             .as_secs();
-        db.insert_burn_block(&test_burn_hash, &test_consensus_hash, 10, &stime)
-            .unwrap();
+        db.insert_burn_block(
+            &test_burn_hash,
+            &test_consensus_hash,
+            10,
+            &stime,
+            &test_burn_hash,
+        )
+        .unwrap();
 
         let stored_time = db
             .get_burn_block_receive_time(&test_burn_hash)
diff --git a/stacks-signer/src/tests/chainstate.rs b/stacks-signer/src/tests/chainstate.rs
index 422b0c84d48..4569343ea1f 100644
--- a/stacks-signer/src/tests/chainstate.rs
+++ b/stacks-signer/src/tests/chainstate.rs
@@ -210,6 +210,7 @@ fn reorg_timing_testing(
             consensus_hash: last_sortition.consensus_hash,
             was_sortition: true,
             first_block_mined: Some(StacksBlockId([1; 32])),
+            nakamoto_blocks: None,
         },
         TenureForkingInfo {
             burn_block_hash: BurnchainHeaderHash([128; 32]),
@@ -219,6 +220,7 @@ fn reorg_timing_testing(
             consensus_hash: view.cur_sortition.parent_tenure_id,
             was_sortition: true,
             first_block_mined: Some(StacksBlockId([2; 32])),
+            nakamoto_blocks: None,
         },
     ];
 
@@ -256,6 +258,7 @@ fn reorg_timing_testing(
             &view.cur_sortition.consensus_hash,
             3,
             &sortition_time,
+            &view.last_sortition.as_ref().unwrap().burn_block_hash,
         )
         .unwrap();
 
@@ -394,7 +397,13 @@ fn check_block_proposal_timeout() {
     let burn_height = 1;
     let received_time = SystemTime::now();
     signer_db
-        .insert_burn_block(&burn_hash, &consensus_hash, burn_height, &received_time)
+        .insert_burn_block(
+            &burn_hash,
+            &consensus_hash,
+            burn_height,
+            &received_time,
+            &view.last_sortition.as_ref().unwrap().burn_block_hash,
+        )
         .unwrap();
 
     view.check_proposal(
@@ -466,7 +475,13 @@ fn check_sortition_timeout() {
     let burn_height = 1;
     let received_time = SystemTime::now();
     signer_db
-        .insert_burn_block(&burn_hash, &consensus_hash, burn_height, &received_time)
+        .insert_burn_block(
+            &burn_hash,
+            &consensus_hash,
+            burn_height,
+            &received_time,
+            &BurnchainHeaderHash([0; 32]),
+        )
         .unwrap();
 
     std::thread::sleep(Duration::from_secs(1));
diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs
index 1080b0543af..a594b3c7692 100644
--- a/stacks-signer/src/v0/signer.rs
+++ b/stacks-signer/src/v0/signer.rs
@@ -51,6 +51,7 @@ use crate::client::{ClientError, SignerSlotID, StackerDB, StacksClient};
 use crate::config::{SignerConfig, SignerConfigMode};
 use crate::runloop::SignerResult;
 use crate::signerdb::{BlockInfo, BlockState, SignerDb};
+use crate::v0::signer_state::NewBurnBlock;
 use crate::Signer as SignerTrait;
 
 /// A global variable that can be used to make signers repeat their proposal
@@ -486,6 +487,7 @@ impl Signer {
                 burn_header_hash,
                 consensus_hash,
                 received_time,
+                parent_burn_block_hash,
             } => {
                 info!("{self}: Received a new burn block event for block height {burn_height}");
                 self.signer_db
@@ -494,6 +496,7 @@ impl Signer {
                         consensus_hash,
                         *burn_height,
                         received_time,
+                        parent_burn_block_hash,
                     )
                     .unwrap_or_else(|e| {
                         error!(
@@ -505,7 +508,10 @@ impl Signer {
                         panic!("{self} Failed to write burn block event to signerdb: {e}");
                     });
                 self.local_state_machine
-                    .bitcoin_block_arrival(&self.signer_db, stacks_client, &self.proposal_config, Some(*burn_height))
+                    .bitcoin_block_arrival(&self.signer_db, stacks_client, &self.proposal_config, Some(NewBurnBlock {
+                        burn_block_height: *burn_height,
+                        consensus_hash: *consensus_hash,
+                    }))
                     .unwrap_or_else(|e| error!("{self}: failed to update local state machine for latest bitcoin block arrival"; "err" => ?e));
                 *sortition_state = None;
             }
diff --git a/stacks-signer/src/v0/signer_state.rs b/stacks-signer/src/v0/signer_state.rs
index c14cb775d6d..eab3ff9051e 100644
--- a/stacks-signer/src/v0/signer_state.rs
+++ b/stacks-signer/src/v0/signer_state.rs
@@ -18,7 +18,7 @@ use std::time::{Duration, UNIX_EPOCH};
 
 use blockstack_lib::chainstate::burn::ConsensusHashExtensions;
 use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader};
-use blockstack_lib::chainstate::stacks::StacksTransaction;
+use blockstack_lib::chainstate::stacks::{StacksTransaction, TransactionPayload};
 use clarity::types::chainstate::StacksAddress;
 use libsigner::v0::messages::{
     MessageSlotID, SignerMessage, StateMachineUpdate as StateMachineUpdateMessage,
@@ -350,8 +350,17 @@ pub enum LocalStateMachine {
 /// A pending update for a signer state machine
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub enum StateMachineUpdate {
-    /// A new burn block at height u64 is expected
-    BurnBlock(u64),
+    /// A new burn block is expected
+    BurnBlock(NewBurnBlock),
+}
+
+/// Minimal struct for a new burn block
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct NewBurnBlock {
+    /// The height of the new burn block
+    pub burn_block_height: u64,
+    /// The hash of the new burn block
+    pub consensus_hash: ConsensusHash,
 }
 
 impl LocalStateMachine {
@@ -410,7 +419,7 @@ impl LocalStateMachine {
             },
             other => {
                 return Err(CodecError::DeserializeError(format!(
-                    "Active signer ptocol version is unknown: {other}"
+                    "Active signer protocol version is unknown: {other}"
                 )))
             }
         };
@@ -646,6 +655,11 @@ impl LocalStateMachine {
             }
         };
 
+        // No matter what, if we're in tx replay mode, remove the tx replay set
+        // TODO: in later versions, we will only clear the tx replay
+        // set when replay is completed.
+        prior_state_machine.tx_replay_set = None;
+
         let MinerState::ActiveMiner {
             parent_tenure_id,
             parent_tenure_last_block,
@@ -727,7 +741,7 @@ impl LocalStateMachine {
         db: &SignerDb,
         client: &StacksClient,
         proposal_config: &ProposalEvalConfig,
-        mut expected_burn_height: Option<u64>,
+        mut expected_burn_block: Option<NewBurnBlock>,
     ) -> Result<(), SignerChainstateError> {
         // set self to uninitialized so that if this function errors,
         //  self is left as uninitialized.
@@ -741,31 +755,56 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            let node_behind_expected =
+                next_burn_block_height < expected_burn_block.burn_block_height;
+            let node_on_equal_fork = next_burn_block_height
+                == expected_burn_block.burn_block_height
+                && next_burn_block_hash != expected_burn_block.consensus_hash;
+            if node_behind_expected || node_on_equal_fork {
+                let err_msg = format!(
+                    "Node has not processed the next burn block yet. Expected height = {}, Expected consensus hash = {}",
+                    expected_burn_block.burn_block_height,
+                    expected_burn_block.consensus_hash,
+                );
                 *self = Self::Pending {
-                    update: StateMachineUpdate::BurnBlock(expected_burn_height),
+                    update: StateMachineUpdate::BurnBlock(expected_burn_block),
                     prior: prior_state_machine,
                 };
-                return Err(ClientError::InvalidResponse(
-                    "Node has not processed the next burn block yet".into(),
-                )
-                .into());
+                return Err(ClientError::InvalidResponse(err_msg).into());
+            }
+            if let Some(new_replay_set) = self.handle_possible_bitcoin_fork(
+                db,
+                client,
+                &expected_burn_block,
+                &prior_state_machine,
+                tx_replay_set.is_some(),
+            )? {
+                tx_replay_set = Some(new_replay_set);
             }
         }
 
@@ -809,7 +848,7 @@ impl LocalStateMachine {
             burn_block_height: next_burn_block_height,
             current_miner: miner_state,
             active_signer_protocol_version: prior_state_machine.active_signer_protocol_version,
-            tx_replay_set: prior_state_machine.tx_replay_set,
+            tx_replay_set,
         });
 
         if prior_state != *self {
@@ -981,4 +1020,74 @@ impl LocalStateMachine {
         };
         state.tx_replay_set.clone()
     }
+
+    /// Handle a possible bitcoin fork. If a fork is detetected,
+    /// return the transactions that should be replayed.
+    pub fn handle_possible_bitcoin_fork(
+        &self,
+        db: &SignerDb,
+        client: &StacksClient,
+        expected_burn_block: &NewBurnBlock,
+        prior_state_machine: &SignerStateMachine,
+        is_in_tx_replay_mode: bool,
+    ) -> Result<Option<Vec<StacksTransaction>>, SignerChainstateError> {
+        if expected_burn_block.burn_block_height > prior_state_machine.burn_block_height {
+            // no bitcoin fork, because we're advancing the burn block height
+            return Ok(None);
+        }
+        if expected_burn_block.consensus_hash == prior_state_machine.burn_block {
+            // no bitcoin fork, because we're at the same burn block hash as before
+            return Ok(None);
+        }
+        if is_in_tx_replay_mode {
+            // TODO: handle fork while still in replay
+            info!("Detected bitcoin fork while in replay mode, will not try to handle the fork");
+            return Ok(None);
+        }
+        info!("Signer State: fork detected";
+            "expected_burn_block.height" => expected_burn_block.burn_block_height,
+            "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+            "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+            "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+        );
+        // Determine the tenures that were forked
+        let mut parent_burn_block_info =
+            db.get_burn_block_by_ch(&prior_state_machine.burn_block)?;
+        let last_forked_tenure = prior_state_machine.burn_block;
+        let mut first_forked_tenure = prior_state_machine.burn_block;
+        let mut forked_tenures = vec![(
+            prior_state_machine.burn_block,
+            prior_state_machine.burn_block_height,
+        )];
+        while parent_burn_block_info.block_height > expected_burn_block.burn_block_height {
+            parent_burn_block_info =
+                db.get_burn_block_by_hash(&parent_burn_block_info.parent_burn_block_hash)?;
+            first_forked_tenure = parent_burn_block_info.consensus_hash;
+            forked_tenures.push((
+                parent_burn_block_info.consensus_hash,
+                parent_burn_block_info.block_height,
+            ));
+        }
+        let fork_info =
+            client.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)?;
+        let mut forked_blocks = fork_info
+            .iter()
+            .flat_map(|fork_info| fork_info.nakamoto_blocks.iter().flatten())
+            .collect::<Vec<_>>();
+        forked_blocks.sort_by_key(|block| block.header.chain_length);
+        let forked_txs = forked_blocks
+            .iter()
+            .flat_map(|block| block.txs.iter())
+            .filter(|tx|
+                // Don't include Coinbase, TenureChange, or PoisonMicroblock transactions
+                !matches!(
+                    tx.payload,
+                    TransactionPayload::TenureChange(..)
+                        | TransactionPayload::Coinbase(..)
+                        | TransactionPayload::PoisonMicroblock(..)
+                ))
+            .cloned()
+            .collect::<Vec<_>>();
+        Ok(Some(forked_txs))
+    }
 }
diff --git a/stackslib/src/chainstate/nakamoto/staging_blocks.rs b/stackslib/src/chainstate/nakamoto/staging_blocks.rs
index 3505d41ebd4..b2164cb4051 100644
--- a/stackslib/src/chainstate/nakamoto/staging_blocks.rs
+++ b/stackslib/src/chainstate/nakamoto/staging_blocks.rs
@@ -165,7 +165,12 @@ pub const NAKAMOTO_STAGING_DB_SCHEMA_4: &[&str] = &[
     r#"UPDATE db_version SET version = 4"#,
 ];
 
-pub const NAKAMOTO_STAGING_DB_SCHEMA_LATEST: u32 = 4;
+pub const NAKAMOTO_STAGING_DB_SCHEMA_5: &[&str] = &[
+    r#"CREATE INDEX nakamoto_staging_blocks_by_consensus_hash_and_processed ON nakamoto_staging_blocks(consensus_hash, processed);"#,
+    r#"UPDATE db_version SET version = 5"#,
+];
+
+pub const NAKAMOTO_STAGING_DB_SCHEMA_LATEST: u32 = 5;
 
 pub struct NakamotoStagingBlocksConn(rusqlite::Connection);
 
@@ -433,6 +438,30 @@ impl<'a> NakamotoStagingBlocksConnRef<'a> {
             .collect())
     }
 
+    /// Get all nakamoto blocks in a tenure
+    pub fn get_nakamoto_blocks_in_tenure(
+        &self,
+        consensus_hash: &ConsensusHash,
+    ) -> Result<Vec<NakamotoBlock>, ChainstateError> {
+        let qry =
+            "SELECT data FROM nakamoto_staging_blocks WHERE consensus_hash = ?1 AND processed = 1";
+        let args = params![consensus_hash];
+        let block_data: Vec<Vec<u8>> = query_rows(self, qry, args)?;
+        Ok(block_data
+            .into_iter()
+            .filter_map(|block_vec| {
+                NakamotoBlock::consensus_deserialize(&mut &block_vec[..])
+                    .map_err(|e| {
+                        error!("Failed to deserialize block from DB, likely database corruption";
+                               "consensus_hash" => %consensus_hash,
+                               "error" => ?e);
+                        e
+                    })
+                    .ok()
+            })
+            .collect())
+    }
+
     /// Find the next ready-to-process Nakamoto block, given a connection to the staging blocks DB.
     /// NOTE: the relevant field queried from `nakamoto_staging_blocks` are updated by a separate
     /// tx from block-processing, so it's imperative that the thread that calls this function is
@@ -810,6 +839,15 @@ impl StacksChainState {
                     assert_eq!(version, 4, "Nakamoto staging DB migration failure");
                     debug!("Migrated Nakamoto staging blocks DB to schema 3");
                 }
+                4 => {
+                    debug!("Migrate Nakamoto staging blocks DB to schema 5");
+                    for cmd in NAKAMOTO_STAGING_DB_SCHEMA_5.iter() {
+                        conn.execute(cmd, NO_PARAMS)?;
+                    }
+                    let version = Self::get_nakamoto_staging_blocks_db_version(conn)?;
+                    assert_eq!(version, 5, "Nakamoto staging DB migration failure");
+                    debug!("Migrated Nakamoto staging blocks DB to schema 5");
+                }
                 NAKAMOTO_STAGING_DB_SCHEMA_LATEST => {
                     break;
                 }
diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs
index 115383d5666..2e7d2672394 100644
--- a/stackslib/src/chainstate/stacks/mod.rs
+++ b/stackslib/src/chainstate/stacks/mod.rs
@@ -1012,14 +1012,6 @@ pub struct StacksTransaction {
     pub payload: TransactionPayload,
 }
 
-impl Hash for StacksTransaction {
-    fn hash<H: Hasher>(&self, state: &mut H) {
-        self.txid().hash(state)
-    }
-}
-
-impl Eq for StacksTransaction {}
-
 #[derive(Debug, Clone, PartialEq)]
 pub struct StacksTransactionSigner {
     pub tx: StacksTransaction,
diff --git a/stackslib/src/chainstate/stacks/transaction.rs b/stackslib/src/chainstate/stacks/transaction.rs
index effa9e8ab4b..80bdf8a9090 100644
--- a/stackslib/src/chainstate/stacks/transaction.rs
+++ b/stackslib/src/chainstate/stacks/transaction.rs
@@ -14,6 +14,7 @@
 // You should have received a copy of the GNU General Public License
 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+use std::hash::{Hash, Hasher};
 use std::io;
 use std::io::prelude::*;
 use std::io::{Read, Write};
@@ -683,6 +684,14 @@ impl StacksTransaction {
     }
 }
 
+impl Hash for StacksTransaction {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.txid().hash(state);
+    }
+}
+
+impl Eq for StacksTransaction {}
+
 impl StacksMessageCodec for StacksTransaction {
     fn consensus_serialize<W: Write>(&self, fd: &mut W) -> Result<(), codec_error> {
         write_next(fd, &(self.version as u8))?;
diff --git a/stackslib/src/net/api/get_tenures_fork_info.rs b/stackslib/src/net/api/get_tenures_fork_info.rs
index 2cb28472906..6958678bf6e 100644
--- a/stackslib/src/net/api/get_tenures_fork_info.rs
+++ b/stackslib/src/net/api/get_tenures_fork_info.rs
@@ -34,7 +34,7 @@ use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState, NakamotoSta
 use crate::chainstate::stacks::db::StacksChainState;
 use crate::chainstate::stacks::Error as ChainError;
 use crate::net::api::getblock_v3::NakamotoBlockStream;
-use crate::net::api::{prefix_hex, prefix_opt_hex};
+use crate::net::api::{prefix_hex, prefix_opt_hex, prefix_opt_hex_codec};
 use crate::net::http::{
     parse_bytes, parse_json, Error, HttpBadRequest, HttpChunkGenerator, HttpContentType,
     HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse,
@@ -80,6 +80,9 @@ pub struct TenureForkingInfo {
     /// tenure's first block.
     #[serde(with = "prefix_opt_hex")]
     pub first_block_mined: Option<StacksBlockId>,
+    /// Nakamoto blocks in the tenure
+    #[serde(with = "prefix_opt_hex_codec")]
+    pub nakamoto_blocks: Option<Vec<NakamotoBlock>>,
 }
 
 #[derive(Clone, Default)]
@@ -154,8 +157,8 @@ impl TenureForkingInfo {
         chainstate: &StacksChainState,
         tip_block_id: &StacksBlockId,
     ) -> Result<Self, ChainError> {
-        let first_block_mined = if !sn.sortition {
-            None
+        let (first_block_mined, nakamoto_blocks) = if !sn.sortition {
+            (None, None)
         } else {
             // is this a nakamoto sortition?
             let epoch = SortitionDB::get_stacks_epoch(sortdb.conn(), sn.block_height)?.ok_or_else(
@@ -168,18 +171,28 @@ impl TenureForkingInfo {
                 },
             )?;
             if epoch.epoch_id < StacksEpochId::Epoch30 {
-                StacksChainState::get_stacks_block_header_info_by_consensus_hash(
-                    chainstate.db(),
-                    &sn.consensus_hash,
-                )?
-                .map(|header| header.index_block_hash())
+                (
+                    StacksChainState::get_stacks_block_header_info_by_consensus_hash(
+                        chainstate.db(),
+                        &sn.consensus_hash,
+                    )?
+                    .map(|header| header.index_block_hash()),
+                    None,
+                )
             } else {
-                NakamotoChainState::get_nakamoto_tenure_start_block_header(
-                    &mut chainstate.index_conn(),
-                    tip_block_id,
-                    &sn.consensus_hash,
-                )?
-                .map(|header| header.index_block_hash())
+                (
+                    NakamotoChainState::get_nakamoto_tenure_start_block_header(
+                        &mut chainstate.index_conn(),
+                        tip_block_id,
+                        &sn.consensus_hash,
+                    )?
+                    .map(|header| header.index_block_hash()),
+                    Some(
+                        chainstate
+                            .nakamoto_blocks_db()
+                            .get_nakamoto_blocks_in_tenure(&sn.consensus_hash)?,
+                    ),
+                )
             }
         };
         Ok(TenureForkingInfo {
@@ -190,6 +203,7 @@ impl TenureForkingInfo {
             consensus_hash: sn.consensus_hash.clone(),
             was_sortition: sn.sortition,
             first_block_mined,
+            nakamoto_blocks,
         })
     }
 }
diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs
index 45fbdda42f1..ab5b6478340 100644
--- a/stackslib/src/net/api/mod.rs
+++ b/stackslib/src/net/api/mod.rs
@@ -24,6 +24,7 @@ use stacks_common::util::hash::{Hash160, Sha512Trunc256Sum};
 use stacks_common::util::HexError;
 
 use crate::burnchains::Txid;
+use crate::chainstate::nakamoto::NakamotoBlock;
 use crate::chainstate::stacks::{StacksMicroblock, StacksTransaction};
 use crate::core::mempool;
 use crate::cost_estimates::FeeRateEstimate;
@@ -256,6 +257,51 @@ pub mod prefix_hex_codec {
     }
 }
 
+/// This module serde encode and decodes structs that
+/// implement StacksMessageCodec as a 0x-prefixed hex string.
+/// This is the same as prefix_hex_codec, but for Option<T>.
+pub mod prefix_opt_hex_codec {
+    use clarity::codec::StacksMessageCodec;
+    use clarity::util::hash::{hex_bytes, to_hex};
+
+    use super::prefix_hex_codec;
+
+    pub fn serialize<S: serde::Serializer, T: StacksMessageCodec>(
+        val: &Option<T>,
+        s: S,
+    ) -> Result<S::Ok, S::Error> {
+        match val {
+            Some(ref some_val) => {
+                let mut bytes = vec![];
+                some_val
+                    .consensus_serialize(&mut bytes)
+                    .map_err(serde::ser::Error::custom)?;
+                let hex_string = format!("0x{}", to_hex(&bytes));
+                s.serialize_some(&hex_string)
+            }
+            None => s.serialize_none(),
+        }
+    }
+
+    pub fn deserialize<'de, D: serde::Deserializer<'de>, T: StacksMessageCodec>(
+        d: D,
+    ) -> Result<Option<T>, D::Error> {
+        let opt_inst_str: Option<String> = serde::Deserialize::deserialize(d)?;
+        let Some(inst_string) = opt_inst_str else {
+            return Ok(None);
+        };
+        let Some(hex_str) = inst_string.get(2..) else {
+            return Err(serde::de::Error::invalid_length(
+                inst_string.len(),
+                &"at least length 2 string",
+            ));
+        };
+        let bytes = hex_bytes(hex_str).map_err(serde::de::Error::custom)?;
+        let val = T::consensus_deserialize(&mut &bytes[..]).map_err(serde::de::Error::custom)?;
+        Ok(Some(val))
+    }
+}
+
 pub trait HexDeser: Sized {
     fn try_from(hex: &str) -> Result<Self, HexError>;
 }
diff --git a/testnet/stacks-node/src/tests/signer/mod.rs b/testnet/stacks-node/src/tests/signer/mod.rs
index 83091b78dea..3ff80f8e55a 100644
--- a/testnet/stacks-node/src/tests/signer/mod.rs
+++ b/testnet/stacks-node/src/tests/signer/mod.rs
@@ -23,6 +23,7 @@ use std::time::{Duration, Instant};
 
 use clarity::boot_util::boot_code_id;
 use clarity::vm::types::PrincipalData;
+use clarity::vm::Value;
 use libsigner::v0::messages::{
     BlockAccepted, BlockResponse, MessageSlotID, PeerInfo, SignerMessage,
 };
@@ -451,43 +452,36 @@ impl<S: Signer<T> + Send + 'static, T: SignerEventTrait + 'static> SignerTest<Sp
         submit_tx_fallible(&http_origin, &transfer_tx).map(|resp| (resp, sender_nonce))
     }
 
-    /// Submit a burn block dependent contract for publishing
-    ///  and wait until it is included in a block
-    pub fn submit_burn_block_contract_and_wait(
+    /// Submit a contract deploy and return (txid, sender_nonce)
+    pub fn submit_contract_deploy(
         &mut self,
         sender_sk: &StacksPrivateKey,
-    ) -> Result<String, String> {
+        contract_code: &str,
+        contract_name: &str,
+    ) -> Result<(String, u64), String> {
         let http_origin = format!("http://{}", &self.running_nodes.conf.node.rpc_bind);
         let sender_addr = to_addr(&sender_sk);
         let sender_nonce = get_account(&http_origin, &sender_addr).nonce;
-        let burn_height_contract = "
-         (define-data-var local-burn-block-ht uint u0)
-         (define-public (run-update)
-           (ok (var-set local-burn-block-ht burn-block-height)))
-        ";
+
         let contract_tx = make_contract_publish(
             &sender_sk,
-            0,
+            sender_nonce,
             1000,
             self.running_nodes.conf.burnchain.chain_id,
-            "burn-height-local",
-            burn_height_contract,
+            contract_name,
+            contract_code,
         );
-        let txid = submit_tx_fallible(&http_origin, &contract_tx)?;
-
-        wait_for(120, || {
-            let next_nonce = get_account(&http_origin, &sender_addr).nonce;
-            Ok(next_nonce > sender_nonce)
-        })
-        .map(|()| txid)
+        submit_tx_fallible(&http_origin, &contract_tx).map(|resp| (resp, sender_nonce))
     }
 
-    /// Submit a burn block dependent contract-call
-    ///  and wait until it is included in a block
-    pub fn submit_burn_block_call_and_wait(
+    /// Submit a contract call and return (txid, sender_nonce)
+    pub fn submit_contract_call(
         &mut self,
         sender_sk: &StacksPrivateKey,
-    ) -> Result<String, String> {
+        contract_name: &str,
+        contract_func: &str,
+        contract_args: &[Value],
+    ) -> Result<(String, u64), String> {
         let http_origin = format!("http://{}", &self.running_nodes.conf.node.rpc_bind);
         let sender_addr = to_addr(&sender_sk);
         let sender_nonce = get_account(&http_origin, &sender_addr).nonce;
@@ -497,17 +491,54 @@ impl<S: Signer<T> + Send + 'static, T: SignerEventTrait + 'static> SignerTest<Sp
             1000,
             self.running_nodes.conf.burnchain.chain_id,
             &sender_addr,
-            "burn-height-local",
-            "run-update",
-            &[],
+            contract_name,
+            contract_func,
+            contract_args,
         );
-        let txid = submit_tx_fallible(&http_origin, &contract_call_tx)?;
+        submit_tx_fallible(&http_origin, &contract_call_tx).map(|resp| (resp, sender_nonce))
+    }
 
+    pub fn wait_for_nonce_increase(
+        &mut self,
+        sender_addr: &StacksAddress,
+        sender_nonce: u64,
+    ) -> Result<(), String> {
+        let http_origin = format!("http://{}", &self.running_nodes.conf.node.rpc_bind);
         wait_for(120, || {
             let next_nonce = get_account(&http_origin, &sender_addr).nonce;
             Ok(next_nonce > sender_nonce)
         })
-        .map(|()| txid)
+    }
+
+    /// Submit a burn block dependent contract for publishing
+    ///  and wait until it is included in a block
+    pub fn submit_burn_block_contract_and_wait(
+        &mut self,
+        sender_sk: &StacksPrivateKey,
+    ) -> Result<String, String> {
+        let burn_height_contract = "
+         (define-data-var local-burn-block-ht uint u0)
+         (define-public (run-update)
+           (ok (var-set local-burn-block-ht burn-block-height)))
+        ";
+        let (txid, sender_nonce) =
+            self.submit_contract_deploy(sender_sk, burn_height_contract, "burn-height-local")?;
+
+        self.wait_for_nonce_increase(&to_addr(&sender_sk), sender_nonce)?;
+        Ok(txid)
+    }
+
+    /// Submit a burn block dependent contract-call
+    ///  and wait until it is included in a block
+    pub fn submit_burn_block_call_and_wait(
+        &mut self,
+        sender_sk: &StacksPrivateKey,
+    ) -> Result<String, String> {
+        let (txid, sender_nonce) =
+            self.submit_contract_call(sender_sk, "burn-height-local", "run-update", &[])?;
+
+        self.wait_for_nonce_increase(&to_addr(&sender_sk), sender_nonce)?;
+        Ok(txid)
     }
 
     /// Get the local state machines and most recent peer info from the stacks-node,
diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs
index 2a5fd3bfd55..87ddeb8871e 100644
--- a/testnet/stacks-node/src/tests/signer/v0.rs
+++ b/testnet/stacks-node/src/tests/signer/v0.rs
@@ -80,7 +80,7 @@ use stacks_signer::client::{SignerSlotID, StackerDB};
 use stacks_signer::config::{build_signer_config_tomls, GlobalConfig as SignerConfig, Network};
 use stacks_signer::signerdb::SignerDb;
 use stacks_signer::v0::signer::TEST_REPEAT_PROPOSAL_RESPONSE;
-use stacks_signer::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION;
+use stacks_signer::v0::signer_state::{LocalStateMachine, SUPPORTED_SIGNER_PROTOCOL_VERSION};
 use stacks_signer::v0::tests::{
     TEST_IGNORE_ALL_BLOCK_PROPOSALS, TEST_PAUSE_BLOCK_BROADCAST,
     TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION, TEST_REJECT_ALL_BLOCK_PROPOSAL,
@@ -3036,6 +3036,349 @@ fn bitcoind_forking_test() {
     signer_test.shutdown();
 }
 
+#[test]
+#[ignore]
+/// Trigger a Bitcoin fork and ensure that the signer
+/// both detects the fork and moves into a tx replay state
+///
+/// The test flow is:
+///
+/// - Mine 10 tenures after epoch 3
+/// - Include a STX transfer in the 10th tenure
+/// - Trigger a Bitcoin fork (3 blocks)
+/// - Verify that the signer moves into tx replay state
+/// - Verify that the signer correctly includes the stx transfer
+///   in the tx replay set
+///
+/// Then, a second fork scenario is tested, which
+/// includes multiple txs across multiple tenures.
+fn tx_replay_forking_test() {
+    if env::var("BITCOIND_TEST") != Ok("1".into()) {
+        return;
+    }
+
+    let num_signers = 5;
+    let sender_sk = Secp256k1PrivateKey::random();
+    let sender_addr = tests::to_addr(&sender_sk);
+    let send_amt = 100;
+    let send_fee = 180;
+    let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
+        num_signers,
+        vec![(sender_addr, (send_amt + send_fee) * 10)],
+        |_| {},
+        |node_config| {
+            node_config.miner.block_commit_delay = Duration::from_secs(1);
+        },
+        None,
+        None,
+    );
+    let conf = signer_test.running_nodes.conf.clone();
+    let http_origin = format!("http://{}", &conf.node.rpc_bind);
+
+    signer_test.boot_to_epoch_3();
+    info!("------------------------- Reached Epoch 3.0 -------------------------");
+    let pre_fork_tenures = 10;
+
+    for i in 0..pre_fork_tenures {
+        info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1);
+        signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
+    }
+
+    signer_test.check_signer_states_normal();
+
+    let burn_blocks = test_observer::get_burn_blocks();
+    let forked_blocks = burn_blocks.iter().rev().take(2).collect::<Vec<_>>();
+    let last_forked_tenure: ConsensusHash = hex_bytes(
+        &forked_blocks[0]
+            .get("consensus_hash")
+            .unwrap()
+            .as_str()
+            .unwrap()[2..],
+    )
+    .unwrap()
+    .as_slice()
+    .into();
+    let first_forked_tenure: ConsensusHash = hex_bytes(
+        &forked_blocks[1]
+            .get("consensus_hash")
+            .unwrap()
+            .as_str()
+            .unwrap()[2..],
+    )
+    .unwrap()
+    .as_slice()
+    .into();
+
+    let tip = get_chain_info(&signer_test.running_nodes.conf);
+    // Make a transfer tx (this will get forked)
+    let (txid, _) = signer_test
+        .submit_transfer_tx(&sender_sk, send_fee, send_amt)
+        .unwrap();
+
+    wait_for(30, || {
+        let new_tip = get_chain_info(&signer_test.running_nodes.conf);
+        Ok(new_tip.stacks_tip_height > tip.stacks_tip_height)
+    })
+    .expect("Timed out waiting for transfer tx to be mined");
+
+    let pre_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce;
+    assert_eq!(pre_fork_1_nonce, 1);
+
+    info!("------------------------- Triggering Bitcoin Fork -------------------------");
+
+    let burn_header_hash_to_fork = signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .get_block_hash(tip.burn_block_height - 2);
+    signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .invalidate_block(&burn_header_hash_to_fork);
+    signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .build_next_block(3);
+
+    // note, we should still have normal signer states!
+    signer_test.check_signer_states_normal();
+
+    info!("Wait for block off of shallow fork");
+
+    TEST_MINE_STALL.set(true);
+
+    let submitted_commits = signer_test
+        .running_nodes
+        .counters
+        .naka_submitted_commits
+        .clone();
+
+    let fork_info = signer_test
+        .stacks_client
+        .get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)
+        .unwrap();
+
+    info!("---- Fork info: {fork_info:?} ----");
+
+    for fork in fork_info {
+        info!("---- Fork: {} ----", fork.consensus_hash);
+        fork.nakamoto_blocks.inspect(|blocks| {
+            for block in blocks {
+                info!("---- Block: {block:?} ----");
+            }
+        });
+    }
+
+    // we need to mine some blocks to get back to being considered a frequent miner
+    for i in 0..3 {
+        let current_burn_height = get_chain_info(&signer_test.running_nodes.conf).burn_block_height;
+        info!(
+            "Mining block #{i} to be considered a frequent miner";
+            "current_burn_height" => current_burn_height,
+        );
+        let commits_count = submitted_commits.load(Ordering::SeqCst);
+        next_block_and_controller(
+            &mut signer_test.running_nodes.btc_regtest_controller,
+            60,
+            |_btc_controller| {
+                let commits_submitted = submitted_commits.load(Ordering::SeqCst);
+                Ok(commits_submitted > commits_count)
+            },
+        )
+        .unwrap();
+    }
+
+    let post_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce;
+
+    let burn_blocks = test_observer::get_burn_blocks().clone();
+
+    for block in burn_blocks {
+        let height = block.get("burn_block_height").unwrap().as_number().unwrap();
+        if height.as_u64().unwrap() < 230 {
+            continue;
+        }
+        let consensus_hash = block.get("consensus_hash").unwrap().as_str().unwrap();
+        info!("---- Burn Block {height} {consensus_hash} ----");
+    }
+
+    let (signer_states, _) = signer_test.get_burn_updated_states();
+    for state in signer_states {
+        match state {
+            LocalStateMachine::Initialized(signer_state_machine) => {
+                let Some(tx_replay_set) = signer_state_machine.tx_replay_set else {
+                    panic!(
+                        "Signer state machine is in tx replay state, but tx replay set is not set"
+                    );
+                };
+                info!("---- Tx replay set: {:?} ----", tx_replay_set);
+                assert_eq!(tx_replay_set.len(), 1);
+                assert_eq!(tx_replay_set[0].txid().to_hex(), txid);
+            }
+            _ => {
+                panic!("Signer state is not in the initialized state");
+            }
+        }
+    }
+
+    // We should have forked 1 tx
+    assert_eq!(post_fork_1_nonce, pre_fork_1_nonce - 1);
+
+    TEST_MINE_STALL.set(false);
+
+    info!("---- Mining post-fork block to clear tx replay set ----");
+
+    // Now, make a new stacks block, which should clear the tx replay set
+    signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
+    let (signer_states, _) = signer_test.get_burn_updated_states();
+    for state in signer_states {
+        assert!(
+            state.get_tx_replay_set().is_none(),
+            "Signer state is in tx replay state, when it shouldn't be"
+        );
+    }
+
+    // Now, we'll trigger another fork, with more txs, across tenures
+
+    // The forked blocks are:
+    // Tenure 1:
+    // - Block with stx transfer
+    // Tenure 2:
+    // - Block with contract deploy
+    // - Block with contract call
+
+    signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
+
+    let pre_fork_2_tip = get_chain_info(&signer_test.running_nodes.conf);
+
+    let contract_code = "
+    (define-public (call-fn)
+      (ok true)
+    )
+    ";
+    let contract_name = "test-contract";
+
+    let (transfer_txid, transfer_nonce) = signer_test
+        .submit_transfer_tx(&sender_sk, send_fee, send_amt)
+        .expect("Failed to submit transfer tx");
+    signer_test
+        .wait_for_nonce_increase(&sender_addr, transfer_nonce)
+        .expect("Failed to wait for nonce increase");
+    signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
+
+    let (contract_deploy_txid, deploy_nonce) = signer_test
+        .submit_contract_deploy(&sender_sk, contract_code, contract_name)
+        .expect("Failed to submit contract deploy");
+    signer_test
+        .wait_for_nonce_increase(&sender_addr, deploy_nonce)
+        .expect("Failed to wait for nonce increase");
+
+    let (contract_call_txid, contract_call_nonce) = signer_test
+        .submit_contract_call(&sender_sk, contract_name, "call-fn", &[])
+        .expect("Failed to submit contract call");
+    signer_test
+        .wait_for_nonce_increase(&sender_addr, contract_call_nonce)
+        .expect("Failed to wait for nonce increase");
+    signer_test.mine_nakamoto_block(Duration::from_secs(30), true);
+
+    TEST_MINE_STALL.set(true);
+
+    let burn_header_hash_to_fork = signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .get_block_hash(pre_fork_2_tip.burn_block_height);
+    signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .invalidate_block(&burn_header_hash_to_fork);
+    signer_test
+        .running_nodes
+        .btc_regtest_controller
+        .build_next_block(3);
+
+    let burn_blocks = test_observer::get_burn_blocks();
+    let forked_blocks = burn_blocks.iter().rev().take(2).collect::<Vec<_>>();
+    let last_forked_tenure: ConsensusHash = hex_bytes(
+        &forked_blocks[0]
+            .get("consensus_hash")
+            .unwrap()
+            .as_str()
+            .unwrap()[2..],
+    )
+    .unwrap()
+    .as_slice()
+    .into();
+    let first_forked_tenure: ConsensusHash = hex_bytes(
+        &forked_blocks[1]
+            .get("consensus_hash")
+            .unwrap()
+            .as_str()
+            .unwrap()[2..],
+    )
+    .unwrap()
+    .as_slice()
+    .into();
+
+    let fork_info = signer_test
+        .stacks_client
+        .get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)
+        .unwrap();
+
+    info!("---- Fork info: {fork_info:?} ----");
+
+    for fork in fork_info {
+        info!("---- Fork: {} ----", fork.consensus_hash);
+        fork.nakamoto_blocks.inspect(|blocks| {
+            for block in blocks {
+                info!("---- Block: {} ----", block.header.chain_length);
+            }
+        });
+    }
+
+    for i in 0..3 {
+        let current_burn_height = get_chain_info(&signer_test.running_nodes.conf).burn_block_height;
+        info!(
+            "Mining block #{i} to be considered a frequent miner";
+            "current_burn_height" => current_burn_height,
+        );
+        let commits_count = submitted_commits.load(Ordering::SeqCst);
+        next_block_and_controller(
+            &mut signer_test.running_nodes.btc_regtest_controller,
+            60,
+            |_btc_controller| {
+                let commits_submitted = submitted_commits.load(Ordering::SeqCst);
+                Ok(commits_submitted > commits_count)
+            },
+        )
+        .unwrap();
+    }
+
+    let expected_tx_replay_txids = vec![transfer_txid, contract_deploy_txid, contract_call_txid];
+
+    let (signer_states, _) = signer_test.get_burn_updated_states();
+    for state in signer_states {
+        match state {
+            LocalStateMachine::Initialized(signer_state_machine) => {
+                let Some(tx_replay_set) = signer_state_machine.tx_replay_set else {
+                    panic!(
+                        "Signer state machine is in tx replay state, but tx replay set is not set"
+                    );
+                };
+                info!("---- Tx replay set: {:?} ----", tx_replay_set);
+                assert_eq!(tx_replay_set.len(), expected_tx_replay_txids.len());
+                let state_replay_txids = tx_replay_set
+                    .iter()
+                    .map(|tx| tx.txid().to_hex())
+                    .collect::<Vec<_>>();
+                assert_eq!(state_replay_txids, expected_tx_replay_txids);
+            }
+            _ => {
+                panic!("Signer state is not in the initialized state");
+            }
+        }
+    }
+
+    signer_test.shutdown();
+}
+
 #[test]
 #[ignore]
 fn multiple_miners() {


Reviews:
user: login: jferrant
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:32:16Z
user: login: jferrant
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T17:18:27Z
user: login: hstove
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:29:43Z
user: login: obycode
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:28:30Z
user: login: kantai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:06:50Z
user: login: kantai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:08:22Z
user: login: kantai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:13:13Z
user: login: kantai
body: This LGTM, just a few comments
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:16:01Z
user: login: kantai
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:58:47Z
user: login: obycode
body: LGTM!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T17:17:25Z
user: login: kantai
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T19:08:16Z
user: login: obycode
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T19:11:28Z
user: login: wileyj
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T20:09:00Z

Review comments:
diff_hunk: @@ -682,31 +696,99 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
+                let err_msg = format!(
+                    "Node has not processed the next burn block ({}) yet",
+                    expected_burn_block.burn_block_height
+                );
                 *self = Self::Pending {
-                    update: StateMachineUpdate::BurnBlock(expected_burn_height),
+                    update: StateMachineUpdate::BurnBlock(expected_burn_block),
                     prior: prior_state_machine,
                 };
-                return Err(ClientError::InvalidResponse(
-                    "Node has not processed the next burn block yet".into(),
-                )
-                .into());
+                return Err(ClientError::InvalidResponse(err_msg).into());
+            }
+            if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && tx_replay_set.is_none()
+            {
+                info!("Signer State: fork detected";
+                    "expected_burn_block.height" => expected_burn_block.burn_block_height,
+                    "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+                    "next_burn_block_height" => next_burn_block_height,
+                    "next_burn_block_hash" => %next_burn_block_hash,
+                    "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+                    "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+                );
+                // Determine the tenures that were forked
+                let mut sortition_info =
path: stacks-signer/src/v0/signer_state.rs
user: login: jferrant
body: I know this is a WIP so sorry about being nit picky already on eit, but I would make this into a sep function such as `handle_bitcoin_fork` or `extract_replay_transactions` as `bitcoin_block_arrival` is getting a bit long/hard to follow.
created_at: 2025-04-23T16:32:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -682,31 +696,99 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
+                let err_msg = format!(
+                    "Node has not processed the next burn block ({}) yet",
+                    expected_burn_block.burn_block_height
+                );
                 *self = Self::Pending {
-                    update: StateMachineUpdate::BurnBlock(expected_burn_height),
+                    update: StateMachineUpdate::BurnBlock(expected_burn_block),
                     prior: prior_state_machine,
                 };
-                return Err(ClientError::InvalidResponse(
-                    "Node has not processed the next burn block yet".into(),
-                )
-                .into());
+                return Err(ClientError::InvalidResponse(err_msg).into());
+            }
+            if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && tx_replay_set.is_none()
+            {
+                info!("Signer State: fork detected";
+                    "expected_burn_block.height" => expected_burn_block.burn_block_height,
+                    "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+                    "next_burn_block_height" => next_burn_block_height,
+                    "next_burn_block_hash" => %next_burn_block_hash,
+                    "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+                    "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+                );
+                // Determine the tenures that were forked
+                let mut sortition_info =
+                    client.get_sortition_by_consensus_hash(&prior_state_machine.burn_block)?;
+                let last_forked_tenure = prior_state_machine.burn_block;
+                let mut first_forked_tenure = prior_state_machine.burn_block;
+                let mut forked_tenures = vec![(
+                    prior_state_machine.burn_block,
+                    prior_state_machine.burn_block_height,
+                )];
+                while sortition_info.burn_block_height > expected_burn_block.burn_block_height {
+                    let Some(stacks_parent_ch) = sortition_info.stacks_parent_ch else {
+                        info!("No stacks parent ch found for sortition info";
+                            "sortition_info" => ?sortition_info,
+                        );
+                        break;
path: stacks-signer/src/v0/signer_state.rs
user: login: jferrant
body: If no sortition occurred, this could be none, right? Does that mean we should just break from this loop? 
created_at: 2025-04-23T17:18:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -682,31 +696,99 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
+                let err_msg = format!(
+                    "Node has not processed the next burn block ({}) yet",
+                    expected_burn_block.burn_block_height
+                );
                 *self = Self::Pending {
-                    update: StateMachineUpdate::BurnBlock(expected_burn_height),
+                    update: StateMachineUpdate::BurnBlock(expected_burn_block),
                     prior: prior_state_machine,
                 };
-                return Err(ClientError::InvalidResponse(
-                    "Node has not processed the next burn block yet".into(),
-                )
-                .into());
+                return Err(ClientError::InvalidResponse(err_msg).into());
+            }
+            if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && tx_replay_set.is_none()
+            {
+                info!("Signer State: fork detected";
+                    "expected_burn_block.height" => expected_burn_block.burn_block_height,
+                    "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+                    "next_burn_block_height" => next_burn_block_height,
+                    "next_burn_block_hash" => %next_burn_block_hash,
+                    "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+                    "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+                );
+                // Determine the tenures that were forked
+                let mut sortition_info =
+                    client.get_sortition_by_consensus_hash(&prior_state_machine.burn_block)?;
+                let last_forked_tenure = prior_state_machine.burn_block;
+                let mut first_forked_tenure = prior_state_machine.burn_block;
+                let mut forked_tenures = vec![(
+                    prior_state_machine.burn_block,
+                    prior_state_machine.burn_block_height,
+                )];
+                while sortition_info.burn_block_height > expected_burn_block.burn_block_height {
+                    let Some(stacks_parent_ch) = sortition_info.stacks_parent_ch else {
+                        info!("No stacks parent ch found for sortition info";
+                            "sortition_info" => ?sortition_info,
+                        );
+                        break;
path: stacks-signer/src/v0/signer_state.rs
user: login: hstove
body: Hmm, yeah, looking at the API now, it does seem like this will be `None` if no sortition occurred, and we don't actually want to break from the loop in that case. I'll have to re-think this a bit.
created_at: 2025-04-24T14:29:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,7 +345,16 @@ pub enum LocalStateMachine {
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub enum StateMachineUpdate {
     /// A new burn block at height u64 is expected
path: stacks-signer/src/v0/signer_state.rs
user: login: obycode
body: This comment could use an update.
created_at: 2025-05-05T21:29:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -343,14 +384,28 @@ impl TryInto<StateMachineUpdateMessage> for &LocalStateMachine {
             MinerState::NoValidMiner => StateMachineUpdateMinerState::NoValidMiner,
         };
 
-        StateMachineUpdateMessage::new(
-            state_machine.active_signer_protocol_version,
-            SUPPORTED_SIGNER_PROTOCOL_VERSION,
-            StateMachineUpdateContent::V0 {
+        let content = match state_machine.active_signer_protocol_version {
+            0 => StateMachineUpdateContent::V0 {
+                burn_block: state_machine.burn_block,
+                burn_block_height: state_machine.burn_block_height,
+                current_miner,
+            },
+            1 => StateMachineUpdateContent::V1 {
                 burn_block: state_machine.burn_block,
                 burn_block_height: state_machine.burn_block_height,
                 current_miner,
+                replay_transactions: state_machine.tx_replay_set.clone().unwrap_or_default(),
             },
+            other => {
+                return Err(CodecError::DeserializeError(format!(
+                    "Active signer ptocol version is unknown: {other}"
path: stacks-signer/src/v0/signer_state.rs
user: login: obycode
body: ```suggestion
                    "Active signer protocol version is unknown: {other}"
```
created_at: 2025-05-06T17:53:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -682,32 +743,51 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
path: stacks-signer/src/v0/signer_state.rs
user: login: obycode
body: I find the use of `{}` here a bit awkward. `()` would be more natural.
created_at: 2025-05-06T17:56:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -981,4 +1017,73 @@ impl LocalStateMachine {
         };
         state.tx_replay_set.clone()
     }
+
+    /// Handle a possible bitcoin fork. If a fork is detetected,
+    /// return the transactions that should be replayed.
+    pub fn handle_possible_bitcoin_fork(
+        &self,
+        db: &SignerDb,
+        client: &StacksClient,
+        expected_burn_block: &NewBurnBlock,
+        prior_state_machine: &SignerStateMachine,
+        is_in_tx_replay_mode: bool,
+    ) -> Result<Option<Vec<StacksTransaction>>, SignerChainstateError> {
+        if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && !is_in_tx_replay_mode
+        {
+            info!("Signer State: fork detected";
+                "expected_burn_block.height" => expected_burn_block.burn_block_height,
+                "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+                "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+                "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+            );
+            // Determine the tenures that were forked
+            let mut parent_burn_block_info =
+                db.get_burn_block_by_ch(&prior_state_machine.burn_block)?;
+            let last_forked_tenure = prior_state_machine.burn_block;
+            let mut first_forked_tenure = prior_state_machine.burn_block;
+            let mut forked_tenures = vec![(
+                prior_state_machine.burn_block,
+                prior_state_machine.burn_block_height,
+            )];
+            while parent_burn_block_info.block_height > expected_burn_block.burn_block_height {
+                parent_burn_block_info =
+                    db.get_burn_block_by_hash(&parent_burn_block_info.parent_burn_block_hash)?;
+                first_forked_tenure = parent_burn_block_info.consensus_hash;
+                forked_tenures.push((
+                    parent_burn_block_info.consensus_hash,
+                    parent_burn_block_info.block_height,
+                ));
+            }
+            let fork_info =
+                client.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)?;
+            let mut forked_blocks = fork_info
+                .iter()
+                .flat_map(|fork_info| {
+                    fork_info
+                        .nakamoto_blocks
+                        .iter()
+                        .flat_map(|blocks| blocks.iter())
+                })
+                .collect::<Vec<_>>();
path: stacks-signer/src/v0/signer_state.rs
user: login: obycode
body: I think this can be simplified with a `flatten`:

```
let mut forked_blocks = fork_info
    .iter()
    .flat_map(|f| f.nakamoto_blocks.iter().flatten())
    .collect::<Vec<_>>();
```
created_at: 2025-05-06T18:16:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -981,4 +1017,73 @@ impl LocalStateMachine {
         };
         state.tx_replay_set.clone()
     }
+
+    /// Handle a possible bitcoin fork. If a fork is detetected,
+    /// return the transactions that should be replayed.
+    pub fn handle_possible_bitcoin_fork(
+        &self,
+        db: &SignerDb,
+        client: &StacksClient,
+        expected_burn_block: &NewBurnBlock,
+        prior_state_machine: &SignerStateMachine,
+        is_in_tx_replay_mode: bool,
+    ) -> Result<Option<Vec<StacksTransaction>>, SignerChainstateError> {
+        if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && !is_in_tx_replay_mode
+        {
+            info!("Signer State: fork detected";
+                "expected_burn_block.height" => expected_burn_block.burn_block_height,
+                "expected_burn_block.hash" => %expected_burn_block.consensus_hash,
+                "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height,
+                "prior_state_machine.burn_block" => %prior_state_machine.burn_block,
+            );
+            // Determine the tenures that were forked
+            let mut parent_burn_block_info =
+                db.get_burn_block_by_ch(&prior_state_machine.burn_block)?;
+            let last_forked_tenure = prior_state_machine.burn_block;
+            let mut first_forked_tenure = prior_state_machine.burn_block;
+            let mut forked_tenures = vec![(
+                prior_state_machine.burn_block,
+                prior_state_machine.burn_block_height,
+            )];
+            while parent_burn_block_info.block_height > expected_burn_block.burn_block_height {
+                parent_burn_block_info =
+                    db.get_burn_block_by_hash(&parent_burn_block_info.parent_burn_block_hash)?;
+                first_forked_tenure = parent_burn_block_info.consensus_hash;
+                forked_tenures.push((
+                    parent_burn_block_info.consensus_hash,
+                    parent_burn_block_info.block_height,
+                ));
+            }
+            let fork_info =
+                client.get_tenure_forking_info(&first_forked_tenure, &last_forked_tenure)?;
+            let mut forked_blocks = fork_info
+                .iter()
+                .flat_map(|fork_info| {
+                    fork_info
+                        .nakamoto_blocks
+                        .iter()
+                        .flat_map(|blocks| blocks.iter())
+                })
+                .collect::<Vec<_>>();
+            forked_blocks.sort_by_key(|block| block.header.chain_length);
+            let forked_txs = forked_blocks
+                .iter()
+                .flat_map(|block| block.txs.iter())
+                .filter(|tx| match tx.payload {
+                    // Don't include Coinbase, TenureChange, or PoisonMicroblock transactions
+                    TransactionPayload::TenureChange(..)
+                    | TransactionPayload::Coinbase(..)
+                    | TransactionPayload::PoisonMicroblock(..) => false,
+                    _ => true,
+                })
path: stacks-signer/src/v0/signer_state.rs
user: login: obycode
body: I think a `!matches!` is easier to read here:
```rust
.filter(|tx| !matches!(
        tx.payload,
        TransactionPayload::TenureChange(..)
            | TransactionPayload::Coinbase(..)
            | TransactionPayload::PoisonMicroblock(..)
    ))
```
created_at: 2025-05-06T18:18:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -433,6 +433,30 @@ impl<'a> NakamotoStagingBlocksConnRef<'a> {
             .collect())
     }
 
+    /// Get all nakamoto blocks in a tenure
+    pub fn get_nakamoto_blocks_in_tenure(
+        &self,
+        consensus_hash: &ConsensusHash,
+    ) -> Result<Vec<NakamotoBlock>, ChainstateError> {
+        let qry =
+            "SELECT data FROM nakamoto_staging_blocks WHERE consensus_hash = ?1 AND processed = 1";
path: stackslib/src/chainstate/nakamoto/staging_blocks.rs
user: login: obycode
body: Can you add a new index for this query?
created_at: 2025-05-06T18:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -682,32 +743,51 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
path: stacks-signer/src/v0/signer_state.rs
user: login: kantai
body: Yeah, my preference in these kinds of things is to be overly explicit:

```rust
let node_behind_expected = next_burn_block_height < expected_burn_block.burn_block_height;
let node_on_equal_fork = next_burn_block_height == expected_burn_block.burn_block_height
    && next_burn_block_hash != expected_burn_block.consensus_hash;
if node_behind_expected || node_on_equal_fork {
...
}
```
created_at: 2025-05-08T14:06:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -741,31 +755,53 @@ impl LocalStateMachine {
                 //  but if we have other kinds of pending updates, this logic will need
                 //  to be changed.
                 match update {
-                    StateMachineUpdate::BurnBlock(pending_burn_height) => {
-                        if pending_burn_height > expected_burn_height.unwrap_or(0) {
-                            expected_burn_height = Some(pending_burn_height);
+                    StateMachineUpdate::BurnBlock(pending_burn_block) => {
+                        match expected_burn_block {
+                            None => expected_burn_block = Some(pending_burn_block),
+                            Some(ref expected) => {
+                                if pending_burn_block.burn_block_height > expected.burn_block_height
+                                {
+                                    expected_burn_block = Some(pending_burn_block);
+                                }
+                            }
                         }
                     }
                 }
 
-                prior
+                prior.clone()
             }
         };
 
         let peer_info = client.get_peer_info()?;
         let next_burn_block_height = peer_info.burn_block_height;
         let next_burn_block_hash = peer_info.pox_consensus;
-
-        if let Some(expected_burn_height) = expected_burn_height {
-            if next_burn_block_height < expected_burn_height {
+        let mut tx_replay_set = prior_state_machine.tx_replay_set.clone();
+
+        if let Some(expected_burn_block) = expected_burn_block {
+            // If the next height is less than the expected height, we need to wait.
+            // OR if the next height is the same, but with a different hash, we need to wait.
+            if next_burn_block_height < expected_burn_block.burn_block_height || {
+                next_burn_block_height == expected_burn_block.burn_block_height
+                    && next_burn_block_hash != expected_burn_block.consensus_hash
+            } {
+                let err_msg = format!(
+                    "Node has not processed the next burn block ({}) yet",
+                    expected_burn_block.burn_block_height
+                );
path: stacks-signer/src/v0/signer_state.rs
user: login: kantai
body: ```suggestion
                let err_msg = format!(
                    "Node has not processed the next burn block yet. Expected height = {}, Expected consensus hash = {}",
                    expected_burn_block.burn_block_height,
                    expected_burn_block.consensus_hash,
                );
```
created_at: 2025-05-08T14:08:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -981,4 +1017,73 @@ impl LocalStateMachine {
         };
         state.tx_replay_set.clone()
     }
+
+    /// Handle a possible bitcoin fork. If a fork is detetected,
+    /// return the transactions that should be replayed.
+    pub fn handle_possible_bitcoin_fork(
+        &self,
+        db: &SignerDb,
+        client: &StacksClient,
+        expected_burn_block: &NewBurnBlock,
+        prior_state_machine: &SignerStateMachine,
+        is_in_tx_replay_mode: bool,
+    ) -> Result<Option<Vec<StacksTransaction>>, SignerChainstateError> {
+        if expected_burn_block.burn_block_height <= prior_state_machine.burn_block_height
+                && expected_burn_block.consensus_hash != prior_state_machine.burn_block
+                // TODO: handle fork while still in replay
+                && !is_in_tx_replay_mode
+        {
path: stacks-signer/src/v0/signer_state.rs
user: login: kantai
body: I think it's usually more clear to invert this kind of logic, so that the function exits immediately (and there's less conditional "depth" in the body of the function):

```
if expected_burn_block.burn_block_height > prior_state_machine.burn_block_height {
    // no bitcoin fork, because we're advancing the burn block height
    return Ok(None)
}
if expected_burn_block.consensus_hash == prior_state_machine.burn_block {
    // no bitcoin fork, because we're at the same burn block hash as before
    return Ok(None)
}
if is_in_tx_replay_mode {
    // TODO: handle fork while still in replay
    info!("Detected bitcoin fork while in replay mode, will not try to handle the fork")
    return Ok(None)
}
...
```
created_at: 2025-05-08T14:13:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: hstove
created_at: 2025-04-23T12:05:34Z
body: Current state of this PR:

- `tx_replay_set`, an `Option<Vec<StacksTransaction>>`, is added the `SignerStateMachine`
- `signer_state` detects a bitcoin fork in bitcoin_block_arrival and fetches dropped blocks
- The "fork info" API is updated to included dropped Nakamoto blocks
- The signer updates their local signer state machine with the dropped tx's from this API
- A new integration test triggers a Bitcoin fork and asserts that the state machine has the right tx replay set
author_association: CONTRIBUTOR
user: login: hstove
created_at: 2025-05-06T18:01:36Z
body: I've updated this PR with an additional test scenario that tests forked txs across multiple tenures, which caught an issue regarding the order of the replay set
author_association: CONTRIBUTOR
user: login: hstove
created_at: 2025-05-09T00:03:24Z
body: All comments addressed, plz re-review!
author_association: CONTRIBUTOR
user: login: hstove
created_at: 2025-05-09T19:03:37Z
body: Fixed a conflict, re-requesting reviews 🙏
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-12T13:27:36Z
body: ## [Codecov](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) Report
Attention: Patch coverage is `46.37437%` with `318 lines` in your changes missing coverage. Please review.
> Project coverage is 83.33%. Comparing base [(`0ca28a0`)](https://app.codecov.io/gh/stacks-network/stacks-core/commit/0ca28a0fe52e503a630d73ff9b74784c0d08a790?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) to head [(`745ce55`)](https://app.codecov.io/gh/stacks-network/stacks-core/commit/745ce55a5fc5efec3946ae89337b471d95ea6417?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network).
> Report is 28 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) | Patch % | Lines |
|---|---|---|
| [testnet/stacks-node/src/tests/signer/v0.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=testnet%2Fstacks-node%2Fsrc%2Ftests%2Fsigner%2Fv0.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-dGVzdG5ldC9zdGFja3Mtbm9kZS9zcmMvdGVzdHMvc2lnbmVyL3YwLnJz) | 0.00% | [276 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stacks-signer/src/client/stacks\_client.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Fclient%2Fstacks_client.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvY2xpZW50L3N0YWNrc19jbGllbnQucnM=) | 0.00% | [21 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stacks-signer/src/v0/signer\_state.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Fv0%2Fsigner_state.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvdjAvc2lnbmVyX3N0YXRlLnJz) | 93.54% | [6 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stacks-signer/src/signerdb.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Fsignerdb.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvc2lnbmVyZGIucnM=) | 90.56% | [5 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stackslib/src/net/api/mod.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fnet%2Fapi%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9uZXQvYXBpL21vZC5ycw==) | 86.20% | [4 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stackslib/src/chainstate/stacks/transaction.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fchainstate%2Fstacks%2Ftransaction.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9jaGFpbnN0YXRlL3N0YWNrcy90cmFuc2FjdGlvbi5ycw==) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [...tackslib/src/chainstate/nakamoto/staging\_blocks.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fchainstate%2Fnakamoto%2Fstaging_blocks.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9jaGFpbnN0YXRlL25ha2Ftb3RvL3N0YWdpbmdfYmxvY2tzLnJz) | 92.30% | [2 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |
| [stackslib/src/net/api/get\_tenures\_fork\_info.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fnet%2Fapi%2Fget_tenures_fork_info.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9uZXQvYXBpL2dldF90ZW51cmVzX2ZvcmtfaW5mby5ycw==) | 95.00% | [1 Missing :warning: ](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop    #6020      +/-   ##
===========================================
+ Coverage    73.43%   83.33%   +9.89%     
===========================================
  Files          538      538              
  Lines       388534   389065     +531     
  Branches       323      323              
===========================================
+ Hits        285306   324210   +38904     
+ Misses      103220    64847   -38373     
  Partials         8        8              
```

| [Files with missing lines](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) | Coverage Δ | |
|---|---|---|
| [libsigner/src/events.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=libsigner%2Fsrc%2Fevents.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-bGlic2lnbmVyL3NyYy9ldmVudHMucnM=) | `89.21% <100.00%> (+3.19%)` | :arrow_up: |
| [stacks-signer/src/tests/chainstate.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Ftests%2Fchainstate.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvdGVzdHMvY2hhaW5zdGF0ZS5ycw==) | `100.00% <100.00%> (+16.00%)` | :arrow_up: |
| [stacks-signer/src/v0/signer.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Fv0%2Fsigner.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvdjAvc2lnbmVyLnJz) | `85.09% <100.00%> (+1.10%)` | :arrow_up: |
| [stackslib/src/chainstate/stacks/mod.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fchainstate%2Fstacks%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9jaGFpbnN0YXRlL3N0YWNrcy9tb2QucnM=) | `78.96% <ø> (+0.23%)` | :arrow_up: |
| [testnet/stacks-node/src/tests/signer/mod.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=testnet%2Fstacks-node%2Fsrc%2Ftests%2Fsigner%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-dGVzdG5ldC9zdGFja3Mtbm9kZS9zcmMvdGVzdHMvc2lnbmVyL21vZC5ycw==) | `87.09% <100.00%> (-4.23%)` | :arrow_down: |
| [stackslib/src/net/api/get\_tenures\_fork\_info.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fnet%2Fapi%2Fget_tenures_fork_info.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9uZXQvYXBpL2dldF90ZW51cmVzX2ZvcmtfaW5mby5ycw==) | `74.75% <95.00%> (+0.90%)` | :arrow_up: |
| [...tackslib/src/chainstate/nakamoto/staging\_blocks.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fchainstate%2Fnakamoto%2Fstaging_blocks.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9jaGFpbnN0YXRlL25ha2Ftb3RvL3N0YWdpbmdfYmxvY2tzLnJz) | `90.74% <92.30%> (+0.08%)` | :arrow_up: |
| [stackslib/src/chainstate/stacks/transaction.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fchainstate%2Fstacks%2Ftransaction.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9jaGFpbnN0YXRlL3N0YWNrcy90cmFuc2FjdGlvbi5ycw==) | `97.21% <0.00%> (+19.69%)` | :arrow_up: |
| [stackslib/src/net/api/mod.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stackslib%2Fsrc%2Fnet%2Fapi%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzbGliL3NyYy9uZXQvYXBpL21vZC5ycw==) | `89.69% <86.20%> (-0.75%)` | :arrow_down: |
| [stacks-signer/src/signerdb.rs](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree&filepath=stacks-signer%2Fsrc%2Fsignerdb.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network#diff-c3RhY2tzLXNpZ25lci9zcmMvc2lnbmVyZGIucnM=) | `94.07% <90.56%> (+10.84%)` | :arrow_up: |
| ... and [3 more](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network) | |

... and [261 files with indirect coverage changes](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network)

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network). Last update [0ca28a0...745ce55](https://app.codecov.io/gh/stacks-network/stacks-core/pull/6020?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stacks-network).

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-06-12T00:25:26Z
body: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
author_association: NONE