diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 2d493753e5..de4a8cfdb3 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -130,6 +130,7 @@ jobs: - tests::signer::v0::continue_after_fast_block_no_sortition - tests::signer::v0::block_validation_response_timeout - tests::signer::v0::tenure_extend_after_bad_commit + - tests::signer::v0::block_proposal_max_age_rejections - tests::nakamoto_integrations::burn_ops_integration_test - tests::nakamoto_integrations::check_block_heights - tests::nakamoto_integrations::clarity_burn_state diff --git a/stacks-signer/CHANGELOG.md b/stacks-signer/CHANGELOG.md index 2f1187de51..073f8d3947 100644 --- a/stacks-signer/CHANGELOG.md +++ b/stacks-signer/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ## Added +- Introduced the `block_proposal_max_age_secs` configuration option for signers, enabling them to automatically ignore block proposals that exceed the specified age in seconds. + ## Changed ## [3.1.0.0.1.0] diff --git a/stacks-signer/src/chainstate.rs b/stacks-signer/src/chainstate.rs index f2f042dffb..a80a51a6dd 100644 --- a/stacks-signer/src/chainstate.rs +++ b/stacks-signer/src/chainstate.rs @@ -367,7 +367,7 @@ impl SortitionsView { tenure_extend.burn_view_consensus_hash != sortition_consensus_hash; let extend_timestamp = signer_db.calculate_tenure_extend_timestamp( self.config.tenure_idle_timeout, - &block, + block, false, ); let epoch_time = get_epoch_time_secs(); diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index ba55bd9810..bdaa368567 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -414,6 +414,7 @@ pub(crate) mod tests { tenure_last_block_proposal_timeout: config.tenure_last_block_proposal_timeout, block_proposal_validation_timeout: config.block_proposal_validation_timeout, tenure_idle_timeout: config.tenure_idle_timeout, + block_proposal_max_age_secs: config.block_proposal_max_age_secs, } } diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index 18412bc5f2..d2d526a589 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -39,6 +39,7 @@ const BLOCK_PROPOSAL_VALIDATION_TIMEOUT_MS: u64 = 120_000; const DEFAULT_FIRST_PROPOSAL_BURN_BLOCK_TIMING_SECS: u64 = 60; const DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS: u64 = 30; const TENURE_IDLE_TIMEOUT_SECS: u64 = 300; +const DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS: u64 = 600; #[derive(thiserror::Error, Debug)] /// An error occurred parsing the provided configuration @@ -138,6 +139,8 @@ pub struct SignerConfig { pub block_proposal_validation_timeout: Duration, /// How much idle time must pass before allowing a tenure extend pub tenure_idle_timeout: Duration, + /// The maximum age of a block proposal in seconds that will be processed by the signer + pub block_proposal_max_age_secs: u64, } /// The parsed configuration for the signer @@ -176,6 +179,8 @@ pub struct GlobalConfig { pub block_proposal_validation_timeout: Duration, /// How much idle time must pass before allowing a tenure extend pub tenure_idle_timeout: Duration, + /// The maximum age of a block proposal that will be processed by the signer + pub block_proposal_max_age_secs: u64, } /// Internal struct for loading up the config file @@ -213,6 +218,8 @@ struct RawConfigFile { pub block_proposal_validation_timeout_ms: Option, /// How much idle time (in seconds) must pass before a tenure extend is allowed pub tenure_idle_timeout_secs: Option, + /// The maximum age of a block proposal (in secs) that will be processed by the signer. + pub block_proposal_max_age_secs: Option, } impl RawConfigFile { @@ -310,6 +317,10 @@ impl TryFrom for GlobalConfig { .unwrap_or(TENURE_IDLE_TIMEOUT_SECS), ); + let block_proposal_max_age_secs = raw_data + .block_proposal_max_age_secs + .unwrap_or(DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS); + Ok(Self { node_host: raw_data.node_host, endpoint, @@ -326,6 +337,7 @@ impl TryFrom for GlobalConfig { tenure_last_block_proposal_timeout, block_proposal_validation_timeout, tenure_idle_timeout, + block_proposal_max_age_secs, }) } } diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index 11faadf871..8af53b2e1a 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -286,6 +286,7 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo tenure_last_block_proposal_timeout: self.config.tenure_last_block_proposal_timeout, block_proposal_validation_timeout: self.config.block_proposal_validation_timeout, tenure_idle_timeout: self.config.tenure_idle_timeout, + block_proposal_max_age_secs: self.config.block_proposal_max_age_secs, })) } diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs index 22a02bc107..df5a1208c3 100644 --- a/stacks-signer/src/v0/signer.rs +++ b/stacks-signer/src/v0/signer.rs @@ -92,6 +92,8 @@ pub struct Signer { pub block_proposal_validation_timeout: Duration, /// The current submitted block proposal and its submission time pub submitted_block_proposal: Option<(BlockProposal, Instant)>, + /// Maximum age of a block proposal in seconds before it is dropped without processing + pub block_proposal_max_age_secs: u64, } impl std::fmt::Display for Signer { @@ -284,6 +286,7 @@ impl From for Signer { proposal_config, submitted_block_proposal: None, block_proposal_validation_timeout: signer_config.block_proposal_validation_timeout, + block_proposal_max_age_secs: signer_config.block_proposal_max_age_secs, } } } @@ -344,6 +347,24 @@ impl Signer { return; } + if block_proposal + .block + .header + .timestamp + .saturating_add(self.block_proposal_max_age_secs) + < get_epoch_time_secs() + { + // Block is too old. Drop it with a warning. Don't even bother broadcasting to the node. + warn!("{self}: Received a block proposal that is more than {} secs old. Ignoring...", self.block_proposal_max_age_secs; + "signer_sighash" => %block_proposal.block.header.signer_signature_hash(), + "block_id" => %block_proposal.block.block_id(), + "block_height" => block_proposal.block.header.chain_length, + "burn_height" => block_proposal.burn_height, + "timestamp" => block_proposal.block.header.timestamp, + ); + return; + } + // TODO: should add a check to ignore an old burn block height if we know its outdated. Would require us to store the burn block height we last saw on the side. // the signer needs to be able to determine whether or not the block they're about to sign would conflict with an already-signed Stacks block let signer_signature_hash = block_proposal.block.header.signer_signature_hash(); diff --git a/testnet/stacks-node/src/tests/signer/mod.rs b/testnet/stacks-node/src/tests/signer/mod.rs index ff128d0a03..3c68b27783 100644 --- a/testnet/stacks-node/src/tests/signer/mod.rs +++ b/testnet/stacks-node/src/tests/signer/mod.rs @@ -310,7 +310,8 @@ impl + Send + 'static, T: SignerEventTrait + 'static> SignerTest + Send + 'static, T: SignerEventTrait + 'static> SignerTest info_before.stacks_tip_height - && blocks_mined > mined_before) + && (!use_nakamoto_blocks_mined || blocks_mined > mined_before)) }) .unwrap(); let mined_block_elapsed_time = mined_block_time.elapsed(); diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs index df20fc0087..00276b09ee 100644 --- a/testnet/stacks-node/src/tests/signer/v0.rs +++ b/testnet/stacks-node/src/tests/signer/v0.rs @@ -23,7 +23,8 @@ use std::{env, thread}; use clarity::vm::types::PrincipalData; use libsigner::v0::messages::{ - BlockRejection, BlockResponse, MessageSlotID, MinerSlotID, RejectCode, SignerMessage, + BlockAccepted, BlockRejection, BlockResponse, MessageSlotID, MinerSlotID, RejectCode, + SignerMessage, }; use libsigner::{BlockProposal, SignerSession, StackerDBSession, VERSION_STRING}; use stacks::address::AddressHashMode; @@ -279,7 +280,7 @@ impl SignerTest { // could be other miners mining blocks. let height_before = get_chain_info(&self.running_nodes.conf).stacks_tip_height; info!("Waiting for first Nakamoto block: {}", height_before + 1); - self.mine_nakamoto_block(Duration::from_secs(30)); + self.mine_nakamoto_block(Duration::from_secs(30), false); wait_for(30, || { Ok(get_chain_info(&self.running_nodes.conf).stacks_tip_height > height_before) }) @@ -288,12 +289,17 @@ impl SignerTest { } // Only call after already past the epoch 3.0 boundary - fn mine_and_verify_confirmed_naka_block(&mut self, timeout: Duration, num_signers: usize) { + fn mine_and_verify_confirmed_naka_block( + &mut self, + timeout: Duration, + num_signers: usize, + use_nakamoto_blocks_mined: bool, + ) { info!("------------------------- Try mining one block -------------------------"); let reward_cycle = self.get_current_reward_cycle(); - self.mine_nakamoto_block(timeout); + self.mine_nakamoto_block(timeout, use_nakamoto_blocks_mined); // Verify that the signers accepted the proposed block, sending back a validate ok response let proposed_signer_signature_hash = self @@ -376,7 +382,7 @@ impl SignerTest { let total_nmb_blocks_to_mine = burnchain_height.saturating_sub(current_block_height); debug!("Mining {total_nmb_blocks_to_mine} Nakamoto block(s) to reach burnchain height {burnchain_height}"); for _ in 0..total_nmb_blocks_to_mine { - self.mine_and_verify_confirmed_naka_block(timeout, num_signers); + self.mine_and_verify_confirmed_naka_block(timeout, num_signers, false); } } @@ -488,6 +494,7 @@ fn block_proposal_rejection() { header: NakamotoBlockHeader::empty(), txs: vec![], }; + block.header.timestamp = get_epoch_time_secs(); // First propose a block to the signers that does not have the correct consensus hash or BitVec. This should be rejected BEFORE // the block is submitted to the node for validation. @@ -588,7 +595,7 @@ fn miner_gather_signatures() { signer_test.boot_to_epoch_3(); info!("------------------------- Test Mine and Verify Confirmed Nakamoto Block -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers); + signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers, true); // Test prometheus metrics response #[cfg(feature = "monitoring_prom")] @@ -820,14 +827,8 @@ fn reloads_signer_set_in() { let sender_addr = tests::to_addr(&sender_sk); let send_amt = 100; let send_fee = 180; - let mut signer_test: SignerTest = SignerTest::new_with_config_modifications( - num_signers, - vec![(sender_addr, send_amt + send_fee)], - |_config| {}, - |_| {}, - None, - None, - ); + let mut signer_test: SignerTest = + SignerTest::new(num_signers, vec![(sender_addr, send_amt + send_fee)]); setup_epoch_3_reward_set( &signer_test.running_nodes.conf, @@ -1331,7 +1332,7 @@ fn bitcoind_forking_test() { 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)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); } let pre_fork_1_nonce = get_account(&http_origin, &miner_address).nonce; @@ -1403,7 +1404,7 @@ fn bitcoind_forking_test() { for i in 0..5 { info!("Mining post-fork tenure {} of 5", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); } let pre_fork_2_nonce = get_account(&http_origin, &miner_address).nonce; @@ -1479,7 +1480,7 @@ fn bitcoind_forking_test() { for i in 0..5 { info!("Mining post-fork tenure {} of 5", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); } let test_end_nonce = get_account(&http_origin, &miner_address).nonce; @@ -2512,7 +2513,7 @@ fn signers_broadcast_signed_blocks() { .running_nodes .nakamoto_blocks_mined .load(Ordering::SeqCst); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); wait_for(30, || { let blocks_mined = signer_test @@ -2613,7 +2614,7 @@ fn tenure_extend_after_idle() { signer_test.boot_to_epoch_3(); info!("---- Nakamoto booted, starting test ----"); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); info!("---- Waiting for a tenure extend ----"); @@ -2675,7 +2676,7 @@ fn stx_transfers_dont_effect_idle_timeout() { "info_height" => info_before.stacks_tip_height, "blocks_before" => blocks_before, ); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); info!("---- Getting current idle timeout ----"); @@ -2813,7 +2814,7 @@ fn idle_tenure_extend_active_mining() { // Add a delay to the block validation process TEST_VALIDATE_DELAY_DURATION_SECS.lock().unwrap().replace(3); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); info!("---- Getting current idle timeout ----"); @@ -2881,7 +2882,7 @@ fn idle_tenure_extend_active_mining() { info!("----- Submitted deploy txs, mining BTC block -----"); - signer_test.mine_nakamoto_block(Duration::from_secs(30)); + signer_test.mine_nakamoto_block(Duration::from_secs(30), true); let mut last_response = signer_test.get_latest_block_response(slot_id); // Make multiple tenures that get extended through idle timeouts @@ -3994,7 +3995,7 @@ fn signer_set_rollover() { send_amt, ); submit_tx(&http_origin, &transfer_tx); - signer_test.mine_nakamoto_block(short_timeout); + signer_test.mine_nakamoto_block(short_timeout, true); let mined_block = test_observer::get_mined_nakamoto_blocks().pop().unwrap(); let block_sighash = mined_block.signer_signature_hash; let signer_signatures = mined_block.signer_signature; @@ -4068,7 +4069,7 @@ fn signer_set_rollover() { }) .expect("Timed out waiting for stacking txs to be mined"); - signer_test.mine_nakamoto_block(short_timeout); + signer_test.mine_nakamoto_block(short_timeout, true); let next_reward_cycle = reward_cycle.saturating_add(1); @@ -4121,7 +4122,7 @@ fn signer_set_rollover() { send_amt, ); submit_tx(&http_origin, &transfer_tx); - signer_test.mine_nakamoto_block(short_timeout); + signer_test.mine_nakamoto_block(short_timeout, true); let mined_block = test_observer::get_mined_nakamoto_blocks().pop().unwrap(); info!("---- Verifying that the new signers signed the block -----"); @@ -4308,7 +4309,7 @@ fn duplicate_signers() { info!("------------------------- Try mining one block -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers); + signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers, true); info!("------------------------- Read all `BlockResponse::Accepted` messages -------------------------"); @@ -6820,7 +6821,7 @@ fn continue_after_tenure_extend() { signer_test.boot_to_epoch_3(); info!("------------------------- Mine Normal Tenure -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers); + signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers, true); info!("------------------------- Extend Tenure -------------------------"); signer_test @@ -7466,7 +7467,7 @@ fn block_validation_response_timeout() { signer_test.boot_to_epoch_3(); info!("------------------------- Test Mine and Verify Confirmed Nakamoto Block -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers); + signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers, true); info!("------------------------- Test Block Validation Stalled -------------------------"); TEST_VALIDATE_STALL.lock().unwrap().replace(true); let validation_stall_start = Instant::now(); @@ -7514,6 +7515,7 @@ fn block_validation_response_timeout() { header: NakamotoBlockHeader::empty(), txs: vec![], }; + block.header.timestamp = get_epoch_time_secs(); let info_before = get_chain_info(&signer_test.running_nodes.conf); // Propose a block to the signers that passes initial checks but will not be submitted to the stacks node due to the submission stall @@ -7583,7 +7585,7 @@ fn block_validation_response_timeout() { ); info!("------------------------- Test Mine and Verify Confirmed Nakamoto Block -------------------------"); let info_before = info_after; - signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers); + signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers, true); wait_for(30, || { let info = get_chain_info(&signer_test.running_nodes.conf); @@ -9029,3 +9031,115 @@ fn tenure_extend_after_2_bad_commits() { run_loop_2_thread.join().unwrap(); signer_test.shutdown(); } + +#[test] +#[ignore] +/// Test the block_proposal_max_age_secs signer configuration option. It should reject blocks that are +/// invalid but within the max age window, otherwise it should simply drop the block without further processing. +/// +/// Test Setup: +/// The test spins up five stacks signers, one miner Nakamoto node, and a corresponding bitcoind. +/// +/// Test Execution: +/// The stacks node is advanced to epoch 3.0 reward set calculation to ensure the signer set is determined. +/// An invalid block proposal with a recent timestamp is forcibly written to the miner's slot to simulate the miner proposing a block. +/// The signers process the invalid block and broadcast a block response rejection to the respective .signers-XXX-YYY contract. +/// A second block proposal with an outdated timestamp is then submitted to the miner's slot to simulate the miner proposing a very old block. +/// The test confirms no further block rejection response is submitted to the .signers-XXX-YYY contract. +/// +/// Test Assertion: +/// - Each signer successfully rejects the recent invalid block proposal. +/// - No signer submits a block proposal response for the outdated block proposal. +/// - The stacks tip does not advance +fn block_proposal_max_age_rejections() { + 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 mut signer_test: SignerTest = SignerTest::new_with_config_modifications( + num_signers, + vec![], + |config| { + config.block_proposal_max_age_secs = 30; + }, + |_| {}, + None, + None, + ); + signer_test.boot_to_epoch_3(); + let short_timeout = Duration::from_secs(30); + + info!("------------------------- Send Block Proposal To Signers -------------------------"); + let info_before = get_chain_info(&signer_test.running_nodes.conf); + let mut block = NakamotoBlock { + header: NakamotoBlockHeader::empty(), + txs: vec![], + }; + // First propose a stale block that is older than the block_proposal_max_age_secs + block.header.timestamp = get_epoch_time_secs().saturating_sub( + signer_test.signer_configs[0] + .block_proposal_max_age_secs + .saturating_add(1), + ); + let block_signer_signature_hash_1 = block.header.signer_signature_hash(); + signer_test.propose_block(block.clone(), short_timeout); + + // Next propose a recent invalid block + block.header.timestamp = get_epoch_time_secs(); + let block_signer_signature_hash_2 = block.header.signer_signature_hash(); + signer_test.propose_block(block, short_timeout); + + info!("------------------------- Test Block Proposal Rejected -------------------------"); + // Verify the signers rejected only the SECOND block proposal. The first was not even processed. + wait_for(30, || { + let rejections: Vec<_> = test_observer::get_stackerdb_chunks() + .into_iter() + .flat_map(|chunk| chunk.modified_slots) + .map(|chunk| { + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + return None; + }; + match message { + SignerMessage::BlockResponse(BlockResponse::Rejected(BlockRejection { + signer_signature_hash, + signature, + .. + })) => { + assert_eq!( + signer_signature_hash, block_signer_signature_hash_2, + "We should only reject the second block" + ); + Some(signature) + } + SignerMessage::BlockResponse(BlockResponse::Accepted(BlockAccepted { + signer_signature_hash, + .. + })) => { + assert_ne!( + signer_signature_hash, block_signer_signature_hash_1, + "We should never have accepted block" + ); + None + } + _ => None, + } + }) + .collect(); + Ok(rejections.len() > num_signers * 7 / 10) + }) + .expect("Timed out waiting for block rejections"); + + info!("------------------------- Test Peer Info-------------------------"); + assert_eq!(info_before, get_chain_info(&signer_test.running_nodes.conf)); + + info!("------------------------- Test Shutdown-------------------------"); + signer_test.shutdown(); +}