diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 14bd7ef023..e38c862552 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -100,6 +100,10 @@ jobs: - tests::signer::v0::signers_broadcast_signed_blocks - tests::signer::v0::min_gap_between_blocks - tests::signer::v0::duplicate_signers + - 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::miner_recovers_when_broadcast_block_delay_across_tenures_occurs - tests::nakamoto_integrations::stack_stx_burn_op_integration_test - tests::nakamoto_integrations::check_block_heights - tests::nakamoto_integrations::clarity_burn_state diff --git a/libsigner/src/v0/messages.rs b/libsigner/src/v0/messages.rs index 5f7b82a937..ae565207a7 100644 --- a/libsigner/src/v0/messages.rs +++ b/libsigner/src/v0/messages.rs @@ -42,6 +42,7 @@ use blockstack_lib::util_lib::boot::boot_code_id; use blockstack_lib::util_lib::signed_structured_data::{ make_structured_data_domain, structured_data_message_hash, }; +use clarity::consts::{CHAIN_ID_MAINNET, CHAIN_ID_TESTNET}; use clarity::types::chainstate::{ BlockHeaderHash, ConsensusHash, StacksPrivateKey, StacksPublicKey, }; @@ -525,7 +526,9 @@ RejectCodeTypePrefix { /// The block was rejected due to no sortition view NoSortitionView = 3, /// The block was rejected due to a mismatch with expected sortition view - SortitionViewMismatch = 4 + SortitionViewMismatch = 4, + /// The block was rejected due to a testing directive + TestingDirective = 5 }); impl TryFrom for RejectCodeTypePrefix { @@ -545,6 +548,7 @@ impl From<&RejectCode> for RejectCodeTypePrefix { RejectCode::RejectedInPriorRound => RejectCodeTypePrefix::RejectedInPriorRound, RejectCode::NoSortitionView => RejectCodeTypePrefix::NoSortitionView, RejectCode::SortitionViewMismatch => RejectCodeTypePrefix::SortitionViewMismatch, + RejectCode::TestingDirective => RejectCodeTypePrefix::TestingDirective, } } } @@ -562,6 +566,8 @@ pub enum RejectCode { RejectedInPriorRound, /// The block was rejected due to a mismatch with expected sortition view SortitionViewMismatch, + /// The block was rejected due to a testing directive + TestingDirective, } define_u8_enum!( @@ -615,8 +621,8 @@ impl std::fmt::Display for BlockResponse { BlockResponse::Rejected(r) => { write!( f, - "BlockRejected: signer_sighash = {}, code = {}, reason = {}", - r.reason_code, r.reason, r.signer_signature_hash + "BlockRejected: signer_sighash = {}, code = {}, reason = {}, signature = {}", + r.reason_code, r.reason, r.signer_signature_hash, r.signature ) } } @@ -629,9 +635,14 @@ impl BlockResponse { Self::Accepted((hash, sig)) } - /// Create a new rejected BlockResponse for the provided block signer signature hash and rejection code - pub fn rejected(hash: Sha512Trunc256Sum, reject_code: RejectCode) -> Self { - Self::Rejected(BlockRejection::new(hash, reject_code)) + /// Create a new rejected BlockResponse for the provided block signer signature hash and rejection code and sign it with the provided private key + pub fn rejected( + hash: Sha512Trunc256Sum, + reject_code: RejectCode, + private_key: &StacksPrivateKey, + mainnet: bool, + ) -> Self { + Self::Rejected(BlockRejection::new(hash, reject_code, private_key, mainnet)) } } @@ -677,16 +688,94 @@ pub struct BlockRejection { pub reason_code: RejectCode, /// The signer signature hash of the block that was rejected pub signer_signature_hash: Sha512Trunc256Sum, + /// The signer's signature across the rejection + pub signature: MessageSignature, + /// The chain id + pub chain_id: u32, } impl BlockRejection { /// Create a new BlockRejection for the provided block and reason code - pub fn new(signer_signature_hash: Sha512Trunc256Sum, reason_code: RejectCode) -> Self { - Self { + pub fn new( + signer_signature_hash: Sha512Trunc256Sum, + reason_code: RejectCode, + private_key: &StacksPrivateKey, + mainnet: bool, + ) -> Self { + let chain_id = if mainnet { + CHAIN_ID_MAINNET + } else { + CHAIN_ID_TESTNET + }; + let mut rejection = Self { reason: reason_code.to_string(), reason_code, signer_signature_hash, + signature: MessageSignature::empty(), + chain_id, + }; + rejection + .sign(private_key) + .expect("Failed to sign BlockRejection"); + rejection + } + + /// Create a new BlockRejection from a BlockValidateRejection + pub fn from_validate_rejection( + reject: BlockValidateReject, + private_key: &StacksPrivateKey, + mainnet: bool, + ) -> Self { + let chain_id = if mainnet { + CHAIN_ID_MAINNET + } else { + CHAIN_ID_TESTNET + }; + let mut rejection = Self { + reason: reject.reason, + reason_code: RejectCode::ValidationFailed(reject.reason_code), + signer_signature_hash: reject.signer_signature_hash, + chain_id, + signature: MessageSignature::empty(), + }; + rejection + .sign(private_key) + .expect("Failed to sign BlockRejection"); + rejection + } + + /// The signature hash for the block rejection + pub fn hash(&self) -> Sha256Sum { + let domain_tuple = make_structured_data_domain("block-rejection", "1.0.0", self.chain_id); + let data = Value::buff_from(self.signer_signature_hash.as_bytes().into()).unwrap(); + structured_data_message_hash(data, domain_tuple) + } + + /// Sign the block rejection and set the internal signature field + fn sign(&mut self, private_key: &StacksPrivateKey) -> Result<(), String> { + let signature_hash = self.hash(); + self.signature = private_key.sign(signature_hash.as_bytes())?; + Ok(()) + } + + /// Verify the rejection's signature against the provided signer public key + pub fn verify(&self, public_key: &StacksPublicKey) -> Result { + if self.signature == MessageSignature::empty() { + return Ok(false); + } + let signature_hash = self.hash(); + public_key + .verify(&signature_hash.0, &self.signature) + .map_err(|e| e.to_string()) + } + + /// Recover the public key from the rejection signature + pub fn recover_public_key(&self) -> Result { + if self.signature == MessageSignature::empty() { + return Err("No signature to recover public key from"); } + let signature_hash = self.hash(); + StacksPublicKey::recover_to_pubkey(signature_hash.as_bytes(), &self.signature) } } @@ -695,6 +784,8 @@ impl StacksMessageCodec for BlockRejection { write_next(fd, &self.reason.as_bytes().to_vec())?; write_next(fd, &self.reason_code)?; write_next(fd, &self.signer_signature_hash)?; + write_next(fd, &self.chain_id)?; + write_next(fd, &self.signature)?; Ok(()) } @@ -705,24 +796,18 @@ impl StacksMessageCodec for BlockRejection { })?; let reason_code = read_next::(fd)?; let signer_signature_hash = read_next::(fd)?; + let chain_id = read_next::(fd)?; + let signature = read_next::(fd)?; Ok(Self { reason, reason_code, signer_signature_hash, + chain_id, + signature, }) } } -impl From for BlockRejection { - fn from(reject: BlockValidateReject) -> Self { - Self { - reason: reject.reason, - reason_code: RejectCode::ValidationFailed(reject.reason_code), - signer_signature_hash: reject.signer_signature_hash, - } - } -} - impl StacksMessageCodec for RejectCode { fn consensus_serialize(&self, fd: &mut W) -> Result<(), CodecError> { write_next(fd, &(RejectCodeTypePrefix::from(self) as u8))?; @@ -732,7 +817,8 @@ impl StacksMessageCodec for RejectCode { RejectCode::ConnectivityIssues | RejectCode::RejectedInPriorRound | RejectCode::NoSortitionView - | RejectCode::SortitionViewMismatch => { + | RejectCode::SortitionViewMismatch + | RejectCode::TestingDirective => { // No additional data to serialize / deserialize } }; @@ -755,6 +841,7 @@ impl StacksMessageCodec for RejectCode { RejectCodeTypePrefix::RejectedInPriorRound => RejectCode::RejectedInPriorRound, RejectCodeTypePrefix::NoSortitionView => RejectCode::NoSortitionView, RejectCodeTypePrefix::SortitionViewMismatch => RejectCode::SortitionViewMismatch, + RejectCodeTypePrefix::TestingDirective => RejectCode::TestingDirective, }; Ok(code) } @@ -782,6 +869,9 @@ impl std::fmt::Display for RejectCode { "The block was rejected due to a mismatch with expected sortition view." ) } + RejectCode::TestingDirective => { + write!(f, "The block was rejected due to a testing directive.") + } } } } @@ -792,12 +882,6 @@ impl From for SignerMessage { } } -impl From for BlockResponse { - fn from(rejection: BlockValidateReject) -> Self { - Self::Rejected(rejection.into()) - } -} - #[cfg(test)] mod test { use blockstack_lib::chainstate::nakamoto::NakamotoBlockHeader; @@ -851,14 +935,20 @@ mod test { let rejection = BlockRejection::new( Sha512Trunc256Sum([0u8; 32]), RejectCode::ValidationFailed(ValidateRejectCode::InvalidBlock), + &StacksPrivateKey::new(), + thread_rng().gen_bool(0.5), ); let serialized_rejection = rejection.serialize_to_vec(); let deserialized_rejection = read_next::(&mut &serialized_rejection[..]) .expect("Failed to deserialize BlockRejection"); assert_eq!(rejection, deserialized_rejection); - let rejection = - BlockRejection::new(Sha512Trunc256Sum([1u8; 32]), RejectCode::ConnectivityIssues); + let rejection = BlockRejection::new( + Sha512Trunc256Sum([1u8; 32]), + RejectCode::ConnectivityIssues, + &StacksPrivateKey::new(), + thread_rng().gen_bool(0.5), + ); let serialized_rejection = rejection.serialize_to_vec(); let deserialized_rejection = read_next::(&mut &serialized_rejection[..]) .expect("Failed to deserialize BlockRejection"); @@ -877,6 +967,8 @@ mod test { let response = BlockResponse::Rejected(BlockRejection::new( Sha512Trunc256Sum([1u8; 32]), RejectCode::ValidationFailed(ValidateRejectCode::InvalidBlock), + &StacksPrivateKey::new(), + thread_rng().gen_bool(0.5), )); let serialized_response = response.serialize_to_vec(); let deserialized_response = read_next::(&mut &serialized_response[..]) diff --git a/stacks-signer/Cargo.toml b/stacks-signer/Cargo.toml index 1d1af6da78..64e3cd5ca9 100644 --- a/stacks-signer/Cargo.toml +++ b/stacks-signer/Cargo.toml @@ -62,4 +62,5 @@ version = "0.24.3" features = ["serde", "recovery"] [features] -monitoring_prom = ["libsigner/monitoring_prom", "prometheus", "tiny_http"] \ No newline at end of file +monitoring_prom = ["libsigner/monitoring_prom", "prometheus", "tiny_http"] +testing = [] \ No newline at end of file diff --git a/stacks-signer/src/chainstate.rs b/stacks-signer/src/chainstate.rs index c35ceb67e0..a017adf44f 100644 --- a/stacks-signer/src/chainstate.rs +++ b/stacks-signer/src/chainstate.rs @@ -27,7 +27,7 @@ use stacks_common::{info, warn}; use crate::client::{ClientError, StacksClient}; use crate::config::SignerConfig; -use crate::signerdb::SignerDb; +use crate::signerdb::{BlockState, SignerDb}; #[derive(thiserror::Error, Debug)] /// Error type for the signer chainstate module @@ -185,9 +185,10 @@ impl SortitionsView { pub fn check_proposal( &mut self, client: &StacksClient, - signer_db: &SignerDb, + signer_db: &mut SignerDb, block: &NakamotoBlock, block_pk: &StacksPublicKey, + reward_cycle: u64, ) -> Result { if self .cur_sortition @@ -280,42 +281,20 @@ impl SortitionsView { }; if let Some(tenure_change) = block.get_tenure_change_tx_payload() { - // in tenure changes, we need to check: - // (1) if the tenure change confirms the expected parent block (i.e., - // the last block we signed in the parent tenure) - // (2) if the parent tenure was a valid choice - let confirms_expected_parent = - Self::check_tenure_change_block_confirmation(tenure_change, block, signer_db)?; - if !confirms_expected_parent { - return Ok(false); - } - // now, we have to check if the parent tenure was a valid choice. - let is_valid_parent_tenure = Self::check_parent_tenure_choice( - proposed_by.state(), + if !self.validate_tenure_change_payload( + &proposed_by, + tenure_change, block, + reward_cycle, signer_db, client, - &self.config.first_proposal_burn_block_timing, - )?; - if !is_valid_parent_tenure { - return Ok(false); - } - let last_in_tenure = signer_db - .get_last_signed_block_in_tenure(&block.header.consensus_hash) - .map_err(|e| ClientError::InvalidResponse(e.to_string()))?; - if let Some(last_in_tenure) = last_in_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(), - ); + )? { return Ok(false); } } else { // check if the new block confirms the last block in the current tenure let confirms_latest_in_tenure = - Self::confirms_known_blocks_in(block, &block.header.consensus_hash, signer_db)?; + Self::confirms_latest_block_in_same_tenure(block, signer_db)?; if !confirms_latest_in_tenure { return Ok(false); } @@ -453,32 +432,146 @@ impl SortitionsView { Ok(true) } - fn check_tenure_change_block_confirmation( + /// Check if the tenure change block confirms the expected parent block (i.e., 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). + /// + /// The rationale here is that the signer DB can be out-of-sync with the node. For example, + /// the signer may have been added to an already-running node. + fn check_tenure_change_confirms_parent( tenure_change: &TenureChangePayload, block: &NakamotoBlock, - signer_db: &SignerDb, + reward_cycle: u64, + signer_db: &mut SignerDb, + client: &StacksClient, ) -> Result { - // in tenure changes, we need to check: - // (1) if the tenure change confirms the expected parent block (i.e., - // the last block we signed in the parent tenure) - // (2) if the parent tenure was a valid choice - Self::confirms_known_blocks_in(block, &tenure_change.prev_tenure_consensus_hash, signer_db) + // 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 let Some(global_info) = last_globally_accepted_block { + // 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 { + 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, + ); + return Ok(false); + } + } + + let tip = match client.get_tenure_tip(&tenure_change.prev_tenure_consensus_hash) { + Ok(tip) => tip, + Err(e) => { + warn!( + "Miner block proposal contains a tenure change, but failed to fetch the tenure tip for the parent tenure: {e:?}. Considering proposal invalid."; + "proposed_block_consensus_hash" => %block.header.consensus_hash, + "proposed_block_signer_sighash" => %block.header.signer_signature_hash(), + "parent_tenure" => %tenure_change.prev_tenure_consensus_hash, + ); + return Ok(false); + } + }; + if let Some(nakamoto_tip) = tip.as_stacks_nakamoto() { + // If we have seen this block already, make sure its state is updated to globally accepted. + // Otherwise, don't worry about it. + if let Ok(Some(mut block_info)) = + signer_db.block_lookup(reward_cycle, &nakamoto_tip.signer_signature_hash()) + { + if block_info.state != BlockState::GloballyAccepted { + if let Err(e) = block_info.mark_globally_accepted() { + warn!("Failed to update block info in db: {e}"); + } else if let Err(e) = signer_db.insert_block(&block_info) { + warn!("Failed to update block info in db: {e}"); + } + } + } + } + let tip_height = tip.height(); + if block.header.chain_length > tip_height { + Ok(true) + } else { + 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" => tip_height + 1, + ); + Ok(false) + } + } + + /// in tenure changes, we need to check: + /// (1) if the tenure change confirms the expected parent block (i.e., + /// the last globally accepted block in the parent tenure) + /// (2) if the parent tenure was a valid choice + fn validate_tenure_change_payload( + &self, + proposed_by: &ProposedBy, + tenure_change: &TenureChangePayload, + block: &NakamotoBlock, + reward_cycle: u64, + signer_db: &mut SignerDb, + client: &StacksClient, + ) -> Result { + // Ensure that the tenure change block confirms the expected parent block + let confirms_expected_parent = Self::check_tenure_change_confirms_parent( + tenure_change, + block, + reward_cycle, + signer_db, + client, + )?; + if !confirms_expected_parent { + return Ok(false); + } + // now, we have to check if the parent tenure was a valid choice. + let is_valid_parent_tenure = Self::check_parent_tenure_choice( + proposed_by.state(), + block, + signer_db, + client, + &self.config.first_proposal_burn_block_timing, + )?; + if !is_valid_parent_tenure { + return Ok(false); + } + let last_in_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 { + 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(), + ); + return Ok(false); + } + Ok(true) } - fn confirms_known_blocks_in( + fn confirms_latest_block_in_same_tenure( block: &NakamotoBlock, - tenure: &ConsensusHash, signer_db: &SignerDb, ) -> Result { let Some(last_known_block) = signer_db - .get_last_signed_block_in_tenure(tenure) + .get_last_accepted_block(&block.header.consensus_hash) .map_err(|e| ClientError::InvalidResponse(e.to_string()))? else { info!( - "Have not signed off on any blocks in the parent tenure, assuming block confirmation is correct"; + "Have no accepted blocks in the tenure, assuming block confirmation is correct"; "proposed_block_consensus_hash" => %block.header.consensus_hash, "proposed_block_signer_sighash" => %block.header.signer_signature_hash(), - "tenure" => %tenure, ); return Ok(true); }; diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index 32951d7990..5ce8706274 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -123,6 +123,7 @@ pub(crate) mod tests { use std::net::{SocketAddr, TcpListener}; use blockstack_lib::chainstate::stacks::boot::POX_4_NAME; + use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; use blockstack_lib::net::api::getaccount::AccountEntryResponse; use blockstack_lib::net::api::getinfo::RPCPeerInfoData; use blockstack_lib::net::api::getpoxinfo::{ @@ -570,7 +571,6 @@ 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, - broadcast_signed_blocks: true, } } @@ -597,4 +597,10 @@ pub(crate) mod tests { let clarity_value = ClarityValue::UInt(threshold as u128); build_read_only_response(&clarity_value) } + + pub fn build_get_tenure_tip_response(header_types: &StacksBlockHeaderTypes) -> String { + let response_json = + serde_json::to_string(header_types).expect("Failed to serialize tenure tip info"); + format!("HTTP/1.1 200 OK\n\n{response_json}") + } } diff --git a/stacks-signer/src/client/stackerdb.rs b/stacks-signer/src/client/stackerdb.rs index de77ccbd72..f2b574ef4f 100644 --- a/stacks-signer/src/client/stackerdb.rs +++ b/stacks-signer/src/client/stackerdb.rs @@ -234,7 +234,9 @@ mod tests { use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; use clarity::util::hash::{MerkleTree, Sha512Trunc256Sum}; + use clarity::util::secp256k1::MessageSignature; use libsigner::v0::messages::{BlockRejection, BlockResponse, RejectCode, SignerMessage}; + use rand::{thread_rng, RngCore}; use super::*; use crate::client::tests::{generate_signer_config, mock_server_from_config, write_response}; @@ -278,6 +280,8 @@ mod tests { reason: "Did not like it".into(), reason_code: RejectCode::RejectedInPriorRound, signer_signature_hash: block.header.signer_signature_hash(), + chain_id: thread_rng().next_u32(), + signature: MessageSignature::empty(), }; let signer_message = SignerMessage::BlockResponse(BlockResponse::Rejected(block_reject)); let ack = StackerDBChunkAckData { diff --git a/stacks-signer/src/client/stacks_client.rs b/stacks-signer/src/client/stacks_client.rs index cd65f7914b..85fa7fd34b 100644 --- a/stacks-signer/src/client/stacks_client.rs +++ b/stacks-signer/src/client/stacks_client.rs @@ -21,6 +21,7 @@ use blockstack_lib::chainstate::nakamoto::NakamotoBlock; use blockstack_lib::chainstate::stacks::boot::{ NakamotoSignerEntry, SIGNERS_VOTING_FUNCTION_NAME, SIGNERS_VOTING_NAME, }; +use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; use blockstack_lib::chainstate::stacks::{ StacksTransaction, StacksTransactionSigner, TransactionAnchorMode, TransactionAuth, TransactionContractCall, TransactionPayload, TransactionPostConditionMode, @@ -139,6 +140,28 @@ impl StacksClient { &self.stacks_address } + /// Get the stacks tip header of the tenure given its consensus hash + pub fn get_tenure_tip( + &self, + consensus_hash: &ConsensusHash, + ) -> Result { + let send_request = || { + self.stacks_node_client + .get(self.tenure_tip_path(consensus_hash)) + .send() + .map_err(|e| { + warn!("Signer failed to request latest sortition"; "err" => ?e); + e + }) + }; + let response = send_request()?; + if !response.status().is_success() { + return Err(ClientError::RequestFailure(response.status())); + } + let sortition_info = response.json()?; + Ok(sortition_info) + } + /// Retrieve the signer slots stored within the stackerdb contract pub fn get_stackerdb_signer_slots( &self, @@ -683,17 +706,23 @@ impl StacksClient { /// Returns `true` if the block was accepted or `false` if the block /// was rejected. pub fn post_block(&self, block: &NakamotoBlock) -> Result { - let response = self - .stacks_node_client - .post(format!( - "{}{}?broadcast=1", - self.http_origin, - postblock_v3::PATH - )) - .header("Content-Type", "application/octet-stream") - .header(AUTHORIZATION, self.auth_password.clone()) - .body(block.serialize_to_vec()) - .send()?; + let send_request = || { + self.stacks_node_client + .post(format!( + "{}{}?broadcast=1", + self.http_origin, + postblock_v3::PATH + )) + .header("Content-Type", "application/octet-stream") + .header(AUTHORIZATION, self.auth_password.clone()) + .body(block.serialize_to_vec()) + .send() + .map_err(|e| { + debug!("Failed to submit block to the Stacks node: {e:?}"); + backoff::Error::transient(e) + }) + }; + let response = retry_with_exponential_backoff(send_request)?; if !response.status().is_success() { return Err(ClientError::RequestFailure(response.status())); } @@ -826,6 +855,10 @@ impl StacksClient { format!("{}/v2/fees/transaction", self.http_origin) } + fn tenure_tip_path(&self, consensus_hash: &ConsensusHash) -> String { + format!("{}/v3/tenures/tip/{}", self.http_origin, consensus_hash) + } + /// Helper function to create a stacks transaction for a modifying contract call #[allow(clippy::too_many_arguments)] pub fn build_unsigned_contract_call_transaction( @@ -893,12 +926,16 @@ mod tests { use blockstack_lib::chainstate::stacks::boot::{ NakamotoSignerEntry, PoxStartCycleInfo, RewardSet, }; + use clarity::types::chainstate::{StacksBlockId, TrieHash}; + use clarity::util::hash::Sha512Trunc256Sum; + use clarity::util::secp256k1::MessageSignature; use clarity::vm::types::{ ListData, ListTypeData, ResponseData, SequenceData, TupleData, TupleTypeSignature, TypeSignature, }; use rand::thread_rng; use rand_core::RngCore; + use stacks_common::bitvec::BitVec; use stacks_common::consts::{CHAIN_ID_TESTNET, SIGNER_SLOTS_PER_USER}; use wsts::curve::scalar::Scalar; @@ -907,8 +944,9 @@ mod tests { build_account_nonce_response, build_get_approved_aggregate_key_response, build_get_last_round_response, build_get_medium_estimated_fee_ustx_response, build_get_peer_info_response, build_get_pox_data_response, build_get_round_info_response, - build_get_vote_for_aggregate_key_response, build_get_weight_threshold_response, - build_read_only_response, write_response, MockServerClient, + build_get_tenure_tip_response, build_get_vote_for_aggregate_key_response, + build_get_weight_threshold_response, build_read_only_response, write_response, + MockServerClient, }; #[test] @@ -1542,4 +1580,27 @@ mod tests { write_response(mock.server, response.as_bytes()); assert_eq!(h.join().unwrap().unwrap(), estimate); } + + #[test] + fn get_tenure_tip_should_succeed() { + let mock = MockServerClient::new(); + let consensus_hash = ConsensusHash([15; 20]); + let header = StacksBlockHeaderTypes::Nakamoto(NakamotoBlockHeader { + version: 1, + chain_length: 10, + burn_spent: 10, + consensus_hash: ConsensusHash([15; 20]), + parent_block_id: StacksBlockId([0; 32]), + tx_merkle_root: Sha512Trunc256Sum([0; 32]), + state_index_root: TrieHash([0; 32]), + timestamp: 3, + miner_signature: MessageSignature::empty(), + signer_signature: vec![], + pox_treatment: BitVec::ones(1).unwrap(), + }); + let response = build_get_tenure_tip_response(&header); + let h = spawn(move || mock.client.get_tenure_tip(&consensus_hash)); + write_response(mock.server, response.as_bytes()); + assert_eq!(h.join().unwrap().unwrap(), header); + } } diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index 037e8af773..66cf5a5f7d 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -157,8 +157,6 @@ 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, - /// Broadcast a block to the node if we gather enough signatures from other signers - pub broadcast_signed_blocks: bool, } /// The parsed configuration for the signer @@ -203,8 +201,6 @@ pub struct GlobalConfig { 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, - /// Broadcast a block to the node if we gather enough signatures from other signers - pub broadcast_signed_blocks: bool, } /// Internal struct for loading up the config file @@ -361,7 +357,6 @@ impl TryFrom for GlobalConfig { metrics_endpoint, first_proposal_burn_block_timing, block_proposal_timeout, - broadcast_signed_blocks: true, }) } } diff --git a/stacks-signer/src/lib.rs b/stacks-signer/src/lib.rs index e16a70b607..c61ae39731 100644 --- a/stacks-signer/src/lib.rs +++ b/stacks-signer/src/lib.rs @@ -80,7 +80,7 @@ pub trait Signer: Debug + Display { command: Option, ); /// Check if the signer is in the middle of processing blocks - fn has_pending_blocks(&self) -> bool; + fn has_unprocessed_blocks(&self) -> bool; } /// A wrapper around the running signer type for the signer diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index 9e1083047b..cb29221ba9 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -335,7 +335,6 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo max_tx_fee_ustx: self.config.max_tx_fee_ustx, db_path: self.config.db_path.clone(), block_proposal_timeout: self.config.block_proposal_timeout, - broadcast_signed_blocks: self.config.broadcast_signed_blocks, })) } @@ -466,7 +465,9 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo std::cmp::Ordering::Equal => { // We are the next reward cycle, so check if we were registered and have any pending blocks to process match signer { - ConfiguredSigner::RegisteredSigner(signer) => !signer.has_pending_blocks(), + ConfiguredSigner::RegisteredSigner(signer) => { + !signer.has_unprocessed_blocks() + } _ => true, } } diff --git a/stacks-signer/src/signerdb.rs b/stacks-signer/src/signerdb.rs index 2d2e9cc22a..6f5b6c6e06 100644 --- a/stacks-signer/src/signerdb.rs +++ b/stacks-signer/src/signerdb.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::fmt::Display; use std::path::Path; use std::time::SystemTime; @@ -22,7 +23,7 @@ use blockstack_lib::util_lib::db::{ query_row, query_rows, sqlite_open, table_exists, tx_begin_immediate, u64_to_sql, Error as DBError, }; -use clarity::types::chainstate::BurnchainHeaderHash; +use clarity::types::chainstate::{BurnchainHeaderHash, StacksAddress}; use clarity::util::get_epoch_time_secs; use libsigner::BlockProposal; use rusqlite::{ @@ -34,7 +35,7 @@ use stacks_common::codec::{read_next, write_next, Error as CodecError, StacksMes use stacks_common::types::chainstate::ConsensusHash; use stacks_common::util::hash::Sha512Trunc256Sum; use stacks_common::util::secp256k1::MessageSignature; -use stacks_common::{debug, error}; +use stacks_common::{debug, define_u8_enum, error}; use wsts::net::NonceRequest; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -113,6 +114,64 @@ impl ExtraBlockInfo { } } +define_u8_enum!( +/// Block state relative to the signer's view of the stacks blockchain +BlockState { + /// The block has not yet been processed by the signer + Unprocessed = 0, + /// The block is accepted by the signer but a threshold of signers has not yet signed it + LocallyAccepted = 1, + /// The block is rejected by the signer but a threshold of signers has not accepted/rejected it yet + LocallyRejected = 2, + /// A threshold number of signers have signed the block + GloballyAccepted = 3, + /// A threshold number of signers have rejected the block + GloballyRejected = 4 +}); + +impl TryFrom for BlockState { + type Error = String; + fn try_from(value: u8) -> Result { + let state = match value { + 0 => BlockState::Unprocessed, + 1 => BlockState::LocallyAccepted, + 2 => BlockState::LocallyRejected, + 3 => BlockState::GloballyAccepted, + 4 => BlockState::GloballyRejected, + _ => return Err("Invalid block state".into()), + }; + Ok(state) + } +} + +impl Display for BlockState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = match self { + BlockState::Unprocessed => "Unprocessed", + BlockState::LocallyAccepted => "LocallyAccepted", + BlockState::LocallyRejected => "LocallyRejected", + BlockState::GloballyAccepted => "GloballyAccepted", + BlockState::GloballyRejected => "GloballyRejected", + }; + write!(f, "{}", state) + } +} + +impl TryFrom<&str> for BlockState { + type Error = String; + fn try_from(value: &str) -> Result { + let state = match value { + "Unprocessed" => BlockState::Unprocessed, + "LocallyAccepted" => BlockState::LocallyAccepted, + "LocallyRejected" => BlockState::LocallyRejected, + "GloballyAccepted" => BlockState::GloballyAccepted, + "GloballyRejected" => BlockState::GloballyRejected, + _ => return Err("Unparsable block state".into()), + }; + Ok(state) + } +} + /// Additional Info about a proposed block #[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct BlockInfo { @@ -134,6 +193,8 @@ pub struct BlockInfo { pub signed_self: Option, /// Time at which the proposal was signed by a threshold in the signer set (epoch time in seconds) pub signed_group: Option, + /// The block state relative to the signer's view of the stacks blockchain + pub state: BlockState, /// Extra data specific to v0, v1, etc. pub ext: ExtraBlockInfo, } @@ -151,6 +212,7 @@ impl From for BlockInfo { signed_self: None, signed_group: None, ext: ExtraBlockInfo::default(), + state: BlockState::Unprocessed, } } } @@ -163,18 +225,74 @@ impl BlockInfo { block_info } - /// Mark this block as valid, signed over, and record a timestamp in the block info if it wasn't + /// Mark this block as locally accepted, valid, signed over, and records either the self or group signed timestamp in the block info if it wasn't + /// already set. + pub fn mark_locally_accepted(&mut self, group_signed: bool) -> Result<(), String> { + self.valid = Some(true); + self.signed_over = true; + if group_signed { + self.signed_group.get_or_insert(get_epoch_time_secs()); + } else { + self.signed_self.get_or_insert(get_epoch_time_secs()); + } + self.move_to(BlockState::LocallyAccepted) + } + + /// Mark this block as valid, signed over, and records a group timestamp in the block info if it wasn't /// already set. - pub fn mark_signed_and_valid(&mut self) { + pub fn mark_globally_accepted(&mut self) -> Result<(), String> { self.valid = Some(true); self.signed_over = true; - self.signed_self.get_or_insert(get_epoch_time_secs()); + self.signed_group.get_or_insert(get_epoch_time_secs()); + self.move_to(BlockState::GloballyAccepted) + } + + /// Mark the block as locally rejected and invalid + pub fn mark_locally_rejected(&mut self) -> Result<(), String> { + self.valid = Some(false); + self.move_to(BlockState::LocallyRejected) + } + + /// Mark the block as globally rejected and invalid + pub fn mark_globally_rejected(&mut self) -> Result<(), String> { + self.valid = Some(false); + self.move_to(BlockState::GloballyRejected) } /// Return the block's signer signature hash pub fn signer_signature_hash(&self) -> Sha512Trunc256Sum { self.block.header.signer_signature_hash() } + + /// Check if the block state transition is valid + fn check_state(&self, state: BlockState) -> bool { + let prev_state = &self.state; + match state { + BlockState::Unprocessed => { + matches!(prev_state, BlockState::Unprocessed) + } + BlockState::LocallyAccepted => { + matches!(prev_state, BlockState::Unprocessed) + } + BlockState::LocallyRejected => { + matches!(prev_state, BlockState::Unprocessed) + } + BlockState::GloballyAccepted => !matches!(prev_state, BlockState::GloballyRejected), + BlockState::GloballyRejected => !matches!(prev_state, BlockState::GloballyAccepted), + } + } + + /// Attempt to transition the block state + pub fn move_to(&mut self, state: BlockState) -> Result<(), String> { + if !self.check_state(state) { + return Err(format!( + "Invalid state transition from {} to {state}", + self.state + )); + } + self.state = state; + Ok(()) + } } /// This struct manages a SQLite database connection @@ -197,6 +315,19 @@ CREATE TABLE IF NOT EXISTS blocks ( PRIMARY KEY (reward_cycle, signer_signature_hash) ) STRICT"; +static CREATE_BLOCKS_TABLE_2: &str = " +CREATE TABLE IF NOT EXISTS blocks ( + reward_cycle INTEGER NOT NULL, + signer_signature_hash TEXT NOT NULL, + block_info TEXT NOT NULL, + consensus_hash TEXT NOT NULL, + signed_over INTEGER NOT NULL, + broadcasted INTEGER, + stacks_height INTEGER NOT NULL, + burn_block_height INTEGER NOT NULL, + PRIMARY KEY (reward_cycle, signer_signature_hash) +) STRICT"; + static CREATE_INDEXES_1: &str = " CREATE INDEX IF NOT EXISTS blocks_signed_over ON blocks (signed_over); CREATE INDEX IF NOT EXISTS blocks_consensus_hash ON blocks (consensus_hash); @@ -204,6 +335,14 @@ CREATE INDEX IF NOT EXISTS blocks_valid ON blocks ((json_extract(block_info, '$. CREATE INDEX IF NOT EXISTS burn_blocks_height ON burn_blocks (block_height); "; +static CREATE_INDEXES_2: &str = r#" +CREATE INDEX IF NOT EXISTS block_signatures_on_signer_signature_hash ON block_signatures(signer_signature_hash); +"#; + +static CREATE_INDEXES_3: &str = r#" +CREATE INDEX IF NOT EXISTS block_rejection_signer_addrs_on_block_signature_hash ON block_rejection_signer_addrs(signer_signature_hash); +"#; + static CREATE_SIGNER_STATE_TABLE: &str = " CREATE TABLE IF NOT EXISTS signer_states ( reward_cycle INTEGER PRIMARY KEY, @@ -235,18 +374,11 @@ static DROP_SCHEMA_1: &str = " DROP TABLE IF EXISTS blocks; DROP TABLE IF EXISTS db_config;"; -static CREATE_BLOCKS_TABLE_2: &str = " -CREATE TABLE IF NOT EXISTS blocks ( - reward_cycle INTEGER NOT NULL, - signer_signature_hash TEXT NOT NULL, - block_info TEXT NOT NULL, - consensus_hash TEXT NOT NULL, - signed_over INTEGER NOT NULL, - broadcasted INTEGER, - stacks_height INTEGER NOT NULL, - burn_block_height INTEGER NOT NULL, - PRIMARY KEY (reward_cycle, signer_signature_hash) -) STRICT"; +static DROP_SCHEMA_2: &str = " + DROP TABLE IF EXISTS burn_blocks; + DROP TABLE IF EXISTS signer_states; + DROP TABLE IF EXISTS blocks; + DROP TABLE IF EXISTS db_config;"; static CREATE_BLOCK_SIGNATURES_TABLE: &str = r#" CREATE TABLE IF NOT EXISTS block_signatures ( @@ -260,9 +392,17 @@ CREATE TABLE IF NOT EXISTS block_signatures ( PRIMARY KEY (signature) ) STRICT;"#; -static CREATE_INDEXES_2: &str = r#" -CREATE INDEX IF NOT EXISTS block_signatures_on_signer_signature_hash ON block_signatures(signer_signature_hash); -"#; +static CREATE_BLOCK_REJECTION_SIGNER_ADDRS_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS block_rejection_signer_addrs ( + -- The block sighash commits to all of the stacks and burnchain state as of its parent, + -- as well as the tenure itself so there's no need to include the reward cycle. Just + -- the sighash is sufficient to uniquely identify the block across all burnchain, PoX, + -- and stacks forks. + signer_signature_hash TEXT NOT NULL, + -- the signer address that rejected the block + signer_addr TEXT NOT NULL, + PRIMARY KEY (signer_addr) +) STRICT;"#; static SCHEMA_1: &[&str] = &[ DROP_SCHEMA_0, @@ -286,9 +426,23 @@ static SCHEMA_2: &[&str] = &[ "INSERT INTO db_config (version) VALUES (2);", ]; +static SCHEMA_3: &[&str] = &[ + DROP_SCHEMA_2, + CREATE_DB_CONFIG, + CREATE_BURN_STATE_TABLE, + CREATE_BLOCKS_TABLE_2, + CREATE_SIGNER_STATE_TABLE, + CREATE_BLOCK_SIGNATURES_TABLE, + CREATE_BLOCK_REJECTION_SIGNER_ADDRS_TABLE, + CREATE_INDEXES_1, + CREATE_INDEXES_2, + CREATE_INDEXES_3, + "INSERT INTO db_config (version) VALUES (3);", +]; + impl SignerDb { /// The current schema version used in this build of the signer binary. - pub const SCHEMA_VERSION: u32 = 2; + pub const SCHEMA_VERSION: u32 = 3; /// Create a new `SignerState` instance. /// This will create a new SQLite database at the given path @@ -346,6 +500,20 @@ impl SignerDb { Ok(()) } + /// Migrate from schema 2 to schema 3 + fn schema_3_migration(tx: &Transaction) -> Result<(), DBError> { + if Self::get_schema_version(tx)? >= 3 { + // no migration necessary + return Ok(()); + } + + for statement in SCHEMA_3.iter() { + tx.execute_batch(statement)?; + } + + Ok(()) + } + /// Either instantiate a new database, or migrate an existing one /// If the detected version of the existing database is 0 (i.e., a pre-migration /// logic DB, the DB will be dropped). @@ -356,7 +524,8 @@ impl SignerDb { match version { 0 => Self::schema_1_migration(&sql_tx)?, 1 => Self::schema_2_migration(&sql_tx)?, - 2 => break, + 2 => Self::schema_3_migration(&sql_tx)?, + 3 => break, x => return Err(DBError::Other(format!( "Database schema is newer than supported by this binary. Expected version = {}, Database version = {x}", Self::SCHEMA_VERSION, @@ -438,6 +607,34 @@ impl SignerDb { try_deserialize(result) } + /// Return the last accepted block in a tenure (identified by its consensus hash). + pub fn get_last_accepted_block( + &self, + tenure: &ConsensusHash, + ) -> Result, DBError> { + let query = "SELECT block_info FROM blocks WHERE consensus_hash = ?1 AND json_extract(block_info, '$.state') IN (?2, ?3) ORDER BY stacks_height DESC LIMIT 1"; + let args = params![ + tenure, + &BlockState::GloballyAccepted.to_string(), + &BlockState::LocallyAccepted.to_string() + ]; + let result: Option = query_row(&self.db, query, args)?; + + try_deserialize(result) + } + + /// Return the last globally accepted block in a tenure (identified by its consensus hash). + pub fn get_last_globally_accepted_block( + &self, + tenure: &ConsensusHash, + ) -> Result, DBError> { + let query = "SELECT block_info FROM blocks WHERE consensus_hash = ?1 AND json_extract(block_info, '$.state') = ?2 ORDER BY stacks_height DESC LIMIT 1"; + let args = params![tenure, &BlockState::GloballyAccepted.to_string()]; + let result: Option = query_row(&self.db, query, args)?; + + try_deserialize(result) + } + /// Insert or replace a burn block into the database pub fn insert_burn_block( &mut self, @@ -491,7 +688,6 @@ impl SignerDb { .as_ref() .map(|v| if v.rejected { "REJECT" } else { "ACCEPT" }); let broadcasted = self.get_block_broadcasted(block_info.reward_cycle, &hash)?; - debug!("Inserting block_info."; "reward_cycle" => %block_info.reward_cycle, "burn_block_height" => %block_info.burn_block_height, @@ -516,11 +712,17 @@ impl SignerDb { Ok(()) } - /// Determine if there are any pending blocks that have not yet been processed by checking the block_info.valid field - pub fn has_pending_blocks(&self, reward_cycle: u64) -> Result { - let query = "SELECT block_info FROM blocks WHERE reward_cycle = ? AND json_extract(block_info, '$.valid') IS NULL LIMIT 1"; - let result: Option = - query_row(&self.db, query, params!(&u64_to_sql(reward_cycle)?))?; + /// Determine if there are any unprocessed blocks + pub fn has_unprocessed_blocks(&self, reward_cycle: u64) -> Result { + let query = "SELECT block_info FROM blocks WHERE reward_cycle = ?1 AND json_extract(block_info, '$.state') = ?2 LIMIT 1"; + let result: Option = query_row( + &self.db, + query, + params!( + &u64_to_sql(reward_cycle)?, + &BlockState::Unprocessed.to_string() + ), + )?; Ok(result.is_some()) } @@ -559,15 +761,48 @@ impl SignerDb { .collect() } - /// Mark a block as having been broadcasted + /// Record an observed block rejection_signature + pub fn add_block_rejection_signer_addr( + &self, + block_sighash: &Sha512Trunc256Sum, + addr: &StacksAddress, + ) -> Result<(), DBError> { + let qry = "INSERT OR REPLACE INTO block_rejection_signer_addrs (signer_signature_hash, signer_addr) VALUES (?1, ?2);"; + let args = params![block_sighash, addr.to_string(),]; + + debug!("Inserting block rejection."; + "block_sighash" => %block_sighash, + "signer_address" => %addr); + + self.db.execute(qry, args)?; + Ok(()) + } + + /// Get all signer addresses that rejected the block + pub fn get_block_rejection_signer_addrs( + &self, + block_sighash: &Sha512Trunc256Sum, + ) -> Result, DBError> { + let qry = + "SELECT signer_addr FROM block_rejection_signer_addrs WHERE signer_signature_hash = ?1"; + let args = params![block_sighash]; + query_rows(&self.db, qry, args) + } + + /// Mark a block as having been broadcasted and therefore GloballyAccepted pub fn set_block_broadcasted( &self, reward_cycle: u64, block_sighash: &Sha512Trunc256Sum, ts: u64, ) -> Result<(), DBError> { - let qry = "UPDATE blocks SET broadcasted = ?1 WHERE reward_cycle = ?2 AND signer_signature_hash = ?3"; - let args = params![u64_to_sql(ts)?, u64_to_sql(reward_cycle)?, block_sighash]; + let qry = "UPDATE blocks SET broadcasted = ?1, block_info = json_set(block_info, '$.state', ?2) WHERE reward_cycle = ?3 AND signer_signature_hash = ?4"; + let args = params![ + u64_to_sql(ts)?, + BlockState::GloballyAccepted.to_string(), + u64_to_sql(reward_cycle)?, + block_sighash + ]; debug!("Marking block {} as broadcasted at {}", block_sighash, ts); self.db.execute(qry, args)?; @@ -592,6 +827,23 @@ impl SignerDb { } Ok(u64::try_from(broadcasted).ok()) } + + /// Get the current state of a given block in the database + pub fn get_block_state( + &self, + reward_cycle: u64, + block_sighash: &Sha512Trunc256Sum, + ) -> Result, DBError> { + let qry = "SELECT json_extract(block_info, '$.state') FROM blocks WHERE reward_cycle = ?1 AND signer_signature_hash = ?2 LIMIT 1"; + let args = params![&u64_to_sql(reward_cycle)?, block_sighash]; + let state_opt: Option = query_row(&self.db, qry, args)?; + let Some(state) = state_opt else { + return Ok(None); + }; + Ok(Some( + BlockState::try_from(state.as_str()).map_err(|_| DBError::Corruption)?, + )) + } } fn try_deserialize(s: Option) -> Result, DBError> @@ -684,11 +936,23 @@ mod tests { ) .unwrap(); assert!(block_info.is_none()); + + // test getting the block state + let block_state = db + .get_block_state( + reward_cycle, + &block_proposal.block.header.signer_signature_hash(), + ) + .unwrap() + .expect("Unable to get block state from db"); + + assert_eq!(block_state, BlockInfo::from(block_proposal.clone()).state); } #[test] fn test_basic_signer_db() { let db_path = tmp_db_path(); + eprintln!("db path is {}", &db_path.display()); test_basic_signer_db_with_path(db_path) } @@ -759,7 +1023,9 @@ mod tests { .unwrap() .is_none()); - block_info.mark_signed_and_valid(); + block_info + .mark_locally_accepted(false) + .expect("Failed to mark block as locally accepted"); db.insert_block(&block_info).unwrap(); let fetched_info = db @@ -824,7 +1090,7 @@ mod tests { } #[test] - fn test_has_pending_blocks() { + fn test_has_unprocessed_blocks() { let db_path = tmp_db_path(); let mut db = SignerDb::new(db_path).expect("Failed to create signer db"); let (mut block_info_1, _block_proposal) = create_block_override(|b| { @@ -841,21 +1107,27 @@ mod tests { db.insert_block(&block_info_2) .expect("Unable to insert block into db"); - assert!(db.has_pending_blocks(block_info_1.reward_cycle).unwrap()); + assert!(db + .has_unprocessed_blocks(block_info_1.reward_cycle) + .unwrap()); - block_info_1.valid = Some(true); + block_info_1.state = BlockState::LocallyRejected; db.insert_block(&block_info_1) .expect("Unable to update block in db"); - assert!(db.has_pending_blocks(block_info_1.reward_cycle).unwrap()); + assert!(db + .has_unprocessed_blocks(block_info_1.reward_cycle) + .unwrap()); - block_info_2.valid = Some(true); + block_info_2.state = BlockState::LocallyAccepted; db.insert_block(&block_info_2) .expect("Unable to update block in db"); - assert!(!db.has_pending_blocks(block_info_1.reward_cycle).unwrap()); + assert!(!db + .has_unprocessed_blocks(block_info_1.reward_cycle) + .unwrap()); } #[test] @@ -912,12 +1184,32 @@ mod tests { ) .unwrap() .is_none()); + assert_eq!( + db.block_lookup( + block_info_1.reward_cycle, + &block_info_1.signer_signature_hash() + ) + .expect("Unable to get block from db") + .expect("Unable to get block from db") + .state, + BlockState::Unprocessed + ); db.set_block_broadcasted( block_info_1.reward_cycle, &block_info_1.signer_signature_hash(), 12345, ) .unwrap(); + assert_eq!( + db.block_lookup( + block_info_1.reward_cycle, + &block_info_1.signer_signature_hash() + ) + .expect("Unable to get block from db") + .expect("Unable to get block from db") + .state, + BlockState::GloballyAccepted + ); db.insert_block(&block_info_1) .expect("Unable to insert block into db a second time"); diff --git a/stacks-signer/src/tests/chainstate.rs b/stacks-signer/src/tests/chainstate.rs index d0c7f1d9f3..a13ab24a59 100644 --- a/stacks-signer/src/tests/chainstate.rs +++ b/stacks-signer/src/tests/chainstate.rs @@ -18,6 +18,7 @@ use std::net::{Ipv4Addr, SocketAddrV4}; use std::time::{Duration, SystemTime}; use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; +use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; use blockstack_lib::chainstate::stacks::{ CoinbasePayload, SinglesigHashMode, SinglesigSpendingCondition, StacksTransaction, TenureChangeCause, TenureChangePayload, TransactionAnchorMode, TransactionAuth, @@ -124,33 +125,45 @@ fn setup_test_environment( #[test] fn check_proposal_units() { - let (stacks_client, signer_db, block_pk, mut view, block) = + let (stacks_client, mut signer_db, block_pk, mut view, block) = setup_test_environment("check_proposal_units"); assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk,) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); view.last_sortition = None; assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk,) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); } #[test] fn check_proposal_miner_pkh_mismatch() { - let (stacks_client, signer_db, _block_pk, mut view, mut block) = + let (stacks_client, mut signer_db, _block_pk, mut view, mut block) = setup_test_environment("miner_pkh_mismatch"); block.header.consensus_hash = view.cur_sortition.consensus_hash; let different_block_pk = StacksPublicKey::from_private(&StacksPrivateKey::from_seed(&[2, 3])); assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &different_block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &block, + &different_block_pk, + 1 + ) .unwrap()); block.header.consensus_hash = view.last_sortition.as_ref().unwrap().consensus_hash; assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &different_block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &block, + &different_block_pk, + 1 + ) .unwrap()); } @@ -228,8 +241,9 @@ fn reorg_timing_testing( burn_height: 2, reward_cycle: 1, }; + let mut header_clone = block_proposal_1.block.header.clone(); let mut block_info_1 = BlockInfo::from(block_proposal_1); - block_info_1.mark_signed_and_valid(); + block_info_1.mark_locally_accepted(false).unwrap(); signer_db.insert_block(&block_info_1).unwrap(); let sortition_time = SystemTime::UNIX_EPOCH @@ -238,8 +252,20 @@ fn reorg_timing_testing( .insert_burn_block(&view.cur_sortition.burn_block_hash, 3, &sortition_time) .unwrap(); - let MockServerClient { server, client, .. } = MockServerClient::new(); - let h = std::thread::spawn(move || view.check_proposal(&client, &signer_db, &block, &block_pk)); + let MockServerClient { + mut server, + client, + config, + } = MockServerClient::new(); + let h = std::thread::spawn(move || { + view.check_proposal(&client, &mut signer_db, &block, &block_pk, 1) + }); + header_clone.chain_length -= 1; + let response = crate::client::tests::build_get_tenure_tip_response( + &StacksBlockHeaderTypes::Nakamoto(header_clone), + ); + crate::client::tests::write_response(server, response.as_bytes()); + server = crate::client::tests::mock_server_from_config(&config); crate::client::tests::write_response( server, @@ -265,20 +291,20 @@ fn check_proposal_reorg_timing_ok() { #[test] fn check_proposal_invalid_status() { - let (stacks_client, signer_db, block_pk, mut view, mut block) = + let (stacks_client, mut signer_db, block_pk, mut view, mut block) = setup_test_environment("invalid_status"); block.header.consensus_hash = view.cur_sortition.consensus_hash; assert!(view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); view.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedAfterFirstBlock; assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); block.header.consensus_hash = view.last_sortition.as_ref().unwrap().consensus_hash; assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); view.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedBeforeFirstBlock; @@ -289,7 +315,7 @@ fn check_proposal_invalid_status() { // parent blocks have been seen before, while the signer state checks are only reasoning about // stacks blocks seen by the signer, which may be a subset) assert!(view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); } @@ -328,7 +354,7 @@ fn make_tenure_change_tx(payload: TenureChangePayload) -> StacksTransaction { #[test] fn check_proposal_tenure_extend_invalid_conditions() { - let (stacks_client, signer_db, block_pk, mut view, mut block) = + let (stacks_client, mut signer_db, block_pk, mut view, mut block) = setup_test_environment("tenure_extend"); block.header.consensus_hash = view.cur_sortition.consensus_hash; let mut extend_payload = make_tenure_change_payload(); @@ -338,7 +364,7 @@ fn check_proposal_tenure_extend_invalid_conditions() { let tx = make_tenure_change_tx(extend_payload); block.txs = vec![tx]; assert!(!view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); let mut extend_payload = make_tenure_change_payload(); @@ -348,7 +374,7 @@ fn check_proposal_tenure_extend_invalid_conditions() { let tx = make_tenure_change_tx(extend_payload); block.txs = vec![tx]; assert!(view - .check_proposal(&stacks_client, &signer_db, &block, &block_pk) + .check_proposal(&stacks_client, &mut signer_db, &block, &block_pk, 1) .unwrap()); } @@ -370,21 +396,45 @@ fn check_block_proposal_timeout() { .unwrap(); assert!(view - .check_proposal(&stacks_client, &signer_db, &curr_sortition_block, &block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &curr_sortition_block, + &block_pk, + 1 + ) .unwrap()); assert!(!view - .check_proposal(&stacks_client, &signer_db, &last_sortition_block, &block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &last_sortition_block, + &block_pk, + 1 + ) .unwrap()); // Sleep a bit to time out the block proposal std::thread::sleep(Duration::from_secs(5)); assert!(!view - .check_proposal(&stacks_client, &signer_db, &curr_sortition_block, &block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &curr_sortition_block, + &block_pk, + 1 + ) .unwrap()); assert!(view - .check_proposal(&stacks_client, &signer_db, &last_sortition_block, &block_pk) + .check_proposal( + &stacks_client, + &mut signer_db, + &last_sortition_block, + &block_pk, + 1 + ) .unwrap()); } diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs index 53a288b7f5..639ace66d2 100644 --- a/stacks-signer/src/v0/signer.rs +++ b/stacks-signer/src/v0/signer.rs @@ -16,14 +16,17 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Debug; use std::sync::mpsc::Sender; -use blockstack_lib::chainstate::nakamoto::NakamotoBlockHeader; -use blockstack_lib::net::api::postblock_proposal::BlockValidateResponse; +use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; +use blockstack_lib::net::api::postblock_proposal::{ + BlockValidateOk, BlockValidateReject, BlockValidateResponse, +}; use clarity::types::chainstate::StacksPrivateKey; use clarity::types::{PrivateKey, StacksEpochId}; use clarity::util::hash::MerkleHashFunc; use clarity::util::secp256k1::Secp256k1PublicKey; use libsigner::v0::messages::{ - BlockResponse, MessageSlotID, MockProposal, MockSignature, RejectCode, SignerMessage, + BlockRejection, BlockResponse, MessageSlotID, MockProposal, MockSignature, RejectCode, + SignerMessage, }; use libsigner::{BlockProposal, SignerEvent}; use slog::{slog_debug, slog_error, slog_info, slog_warn}; @@ -37,9 +40,29 @@ use crate::chainstate::{ProposalEvalConfig, SortitionsView}; use crate::client::{SignerSlotID, StackerDB, StacksClient}; use crate::config::SignerConfig; use crate::runloop::{RunLoopCommand, SignerResult}; -use crate::signerdb::{BlockInfo, SignerDb}; +use crate::signerdb::{BlockInfo, BlockState, SignerDb}; use crate::Signer as SignerTrait; +#[cfg(any(test, feature = "testing"))] +/// A global variable that can be used to reject all block proposals if the signer's public key is in the provided list +pub static TEST_REJECT_ALL_BLOCK_PROPOSAL: std::sync::Mutex< + Option>, +> = std::sync::Mutex::new(None); + +#[cfg(any(test, feature = "testing"))] +/// A global variable that can be used to ignore block proposals if the signer's public key is in the provided list +pub static TEST_IGNORE_ALL_BLOCK_PROPOSALS: std::sync::Mutex< + Option>, +> = std::sync::Mutex::new(None); + +#[cfg(any(test, feature = "testing"))] +/// Pause the block broadcast +pub static TEST_PAUSE_BLOCK_BROADCAST: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(any(test, feature = "testing"))] +/// Skip broadcasting the block to the network +pub static TEST_SKIP_BLOCK_BROADCAST: std::sync::Mutex> = std::sync::Mutex::new(None); + /// The stacks signer registered for the reward cycle #[derive(Debug)] pub struct Signer { @@ -63,8 +86,6 @@ pub struct Signer { pub signer_db: SignerDb, /// Configuration for proposal evaluation pub proposal_config: ProposalEvalConfig, - /// Whether or not to broadcast signed blocks if we gather all signatures - pub broadcast_signed_blocks: bool, } impl std::fmt::Display for Signer { @@ -128,10 +149,7 @@ impl SignerTrait for Signer { let SignerMessage::BlockResponse(block_response) = message else { continue; }; - let BlockResponse::Accepted((block_hash, signature)) = block_response else { - continue; - }; - self.handle_block_signature(stacks_client, block_hash, signature); + self.handle_block_response(stacks_client, block_response); } } SignerEvent::MinerMessages(messages, miner_pubkey) => { @@ -142,6 +160,23 @@ impl SignerTrait for Signer { for message in messages { match message { SignerMessage::BlockProposal(block_proposal) => { + #[cfg(any(test, feature = "testing"))] + if let Some(public_keys) = + &*TEST_IGNORE_ALL_BLOCK_PROPOSALS.lock().unwrap() + { + if public_keys.contains( + &stacks_common::types::chainstate::StacksPublicKey::from_private( + &self.private_key, + ), + ) { + warn!("{self}: Ignoring block proposal due to testing directive"; + "block_id" => %block_proposal.block.block_id(), + "height" => block_proposal.block.header.chain_length, + "consensus_hash" => %block_proposal.block.header.consensus_hash + ); + continue; + } + } self.handle_block_proposal( stacks_client, sortition_state, @@ -150,13 +185,23 @@ impl SignerTrait for Signer { ); } SignerMessage::BlockPushed(b) => { - let block_push_result = stacks_client.post_block(b); + // This will infinitely loop until the block is acknowledged by the node info!( "{self}: Got block pushed message"; "block_id" => %b.block_id(), "signer_sighash" => %b.header.signer_signature_hash(), - "push_result" => ?block_push_result, ); + loop { + match stacks_client.post_block(b) { + Ok(block_push_result) => { + debug!("{self}: Block pushed to stacks node: {block_push_result:?}"); + break; + } + Err(e) => { + warn!("{self}: Failed to push block to stacks node: {e}. Retrying..."); + } + }; + } } SignerMessage::MockProposal(mock_proposal) => { let epoch = match stacks_client.get_node_epoch() { @@ -217,9 +262,9 @@ impl SignerTrait for Signer { } } - fn has_pending_blocks(&self) -> bool { + fn has_unprocessed_blocks(&self) -> bool { self.signer_db - .has_pending_blocks(self.reward_cycle) + .has_unprocessed_blocks(self.reward_cycle) .unwrap_or_else(|e| { error!("{self}: Failed to check for pending blocks: {e:?}",); // Assume we have pending blocks to prevent premature cleanup @@ -277,7 +322,6 @@ impl From for Signer { reward_cycle: signer_config.reward_cycle, signer_db, proposal_config, - broadcast_signed_blocks: signer_config.broadcast_signed_blocks, } } } @@ -300,6 +344,8 @@ impl Signer { BlockResponse::rejected( block_info.signer_signature_hash(), RejectCode::RejectedInPriorRound, + &self.private_key, + self.mainnet, ) }; Some(response) @@ -322,6 +368,7 @@ impl Signer { ); 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(); @@ -375,9 +422,10 @@ impl Signer { let block_response = if let Some(sortition_state) = sortition_state { match sortition_state.check_proposal( stacks_client, - &self.signer_db, + &mut self.signer_db, &block_proposal.block, miner_pubkey, + self.reward_cycle, ) { // Error validating block Err(e) => { @@ -389,6 +437,8 @@ impl Signer { Some(BlockResponse::rejected( block_proposal.block.header.signer_signature_hash(), RejectCode::ConnectivityIssues, + &self.private_key, + self.mainnet, )) } // Block proposal is bad @@ -401,6 +451,8 @@ impl Signer { Some(BlockResponse::rejected( block_proposal.block.header.signer_signature_hash(), RejectCode::SortitionViewMismatch, + &self.private_key, + self.mainnet, )) } // Block proposal passed check, still don't know if valid @@ -415,12 +467,42 @@ impl Signer { Some(BlockResponse::rejected( block_proposal.block.header.signer_signature_hash(), RejectCode::NoSortitionView, + &self.private_key, + self.mainnet, )) }; + #[cfg(any(test, feature = "testing"))] + let block_response = match &*TEST_REJECT_ALL_BLOCK_PROPOSAL.lock().unwrap() { + Some(public_keys) => { + if public_keys.contains( + &stacks_common::types::chainstate::StacksPublicKey::from_private( + &self.private_key, + ), + ) { + warn!("{self}: Rejecting block proposal automatically due to testing directive"; + "block_id" => %block_proposal.block.block_id(), + "height" => block_proposal.block.header.chain_length, + "consensus_hash" => %block_proposal.block.header.consensus_hash + ); + Some(BlockResponse::rejected( + block_proposal.block.header.signer_signature_hash(), + RejectCode::TestingDirective, + &self.private_key, + self.mainnet, + )) + } else { + None + } + } + None => block_response, + }; + if let Some(block_response) = block_response { // We know proposal is invalid. Send rejection message, do not do further validation - block_info.valid = Some(false); + if let Err(e) = block_info.mark_locally_rejected() { + warn!("{self}: Failed to mark block as locally rejected: {e:?}",); + }; debug!("{self}: Broadcasting a block response to stacks node: {block_response:?}"); let res = self .stackerdb @@ -435,17 +517,116 @@ impl Signer { Ok(_) => debug!("{self}: Block rejection accepted by stacker-db"), } } else { - // We don't know if proposal is valid, submit to stacks-node for further checks + // We don't know if proposal is valid, submit to stacks-node for further checks and store it locally. + // Do not store invalid blocks as this could DOS the signer. We only store blocks that are valid or unknown. stacks_client .submit_block_for_validation(block_info.block.clone()) .unwrap_or_else(|e| { warn!("{self}: Failed to submit block for validation: {e:?}"); }); + + self.signer_db + .insert_block(&block_info) + .unwrap_or_else(|_| panic!("{self}: Failed to insert block in DB")); + } + } + + /// Handle block response messages from a signer + fn handle_block_response( + &mut self, + stacks_client: &StacksClient, + block_response: &BlockResponse, + ) { + match block_response { + BlockResponse::Accepted((block_hash, signature)) => { + self.handle_block_signature(stacks_client, block_hash, signature); + } + BlockResponse::Rejected(block_rejection) => { + self.handle_block_rejection(block_rejection); + } + } + } + /// Handle the block validate ok response. Returns our block response if we have one + fn handle_block_validate_ok( + &mut self, + stacks_client: &StacksClient, + block_validate_ok: &BlockValidateOk, + ) -> Option { + crate::monitoring::increment_block_validation_responses(true); + let signer_signature_hash = block_validate_ok.signer_signature_hash; + // For mutability reasons, we need to take the block_info out of the map and add it back after processing + let mut block_info = match self + .signer_db + .block_lookup(self.reward_cycle, &signer_signature_hash) + { + Ok(Some(block_info)) => block_info, + Ok(None) => { + // We have not seen this block before. Why are we getting a response for it? + debug!("{self}: Received a block validate response for a block we have not seen before. Ignoring..."); + return None; + } + Err(e) => { + error!("{self}: Failed to lookup block in signer db: {e:?}",); + return None; + } + }; + if let Err(e) = block_info.mark_locally_accepted(false) { + warn!("{self}: Failed to mark block as locally accepted: {e:?}",); + return None; } + let signature = self + .private_key + .sign(&signer_signature_hash.0) + .expect("Failed to sign block"); self.signer_db .insert_block(&block_info) .unwrap_or_else(|_| panic!("{self}: Failed to insert block in DB")); + // have to save the signature _after_ the block info + self.handle_block_signature( + stacks_client, + &block_info.signer_signature_hash(), + &signature, + ); + Some(BlockResponse::accepted(signer_signature_hash, signature)) + } + + /// Handle the block validate reject response. Returns our block response if we have one + fn handle_block_validate_reject( + &mut self, + block_validate_reject: &BlockValidateReject, + ) -> Option { + crate::monitoring::increment_block_validation_responses(false); + let signer_signature_hash = block_validate_reject.signer_signature_hash; + let mut block_info = match self + .signer_db + .block_lookup(self.reward_cycle, &signer_signature_hash) + { + Ok(Some(block_info)) => block_info, + Ok(None) => { + // We have not seen this block before. Why are we getting a response for it? + debug!("{self}: Received a block validate response for a block we have not seen before. Ignoring..."); + return None; + } + Err(e) => { + error!("{self}: Failed to lookup block in signer db: {e:?}"); + return None; + } + }; + if let Err(e) = block_info.mark_locally_rejected() { + warn!("{self}: Failed to mark block as locally rejected: {e:?}",); + return None; + } + let block_rejection = BlockRejection::from_validate_rejection( + block_validate_reject.clone(), + &self.private_key, + self.mainnet, + ); + self.signer_db + .insert_block(&block_info) + .unwrap_or_else(|_| panic!("{self}: Failed to insert block in DB")); + self.handle_block_rejection(&block_rejection); + Some(BlockResponse::Rejected(block_rejection)) } /// Handle the block validate response returned from our prior calls to submit a block for validation @@ -455,68 +636,20 @@ impl Signer { block_validate_response: &BlockValidateResponse, ) { info!("{self}: Received a block validate response: {block_validate_response:?}"); - let (response, block_info, signature_opt) = match block_validate_response { + let block_response = match block_validate_response { BlockValidateResponse::Ok(block_validate_ok) => { - crate::monitoring::increment_block_validation_responses(true); - let signer_signature_hash = block_validate_ok.signer_signature_hash; - // For mutability reasons, we need to take the block_info out of the map and add it back after processing - let mut block_info = match self - .signer_db - .block_lookup(self.reward_cycle, &signer_signature_hash) - { - Ok(Some(block_info)) => block_info, - Ok(None) => { - // We have not seen this block before. Why are we getting a response for it? - debug!("{self}: Received a block validate response for a block we have not seen before. Ignoring..."); - return; - } - Err(e) => { - error!("{self}: Failed to lookup block in signer db: {e:?}",); - return; - } - }; - block_info.mark_signed_and_valid(); - let signature = self - .private_key - .sign(&signer_signature_hash.0) - .expect("Failed to sign block"); - - ( - BlockResponse::accepted(signer_signature_hash, signature), - block_info, - Some(signature.clone()), - ) + self.handle_block_validate_ok(stacks_client, block_validate_ok) } BlockValidateResponse::Reject(block_validate_reject) => { - crate::monitoring::increment_block_validation_responses(false); - let signer_signature_hash = block_validate_reject.signer_signature_hash; - let mut block_info = match self - .signer_db - .block_lookup(self.reward_cycle, &signer_signature_hash) - { - Ok(Some(block_info)) => block_info, - Ok(None) => { - // We have not seen this block before. Why are we getting a response for it? - debug!("{self}: Received a block validate response for a block we have not seen before. Ignoring..."); - return; - } - Err(e) => { - error!("{self}: Failed to lookup block in signer db: {e:?}"); - return; - } - }; - block_info.valid = Some(false); - ( - BlockResponse::from(block_validate_reject.clone()), - block_info, - None, - ) + self.handle_block_validate_reject(block_validate_reject) } }; + let Some(response) = block_response else { + return; + }; // Submit a proposal response to the .signers contract for miners info!( "{self}: Broadcasting a block response to stacks node: {response:?}"; - "signer_sighash" => %block_info.signer_signature_hash(), ); match self .stackerdb @@ -530,18 +663,6 @@ impl Signer { warn!("{self}: Failed to send block rejection to stacker-db: {e:?}",); } } - self.signer_db - .insert_block(&block_info) - .unwrap_or_else(|_| panic!("{self}: Failed to insert block in DB")); - - if let Some(signature) = signature_opt { - // have to save the signature _after_ the block info - self.handle_block_signature( - stacks_client, - &block_info.signer_signature_hash(), - &signature, - ); - } } /// Compute the signing weight, given a list of signatures @@ -567,6 +688,99 @@ impl Signer { .unwrap_or_else(|_| panic!("FATAL: total weight exceeds u32::MAX")) } + /// Handle an observed rejection from another signer + fn handle_block_rejection(&mut self, rejection: &BlockRejection) { + debug!("{self}: Received a block-reject signature: {rejection:?}"); + + let block_hash = &rejection.signer_signature_hash; + let signature = &rejection.signature; + + let mut block_info = match self.signer_db.block_lookup(self.reward_cycle, block_hash) { + Ok(Some(block_info)) => { + if block_info.state == BlockState::GloballyRejected + || block_info.state == BlockState::GloballyAccepted + { + debug!("{self}: Received block rejection for a block that is already marked as {}. Ignoring...", block_info.state); + return; + } + block_info + } + Ok(None) => { + debug!("{self}: Received block rejection for a block we have not seen before. Ignoring..."); + return; + } + Err(e) => { + warn!("{self}: Failed to load block state: {e:?}",); + return; + } + }; + + // recover public key + let Ok(public_key) = rejection.recover_public_key() else { + debug!("{self}: Received block rejection with an unrecovarable signature. Will not store."; + "block_hash" => %block_hash, + "signature" => %signature + ); + return; + }; + + let signer_address = StacksAddress::p2pkh(self.mainnet, &public_key); + + // authenticate the signature -- it must be signed by one of the stacking set + let is_valid_sig = self + .signer_addresses + .iter() + .find(|addr| { + // it only matters that the address hash bytes match + signer_address.bytes == addr.bytes + }) + .is_some(); + + if !is_valid_sig { + debug!("{self}: Receive block rejection with an invalid signature. Will not store."; + "block_hash" => %block_hash, + "signature" => %signature + ); + return; + } + + // signature is valid! store it + if let Err(e) = self + .signer_db + .add_block_rejection_signer_addr(block_hash, &signer_address) + { + warn!("{self}: Failed to save block rejection signature: {e:?}",); + } + + // do we have enough signatures to mark a block a globally rejected? + // i.e. is (set-size) - (threshold) + 1 reached. + let rejection_addrs = match self.signer_db.get_block_rejection_signer_addrs(block_hash) { + Ok(addrs) => addrs, + Err(e) => { + warn!("{self}: Failed to load block rejection addresses: {e:?}.",); + return; + } + }; + let total_reject_weight = self.compute_signature_signing_weight(rejection_addrs.iter()); + let total_weight = self.compute_signature_total_weight(); + + let min_weight = NakamotoBlockHeader::compute_voting_weight_threshold(total_weight) + .unwrap_or_else(|_| { + panic!("{self}: Failed to compute threshold weight for {total_weight}") + }); + if total_reject_weight.saturating_add(min_weight) <= total_weight { + // Not enough rejection signatures to make a decision + return; + } + debug!("{self}: {total_reject_weight}/{total_weight} signers voteed to reject the block {block_hash}"); + if let Err(e) = block_info.mark_globally_rejected() { + warn!("{self}: Failed to mark block as globally rejected: {e:?}",); + } + if let Err(e) = self.signer_db.insert_block(&block_info) { + warn!("{self}: Failed to update block state: {e:?}",); + } + } + /// Handle an observed signature from another signer fn handle_block_signature( &mut self, @@ -574,26 +788,27 @@ impl Signer { block_hash: &Sha512Trunc256Sum, signature: &MessageSignature, ) { - if !self.broadcast_signed_blocks { - debug!("{self}: Will ignore block-accept signature, since configured not to broadcast signed blocks"); - return; - } - debug!("{self}: Received a block-accept signature: ({block_hash}, {signature})"); - // have we broadcasted before? - if let Some(ts) = self + // Have we already processed this block? + match self .signer_db - .get_block_broadcasted(self.reward_cycle, block_hash) - .unwrap_or_else(|_| { - panic!("{self}: failed to determine if block {block_hash} was broadcasted") - }) + .get_block_state(self.reward_cycle, block_hash) { - debug!( - "{self}: have already broadcasted block {} at {}, so will not re-attempt", - block_hash, ts - ); - return; + Ok(Some(state)) => { + if state == BlockState::GloballyAccepted || state == BlockState::GloballyRejected { + debug!("{self}: Received block signature for a block that is already marked as {}. Ignoring...", state); + return; + } + } + Ok(None) => { + debug!("{self}: Received block signature for a block we have not seen before. Ignoring..."); + return; + } + Err(e) => { + warn!("{self}: Failed to load block state: {e:?}",); + return; + } } // recover public key @@ -611,7 +826,7 @@ impl Signer { .signer_addresses .iter() .find(|addr| { - let stacker_address = StacksAddress::p2pkh(true, &public_key); + let stacker_address = StacksAddress::p2pkh(self.mainnet, &public_key); // it only matters that the address hash bytes match stacker_address.bytes == addr.bytes @@ -676,9 +891,12 @@ impl Signer { warn!("{self}: No such block {block_hash}"); return; }; - - // record time at which we reached the threshold - block_info.signed_group = Some(get_epoch_time_secs()); + // move block to LOCALLY accepted state. + // We only mark this GLOBALLY accepted if we manage to broadcast it... + if let Err(e) = block_info.mark_locally_accepted(true) { + // Do not abort as we should still try to store the block signature threshold + warn!("{self}: Failed to mark block as locally accepted: {e:?}"); + } let _ = self.signer_db.insert_block(&block_info).map_err(|e| { warn!( "Failed to set group threshold signature timestamp for {}: {:?}", @@ -686,7 +904,33 @@ impl Signer { ); e }); + #[cfg(any(test, feature = "testing"))] + { + if *TEST_PAUSE_BLOCK_BROADCAST.lock().unwrap() == Some(true) { + // Do an extra check just so we don't log EVERY time. + warn!("Block broadcast is stalled due to testing directive."; + "block_id" => %block_info.block.block_id(), + "height" => block_info.block.header.chain_length, + ); + while *TEST_PAUSE_BLOCK_BROADCAST.lock().unwrap() == Some(true) { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + info!("Block validation is no longer stalled due to testing directive."; + "block_id" => %block_info.block.block_id(), + "height" => block_info.block.header.chain_length, + ); + } + } + self.broadcast_signed_block(stacks_client, block_info.block, &addrs_to_sigs); + } + fn broadcast_signed_block( + &self, + stacks_client: &StacksClient, + mut block: NakamotoBlock, + addrs_to_sigs: &HashMap, + ) { + let block_hash = block.header.signer_signature_hash(); // collect signatures for the block let signatures: Vec<_> = self .signer_addresses @@ -694,30 +938,49 @@ impl Signer { .filter_map(|addr| addrs_to_sigs.get(addr).cloned()) .collect(); - let mut block = block_info.block; + block.header.signer_signature_hash(); block.header.signer_signature = signatures; + #[cfg(any(test, feature = "testing"))] + { + if *TEST_SKIP_BLOCK_BROADCAST.lock().unwrap() == Some(true) { + warn!( + "{self}: Skipping block broadcast due to testing directive"; + "block_id" => %block.block_id(), + "height" => block.header.chain_length, + "consensus_hash" => %block.header.consensus_hash + ); + + if let Err(e) = self.signer_db.set_block_broadcasted( + self.reward_cycle, + &block_hash, + get_epoch_time_secs(), + ) { + warn!("{self}: Failed to set block broadcasted for {block_hash}: {e:?}"); + } + return; + } + } debug!( "{self}: Broadcasting Stacks block {} to node", &block.block_id() ); - let broadcasted = stacks_client - .post_block(&block) - .map_err(|e| { - warn!( - "{self}: Failed to post block {block_hash} (id {}): {e:?}", - &block.block_id() - ); - e - }) - .is_ok(); + if let Err(e) = stacks_client.post_block(&block) { + warn!( + "{self}: Failed to post block {block_hash}: {e:?}"; + "stacks_block_id" => %block.block_id(), + "parent_block_id" => %block.header.parent_block_id, + "burnchain_consensus_hash" => %block.header.consensus_hash + ); + return; + } - if broadcasted { - self.signer_db - .set_block_broadcasted(self.reward_cycle, block_hash, get_epoch_time_secs()) - .unwrap_or_else(|_| { - panic!("{self}: failed to determine if block {block_hash} was broadcasted") - }); + if let Err(e) = self.signer_db.set_block_broadcasted( + self.reward_cycle, + &block_hash, + get_epoch_time_secs(), + ) { + warn!("{self}: Failed to set block broadcasted for {block_hash}: {e:?}"); } } diff --git a/stacks-signer/src/v1/signer.rs b/stacks-signer/src/v1/signer.rs index fca9282ec5..08ccde5a92 100644 --- a/stacks-signer/src/v1/signer.rs +++ b/stacks-signer/src/v1/signer.rs @@ -273,9 +273,9 @@ impl SignerTrait for Signer { self.process_next_command(stacks_client, current_reward_cycle); } - fn has_pending_blocks(&self) -> bool { + fn has_unprocessed_blocks(&self) -> bool { self.signer_db - .has_pending_blocks(self.reward_cycle) + .has_unprocessed_blocks(self.reward_cycle) .unwrap_or_else(|e| { error!("{self}: Failed to check if there are pending blocks: {e:?}"); // Assume there are pending blocks to prevent premature cleanup diff --git a/stackslib/src/chainstate/stacks/db/mod.rs b/stackslib/src/chainstate/stacks/db/mod.rs index ed3158c761..a942ec7fd1 100644 --- a/stackslib/src/chainstate/stacks/db/mod.rs +++ b/stackslib/src/chainstate/stacks/db/mod.rs @@ -1038,13 +1038,17 @@ impl StacksChainState { Ok(config.expect("BUG: no db_config installed")) } - fn apply_schema_migrations<'a>( - tx: &DBTx<'a>, + /// Do we need a schema migration? + /// Return Ok(true) if so + /// Return Ok(false) if not + /// Return Err(..) on DB errors, or if this DB is not consistent with `mainnet` or `chain_id` + fn need_schema_migrations( + conn: &Connection, mainnet: bool, chain_id: u32, - ) -> Result<(), Error> { - let mut db_config = - StacksChainState::load_db_config(tx).expect("CORRUPTION: no db_config found"); + ) -> Result { + let db_config = + StacksChainState::load_db_config(conn).expect("CORRUPTION: no db_config found"); if db_config.mainnet != mainnet { error!( @@ -1062,55 +1066,70 @@ impl StacksChainState { return Err(Error::InvalidChainstateDB); } - if db_config.version != CHAINSTATE_VERSION { - while db_config.version != CHAINSTATE_VERSION { - match db_config.version.as_str() { - "1" => { - // migrate to 2 - info!("Migrating chainstate schema from version 1 to 2"); - for cmd in CHAINSTATE_SCHEMA_2.iter() { - tx.execute_batch(cmd)?; - } - } - "2" => { - // migrate to 3 - info!("Migrating chainstate schema from version 2 to 3"); - for cmd in CHAINSTATE_SCHEMA_3.iter() { - tx.execute_batch(cmd)?; - } + Ok(db_config.version != CHAINSTATE_VERSION) + } + + fn apply_schema_migrations<'a>( + tx: &DBTx<'a>, + mainnet: bool, + chain_id: u32, + ) -> Result<(), Error> { + if !Self::need_schema_migrations(tx, mainnet, chain_id)? { + return Ok(()); + } + + let mut db_config = + StacksChainState::load_db_config(tx).expect("CORRUPTION: no db_config found"); + + while db_config.version != CHAINSTATE_VERSION { + match db_config.version.as_str() { + "1" => { + // migrate to 2 + info!("Migrating chainstate schema from version 1 to 2"); + for cmd in CHAINSTATE_SCHEMA_2.iter() { + tx.execute_batch(cmd)?; } - "3" => { - // migrate to nakamoto 1 - info!("Migrating chainstate schema from version 3 to 4: nakamoto support"); - for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_1.iter() { - tx.execute_batch(cmd)?; - } + } + "2" => { + // migrate to 3 + info!("Migrating chainstate schema from version 2 to 3"); + for cmd in CHAINSTATE_SCHEMA_3.iter() { + tx.execute_batch(cmd)?; } - "4" => { - // migrate to nakamoto 2 - info!("Migrating chainstate schema from version 4 to 5: fix nakamoto tenure typo"); - for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_2.iter() { - tx.execute_batch(cmd)?; - } + } + "3" => { + // migrate to nakamoto 1 + info!("Migrating chainstate schema from version 3 to 4: nakamoto support"); + for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_1.iter() { + tx.execute_batch(cmd)?; } - "5" => { - // migrate to nakamoto 3 - info!("Migrating chainstate schema from version 5 to 6: adds height_in_tenure field"); - for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_3.iter() { - tx.execute_batch(cmd)?; - } + } + "4" => { + // migrate to nakamoto 2 + info!( + "Migrating chainstate schema from version 4 to 5: fix nakamoto tenure typo" + ); + for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_2.iter() { + tx.execute_batch(cmd)?; } - _ => { - error!( - "Invalid chain state database: expected version = {}, got {}", - CHAINSTATE_VERSION, db_config.version - ); - return Err(Error::InvalidChainstateDB); + } + "5" => { + // migrate to nakamoto 3 + info!("Migrating chainstate schema from version 5 to 6: adds height_in_tenure field"); + for cmd in NAKAMOTO_CHAINSTATE_SCHEMA_3.iter() { + tx.execute_batch(cmd)?; } } - db_config = - StacksChainState::load_db_config(tx).expect("CORRUPTION: no db_config found"); + _ => { + error!( + "Invalid chain state database: expected version = {}, got {}", + CHAINSTATE_VERSION, db_config.version + ); + return Err(Error::InvalidChainstateDB); + } } + db_config = + StacksChainState::load_db_config(tx).expect("CORRUPTION: no db_config found"); } Ok(()) } @@ -1134,6 +1153,11 @@ impl StacksChainState { StacksChainState::instantiate_db(mainnet, chain_id, index_path, true) } else { let mut marf = StacksChainState::open_index(index_path)?; + if !Self::need_schema_migrations(marf.sqlite_conn(), mainnet, chain_id)? { + return Ok(marf); + } + + // need a migration let tx = marf.storage_tx()?; StacksChainState::apply_schema_migrations(&tx, mainnet, chain_id)?; StacksChainState::add_indexes(&tx)?; @@ -1155,6 +1179,11 @@ impl StacksChainState { StacksChainState::instantiate_db(mainnet, chain_id, index_path, false) } else { let mut marf = StacksChainState::open_index(index_path)?; + + // do we need to apply a schema change? + let db_config = StacksChainState::load_db_config(marf.sqlite_conn()) + .expect("CORRUPTION: no db_config found"); + let tx = marf.storage_tx()?; StacksChainState::add_indexes(&tx)?; tx.commit()?; diff --git a/stackslib/src/chainstate/stacks/miner.rs b/stackslib/src/chainstate/stacks/miner.rs index f0e4c96307..0195385d3b 100644 --- a/stackslib/src/chainstate/stacks/miner.rs +++ b/stackslib/src/chainstate/stacks/miner.rs @@ -321,7 +321,7 @@ pub struct TransactionSuccessEvent { } /// Represents an event for a failed transaction. Something went wrong when processing this transaction. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TransactionErrorEvent { #[serde(deserialize_with = "hex_deserialize", serialize_with = "hex_serialize")] pub txid: Txid, @@ -378,7 +378,7 @@ pub enum TransactionResult { /// This struct is used to transmit data about transaction results through either the `mined_block` /// or `mined_microblock` event. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum TransactionEvent { /// Transaction has already succeeded. Success(TransactionSuccessEvent), diff --git a/stackslib/src/net/mod.rs b/stackslib/src/net/mod.rs index af391e03e8..7f8dea9329 100644 --- a/stackslib/src/net/mod.rs +++ b/stackslib/src/net/mod.rs @@ -1571,7 +1571,9 @@ impl NetworkResult { } pub fn has_nakamoto_blocks(&self) -> bool { - self.nakamoto_blocks.len() > 0 || self.pushed_nakamoto_blocks.len() > 0 + self.nakamoto_blocks.len() > 0 + || self.pushed_nakamoto_blocks.len() > 0 + || self.uploaded_nakamoto_blocks.len() > 0 } pub fn has_transactions(&self) -> bool { diff --git a/stackslib/src/net/relay.rs b/stackslib/src/net/relay.rs index 1b08f5cd35..dde4e9bbd8 100644 --- a/stackslib/src/net/relay.rs +++ b/stackslib/src/net/relay.rs @@ -1678,6 +1678,8 @@ impl Relayer { ); accepted_blocks.push(nakamoto_block); } else { + // TODO: this shouldn't be a warning if it's only because we + // already have the block warn!( "Rejected Nakamoto block {} ({}) from {}", &block_id, &nakamoto_block.header.consensus_hash, &neighbor_key, diff --git a/stackslib/src/net/server.rs b/stackslib/src/net/server.rs index a26fa2f7b4..3849b9b058 100644 --- a/stackslib/src/net/server.rs +++ b/stackslib/src/net/server.rs @@ -560,14 +560,14 @@ impl HttpPeer { let mut msgs = vec![]; for event_id in &poll_state.ready { if !self.sockets.contains_key(&event_id) { - test_debug!("Rogue socket event {}", event_id); + debug!("Rogue socket event {}", event_id); to_remove.push(*event_id); continue; } let client_sock_opt = self.sockets.get_mut(&event_id); if client_sock_opt.is_none() { - test_debug!("No such socket event {}", event_id); + debug!("No such socket event {}", event_id); to_remove.push(*event_id); continue; } @@ -576,7 +576,7 @@ impl HttpPeer { match self.peers.get_mut(event_id) { Some(ref mut convo) => { // activity on a http socket - test_debug!("Process HTTP data from {:?}", convo); + debug!("Process HTTP data from {:?}", convo); match HttpPeer::process_http_conversation( node_state, *event_id, @@ -585,11 +585,13 @@ impl HttpPeer { ) { Ok((alive, mut new_msgs)) => { if !alive { + debug!("HTTP convo {:?} is no longer alive", &convo); to_remove.push(*event_id); } msgs.append(&mut new_msgs); } - Err(_e) => { + Err(e) => { + debug!("Failed to process HTTP convo {:?}: {:?}", &convo, &e); to_remove.push(*event_id); continue; } diff --git a/stackslib/src/net/stackerdb/db.rs b/stackslib/src/net/stackerdb/db.rs index 1dab3f4052..2b735668ac 100644 --- a/stackslib/src/net/stackerdb/db.rs +++ b/stackslib/src/net/stackerdb/db.rs @@ -293,6 +293,15 @@ impl<'a> StackerDBTx<'a> { Ok(()) } + /// Shrink a StackerDB. Remove all slots at and beyond a particular slot ID. + fn shrink_stackerdb(&self, stackerdb_id: i64, first_slot_id: u32) -> Result<(), net_error> { + let qry = "DELETE FROM chunks WHERE stackerdb_id = ?1 AND slot_id >= ?2"; + let args = params![&stackerdb_id, &first_slot_id]; + let mut stmt = self.sql_tx.prepare(&qry)?; + stmt.execute(args)?; + Ok(()) + } + /// Update a database's storage slots, e.g. from new configuration state in its smart contract. /// Chunk data for slots that no longer exist will be dropped. /// Newly-created slots will be instantiated with empty data. @@ -343,6 +352,8 @@ impl<'a> StackerDBTx<'a> { stmt.execute(args)?; } } + debug!("Shrink {} to {} slots", smart_contract, total_slots_read); + self.shrink_stackerdb(stackerdb_id, total_slots_read)?; Ok(()) } diff --git a/stackslib/src/net/stackerdb/mod.rs b/stackslib/src/net/stackerdb/mod.rs index a2de124793..b022746d6a 100644 --- a/stackslib/src/net/stackerdb/mod.rs +++ b/stackslib/src/net/stackerdb/mod.rs @@ -341,8 +341,14 @@ impl StackerDBs { &e ); } - } else if new_config != stackerdb_config && new_config.signers.len() > 0 { + } else if (new_config != stackerdb_config && new_config.signers.len() > 0) + || (new_config == stackerdb_config + && new_config.signers.len() + != self.get_slot_versions(&stackerdb_contract_id)?.len()) + { // only reconfigure if the config has changed + // (that second check on the length is needed in case the node is a victim of + // #5142, which was a bug whereby a stackerdb could never shrink) if let Err(e) = self.reconfigure_stackerdb(&stackerdb_contract_id, &new_config) { warn!( "Failed to create or reconfigure StackerDB {stackerdb_contract_id}: DB error {:?}", diff --git a/stackslib/src/net/stackerdb/sync.rs b/stackslib/src/net/stackerdb/sync.rs index 32d7a7e37e..fa94c5be55 100644 --- a/stackslib/src/net/stackerdb/sync.rs +++ b/stackslib/src/net/stackerdb/sync.rs @@ -808,7 +808,7 @@ impl StackerDBSync { ); let chunks_req = self.make_getchunkinv(&network.get_chain_view().rc_consensus_hash); if let Err(e) = self.comms.neighbor_send(network, &naddr, chunks_req) { - info!( + debug!( "{:?}: failed to send StackerDBGetChunkInv to {:?}: {:?}", network.get_local_peer(), &naddr, diff --git a/stackslib/src/net/stackerdb/tests/db.rs b/stackslib/src/net/stackerdb/tests/db.rs index 7371b6b9c5..9bcf800529 100644 --- a/stackslib/src/net/stackerdb/tests/db.rs +++ b/stackslib/src/net/stackerdb/tests/db.rs @@ -20,6 +20,7 @@ use std::path::Path; use clarity::vm::types::QualifiedContractIdentifier; use clarity::vm::ContractName; use libstackerdb::SlotMetadata; +use rusqlite::params; use stacks_common::address::{ AddressHashMode, C32_ADDRESS_VERSION_MAINNET_MULTISIG, C32_ADDRESS_VERSION_MAINNET_SINGLESIG, }; @@ -649,6 +650,16 @@ fn test_reconfigure_stackerdb() { initial_metadata.push((slot_metadata, chunk_data)); } + tx.commit().unwrap(); + + let db_slot_metadata = db.get_db_slot_metadata(&sc).unwrap(); + assert_eq!(db_slot_metadata.len(), pks.len()); + for (i, slot_md) in db_slot_metadata.iter().enumerate() { + let slot_metadata = db.get_slot_metadata(&sc, i as u32).unwrap().unwrap(); + assert_eq!(slot_metadata, *slot_md); + } + + let tx = db.tx_begin(StackerDBConfig::noop()).unwrap(); let new_pks: Vec<_> = (0..10).map(|_| StacksPrivateKey::new()).collect(); let reconfigured_pks = vec![ // first five slots are unchanged @@ -722,6 +733,91 @@ fn test_reconfigure_stackerdb() { assert_eq!(chunk.len(), 0); } } + + let db_slot_metadata = db.get_db_slot_metadata(&sc).unwrap(); + assert_eq!(db_slot_metadata.len(), reconfigured_pks.len()); + for (i, slot_md) in db_slot_metadata.iter().enumerate() { + let slot_metadata = db.get_slot_metadata(&sc, i as u32).unwrap().unwrap(); + assert_eq!(slot_metadata, *slot_md); + } + + // reconfigure with fewer slots + let new_pks: Vec<_> = (0..10).map(|_| StacksPrivateKey::new()).collect(); + let reconfigured_pks = vec![ + // first five slots are unchanged + pks[0], pks[1], pks[2], pks[3], pks[4], + // next five slots are different, so their contents will be dropped and versions and write + // timestamps reset + new_pks[0], new_pks[1], new_pks[2], new_pks[3], + new_pks[4], + // slots 10-15 will disappear + ]; + let reconfigured_addrs: Vec<_> = reconfigured_pks + .iter() + .map(|pk| { + StacksAddress::from_public_keys( + C32_ADDRESS_VERSION_MAINNET_SINGLESIG, + &AddressHashMode::SerializeP2PKH, + 1, + &vec![StacksPublicKey::from_private(&pk)], + ) + .unwrap() + }) + .collect(); + + let tx = db.tx_begin(StackerDBConfig::noop()).unwrap(); + + // reconfigure + tx.reconfigure_stackerdb( + &sc, + &reconfigured_addrs + .clone() + .into_iter() + .map(|addr| (addr, 1)) + .collect::>(), + ) + .unwrap(); + + tx.commit().unwrap(); + + for (i, pk) in new_pks.iter().enumerate() { + if i < 5 { + // first five are unchanged + let chunk_data = StackerDBChunkData { + slot_id: i as u32, + slot_version: 1, + sig: MessageSignature::empty(), + data: vec![i as u8; 128], + }; + + let slot_metadata = db.get_slot_metadata(&sc, i as u32).unwrap().unwrap(); + let chunk = db.get_latest_chunk(&sc, i as u32).unwrap().unwrap(); + + assert_eq!(initial_metadata[i].0, slot_metadata); + assert_eq!(initial_metadata[i].1.data, chunk); + } else if i < 10 { + // next five are wiped + let slot_metadata = db.get_slot_metadata(&sc, i as u32).unwrap().unwrap(); + assert_eq!(slot_metadata.slot_id, i as u32); + assert_eq!(slot_metadata.slot_version, 0); + assert_eq!(slot_metadata.data_hash, Sha512Trunc256Sum([0x00; 32])); + assert_eq!(slot_metadata.signature, MessageSignature::empty()); + + let chunk = db.get_latest_chunk(&sc, i as u32).unwrap().unwrap(); + assert_eq!(chunk.len(), 0); + } else { + // final five are gone + let slot_metadata_opt = db.get_slot_metadata(&sc, i as u32).unwrap(); + assert!(slot_metadata_opt.is_none()); + } + } + + let db_slot_metadata = db.get_db_slot_metadata(&sc).unwrap(); + assert_eq!(db_slot_metadata.len(), reconfigured_pks.len()); + for (i, slot_md) in db_slot_metadata.iter().enumerate() { + let slot_metadata = db.get_slot_metadata(&sc, i as u32).unwrap().unwrap(); + assert_eq!(slot_metadata, *slot_md); + } } // TODO: max chunk size diff --git a/testnet/stacks-node/Cargo.toml b/testnet/stacks-node/Cargo.toml index 5128f17f03..19165db0a8 100644 --- a/testnet/stacks-node/Cargo.toml +++ b/testnet/stacks-node/Cargo.toml @@ -45,7 +45,7 @@ reqwest = { version = "0.11", default-features = false, features = ["blocking", clarity = { path = "../../clarity", features = ["default", "testing"]} stacks-common = { path = "../../stacks-common", features = ["default", "testing"] } stacks = { package = "stackslib", path = "../../stackslib", features = ["default", "testing"] } -stacks-signer = { path = "../../stacks-signer" } +stacks-signer = { path = "../../stacks-signer", features = ["testing"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } wsts = {workspace = true} diff --git a/testnet/stacks-node/src/event_dispatcher.rs b/testnet/stacks-node/src/event_dispatcher.rs index 34e42501ac..7ad55a994b 100644 --- a/testnet/stacks-node/src/event_dispatcher.rs +++ b/testnet/stacks-node/src/event_dispatcher.rs @@ -142,7 +142,7 @@ pub struct MinedMicroblockEvent { pub anchor_block: BlockHeaderHash, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MinedNakamotoBlockEvent { pub target_burn_height: u64, pub parent_block_id: String, diff --git a/testnet/stacks-node/src/nakamoto_node/miner.rs b/testnet/stacks-node/src/nakamoto_node/miner.rs index fb79c6abc7..ba32122b6d 100644 --- a/testnet/stacks-node/src/nakamoto_node/miner.rs +++ b/testnet/stacks-node/src/nakamoto_node/miner.rs @@ -138,8 +138,8 @@ pub struct BlockMinerThread { keychain: Keychain, /// burnchain configuration burnchain: Burnchain, - /// Set of blocks that we have mined - mined_blocks: Vec, + /// Last block mined + last_block_mined: Option, /// Copy of the node's registered VRF key registered_key: RegisteredKey, /// Burnchain block snapshot which elected this miner @@ -172,7 +172,7 @@ impl BlockMinerThread { globals: rt.globals.clone(), keychain: rt.keychain.clone(), burnchain: rt.burnchain.clone(), - mined_blocks: vec![], + last_block_mined: None, registered_key, burn_election_block, burn_block, @@ -250,6 +250,10 @@ impl BlockMinerThread { globals: &Globals, prior_miner: JoinHandle>, ) -> Result<(), NakamotoNodeError> { + debug!( + "Stopping prior miner thread ID {:?}", + prior_miner.thread().id() + ); globals.block_miner(); let prior_miner_result = prior_miner .join() @@ -344,6 +348,10 @@ impl BlockMinerThread { } Err(e) => { warn!("Failed to mine block: {e:?}"); + + // try again, in case a new sortition is pending + self.globals + .raise_initiative(format!("MiningFailure: {:?}", &e)); return Err(NakamotoNodeError::MiningFailure( ChainstateError::MinerAborted, )); @@ -394,7 +402,7 @@ impl BlockMinerThread { // update mined-block counters and mined-tenure counters self.globals.counters.bump_naka_mined_blocks(); - if self.mined_blocks.is_empty() { + if !self.last_block_mined.is_none() { // this is the first block of the tenure, bump tenure counter self.globals.counters.bump_naka_mined_tenures(); } @@ -403,8 +411,7 @@ impl BlockMinerThread { Self::fault_injection_block_announce_stall(&new_block); self.globals.coord().announce_new_stacks_block(); - // store mined block - self.mined_blocks.push(new_block); + self.last_block_mined = Some(new_block); } let Ok(sort_db) = SortitionDB::open( @@ -905,32 +912,42 @@ impl BlockMinerThread { burn_db: &mut SortitionDB, chain_state: &mut StacksChainState, ) -> Result { + // load up stacks chain tip + let (stacks_tip_ch, stacks_tip_bh) = + SortitionDB::get_canonical_stacks_chain_tip_hash(burn_db.conn()).map_err(|e| { + error!("Failed to load canonical Stacks tip: {:?}", &e); + NakamotoNodeError::ParentNotFound + })?; + + let stacks_tip_block_id = StacksBlockId::new(&stacks_tip_ch, &stacks_tip_bh); + let tenure_tip_opt = NakamotoChainState::get_highest_block_header_in_tenure( + &mut chain_state.index_conn(), + &stacks_tip_block_id, + &self.burn_election_block.consensus_hash, + ) + .map_err(|e| { + error!( + "Could not query header info for tenure tip {} off of {}: {:?}", + &self.burn_election_block.consensus_hash, &stacks_tip_block_id, &e + ); + NakamotoNodeError::ParentNotFound + })?; + // The nakamoto miner must always build off of a chain tip that is the highest of: // 1. The highest block in the miner's current tenure // 2. The highest block in the current tenure's parent tenure + // // Where the current tenure's parent tenure is the tenure start block committed to in the current tenure's associated block commit. - let stacks_tip_header = if let Some(block) = self.mined_blocks.last() { - test_debug!( - "Stacks block parent ID is last mined block {}", - &block.block_id() + let stacks_tip_header = if let Some(tenure_tip) = tenure_tip_opt { + debug!( + "Stacks block parent ID is last block in tenure ID {}", + &tenure_tip.consensus_hash ); - let stacks_block_id = block.block_id(); - NakamotoChainState::get_block_header(chain_state.db(), &stacks_block_id) - .map_err(|e| { - error!( - "Could not query header info for last-mined block ID {}: {:?}", - &stacks_block_id, &e - ); - NakamotoNodeError::ParentNotFound - })? - .ok_or_else(|| { - error!("No header for parent tenure ID {}", &stacks_block_id); - NakamotoNodeError::ParentNotFound - })? + tenure_tip } else { - // no mined blocks yet - test_debug!( - "Stacks block parent ID is last block in parent tenure ID {}", + // This tenure is empty on the canonical fork, so mine the first tenure block. + debug!( + "Stacks block parent ID is last block in parent tenure tipped by {}", &self.parent_tenure_id ); @@ -949,18 +966,9 @@ impl BlockMinerThread { NakamotoNodeError::ParentNotFound })?; - // NOTE: this is the soon-to-be parent's block ID, since it's the tip we mine on top - // of. We're only interested in performing queries relative to the canonical tip. - let (stacks_tip_ch, stacks_tip_bh) = - SortitionDB::get_canonical_stacks_chain_tip_hash(burn_db.conn()).map_err(|e| { - error!("Failed to load canonical Stacks tip: {:?}", &e); - NakamotoNodeError::ParentNotFound - })?; - - let stacks_tip = StacksBlockId::new(&stacks_tip_ch, &stacks_tip_bh); let header_opt = NakamotoChainState::get_highest_block_header_in_tenure( &mut chain_state.index_conn(), - &stacks_tip, + &stacks_tip_block_id, &parent_tenure_header.consensus_hash, ) .map_err(|e| { @@ -996,7 +1004,7 @@ impl BlockMinerThread { } }; - test_debug!( + debug!( "Miner: stacks tip parent header is {} {:?}", &stacks_tip_header.index_block_hash(), &stacks_tip_header @@ -1124,7 +1132,7 @@ impl BlockMinerThread { .make_vrf_proof() .ok_or_else(|| NakamotoNodeError::BadVrfConstruction)?; - if self.mined_blocks.is_empty() && parent_block_info.parent_tenure.is_none() { + if self.last_block_mined.is_none() && parent_block_info.parent_tenure.is_none() { warn!("Miner should be starting a new tenure, but failed to load parent tenure info"); return Err(NakamotoNodeError::ParentNotFound); }; diff --git a/testnet/stacks-node/src/nakamoto_node/relayer.rs b/testnet/stacks-node/src/nakamoto_node/relayer.rs index 4701656587..b48d93db44 100644 --- a/testnet/stacks-node/src/nakamoto_node/relayer.rs +++ b/testnet/stacks-node/src/nakamoto_node/relayer.rs @@ -817,7 +817,14 @@ impl RelayerThread { let new_miner_handle = std::thread::Builder::new() .name(format!("miner.{parent_tenure_start}",)) .stack_size(BLOCK_PROCESSOR_STACK_SIZE) - .spawn(move || new_miner_state.run_miner(prior_tenure_thread)) + .spawn(move || { + if let Err(e) = new_miner_state.run_miner(prior_tenure_thread) { + info!("Miner thread failed: {:?}", &e); + Err(e) + } else { + Ok(()) + } + }) .map_err(|e| { error!("Relayer: Failed to start tenure thread: {:?}", &e); NakamotoNodeError::SpawnError(e) diff --git a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs index d2c4f2b390..beece7f99e 100644 --- a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs +++ b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs @@ -15,7 +15,7 @@ use std::collections::BTreeMap; use std::sync::mpsc::Receiver; -use std::time::Duration; +use std::time::{Duration, Instant}; use hashbrown::{HashMap, HashSet}; use libsigner::v0::messages::{BlockResponse, MinerSlotID, SignerMessage as SignerMessageV0}; @@ -76,6 +76,7 @@ pub struct SignCoordinator { signer_entries: HashMap, weight_threshold: u32, total_weight: u32, + config: Config, pub next_signer_bitvec: BitVec<4000>, } @@ -305,6 +306,7 @@ impl SignCoordinator { signer_entries: signer_public_keys, weight_threshold: threshold, total_weight, + config: config.clone(), }; return Ok(sign_coordinator); } @@ -326,6 +328,7 @@ impl SignCoordinator { signer_entries: signer_public_keys, weight_threshold: threshold, total_weight, + config: config.clone(), }) } @@ -642,6 +645,19 @@ impl SignCoordinator { false } + /// Check if the tenure needs to change + fn check_burn_tip_changed(sortdb: &SortitionDB, consensus_hash: &ConsensusHash) -> bool { + let cur_burn_chain_tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .expect("FATAL: failed to query sortition DB for canonical burn chain tip"); + + if cur_burn_chain_tip.consensus_hash != *consensus_hash { + info!("SignCoordinator: Cancel signature aggregation; burnchain tip has changed"); + true + } else { + false + } + } + /// Start gathering signatures for a Nakamoto block. /// This function begins by sending a `BlockProposal` message /// to the signers, and then waits for the signers to respond @@ -722,12 +738,15 @@ impl SignCoordinator { let mut total_weight_signed: u32 = 0; let mut total_reject_weight: u32 = 0; + let mut responded_signers = HashSet::new(); let mut gathered_signatures = BTreeMap::new(); info!("SignCoordinator: beginning to watch for block signatures OR posted blocks."; "threshold" => self.weight_threshold, ); + let mut new_burn_tip_ts = None; + loop { // look in the nakamoto staging db -- a block can only get stored there if it has // enough signing weight to clear the threshold @@ -748,6 +767,18 @@ impl SignCoordinator { return Ok(stored_block.header.signer_signature); } + if new_burn_tip_ts.is_none() { + if Self::check_burn_tip_changed(&sortdb, &burn_tip.consensus_hash) { + new_burn_tip_ts = Some(Instant::now()); + } + } + if let Some(ref new_burn_tip_ts) = new_burn_tip_ts.as_ref() { + if new_burn_tip_ts.elapsed() >= self.config.miner.wait_on_interim_blocks { + debug!("SignCoordinator: Exiting due to new burnchain tip"); + return Err(NakamotoNodeError::BurnchainTipChanged); + } + } + // one of two things can happen: // * we get enough signatures from stackerdb from the signers, OR // * we see our block get processed in our chainstate (meaning, the signers broadcasted @@ -800,24 +831,119 @@ impl SignCoordinator { ); for (message, slot_id) in messages.into_iter().zip(slot_ids) { - let (response_hash, signature) = match message { + let Some(signer_entry) = &self.signer_entries.get(&slot_id) else { + return Err(NakamotoNodeError::SignerSignatureError( + "Signer entry not found".into(), + )); + }; + let Ok(signer_pubkey) = StacksPublicKey::from_slice(&signer_entry.signing_key) + else { + return Err(NakamotoNodeError::SignerSignatureError( + "Failed to parse signer public key".into(), + )); + }; + + if responded_signers.contains(&signer_pubkey) { + debug!( + "Signer {slot_id} already responded for block {}. Ignoring {message:?}.", block.header.signer_signature_hash(); + "stacks_block_hash" => %block.header.block_hash(), + "stacks_block_id" => %block.header.block_id() + ); + continue; + } + + match message { SignerMessageV0::BlockResponse(BlockResponse::Accepted(( response_hash, signature, - ))) => (response_hash, signature), - SignerMessageV0::BlockResponse(BlockResponse::Rejected(rejected_data)) => { - let Some(signer_entry) = &self.signer_entries.get(&slot_id) else { - return Err(NakamotoNodeError::SignerSignatureError( - "Signer entry not found".into(), - )); + ))) => { + let block_sighash = block.header.signer_signature_hash(); + if block_sighash != response_hash { + warn!( + "Processed signature for a different block. Will try to continue."; + "signature" => %signature, + "block_signer_signature_hash" => %block_sighash, + "response_hash" => %response_hash, + "slot_id" => slot_id, + "reward_cycle_id" => reward_cycle_id, + "response_hash" => %response_hash + ); + continue; + } + debug!("SignCoordinator: Received valid signature from signer"; "slot_id" => slot_id, "signature" => %signature); + let Ok(valid_sig) = signer_pubkey.verify(block_sighash.bits(), &signature) + else { + warn!("Got invalid signature from a signer. Ignoring."); + continue; }; - if rejected_data.signer_signature_hash - != block.header.signer_signature_hash() - { - debug!("Received rejected block response for a block besides my own. Ignoring."); + if !valid_sig { + warn!( + "Processed signature but didn't validate over the expected block. Ignoring"; + "signature" => %signature, + "block_signer_signature_hash" => %block_sighash, + "slot_id" => slot_id, + ); continue; } + if !gathered_signatures.contains_key(&slot_id) { + total_weight_signed = total_weight_signed + .checked_add(signer_entry.weight) + .expect("FATAL: total weight signed exceeds u32::MAX"); + } + if Self::fault_injection_ignore_signatures() { + warn!("SignCoordinator: fault injection: ignoring well-formed signature for block"; + "block_signer_sighash" => %block_sighash, + "signer_pubkey" => signer_pubkey.to_hex(), + "signer_slot_id" => slot_id, + "signature" => %signature, + "signer_weight" => signer_entry.weight, + "total_weight_signed" => total_weight_signed, + "stacks_block_hash" => %block.header.block_hash(), + "stacks_block_id" => %block.header.block_id() + ); + continue; + } + + info!("SignCoordinator: Signature Added to block"; + "block_signer_sighash" => %block_sighash, + "signer_pubkey" => signer_pubkey.to_hex(), + "signer_slot_id" => slot_id, + "signature" => %signature, + "signer_weight" => signer_entry.weight, + "total_weight_signed" => total_weight_signed, + "stacks_block_hash" => %block.header.block_hash(), + "stacks_block_id" => %block.header.block_id() + ); + gathered_signatures.insert(slot_id, signature); + responded_signers.insert(signer_pubkey); + } + SignerMessageV0::BlockResponse(BlockResponse::Rejected(rejected_data)) => { + let block_sighash = block.header.signer_signature_hash(); + if block_sighash != rejected_data.signer_signature_hash { + warn!( + "Processed rejection for a different block. Will try to continue."; + "block_signer_signature_hash" => %block_sighash, + "rejected_data.signer_signature_hash" => %rejected_data.signer_signature_hash, + "slot_id" => slot_id, + "reward_cycle_id" => reward_cycle_id, + ); + continue; + } + let rejected_pubkey = match rejected_data.recover_public_key() { + Ok(rejected_pubkey) => { + if rejected_pubkey != signer_pubkey { + warn!("Recovered public key from rejected data does not match signer's public key. Ignoring."); + continue; + } + rejected_pubkey + } + Err(e) => { + warn!("Failed to recover public key from rejected data: {e:?}. Ignoring."); + continue; + } + }; + responded_signers.insert(rejected_pubkey); debug!( "Signer {} rejected our block {}/{}", slot_id, @@ -858,75 +984,6 @@ impl SignCoordinator { continue; } }; - let block_sighash = block.header.signer_signature_hash(); - if block_sighash != response_hash { - warn!( - "Processed signature for a different block. Will try to continue."; - "signature" => %signature, - "block_signer_signature_hash" => %block_sighash, - "response_hash" => %response_hash, - "slot_id" => slot_id, - "reward_cycle_id" => reward_cycle_id, - "response_hash" => %response_hash - ); - continue; - } - debug!("SignCoordinator: Received valid signature from signer"; "slot_id" => slot_id, "signature" => %signature); - let Some(signer_entry) = &self.signer_entries.get(&slot_id) else { - return Err(NakamotoNodeError::SignerSignatureError( - "Signer entry not found".into(), - )); - }; - let Ok(signer_pubkey) = StacksPublicKey::from_slice(&signer_entry.signing_key) - else { - return Err(NakamotoNodeError::SignerSignatureError( - "Failed to parse signer public key".into(), - )); - }; - let Ok(valid_sig) = signer_pubkey.verify(block_sighash.bits(), &signature) else { - warn!("Got invalid signature from a signer. Ignoring."); - continue; - }; - if !valid_sig { - warn!( - "Processed signature but didn't validate over the expected block. Ignoring"; - "signature" => %signature, - "block_signer_signature_hash" => %block_sighash, - "slot_id" => slot_id, - ); - continue; - } - if !gathered_signatures.contains_key(&slot_id) { - total_weight_signed = total_weight_signed - .checked_add(signer_entry.weight) - .expect("FATAL: total weight signed exceeds u32::MAX"); - } - - if Self::fault_injection_ignore_signatures() { - warn!("SignCoordinator: fault injection: ignoring well-formed signature for block"; - "block_signer_sighash" => %block_sighash, - "signer_pubkey" => signer_pubkey.to_hex(), - "signer_slot_id" => slot_id, - "signature" => %signature, - "signer_weight" => signer_entry.weight, - "total_weight_signed" => total_weight_signed, - "stacks_block_hash" => %block.header.block_hash(), - "stacks_block_id" => %block.header.block_id() - ); - continue; - } - - info!("SignCoordinator: Signature Added to block"; - "block_signer_sighash" => %block_sighash, - "signer_pubkey" => signer_pubkey.to_hex(), - "signer_slot_id" => slot_id, - "signature" => %signature, - "signer_weight" => signer_entry.weight, - "total_weight_signed" => total_weight_signed, - "stacks_block_hash" => %block.header.block_hash(), - "stacks_block_id" => %block.header.block_id() - ); - gathered_signatures.insert(slot_id, signature); } // After gathering all signatures, return them if we've hit the threshold diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index 9f30ea2908..a61713bd0f 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -89,7 +89,7 @@ use stacks_common::util::hash::{to_hex, Hash160, Sha512Trunc256Sum}; use stacks_common::util::secp256k1::{MessageSignature, Secp256k1PrivateKey, Secp256k1PublicKey}; use stacks_common::util::{get_epoch_time_secs, sleep_ms}; use stacks_signer::chainstate::{ProposalEvalConfig, SortitionsView}; -use stacks_signer::signerdb::{BlockInfo, ExtraBlockInfo, SignerDb}; +use stacks_signer::signerdb::{BlockInfo, BlockState, ExtraBlockInfo, SignerDb}; use wsts::net::Message; use super::bitcoin_regtest::BitcoinCoreController; @@ -2501,13 +2501,23 @@ fn block_proposal_api_endpoint() { ), ("Must wait", sign(&proposal), HTTP_TOO_MANY, None), ( - "Corrupted (bit flipped after signing)", + "Non-canonical or absent tenure", (|| { let mut sp = sign(&proposal); sp.block.header.consensus_hash.0[3] ^= 0x07; sp })(), HTTP_ACCEPTED, + Some(Err(ValidateRejectCode::NonCanonicalTenure)), + ), + ( + "Corrupted (bit flipped after signing)", + (|| { + let mut sp = sign(&proposal); + sp.block.header.timestamp ^= 0x07; + sp + })(), + HTTP_ACCEPTED, Some(Err(ValidateRejectCode::ChainstateError)), ), ( @@ -2624,6 +2634,10 @@ fn block_proposal_api_endpoint() { .iter() .zip(proposal_responses.iter()) { + info!( + "Received response {:?}, expecting {:?}", + &response, &expected_response + ); match expected_response { Ok(_) => { assert!(matches!(response, BlockValidateResponse::Ok(_))); @@ -3111,6 +3125,7 @@ fn follower_bootup() { wait_for_first_naka_block_commit(60, &commits_submitted); let mut follower_conf = naka_conf.clone(); + follower_conf.node.miner = false; follower_conf.events_observers.clear(); follower_conf.node.working_dir = format!("{}-follower", &naka_conf.node.working_dir); follower_conf.node.seed = vec![0x01; 32]; @@ -3468,6 +3483,7 @@ fn follower_bootup_across_multiple_cycles() { follower_conf.node.working_dir = format!("{}-follower", &naka_conf.node.working_dir); follower_conf.node.seed = vec![0x01; 32]; follower_conf.node.local_peer_seed = vec![0x02; 32]; + follower_conf.node.miner = false; let mut rng = rand::thread_rng(); let mut buf = [0u8; 8]; @@ -5427,6 +5443,13 @@ fn signer_chainstate() { ) .unwrap(); + let reward_cycle = burnchain + .block_height_to_reward_cycle( + SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .block_height, + ) + .unwrap(); // this config disallows any reorg due to poorly timed block commits let proposal_conf = ProposalEvalConfig { first_proposal_burn_block_timing: Duration::from_secs(0), @@ -5441,7 +5464,13 @@ fn signer_chainstate() { last_tenures_proposals { let valid = sortitions_view - .check_proposal(&signer_client, &signer_db, prior_tenure_first, miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + prior_tenure_first, + miner_pk, + reward_cycle, + ) .unwrap(); assert!( !valid, @@ -5449,7 +5478,13 @@ fn signer_chainstate() { ); for block in prior_tenure_interims.iter() { let valid = sortitions_view - .check_proposal(&signer_client, &signer_db, block, miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + block, + miner_pk, + reward_cycle, + ) .unwrap(); assert!( !valid, @@ -5472,20 +5507,26 @@ fn signer_chainstate() { thread::sleep(Duration::from_secs(1)); }; + let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .block_height; + let reward_cycle = burnchain + .block_height_to_reward_cycle(burn_block_height) + .unwrap(); let valid = sortitions_view - .check_proposal(&signer_client, &signer_db, &proposal.0, &proposal.1) + .check_proposal( + &signer_client, + &mut signer_db, + &proposal.0, + &proposal.1, + reward_cycle, + ) .unwrap(); assert!( valid, "Nakamoto integration test produced invalid block proposal" ); - let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) - .unwrap() - .block_height; - let reward_cycle = burnchain - .block_height_to_reward_cycle(burn_block_height) - .unwrap(); signer_db .insert_block(&BlockInfo { block: proposal.0.clone(), @@ -5498,6 +5539,7 @@ fn signer_chainstate() { signed_self: None, signed_group: None, ext: ExtraBlockInfo::None, + state: BlockState::Unprocessed, }) .unwrap(); @@ -5530,9 +5572,10 @@ fn signer_chainstate() { let valid = sortitions_view .check_proposal( &signer_client, - &signer_db, + &mut signer_db, &proposal_interim.0, &proposal_interim.1, + reward_cycle, ) .unwrap(); @@ -5547,14 +5590,21 @@ fn signer_chainstate() { first_proposal_burn_block_timing: Duration::from_secs(0), block_proposal_timeout: Duration::from_secs(100), }; + let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .block_height; + let reward_cycle = burnchain + .block_height_to_reward_cycle(burn_block_height) + .unwrap(); let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); let valid = sortitions_view .check_proposal( &signer_client, - &signer_db, + &mut signer_db, &proposal_interim.0, &proposal_interim.1, + reward_cycle, ) .unwrap(); @@ -5575,6 +5625,7 @@ fn signer_chainstate() { signed_self: None, signed_group: None, ext: ExtraBlockInfo::None, + state: BlockState::Unprocessed, }) .unwrap(); @@ -5616,10 +5667,21 @@ fn signer_chainstate() { block_proposal_timeout: Duration::from_secs(100), }; let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); - + let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .block_height; + let reward_cycle = burnchain + .block_height_to_reward_cycle(burn_block_height) + .unwrap(); assert!( !sortitions_view - .check_proposal(&signer_client, &signer_db, &sibling_block, &miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + &sibling_block, + &miner_pk, + reward_cycle + ) .unwrap(), "A sibling of a previously approved block must be rejected." ); @@ -5670,7 +5732,13 @@ fn signer_chainstate() { assert!( !sortitions_view - .check_proposal(&signer_client, &signer_db, &sibling_block, &miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + &sibling_block, + &miner_pk, + reward_cycle + ) .unwrap(), "A sibling of a previously approved block must be rejected." ); @@ -5727,7 +5795,13 @@ fn signer_chainstate() { assert!( !sortitions_view - .check_proposal(&signer_client, &signer_db, &sibling_block, &miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + &sibling_block, + &miner_pk, + reward_cycle + ) .unwrap(), "A sibling of a previously approved block must be rejected." ); @@ -5786,7 +5860,13 @@ fn signer_chainstate() { assert!( !sortitions_view - .check_proposal(&signer_client, &signer_db, &sibling_block, &miner_pk) + .check_proposal( + &signer_client, + &mut signer_db, + &sibling_block, + &miner_pk, + reward_cycle + ) .unwrap(), "A sibling of a previously approved block must be rejected." ); diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs index 5a0c294329..1c1f4117ed 100644 --- a/testnet/stacks-node/src/tests/signer/v0.rs +++ b/testnet/stacks-node/src/tests/signer/v0.rs @@ -51,6 +51,10 @@ use stacks_common::util::sleep_ms; use stacks_signer::chainstate::{ProposalEvalConfig, SortitionsView}; use stacks_signer::client::{SignerSlotID, StackerDB}; use stacks_signer::config::{build_signer_config_tomls, GlobalConfig as SignerConfig, Network}; +use stacks_signer::v0::signer::{ + TEST_IGNORE_ALL_BLOCK_PROPOSALS, TEST_PAUSE_BLOCK_BROADCAST, TEST_REJECT_ALL_BLOCK_PROPOSAL, + TEST_SKIP_BLOCK_BROADCAST, +}; use stacks_signer::v0::SpawnedSigner; use tracing_subscriber::prelude::*; use tracing_subscriber::{fmt, EnvFilter}; @@ -501,6 +505,7 @@ fn block_proposal_rejection() { reason: _reason, reason_code, signer_signature_hash, + .. })) = message { if signer_signature_hash == block_signer_signature_hash_1 { @@ -921,7 +926,7 @@ fn forked_tenure_testing( config.first_proposal_burn_block_timing = proposal_limit; // don't allow signers to post signed blocks (limits the amount of fault injection we // need) - config.broadcast_signed_blocks = false; + TEST_SKIP_BLOCK_BROADCAST.lock().unwrap().replace(true); }, |_| {}, None, @@ -2956,3 +2961,937 @@ fn duplicate_signers() { signer_test.shutdown(); } + +#[test] +#[ignore] +/// Test that signers that accept a block locally, but that was rejected globally will accept a subsequent attempt +/// by the miner essentially reorg their prior locally accepted/signed block, i.e. the globally rejected block overrides +/// their local view. +/// +/// 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 rejected by >30% of the signers. +/// The miner then attempts to mine N+1', and all signers accept the block. +/// +/// Test Assertion: +/// Stacks tip advances to N+1' +fn locally_accepted_blocks_overriden_by_global_rejection() { + 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( + num_signers, + vec![(sender_addr.clone(), (send_amt + send_fee) * nmb_txs)], + ); + let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind); + let long_timeout = Duration::from_secs(200); + let short_timeout = Duration::from_secs(20); + signer_test.boot_to_epoch_3(); + + info!("------------------------- Test Mine Nakamoto Block N -------------------------"); + let info_before = signer_test.stacks_client.get_peer_info().unwrap(); + let mined_blocks = signer_test.running_nodes.nakamoto_blocks_mined.clone(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let start_time = Instant::now(); + // 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, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + sender_nonce += 1; + let info_after = signer_test.stacks_client.get_peer_info().unwrap(); + 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 half of the signers reject the block proposal by the miner to ensure its marked globally rejected + let rejecting_signers: Vec<_> = signer_test + .signer_stacks_private_keys + .iter() + .map(StacksPublicKey::from_private) + .take(num_signers / 2) + .collect(); + TEST_REJECT_ALL_BLOCK_PROPOSAL + .lock() + .unwrap() + .replace(rejecting_signers.clone()); + test_observer::clear(); + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} to mine block N+1"); + let start_time = Instant::now(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test.stacks_client.get_peer_info().unwrap(); + loop { + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let block_rejections = stackerdb_events + .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(rejection)) => { + let rejected_pubkey = rejection + .recover_public_key() + .expect("Failed to recover public key from rejection"); + if rejecting_signers.contains(&rejected_pubkey) + && rejection.reason_code == RejectCode::TestingDirective + { + Some(rejection) + } else { + None + } + } + _ => None, + } + }) + .collect::>(); + if block_rejections.len() == rejecting_signers.len() { + break; + } + assert!( + start_time.elapsed() < long_timeout, + "FAIL: Test timed out while waiting for block proposal rejections", + ); + } + + assert_eq!(blocks_before, mined_blocks.load(Ordering::SeqCst)); + let info_after = signer_test.stacks_client.get_peer_info().unwrap(); + assert_eq!(info_before, info_after); + // 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); + + info!("------------------------- Test Mine Nakamoto Block N+1' -------------------------"); + let info_before = signer_test.stacks_client.get_peer_info().unwrap(); + TEST_REJECT_ALL_BLOCK_PROPOSAL + .lock() + .unwrap() + .replace(Vec::new()); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + let blocks_after = mined_blocks.load(Ordering::SeqCst); + assert_eq!(blocks_after, blocks_before + 1); + + let info_after = signer_test.stacks_client.get_peer_info().unwrap(); + assert_eq!( + info_after.stacks_tip_height, + info_before.stacks_tip_height + 1 + ); + // Ensure that the block was accepted globally so the stacks tip has advanced to N+1' + let start_time = Instant::now(); + while test_observer::get_mined_nakamoto_blocks().last().unwrap() == block_n_1 { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_1_prime = nakamoto_blocks.last().unwrap(); + assert_eq!( + info_after.stacks_tip.to_string(), + block_n_1_prime.block_hash + ); + assert_ne!(block_n_1_prime, block_n_1); +} + +#[test] +#[ignore] +/// Test that signers that reject a block locally, but that was accepted globally will accept +/// a subsequent block built on top of the accepted block +/// +/// 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 rejected by <30% of the signers. +/// The miner then attempts to mine N+2, and all signers accept the block. +/// +/// Test Assertion: +/// Stacks tip advances to N+2 +fn locally_rejected_blocks_overriden_by_global_acceptance() { + 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 = 3; + let recipient = PrincipalData::from(StacksAddress::burn_address(false)); + let mut signer_test: SignerTest = SignerTest::new( + num_signers, + vec![(sender_addr.clone(), (send_amt + send_fee) * nmb_txs)], + ); + let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind); + let long_timeout = Duration::from_secs(200); + let short_timeout = Duration::from_secs(30); + signer_test.boot_to_epoch_3(); + info!("------------------------- Test Mine Nakamoto Block N -------------------------"); + let mined_blocks = signer_test.running_nodes.nakamoto_blocks_mined.clone(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + let start_time = Instant::now(); + // 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, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + + sender_nonce += 1; + 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 nmb_signatures = signer_test + .stacks_client + .get_tenure_tip(&info_after.stacks_tip_consensus_hash) + .expect("Failed to get tip") + .as_stacks_nakamoto() + .expect("Not a Nakamoto block") + .signer_signature + .len(); + assert_eq!(nmb_signatures, num_signers); + + // Ensure that the block was accepted globally so the stacks tip has not advanced to N + 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!("------------------------- Mine Nakamoto Block N+1 -------------------------"); + // Make less than 30% of the signers reject the block to ensure it is marked globally accepted + let rejecting_signers: Vec<_> = signer_test + .signer_stacks_private_keys + .iter() + .map(StacksPublicKey::from_private) + .take(num_signers * 3 / 10) + .collect(); + TEST_REJECT_ALL_BLOCK_PROPOSAL + .lock() + .unwrap() + .replace(rejecting_signers.clone()); + test_observer::clear(); + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + sender_nonce += 1; + info!("Submitted tx {tx} in to mine block N+1"); + let start_time = Instant::now(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + loop { + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let block_rejections = stackerdb_events + .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(rejection)) => { + let rejected_pubkey = rejection + .recover_public_key() + .expect("Failed to recover public key from rejection"); + if rejecting_signers.contains(&rejected_pubkey) + && rejection.reason_code == RejectCode::TestingDirective + { + Some(rejection) + } else { + None + } + } + _ => None, + } + }) + .collect::>(); + if block_rejections.len() == rejecting_signers.len() { + break; + } + assert!( + start_time.elapsed() < long_timeout, + "FAIL: Test timed out while waiting for block proposal rejections", + ); + } + // Assert the block was mined + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + assert_eq!(blocks_before + 1, mined_blocks.load(Ordering::SeqCst)); + assert_eq!( + info_before.stacks_tip_height + 1, + info_after.stacks_tip_height + ); + let nmb_signatures = signer_test + .stacks_client + .get_tenure_tip(&info_after.stacks_tip_consensus_hash) + .expect("Failed to get tip") + .as_stacks_nakamoto() + .expect("Not a Nakamoto block") + .signer_signature + .len(); + assert_eq!(nmb_signatures, num_signers - rejecting_signers.len()); + + // Ensure that the block was accepted globally so the stacks tip has advanced to N+1 + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_1 = nakamoto_blocks.last().unwrap(); + assert_eq!(info_after.stacks_tip.to_string(), block_n_1.block_hash); + assert_ne!(block_n_1, block_n); + + info!("------------------------- Test Mine Nakamoto Block N+2 -------------------------"); + let info_before = signer_test.stacks_client.get_peer_info().unwrap(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + TEST_REJECT_ALL_BLOCK_PROPOSAL + .lock() + .unwrap() + .replace(Vec::new()); + + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N+2"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + let blocks_after = mined_blocks.load(Ordering::SeqCst); + assert_eq!(blocks_after, blocks_before + 1); + + let info_after = signer_test.stacks_client.get_peer_info().unwrap(); + assert_eq!( + info_before.stacks_tip_height + 1, + info_after.stacks_tip_height, + ); + let nmb_signatures = signer_test + .stacks_client + .get_tenure_tip(&info_after.stacks_tip_consensus_hash) + .expect("Failed to get tip") + .as_stacks_nakamoto() + .expect("Not a Nakamoto block") + .signer_signature + .len(); + assert_eq!(nmb_signatures, num_signers); + // Ensure that the block was accepted globally so the stacks tip has advanced to N+2 + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_2 = nakamoto_blocks.last().unwrap(); + assert_eq!(info_after.stacks_tip.to_string(), block_n_2.block_hash); + assert_ne!(block_n_2, block_n_1); +} + +#[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 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 accept. +/// +/// Test Assertion: +/// Stacks tip advances to N+1' +fn reorg_locally_accepted_blocks_across_tenures_succeeds() { + 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( + num_signers, + vec![(sender_addr.clone(), (send_amt + send_fee) * nmb_txs)], + ); + let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind); + let short_timeout = Duration::from_secs(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 blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + let start_time = Instant::now(); + // 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, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + + sender_nonce += 1; + 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<_> = signer_test + .signer_stacks_private_keys + .iter() + .map(StacksPublicKey::from_private) + .take(num_signers * 7 / 10) + .collect(); + TEST_IGNORE_ALL_BLOCK_PROPOSALS + .lock() + .unwrap() + .replace(ignoring_signers.clone()); + // Clear the stackerdb chunks + test_observer::clear(); + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to attempt to mine block N+1"); + let start_time = Instant::now(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + loop { + let ignored_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((hash, signature))) => { + ignoring_signers + .iter() + .find(|key| key.verify(hash.bits(), &signature).is_ok()) + } + _ => None, + } + }) + .collect::>(); + if ignored_signers.len() + ignoring_signers.len() == num_signers { + break; + } + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block proposal acceptance", + ); + sleep_ms(1000); + } + 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 -------------------------"); + 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!( + "------------------------- Mine Nakamoto Block N+1' in Tenure B -------------------------" + ); + TEST_IGNORE_ALL_BLOCK_PROPOSALS + .lock() + .unwrap() + .replace(Vec::new()); + let mined_blocks = signer_test.running_nodes.nakamoto_blocks_mined.clone(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + let start_time = Instant::now(); + // submit a tx so that the miner will mine a stacks block + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N"); + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + + 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 nmb_signatures = signer_test + .stacks_client + .get_tenure_tip(&info_after.stacks_tip_consensus_hash) + .expect("Failed to get tip") + .as_stacks_nakamoto() + .expect("Not a Nakamoto block") + .signer_signature + .len(); + assert_eq!(nmb_signatures, num_signers); + + // Ensure that the block was accepted globally so the stacks tip has advanced to N+1' + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_1_prime = nakamoto_blocks.last().unwrap(); + assert_eq!( + info_after.stacks_tip.to_string(), + block_n_1_prime.block_hash + ); + assert_ne!(block_n_1_prime, block_n); +} + +#[test] +#[ignore] +/// Test that when 70% of signers accept a block, mark it globally accepted, but a miner ends its tenure +/// before it receives these signatures, the miner can recover in the following tenure. +/// +/// 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 >70% accept it. +/// The signers delay broadcasting the block and the miner ends its tenure before it receives these signatures. The +/// miner will propose an invalid block N+1' which all signers reject. The broadcast delay is removed and the miner +/// proposes a new block N+2 which all signers accept. +/// +/// Test Assertion: +/// Stacks tip advances to N+2 +fn miner_recovers_when_broadcast_block_delay_across_tenures_occurs() { + 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 = 3; + let recipient = PrincipalData::from(StacksAddress::burn_address(false)); + let mut signer_test: SignerTest = SignerTest::new( + num_signers, + vec![(sender_addr.clone(), (send_amt + send_fee) * nmb_txs)], + ); + let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind); + let short_timeout = Duration::from_secs(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 blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + let start_time = Instant::now(); + + // wait until we get a sortition. + // we might miss a block-commit at the start of epoch 3 + let burnchain = signer_test.running_nodes.conf.get_burnchain(); + let sortdb = burnchain.open_sortition_db(true).unwrap(); + + loop { + next_block_and( + &mut signer_test.running_nodes.btc_regtest_controller, + 60, + || Ok(true), + ) + .unwrap(); + + sleep_ms(10_000); + + let tip = SortitionDB::get_canonical_burn_chain_tip(&sortdb.conn()).unwrap(); + if tip.sortition { + break; + } + } + + // 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, &recipient, send_amt); + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to mine block N"); + + // a tenure has begun, so wait until we mine a block + while mined_blocks.load(Ordering::SeqCst) <= blocks_before { + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + + sender_nonce += 1; + 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 -------------------------"); + // Propose a valid block, but force the miner to ignore the returned signatures and delay the block being + // broadcasted to the miner so it can end its tenure before block confirmation obtained + // Clear the stackerdb chunks + info!("Forcing miner to ignore block responses for block N+1"); + TEST_IGNORE_SIGNERS.lock().unwrap().replace(true); + info!("Delaying signer block N+1 broadcasting to the miner"); + TEST_PAUSE_BLOCK_BROADCAST.lock().unwrap().replace(true); + test_observer::clear(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + let info_before = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + sender_nonce += 1; + + let tx = submit_tx(&http_origin, &transfer_tx); + + info!("Submitted tx {tx} in to attempt to mine block N+1"); + let start_time = Instant::now(); + let mut block = None; + loop { + if block.is_none() { + block = test_observer::get_stackerdb_chunks() + .into_iter() + .flat_map(|chunk| chunk.modified_slots) + .find_map(|chunk| { + let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + .expect("Failed to deserialize SignerMessage"); + match message { + SignerMessage::BlockProposal(proposal) => { + if proposal.block.header.consensus_hash + == info_before.stacks_tip_consensus_hash + { + Some(proposal.block) + } else { + None + } + } + _ => None, + } + }); + } + if let Some(block) = &block { + let signatures = 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(( + hash, + signature, + ))) => { + if block.header.signer_signature_hash() == hash { + Some(signature) + } else { + None + } + } + _ => None, + } + }) + .collect::>(); + if signatures.len() == num_signers { + break; + } + } + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for signers signatures for first block proposal", + ); + sleep_ms(1000); + } + let block = block.unwrap(); + + 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 yet broadcasted to the miner so the stacks tip has NOT advanced to N+1 + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_same = nakamoto_blocks.last().unwrap(); + assert_ne!(block_n_same, block_n); + assert_ne!(info_after.stacks_tip.to_string(), block_n_same.block_hash); + + info!("------------------------- Starting Tenure B -------------------------"); + 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' -------------------------" + ); + // Wait for the miner to propose a new invalid block N+1' + let start_time = Instant::now(); + let mut rejected_block = None; + while rejected_block.is_none() { + rejected_block = test_observer::get_stackerdb_chunks() + .into_iter() + .flat_map(|chunk| chunk.modified_slots) + .find_map(|chunk| { + let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + .expect("Failed to deserialize SignerMessage"); + match message { + SignerMessage::BlockProposal(proposal) => { + if proposal.block.header.consensus_hash != block.header.consensus_hash { + assert!( + proposal.block.header.chain_length == block.header.chain_length + ); + Some(proposal.block) + } else { + None + } + } + _ => None, + } + }); + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for N+1' block proposal", + ); + } + + info!("Allowing miner to accept block responses again. "); + TEST_IGNORE_SIGNERS.lock().unwrap().replace(false); + info!("Allowing singers to broadcast block N+1 to the miner"); + TEST_PAUSE_BLOCK_BROADCAST.lock().unwrap().replace(false); + + // Assert the N+1' block was rejected + let rejected_block = rejected_block.unwrap(); + loop { + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let block_rejections = stackerdb_events + .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(rejection)) => { + if rejection.signer_signature_hash + == rejected_block.header.signer_signature_hash() + { + Some(rejection) + } else { + None + } + } + _ => None, + } + }) + .collect::>(); + if block_rejections.len() == num_signers { + break; + } + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block proposal rejections", + ); + } + + // Induce block N+2 to get mined + let transfer_tx = + make_stacks_transfer(&sender_sk, sender_nonce, send_fee, &recipient, send_amt); + sender_nonce += 1; + + let tx = submit_tx(&http_origin, &transfer_tx); + info!("Submitted tx {tx} in to attempt to mine block N+2"); + + info!("------------------------- Asserting a both N+1 and N+2 are accepted -------------------------"); + loop { + // N.B. have to use /v2/info because mined_blocks only increments if the miner's signing + // coordinator returns successfully (meaning, mined_blocks won't increment for block N+1) + let info = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + + if info_before.stacks_tip_height + 2 <= info.stacks_tip_height { + break; + } + + assert!( + start_time.elapsed() < short_timeout, + "FAIL: Test timed out while waiting for block production", + ); + thread::sleep(Duration::from_secs(1)); + } + + let info_after = signer_test + .stacks_client + .get_peer_info() + .expect("Failed to get peer info"); + + assert_eq!( + info_before.stacks_tip_height + 2, + info_after.stacks_tip_height + ); + let nmb_signatures = signer_test + .stacks_client + .get_tenure_tip(&info_after.stacks_tip_consensus_hash) + .expect("Failed to get tip") + .as_stacks_nakamoto() + .expect("Not a Nakamoto block") + .signer_signature + .len(); + assert_eq!(nmb_signatures, num_signers); + + // Ensure that the block was accepted globally so the stacks tip has advanced to N+2 + let nakamoto_blocks = test_observer::get_mined_nakamoto_blocks(); + let block_n_2 = nakamoto_blocks.last().unwrap(); + assert_eq!(info_after.stacks_tip.to_string(), block_n_2.block_hash); + assert_ne!(block_n_2, block_n); +}