diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 456fc27d80..8921f9855d 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -114,6 +114,7 @@ jobs: - tests::signer::v0::locally_accepted_blocks_overriden_by_global_rejection - tests::signer::v0::locally_rejected_blocks_overriden_by_global_acceptance - tests::signer::v0::reorg_locally_accepted_blocks_across_tenures_succeeds + - tests::signer::v0::reorg_locally_accepted_blocks_across_tenures_fails - tests::signer::v0::miner_recovers_when_broadcast_block_delay_across_tenures_occurs - tests::signer::v0::multiple_miners_with_nakamoto_blocks - tests::signer::v0::partial_tenure_fork @@ -135,6 +136,7 @@ jobs: - tests::nakamoto_integrations::utxo_check_on_startup_panic - tests::nakamoto_integrations::utxo_check_on_startup_recover - tests::nakamoto_integrations::v3_signer_api_endpoint + - tests::nakamoto_integrations::signer_chainstate # TODO: enable these once v1 signer is supported by a new nakamoto epoch # - tests::signer::v1::dkg # - tests::signer::v1::sign_request_rejected diff --git a/stacks-signer/src/chainstate.rs b/stacks-signer/src/chainstate.rs index 44ae11b252..fa24c8b22e 100644 --- a/stacks-signer/src/chainstate.rs +++ b/stacks-signer/src/chainstate.rs @@ -19,15 +19,15 @@ use blockstack_lib::chainstate::nakamoto::NakamotoBlock; use blockstack_lib::chainstate::stacks::TenureChangePayload; use blockstack_lib::net::api::getsortition::SortitionInfo; use blockstack_lib::util_lib::db::Error as DBError; -use clarity::types::chainstate::BurnchainHeaderHash; use slog::{slog_info, slog_warn}; -use stacks_common::types::chainstate::{ConsensusHash, StacksPublicKey}; +use stacks_common::types::chainstate::{BurnchainHeaderHash, ConsensusHash, StacksPublicKey}; +use stacks_common::util::get_epoch_time_secs; use stacks_common::util::hash::Hash160; use stacks_common::{info, warn}; use crate::client::{ClientError, CurrentAndLastSortition, StacksClient}; use crate::config::SignerConfig; -use crate::signerdb::{BlockState, SignerDb}; +use crate::signerdb::{BlockInfo, BlockState, SignerDb}; #[derive(thiserror::Error, Debug)] /// Error type for the signer chainstate module @@ -119,6 +119,9 @@ pub struct ProposalEvalConfig { pub first_proposal_burn_block_timing: Duration, /// Time between processing a sortition and proposing a block before the block is considered invalid pub block_proposal_timeout: Duration, + /// Time to wait for the last block of a tenure to be globally accepted or rejected before considering + /// a new miner's block at the same height as valid. + pub tenure_last_block_proposal_timeout: Duration, } impl From<&SignerConfig> for ProposalEvalConfig { @@ -126,6 +129,7 @@ impl From<&SignerConfig> for ProposalEvalConfig { Self { first_proposal_burn_block_timing: value.first_proposal_burn_block_timing, block_proposal_timeout: value.block_proposal_timeout, + tenure_last_block_proposal_timeout: value.tenure_last_block_proposal_timeout, } } } @@ -460,7 +464,36 @@ impl SortitionsView { Ok(true) } - /// Check if the tenure change block confirms the expected parent block (i.e., the last globally accepted block in the parent tenure) + /// Get the last block from the given tenure + /// Returns the last locally accepted block if it is not timed out, otherwise it will return the last globally accepted block. + fn get_tenure_last_block_info( + consensus_hash: &ConsensusHash, + signer_db: &SignerDb, + tenure_last_block_proposal_timeout: Duration, + ) -> Result, ClientError> { + // Get the last known block in the previous tenure + let last_locally_accepted_block = signer_db + .get_last_accepted_block(consensus_hash) + .map_err(|e| ClientError::InvalidResponse(e.to_string()))?; + + if let Some(local_info) = last_locally_accepted_block { + if let Some(signed_over_time) = local_info.signed_self { + if signed_over_time + tenure_last_block_proposal_timeout.as_secs() + > get_epoch_time_secs() + { + // The last locally accepted block is not timed out, return it + return Ok(Some(local_info)); + } + } + } + // The last locally accepted block is timed out, get the last globally accepted block + signer_db + .get_last_globally_accepted_block(consensus_hash) + .map_err(|e| ClientError::InvalidResponse(e.to_string())) + } + + /// Check if the tenure change block confirms the expected parent block + /// (i.e., the last locally accepted block in the parent tenure, or if that block is timed out, the last globally accepted block in the parent tenure) /// It checks the local DB first, and if the block is not present in the local DB, it asks the /// Stacks node for the highest processed block header in the given tenure (and then caches it /// in the DB). @@ -473,24 +506,27 @@ impl SortitionsView { reward_cycle: u64, signer_db: &mut SignerDb, client: &StacksClient, + tenure_last_block_proposal_timeout: Duration, ) -> Result { - // If the tenure change block confirms the expected parent block, it should confirm at least one more block than the last globally accepted block in the parent tenure. - let last_globally_accepted_block = signer_db - .get_last_globally_accepted_block(&tenure_change.prev_tenure_consensus_hash) - .map_err(|e| ClientError::InvalidResponse(e.to_string()))?; + // If the tenure change block confirms the expected parent block, it should confirm at least one more block than the last accepted block in the parent tenure. + let last_block_info = Self::get_tenure_last_block_info( + &tenure_change.prev_tenure_consensus_hash, + signer_db, + tenure_last_block_proposal_timeout, + )?; - if let Some(global_info) = last_globally_accepted_block { + if let Some(info) = last_block_info { // N.B. this block might not be the last globally accepted block across the network; // it's just the highest one in this tenure that we know about. If this given block is // no higher than it, then it's definitely no higher than the last globally accepted // block across the network, so we can do an early rejection here. - if block.header.chain_length <= global_info.block.header.chain_length { + if block.header.chain_length <= info.block.header.chain_length { warn!( "Miner's block proposal does not confirm as many blocks as we expect"; "proposed_block_consensus_hash" => %block.header.consensus_hash, "proposed_block_signer_sighash" => %block.header.signer_signature_hash(), "proposed_chain_length" => block.header.chain_length, - "expected_at_least" => global_info.block.header.chain_length + 1, + "expected_at_least" => info.block.header.chain_length + 1, ); return Ok(false); } @@ -558,6 +594,7 @@ impl SortitionsView { reward_cycle, signer_db, client, + self.config.tenure_last_block_proposal_timeout, )?; if !confirms_expected_parent { return Ok(false); @@ -573,15 +610,15 @@ impl SortitionsView { if !is_valid_parent_tenure { return Ok(false); } - let last_in_tenure = signer_db + let last_in_current_tenure = signer_db .get_last_globally_accepted_block(&block.header.consensus_hash) .map_err(|e| ClientError::InvalidResponse(e.to_string()))?; - if let Some(last_in_tenure) = last_in_tenure { + if let Some(last_in_current_tenure) = last_in_current_tenure { warn!( "Miner block proposal contains a tenure change, but we've already signed a block in this tenure. Considering proposal invalid."; "proposed_block_consensus_hash" => %block.header.consensus_hash, "proposed_block_signer_sighash" => %block.header.signer_signature_hash(), - "last_in_tenure_signer_sighash" => %last_in_tenure.block.header.signer_signature_hash(), + "last_in_tenure_signer_sighash" => %last_in_current_tenure.block.header.signer_signature_hash(), ); return Ok(false); } diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index 9885182d98..8df1d81daf 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -411,6 +411,7 @@ pub(crate) mod tests { db_path: config.db_path.clone(), first_proposal_burn_block_timing: config.first_proposal_burn_block_timing, block_proposal_timeout: config.block_proposal_timeout, + tenure_last_block_proposal_timeout: config.tenure_last_block_proposal_timeout, } } diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index 7dd9cc4fdf..2fe8e48093 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -36,6 +36,7 @@ use crate::client::SignerSlotID; const EVENT_TIMEOUT_MS: u64 = 5000; const BLOCK_PROPOSAL_TIMEOUT_MS: u64 = 600_000; const DEFAULT_FIRST_PROPOSAL_BURN_BLOCK_TIMING_SECS: u64 = 60; +const DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS: u64 = 30; #[derive(thiserror::Error, Debug)] /// An error occurred parsing the provided configuration @@ -128,6 +129,9 @@ pub struct SignerConfig { pub first_proposal_burn_block_timing: Duration, /// How much time to wait for a miner to propose a block following a sortition pub block_proposal_timeout: Duration, + /// Time to wait for the last block of a tenure to be globally accepted or rejected + /// before considering a new miner's block at the same height as potentially valid. + pub tenure_last_block_proposal_timeout: Duration, } /// The parsed configuration for the signer @@ -158,6 +162,9 @@ pub struct GlobalConfig { pub block_proposal_timeout: Duration, /// An optional custom Chain ID pub chain_id: Option, + /// Time to wait for the last block of a tenure to be globally accepted or rejected + /// before considering a new miner's block at the same height as potentially valid. + pub tenure_last_block_proposal_timeout: Duration, } /// Internal struct for loading up the config file @@ -180,13 +187,16 @@ struct RawConfigFile { pub db_path: String, /// Metrics endpoint pub metrics_endpoint: Option, - /// How much time must pass between the first block proposal in a tenure and the next bitcoin block + /// How much time must pass in seconds between the first block proposal in a tenure and the next bitcoin block /// before a subsequent miner isn't allowed to reorg the tenure pub first_proposal_burn_block_timing_secs: Option, /// How much time to wait for a miner to propose a block following a sortition in milliseconds pub block_proposal_timeout_ms: Option, /// An optional custom Chain ID pub chain_id: Option, + /// Time in seconds to wait for the last block of a tenure to be globally accepted or rejected + /// before considering a new miner's block at the same height as potentially valid. + pub tenure_last_block_proposal_timeout_secs: Option, } impl RawConfigFile { @@ -266,6 +276,12 @@ impl TryFrom for GlobalConfig { .unwrap_or(BLOCK_PROPOSAL_TIMEOUT_MS), ); + let tenure_last_block_proposal_timeout = Duration::from_secs( + raw_data + .tenure_last_block_proposal_timeout_secs + .unwrap_or(DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS), + ); + Ok(Self { node_host: raw_data.node_host, endpoint, @@ -279,6 +295,7 @@ impl TryFrom for GlobalConfig { first_proposal_burn_block_timing, block_proposal_timeout, chain_id: raw_data.chain_id, + tenure_last_block_proposal_timeout, }) } } diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index a0e2b739e9..2901bb0052 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -283,6 +283,7 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo mainnet: self.config.network.is_mainnet(), db_path: self.config.db_path.clone(), block_proposal_timeout: self.config.block_proposal_timeout, + tenure_last_block_proposal_timeout: self.config.tenure_last_block_proposal_timeout, })) } diff --git a/stacks-signer/src/signerdb.rs b/stacks-signer/src/signerdb.rs index 06b9d703c3..1d2e650207 100644 --- a/stacks-signer/src/signerdb.rs +++ b/stacks-signer/src/signerdb.rs @@ -24,7 +24,6 @@ use blockstack_lib::util_lib::db::{ Error as DBError, }; use clarity::types::chainstate::{BurnchainHeaderHash, StacksAddress}; -use clarity::util::get_epoch_time_secs; use libsigner::BlockProposal; use rusqlite::{ params, Connection, Error as SqliteError, OpenFlags, OptionalExtension, Transaction, @@ -33,6 +32,7 @@ use serde::{Deserialize, Serialize}; use slog::{slog_debug, slog_error}; use stacks_common::codec::{read_next, write_next, Error as CodecError, StacksMessageCodec}; use stacks_common::types::chainstate::ConsensusHash; +use stacks_common::util::get_epoch_time_secs; use stacks_common::util::hash::Sha512Trunc256Sum; use stacks_common::util::secp256k1::MessageSignature; use stacks_common::{debug, define_u8_enum, error}; diff --git a/stacks-signer/src/tests/chainstate.rs b/stacks-signer/src/tests/chainstate.rs index 886480f063..bec9f1258d 100644 --- a/stacks-signer/src/tests/chainstate.rs +++ b/stacks-signer/src/tests/chainstate.rs @@ -89,6 +89,7 @@ fn setup_test_environment( config: ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(30), block_proposal_timeout: Duration::from_secs(5), + tenure_last_block_proposal_timeout: Duration::from_secs(30), }, }; diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index 8af9cf6ec7..fd370a8b12 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -1101,13 +1101,12 @@ pub const MAX_MICROBLOCK_SIZE: u32 = 65536; #[cfg(test)] pub mod test { - use clarity::util::get_epoch_time_secs; use clarity::vm::representations::{ClarityName, ContractName}; use clarity::vm::ClarityVersion; use stacks_common::bitvec::BitVec; use stacks_common::util::hash::*; - use stacks_common::util::log; use stacks_common::util::secp256k1::Secp256k1PrivateKey; + use stacks_common::util::{get_epoch_time_secs, log}; use super::*; use crate::chainstate::burn::BlockSnapshot; diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index 3e9f235424..6d70f5b51e 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -6369,6 +6369,7 @@ fn signer_chainstate() { let proposal_conf = ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(0), block_proposal_timeout: Duration::from_secs(100), + tenure_last_block_proposal_timeout: Duration::from_secs(30), }; let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); @@ -6507,6 +6508,7 @@ fn signer_chainstate() { let proposal_conf = ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(0), block_proposal_timeout: Duration::from_secs(100), + tenure_last_block_proposal_timeout: Duration::from_secs(30), }; let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) .unwrap() @@ -6541,10 +6543,10 @@ fn signer_chainstate() { valid: Some(true), signed_over: true, proposed_time: get_epoch_time_secs(), - signed_self: None, - signed_group: None, + signed_self: Some(get_epoch_time_secs()), + signed_group: Some(get_epoch_time_secs()), ext: ExtraBlockInfo::None, - state: BlockState::Unprocessed, + state: BlockState::GloballyAccepted, }) .unwrap(); @@ -6584,6 +6586,7 @@ fn signer_chainstate() { let proposal_conf = ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(0), block_proposal_timeout: Duration::from_secs(100), + tenure_last_block_proposal_timeout: Duration::from_secs(30), }; let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs index 52ecddbfae..7fcfc6b3f3 100644 --- a/testnet/stacks-node/src/tests/signer/v0.rs +++ b/testnet/stacks-node/src/tests/signer/v0.rs @@ -455,6 +455,7 @@ fn block_proposal_rejection() { let proposal_conf = ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(0), block_proposal_timeout: Duration::from_secs(100), + tenure_last_block_proposal_timeout: Duration::from_secs(30), }; let mut block = NakamotoBlock { header: NakamotoBlockHeader::empty(), @@ -4834,8 +4835,8 @@ fn locally_rejected_blocks_overriden_by_global_acceptance() { #[test] #[ignore] -/// Test that signers that have accept a locally signed block N+1 built in tenure A can sign a block proposed during a -/// new tenure B built upon the last globally accepted block N, i.e. a reorg can occur at a tenure boundary. +/// Test that signers that have accepted a locally signed block N+1 built in tenure A can sign a block proposed during a +/// new tenure B built upon the last globally accepted block N if the timeout is exceeded, i.e. a reorg can occur at a tenure boundary. /// /// Test Setup: /// The test spins up five stacks signers, one miner Nakamoto node, and a corresponding bitcoind. @@ -4865,9 +4866,16 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { let send_fee = 180; let nmb_txs = 2; let recipient = PrincipalData::from(StacksAddress::burn_address(false)); - let mut signer_test: SignerTest = SignerTest::new( + let mut signer_test: SignerTest = SignerTest::new_with_config_modifications( num_signers, vec![(sender_addr, (send_amt + send_fee) * nmb_txs)], + |config| { + // Just accept all reorg attempts + config.tenure_last_block_proposal_timeout = Duration::from_secs(0); + }, + |_| {}, + None, + None, ); let all_signers = signer_test .signer_stacks_private_keys @@ -4927,6 +4935,11 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { .cloned() .take(num_signers * 7 / 10) .collect(); + let non_ignoring_signers: Vec<_> = all_signers + .iter() + .cloned() + .skip(num_signers * 7 / 10) + .collect(); TEST_IGNORE_ALL_BLOCK_PROPOSALS .lock() .unwrap() @@ -4952,7 +4965,7 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { .get_peer_info() .expect("Failed to get peer info"); wait_for(short_timeout, || { - let ignored_signers = test_observer::get_stackerdb_chunks() + let accepted_signers = test_observer::get_stackerdb_chunks() .into_iter() .flat_map(|chunk| chunk.modified_slots) .filter_map(|chunk| { @@ -4960,7 +4973,7 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { .expect("Failed to deserialize SignerMessage"); match message { SignerMessage::BlockResponse(BlockResponse::Accepted(accepted)) => { - ignoring_signers.iter().find(|key| { + non_ignoring_signers.iter().find(|key| { key.verify(accepted.signer_signature_hash.bits(), &accepted.signature) .is_ok() }) @@ -4969,7 +4982,7 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { } }) .collect::>(); - Ok(ignored_signers.len() + ignoring_signers.len() == num_signers) + Ok(accepted_signers.len() + ignoring_signers.len() == num_signers) }) .expect("FAIL: Timed out waiting for block proposal acceptance"); @@ -5044,6 +5057,237 @@ fn reorg_locally_accepted_blocks_across_tenures_succeeds() { .expect("Timed out waiting for block acceptance of N+1'"); } +#[test] +#[ignore] +/// Test that signers that have accepted a locally signed block N+1 built in tenure A cannot sign a block proposed during a +/// new tenure B built upon the last globally accepted block N if the timeout is not exceeded, i.e. a reorg cannot occur at a tenure boundary +/// before the specified timeout has been exceeded. +/// +/// Test Setup: +/// The test spins up five stacks signers, one miner Nakamoto node, and a corresponding bitcoind. +/// The stacks node is then advanced to Epoch 3.0 boundary to allow block signing. +/// +/// Test Execution: +/// The node mines 1 stacks block N (all signers sign it). The subsequent block N+1 is proposed, but <30% accept it. The remaining signers +/// do not make a decision on the block. A new tenure begins and the miner proposes a new block N+1' which all signers reject as the timeout +/// has not been exceeded. +/// +/// Test Assertion: +/// Stacks tip remains at N. +fn reorg_locally_accepted_blocks_across_tenures_fails() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env()) + .init(); + + info!("------------------------- Test Setup -------------------------"); + let num_signers = 5; + let sender_sk = Secp256k1PrivateKey::new(); + let sender_addr = tests::to_addr(&sender_sk); + let send_amt = 100; + let send_fee = 180; + let nmb_txs = 2; + let recipient = PrincipalData::from(StacksAddress::burn_address(false)); + let mut signer_test: SignerTest = SignerTest::new_with_config_modifications( + num_signers, + vec![(sender_addr, (send_amt + send_fee) * nmb_txs)], + |config| { + // Do not alow any reorg attempts essentially + config.tenure_last_block_proposal_timeout = Duration::from_secs(100_000); + }, + |_| {}, + None, + None, + ); + let all_signers = signer_test + .signer_stacks_private_keys + .iter() + .map(StacksPublicKey::from_private) + .collect::>(); + let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind); + let short_timeout = 30; + signer_test.boot_to_epoch_3(); + info!("------------------------- Starting Tenure A -------------------------"); + info!("------------------------- Test Mine Nakamoto Block N -------------------------"); + let mined_blocks = signer_test.running_nodes.nakamoto_blocks_mined.clone(); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + + // submit a tx so that the miner will mine a stacks block + let mut sender_nonce = 0; + let transfer_tx = make_stacks_transfer( + &sender_sk, + sender_nonce, + send_fee, + signer_test.running_nodes.conf.burnchain.chain_id, + &recipient, + send_amt, + ); + let tx = submit_tx(&http_origin, &transfer_tx); + sender_nonce += 1; + info!("Submitted tx {tx} in to mine block N"); + wait_for(short_timeout, || { + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + Ok(info_after.stacks_tip_height > info_before.stacks_tip_height) + }) + .expect("Timed out waiting for block to be mined and processed"); + + // Ensure that the block was accepted globally so the stacks tip has advanced to N + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + assert_eq!( + info_before.stacks_tip_height + 1, + info_after.stacks_tip_height + ); + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n = nakamoto_blocks.last().unwrap(); + assert_eq!(info_after.stacks_tip.to_string(), block_n.block_hash); + + info!("------------------------- Attempt to Mine Nakamoto Block N+1 -------------------------"); + // Make more than >70% of the signers ignore the block proposal to ensure it it is not globally accepted/rejected + let ignoring_signers: Vec<_> = all_signers + .iter() + .cloned() + .take(num_signers * 7 / 10) + .collect(); + let non_ignoring_signers: Vec<_> = all_signers + .iter() + .cloned() + .skip(num_signers * 7 / 10) + .collect(); + TEST_IGNORE_ALL_BLOCK_PROPOSALS + .lock() + .unwrap() + .replace(ignoring_signers.clone()); + // Clear the stackerdb chunks + test_observer::clear(); + + // submit a tx so that the miner will ATTEMPT to mine a stacks block N+1 + let transfer_tx = make_stacks_transfer( + &sender_sk, + sender_nonce, + send_fee, + signer_test.running_nodes.conf.burnchain.chain_id, + &recipient, + send_amt, + ); + let tx = submit_tx(&http_origin, &transfer_tx); + + info!("Submitted tx {tx} in to attempt to mine block N+1"); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + wait_for(short_timeout, || { + let accepted_signers = test_observer::get_stackerdb_chunks() + .into_iter() + .flat_map(|chunk| chunk.modified_slots) + .filter_map(|chunk| { + let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + .expect("Failed to deserialize SignerMessage"); + match message { + SignerMessage::BlockResponse(BlockResponse::Accepted(accepted)) => { + non_ignoring_signers.iter().find(|key| { + key.verify(accepted.signer_signature_hash.bits(), &accepted.signature) + .is_ok() + }) + } + _ => None, + } + }) + .collect::>(); + Ok(accepted_signers.len() + ignoring_signers.len() == num_signers) + }) + .expect("FAIL: Timed out waiting for block proposal acceptance"); + + let blocks_after = mined_blocks.load(Ordering::SeqCst); + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + assert_eq!(blocks_after, blocks_before); + assert_eq!(info_after, info_before); + // Ensure that the block was NOT accepted globally so the stacks tip has NOT advanced to N+1 + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_1 = nakamoto_blocks.last().unwrap(); + assert_ne!(block_n_1, block_n); + assert_ne!(info_after.stacks_tip.to_string(), block_n_1.block_hash); + + info!("------------------------- Starting Tenure B -------------------------"); + // Start a new tenure and ensure the miner can propose a new block N+1' that is accepted by all signers + let commits_submitted = signer_test.running_nodes.commits_submitted.clone(); + let commits_before = commits_submitted.load(Ordering::SeqCst); + next_block_and( + &mut signer_test.running_nodes.btc_regtest_controller, + 60, + || { + let commits_count = commits_submitted.load(Ordering::SeqCst); + Ok(commits_count > commits_before) + }, + ) + .unwrap(); + + info!( + "------------------------- Attempt to mine Nakamoto Block N+1' in Tenure B -------------------------" + ); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + // The miner's proposed block should get rejected by all the signers that PREVIOUSLY accepted the block + wait_for(short_timeout, || { + let rejected_signers = test_observer::get_stackerdb_chunks() + .into_iter() + .flat_map(|chunk| chunk.modified_slots) + .filter_map(|chunk| { + let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + .expect("Failed to deserialize SignerMessage"); + match message { + SignerMessage::BlockResponse(BlockResponse::Rejected(BlockRejection { + signature, + signer_signature_hash, + .. + })) => non_ignoring_signers + .iter() + .find(|key| key.verify(signer_signature_hash.bits(), &signature).is_ok()), + _ => None, + } + }) + .collect::>(); + Ok(rejected_signers.len() + ignoring_signers.len() == num_signers) + }) + .expect("FAIL: Timed out waiting for block proposal rejections"); + + let blocks_after = mined_blocks.load(Ordering::SeqCst); + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + assert_eq!(blocks_after, blocks_before); + assert_eq!(info_after, info_before); + // Ensure that the block was NOT accepted globally so the stacks tip has NOT advanced to N+1' + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_1_prime = nakamoto_blocks.last().unwrap(); + assert_ne!(block_n_1, block_n_1_prime); + assert_ne!( + info_after.stacks_tip.to_string(), + block_n_1_prime.block_hash + ); +} + #[test] #[ignore] /// Test that when 70% of signers accept a block, mark it globally accepted, but a miner ends its tenure