diff --git a/changelog.d/signer-proposal-tenure-placement.fixed b/changelog.d/signer-proposal-tenure-placement.fixed new file mode 100644 index 0000000000..c9b5f230ae --- /dev/null +++ b/changelog.d/signer-proposal-tenure-placement.fixed @@ -0,0 +1,2 @@ +Signers now take a block proposal's burn height and reward cycle from the sortition that elected the block's tenure, and ignore proposals that misstate them, rather than trusting the values the miner sends alongside the block. +A block proposal for a tenure that the signer's node has not yet processed is rejected with the transient `NoSortitionView` reason, so the miner re-proposes it once the signer has caught up. diff --git a/stacks-node/src/tests/signer/v0/mod.rs b/stacks-node/src/tests/signer/v0/mod.rs index aa6fef5193..4bfafe0e88 100644 --- a/stacks-node/src/tests/signer/v0/mod.rs +++ b/stacks-node/src/tests/signer/v0/mod.rs @@ -899,13 +899,25 @@ impl SignerTest { self.running_nodes.conf.miner.mining_key.as_ref().unwrap() } - /// Propose a block to the signers + /// Propose a block to the signers, placed (as a miner does) at the burn height and reward + /// cycle of the sortition that elected its tenure. A block whose tenure the node does not + /// know is placed at the burnchain tip; signers ignore such a proposal. fn propose_block(&self, block: NakamotoBlock, timeout: Duration) { let burn_height = self + .stacks_client + .get_sortition_by_consensus_hash(&block.header.consensus_hash) + .map(|sortition| sortition.burn_block_height) + .unwrap_or_else(|_| { + self.running_nodes + .btc_regtest_controller + .get_headers_height() + }); + let reward_cycle = self .running_nodes .btc_regtest_controller - .get_headers_height(); - let reward_cycle = self.get_current_reward_cycle(); + .get_burnchain() + .block_height_to_reward_cycle(burn_height) + .unwrap(); let block_proposal = BlockProposal { block, burn_height, @@ -2697,10 +2709,11 @@ fn block_proposal_rejection() { }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); block.header.timestamp = get_epoch_time_secs(); - let block_1_consensus_hash = block.header.consensus_hash.clone(); - // First propose a block to the signers that does not have the correct consensus hash or BitVec. This should be rejected BEFORE - // the block is submitted to the node for validation. + // First propose a block to the signers whose consensus hash names a tenure the node has + // no sortition for. Signers cannot place it in a reward cycle, so they reject it as + // transient BEFORE the block is submitted to the node for validation. + block.header.consensus_hash = ConsensusHash(thread_rng().gen()); block .header .sign_miner(signer_test.get_miner_key()) @@ -2713,7 +2726,6 @@ fn block_proposal_rejection() { block.header.pox_treatment = BitVec::ones(1).unwrap(); block.header.consensus_hash = view.cur_sortition.data.consensus_hash; block.header.chain_length = 35; // We have mined 35 blocks so far. - let block_2_consensus_hash = block.header.consensus_hash.clone(); block .header @@ -2722,7 +2734,18 @@ fn block_proposal_rejection() { let block_signer_signature_hash_2 = block.header.signer_signature_hash(); signer_test.propose_block(block, short_timeout); - info!("------------------------- Test Block Proposal Rejected -------------------------"); + info!("------------------------- Test Block Proposals Rejected -------------------------"); + let all_signers = signer_test.signer_test_pks(); + let rejections = wait_for_block_rejections_from_signers( + short_timeout.as_secs(), + &block_signer_signature_hash_1, + &all_signers, + ) + .expect("Timed out waiting for every signer to reject the block outside any known tenure"); + for rejection in rejections { + assert_eq!(rejection.reason_code, RejectCode::NoSortitionView); + } + // Verify the signers rejected the second block via the endpoint let reject = signer_test .wait_for_validate_reject_response(short_timeout.as_secs(), &block_signer_signature_hash_2); @@ -2730,46 +2753,17 @@ fn block_proposal_rejection() { reject.reason_code, ValidateRejectCode::UnknownParent )); - - let start_polling = Instant::now(); - let mut found_signer_signature_hash_1 = false; - let mut found_signer_signature_hash_2 = false; - let block_1_rejection_reason = RejectReason::ConsensusHashMismatch { - expected: block_2_consensus_hash, - actual: block_1_consensus_hash, - }; - while !found_signer_signature_hash_1 && !found_signer_signature_hash_2 { - std::thread::sleep(Duration::from_secs(1)); - for (_chunk, message) in get_stackerdb_signer_messages() { - if let SignerMessage::BlockResponse(BlockResponse::Rejected(BlockRejection { - reason: _reason, - reason_code, - signer_signature_hash, - response_data, - .. - })) = message - { - if signer_signature_hash == block_signer_signature_hash_1 { - found_signer_signature_hash_1 = true; - assert_eq!(reason_code, RejectCode::SortitionViewMismatch,); - assert_eq!(response_data.reject_reason, block_1_rejection_reason); - } else if signer_signature_hash == block_signer_signature_hash_2 { - found_signer_signature_hash_2 = true; - assert!(matches!( - reason_code, - RejectCode::ValidationFailed(ValidateRejectCode::UnknownParent) - )); - } else { - continue; - } - } else { - continue; - } - } - assert!( - start_polling.elapsed() <= short_timeout, - "Timed out after waiting for response from signer" - ); + let rejections = wait_for_block_rejections_from_signers( + short_timeout.as_secs(), + &block_signer_signature_hash_2, + &all_signers, + ) + .expect("Timed out waiting for every signer to reject the block with an unknown parent"); + for rejection in rejections { + assert!(matches!( + rejection.reason_code, + RejectCode::ValidationFailed(ValidateRejectCode::UnknownParent) + )); } signer_test.shutdown(); } @@ -6012,8 +6006,9 @@ fn block_proposal_max_age_rejections() { let short_timeout = Duration::from_secs(30); info!("------------------------- Send Block Proposal To Signers -------------------------"); - let _ = get_chain_info(&signer_test.running_nodes.conf); let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); + // Both blocks belong to the current tenure, so signers place them in its reward cycle. + block.header.consensus_hash = get_chain_info(&signer_test.running_nodes.conf).pox_consensus; // First propose a stale block that is older than the block_proposal_max_age_secs block.header.timestamp = get_epoch_time_secs().saturating_sub( signer_test.signer_configs[0] diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index e0ed46b8bb..2567410765 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -174,6 +174,7 @@ pub(crate) mod tests { use crate::config::{ GlobalConfig, SignerConfig, SignerConfigMode, DEFAULT_STACKERDB_TIMEOUT_SECS, }; + use crate::runloop::PoxGeometry; #[cfg(any(test, feature = "testing"))] use crate::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION; @@ -468,6 +469,11 @@ pub(crate) mod tests { } SignerConfig { reward_cycle, + pox_geometry: PoxGeometry { + reward_cycle_length: 20, + prepare_phase_block_length: 5, + first_burnchain_block_height: 0, + }, signer_mode: SignerConfigMode::Normal { signer_id: 0, signer_slot_id: SignerSlotID(rand::thread_rng().gen_range(0..num_signers)), // Give a random signer slot id between 0 and num_signers diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index a1f99fb100..e73a81a71c 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -33,6 +33,7 @@ use stacks_common::types::chainstate::{StacksAddress, StacksPrivateKey, StacksPu use stacks_common::util::hash::Hash160; use crate::client::SignerSlotID; +use crate::runloop::PoxGeometry; #[cfg(any(test, feature = "testing"))] use crate::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION; @@ -157,6 +158,8 @@ impl std::fmt::Display for SignerConfigMode { pub struct SignerConfig { /// The reward cycle of the configuration pub reward_cycle: u64, + /// The PoX reward cycle geometry, used to place a block proposal in its reward cycle + pub pox_geometry: PoxGeometry, /// The registered signers for this reward cycle pub signer_entries: SignerEntries, /// The signer slot ids of all signers registered for this reward cycle diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index 0da890ac50..7d3eb4dfd6 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -513,8 +513,14 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo }; SignerConfigMode::DryRun }; + let pox_geometry = self + .burnchain_view + .as_ref() + .expect("FATAL: cannot configure a signer with no burnchain view.") + .geometry; Ok(Some(SignerConfig { reward_cycle, + pox_geometry, signer_mode: signer_config_mode, signer_entries, signer_slot_ids: signer_slot_ids.into_values().collect(), @@ -583,12 +589,14 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo )) })?; let current_reward_cycle = burnchain_view.current_reward_cycle(); + let is_in_next_prepare_phase = burnchain_view.is_in_next_prepare_phase(); + // Set before configuring any signer: a signer takes its PoX geometry from the view. + self.burnchain_view = Some(burnchain_view); self.refresh_signer_config(current_reward_cycle); // We should only attempt to initialize the next reward cycle signer if we are in the prepare phase of the next reward cycle - if burnchain_view.is_in_next_prepare_phase() { + if is_in_next_prepare_phase { self.refresh_signer_config(current_reward_cycle.saturating_add(1)); } - self.burnchain_view = Some(burnchain_view); self.refresh_signer_retention(); Ok(()) } diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs index 35b7f7c48d..dcf984e3d8 100644 --- a/stacks-signer/src/v0/signer.rs +++ b/stacks-signer/src/v0/signer.rs @@ -50,7 +50,7 @@ use crate::chainstate::v2::GlobalStateView; use crate::chainstate::{ProposalEvalConfig, SelfAsTip, SortitionData, SortitionStateVersion}; use crate::client::{ClientError, SignerSlotID, StackerDB, StacksClient}; use crate::config::{SignerConfig, SignerConfigMode}; -use crate::runloop::SignerResult; +use crate::runloop::{PoxGeometry, SignerResult}; use crate::signerdb::{ BlockInfo, BlockState, PendingBlockResponses, ReorgPermit, SignedConflictInfo, SignerDb, MAX_FORK_DEPTH, @@ -119,6 +119,8 @@ pub struct Signer { pub signer_addresses: Vec, /// The reward cycle this signer belongs to pub reward_cycle: u64, + /// The PoX reward cycle geometry + pub pox_geometry: PoxGeometry, /// Reward set signer addresses and their weights pub signer_weights: HashMap, /// SignerDB for state management @@ -337,6 +339,7 @@ impl SignerTrait for Signer { signer_weights: signer_config.signer_entries.signer_addr_to_weight.clone(), signer_slot_ids: signer_config.signer_slot_ids.clone(), reward_cycle: signer_config.reward_cycle, + pox_geometry: signer_config.pox_geometry, signer_db, proposal_config, submitted_block_proposal: None, @@ -1904,6 +1907,41 @@ impl Signer { ); return; } + match self.place_proposal_tenure(stacks_client, block_proposal) { + TenurePlacement::Matches => {} + TenurePlacement::Misstated => return, + TenurePlacement::Unknown => { + // A stored row means this block already passed placement, so keep our earlier + // decision rather than contradict it on a failed lookup. + if let Some(block_info) = self.block_lookup_by_reward_cycle( + &block_proposal.block.header.signer_signature_hash(), + ) { + if !self.should_reevaluate_block( + stacks_client, + sortition_state, + &block_info, + block_proposal, + ) { + return; + } + } + // Our node has not processed the tenure's sortition yet, which is usual right + // after a new burn block. Reject rather than silently drop, and without + // storing the block: the miner treats `NoSortitionView` as transient and + // re-sends the proposal after its transient retry timeout, and with no row + // for the block we then judge the re-sent proposal afresh. + warn!( + "{self}: Received a block proposal for a tenure our node has no sortition for yet. Rejecting it as transient."; + "signer_signature_hash" => %block_proposal.block.header.signer_signature_hash(), + "consensus_hash" => %block_proposal.block.header.consensus_hash, + "proposed_burn_height" => block_proposal.burn_height, + ); + let rejection = self + .create_block_rejection(RejectReason::NoSortitionView, &block_proposal.block); + self.send_block_response(&block_proposal.block, rejection.into()); + return; + } + } let signer_signature_hash = block_proposal.block.header.signer_signature_hash(); let prior_block_info = self.block_lookup_by_reward_cycle(&signer_signature_hash); @@ -2066,6 +2104,60 @@ impl Signer { } } + /// Place a block proposal by the tenure its block commits to (the sortition named by the + /// block's consensus hash), and check the proposal's `burn_height` and `reward_cycle` + /// against it. + /// + /// Both fields are supplied by the miner, outside the signed block, so they must not decide + /// which cycle's signer instance owns the block: the two instances share one signer DB, + /// where a block's row is keyed by its signer signature hash alone. The burn height comes + /// from the signer DB when we have seen the tenure's burn block, and from the node + /// otherwise. + fn place_proposal_tenure( + &self, + stacks_client: &StacksClient, + block_proposal: &BlockProposal, + ) -> TenurePlacement { + let consensus_hash = &block_proposal.block.header.consensus_hash; + let burn_height = match self.signer_db.get_burn_block_by_ch(consensus_hash) { + Ok(burn_block) => burn_block.block_height, + Err(DBError::NotFoundError) => { + match stacks_client.get_sortition_by_consensus_hash(consensus_hash) { + Ok(sortition) => sortition.burn_block_height, + Err(e) => { + debug!( + "{self}: The node has no sortition for a block proposal's tenure: {e:?}"; + "consensus_hash" => %consensus_hash, + ); + return TenurePlacement::Unknown; + } + } + } + Err(e) => { + warn!( + "{self}: Failed to look up the burn block for a block proposal's tenure: {e:?}"; + "consensus_hash" => %consensus_hash, + ); + return TenurePlacement::Unknown; + } + }; + let reward_cycle = self.pox_geometry.reward_cycle_of(burn_height); + if block_proposal.burn_height != burn_height || block_proposal.reward_cycle != reward_cycle + { + warn!( + "{self}: Received a block proposal that misstates its tenure's burn height or reward cycle. Ignoring it."; + "signer_signature_hash" => %block_proposal.block.header.signer_signature_hash(), + "consensus_hash" => %consensus_hash, + "burn_height" => burn_height, + "reward_cycle" => reward_cycle, + "proposed_burn_height" => block_proposal.burn_height, + "proposed_reward_cycle" => block_proposal.reward_cycle, + ); + return TenurePlacement::Misstated; + } + TenurePlacement::Matches + } + /// Process pending responses for a block proposal that we may have received late. fn process_pending_responses_for_block( &mut self, @@ -3103,6 +3195,19 @@ impl Signer { } } +/// How a block proposal's `burn_height` and `reward_cycle` relate to the tenure its block +/// commits to; see [`Signer::place_proposal_tenure`]. +#[derive(Debug, PartialEq, Eq)] +enum TenurePlacement { + /// Both are those of the tenure's sortition. + Matches, + /// At least one is not: the proposal is dropped. + Misstated, + /// Neither the signer DB nor the node knows the tenure's sortition, so the proposal cannot + /// be placed yet. + Unknown, +} + /// Determine if a block should be re-evaluated based on its rejection reason. /// /// Every transient rejection (see [`RejectReason::is_transient`]) is diff --git a/stacks-signer/src/v0/tests.rs b/stacks-signer/src/v0/tests.rs index 3050728b2e..6f4919d094 100644 --- a/stacks-signer/src/v0/tests.rs +++ b/stacks-signer/src/v0/tests.rs @@ -411,6 +411,7 @@ mod async_sibling_validation { use crate::chainstate::{SelfAsTip, SortitionData}; use crate::client::{SignerSlotID, StacksClient}; use crate::config::{SignerConfig, SignerConfigMode}; + use crate::runloop::PoxGeometry; use crate::signerdb::{BlockInfo, BlockState}; use crate::v0::signer::Signer; use crate::v0::signer_state::LocalStateMachine; @@ -508,12 +509,23 @@ mod async_sibling_validation { block } + /// The burn height of the sortition that elected every fixture tenure. + pub(super) const FIXTURE_BURN_HEIGHT: u64 = 1; + /// One burn block per reward cycle, so `FIXTURE_BURN_HEIGHT` is in reward cycle 1, the + /// cycle the mock signer signs for. + const FIXTURE_POX_GEOMETRY: PoxGeometry = PoxGeometry { + reward_cycle_length: 1, + prepare_phase_block_length: 0, + first_burnchain_block_height: 0, + }; + /// Binary response bodies served by the mock node, as `(path fragment, bytes)`. type BinaryResponses = Arc)>>>; /// A mock stacks-node that serves a tenure tip per consensus hash (so the tenure-change /// parent check and the signing-time tip check each see their own tenure's tip) and an - /// empty `/v2/info`; everything else 404s. `tips` maps a path fragment to a response body, + /// empty `/v2/info` and, by default, a sortition at `FIXTURE_BURN_HEIGHT` for any consensus + /// hash; everything else 404s. `tips` maps a path fragment to a response body, /// first match wins. `binary_responses` does the same with binary bodies (StackerDB chunks) /// and is checked first. fn serve_node( @@ -561,7 +573,17 @@ mod async_sibling_validation { .unwrap() .iter() .find(|(fragment, _)| request.contains(fragment.as_str())) - .map(|(_, body)| body.clone()); + .map(|(_, body)| body.clone()) + .or_else(|| { + // Unless a test says otherwise, every tenure was elected at the fixture + // burn height. + request.contains("/v3/sortitions/consensus/").then(|| { + canonical_sortition_response( + &BurnchainHeaderHash([0; 32]), + FIXTURE_BURN_HEIGHT, + ) + }) + }); let (status, body) = if let Some(body) = tip_body.as_deref() { ("200 OK", body) } else if request.contains("/v2/info") { @@ -587,7 +609,7 @@ mod async_sibling_validation { db_path: std::path::PathBuf, /// What the node currently answers, as `(path fragment, body)`; anything unmatched /// 404s. Replaceable so a test can change the node's view between events. - tips: Arc>>, + pub(super) tips: Arc>>, /// Binary bodies the node answers, as `(path fragment, bytes)`, checked before `tips`. pub(super) binary_responses: BinaryResponses, } @@ -638,6 +660,7 @@ mod async_sibling_validation { let signer_config = SignerConfig { reward_cycle: 1, + pox_geometry: FIXTURE_POX_GEOMETRY, supported_signer_protocol_version: 2, signer_entries, signer_slot_ids: vec![SignerSlotID(0)], @@ -1881,7 +1904,10 @@ mod async_sibling_validation { /// Build the JSON the node serves for a burn block that IS on its canonical chain. Only /// the fact that the request succeeded matters to the caller under test. - fn canonical_sortition_response(burn_hash: &BurnchainHeaderHash, height: u64) -> String { + pub(super) fn canonical_sortition_response( + burn_hash: &BurnchainHeaderHash, + height: u64, + ) -> String { let info = SortitionInfo { burn_block_hash: burn_hash.clone(), burn_block_height: height, @@ -3146,3 +3172,269 @@ mod cycle_start_state_sync { assert_eq!(second, Some(reorged_burn_block)); } } + +/// A block proposal's `burn_height` and `reward_cycle` are miner-supplied and outside the signed +/// block. The two cycles' signer instances share one signer DB row per block, so a miner that +/// could steer a block to the other instance by misstating its cycle could have that instance +/// overwrite the row. A signer instead places the proposal by the tenure its block commits to, +/// and drops a proposal that misstates it. A tenure the signer's node has no sortition for yet +/// draws a transient rejection instead, so that the miner re-sends the proposal once the node +/// has caught up. +#[cfg(test)] +mod proposal_tenure_placement { + use std::sync::mpsc; + use std::time::{Duration, SystemTime}; + + use clarity::types::chainstate::{BurnchainHeaderHash, ConsensusHash, StacksBlockId}; + use libsigner::v0::messages::{RejectReason, SignerMessage}; + use libsigner::{BlockProposal, BlockProposalData, SignerEvent}; + use stacks_common::types::chainstate::StacksPrivateKey; + + use super::async_sibling_validation::{ + canonical_sortition_response, tenure_start, MockNode, FIXTURE_BURN_HEIGHT, + }; + use super::BlockMessageRecorder; + use crate::signerdb::BlockInfo; + use crate::Signer as SignerTrait; + + /// The tenure every proposal here belongs to. + const TENURE: ConsensusHash = ConsensusHash([1; 20]); + + /// A proposal for a block in `TENURE` whose wrapper claims `burn_height` and + /// `reward_cycle`. The block is too old to sign, so a proposal the signer accepts as its own + /// draws an immediate `ProposalTooOld` rejection without needing any other node state. + fn stale_proposal(burn_height: u64, reward_cycle: u64) -> BlockProposal { + let block = tenure_start( + &StacksPrivateKey::from_seed(&[0, 1]), + &TENURE, + &ConsensusHash([0; 20]), + &StacksBlockId([9; 32]), + 10, + 1, + ); + BlockProposal { + block, + burn_height, + reward_cycle, + block_proposal_data: BlockProposalData::empty(), + } + } + + /// Hand `proposal` to the signer of `node`, which signs for reward cycle 1, and return the + /// reject reason of every response it sent for the block. + fn judge_with(node: &mut MockNode, proposal: &BlockProposal) -> Vec { + let hash = proposal.block.header.signer_signature_hash(); + node.signer.test_block_messages = Some(BlockMessageRecorder::new(hash.clone())); + let (result_tx, _result_rx) = mpsc::channel(); + let mut sortition = None; + node.signer.process_event( + &node.client, + &mut sortition, + Some(&SignerEvent::MinerMessages(vec![ + SignerMessage::BlockProposal(proposal.clone()), + ])), + &result_tx, + 1, + Some(1), + ); + let stored = node.signer.signer_db.block_lookup(&hash).unwrap(); + let recorder = node.signer.test_block_messages.take().unwrap(); + assert!(stored.is_none(), "a proposal judged here is never stored"); + let reason = recorder + .first_response + .as_ref() + .and_then(|response| response.as_block_rejection()) + .map(|rejection| rejection.response_data.reject_reason.clone()); + assert_eq!(recorder.response_count, usize::from(reason.is_some())); + reason.into_iter().collect() + } + + /// `judge_with` on a fresh node that answers `tips`, after `setup`. + fn judge( + tips: Vec<(String, String)>, + setup: impl FnOnce(&mut MockNode), + proposal: &BlockProposal, + ) -> Vec { + let mut node = MockNode::new(tips, Duration::from_secs(100_000)); + setup(&mut node); + let reasons = judge_with(&mut node, proposal); + node.shutdown(); + reasons + } + + /// The node places `TENURE` at `burn_height`. + fn tenure_at(burn_height: u64) -> Vec<(String, String)> { + vec![( + format!("/v3/sortitions/consensus/{}", TENURE.to_hex()), + canonical_sortition_response(&BurnchainHeaderHash([0; 32]), burn_height), + )] + } + + #[test] + fn proposal_that_states_its_tenure_is_judged() { + let reasons = judge( + tenure_at(FIXTURE_BURN_HEIGHT), + |_| {}, + &stale_proposal(FIXTURE_BURN_HEIGHT, 1), + ); + assert_eq!(reasons, vec![RejectReason::ProposalTooOld]); + } + + #[test] + fn proposal_steered_into_our_cycle_is_ignored() { + // The tenure was elected in reward cycle 2, but the wrapper claims cycle 1 to reach + // the cycle 1 signer: with and without also misstating the burn height. + for proposal in [stale_proposal(1, 1), stale_proposal(2, 1)] { + assert_eq!(judge(tenure_at(2), |_| {}, &proposal), vec![]); + } + } + + #[test] + fn proposal_misstating_its_burn_height_is_ignored() { + // Same reward cycle, wrong burn height. + assert_eq!( + judge( + tenure_at(FIXTURE_BURN_HEIGHT), + |_| {}, + &stale_proposal(0, 1) + ), + vec![] + ); + } + + #[test] + fn burn_block_the_signer_has_seen_places_the_tenure() { + // The signer heard the tenure's burn block arrive at height 2, so the node is not + // asked (its answer of `FIXTURE_BURN_HEIGHT` would have placed the proposal in our cycle). + let reasons = judge( + tenure_at(FIXTURE_BURN_HEIGHT), + |node| { + node.signer + .signer_db + .insert_burn_block( + &BurnchainHeaderHash([0xaa; 32]), + &TENURE, + 2, + &SystemTime::now(), + &BurnchainHeaderHash([0xa9; 32]), + ) + .unwrap(); + }, + &stale_proposal(FIXTURE_BURN_HEIGHT, 1), + ); + assert_eq!(reasons, vec![]); + } + + /// The node knows no sortition for `TENURE`. + fn unknown_tenure() -> Vec<(String, String)> { + vec![( + format!("/v3/sortitions/consensus/{}", TENURE.to_hex()), + "[]".to_string(), + )] + } + + #[test] + fn proposal_whose_tenure_cannot_be_placed_yet_is_rejected_transiently() { + // Neither the signer DB nor the node can place the tenure, as right after a new burn + // block that the node has not processed yet. The miner re-sends on a transient + // rejection, so that is the answer rather than silence. + assert!(RejectReason::NoSortitionView.is_transient()); + assert_eq!( + judge( + unknown_tenure(), + |_| {}, + &stale_proposal(FIXTURE_BURN_HEIGHT, 1) + ), + vec![RejectReason::NoSortitionView] + ); + } + + #[test] + fn proposal_re_sent_once_the_node_knows_the_tenure_is_judged() { + let mut node = MockNode::new(unknown_tenure(), Duration::from_secs(100_000)); + let proposal = stale_proposal(FIXTURE_BURN_HEIGHT, 1); + assert_eq!( + judge_with(&mut node, &proposal), + vec![RejectReason::NoSortitionView] + ); + // The node processes the tenure's sortition, and the miner re-sends the proposal: + // nothing was stored for the first attempt, so it is judged afresh. + *node.tips.lock().unwrap() = tenure_at(FIXTURE_BURN_HEIGHT); + assert_eq!( + judge_with(&mut node, &proposal), + vec![RejectReason::ProposalTooOld] + ); + node.shutdown(); + } + + /// Store `proposal`'s block as `decide` leaves it, then re-propose it to a signer that can + /// place neither the tenure (no burn block row, and the node's lookup fails) nor, therefore, + /// the proposal. Returns the signer's responses and the block's row afterwards. + fn repropose_unplaceable( + proposal: &BlockProposal, + decide: impl FnOnce(&mut BlockInfo), + ) -> (BlockMessageRecorder, BlockInfo, BlockInfo) { + let mut node = MockNode::new(unknown_tenure(), Duration::from_secs(100_000)); + let hash = proposal.block.header.signer_signature_hash(); + let mut stored = BlockInfo::from(proposal.clone()); + decide(&mut stored); + node.signer.signer_db.insert_block(&stored).unwrap(); + assert!(node.signer.signer_db.get_burn_block_by_ch(&TENURE).is_err()); + + node.signer.test_block_messages = Some(BlockMessageRecorder::new(hash.clone())); + let (result_tx, _result_rx) = mpsc::channel(); + let mut sortition = None; + node.signer.process_event( + &node.client, + &mut sortition, + Some(&SignerEvent::MinerMessages(vec![ + SignerMessage::BlockProposal(proposal.clone()), + ])), + &result_tx, + 1, + Some(1), + ); + let after = node.signer.signer_db.block_lookup(&hash).unwrap().unwrap(); + let recorder = node.signer.test_block_messages.take().unwrap(); + node.shutdown(); + (recorder, stored, after) + } + + #[test] + fn signed_block_is_not_contradicted_when_its_tenure_cannot_be_placed() { + // We signed the block when the node could place its tenure. On a re-proposal the + // lookup fails, but our signature stands: we repeat it rather than reject the block. + let (recorder, stored, after) = + repropose_unplaceable(&stale_proposal(FIXTURE_BURN_HEIGHT, 1), |info| { + info.mark_locally_accepted(false).unwrap(); + }); + assert_eq!(recorder.response_count, 1); + assert!(recorder + .first_response + .as_ref() + .and_then(|response| response.as_block_accepted()) + .is_some()); + assert_eq!(after, stored, "the decision is kept as it was"); + } + + #[test] + fn transiently_rejected_block_is_still_refused_when_its_tenure_cannot_be_placed() { + // A stored decision we would reconsider anyway gives no reason to judge the block + // without placing it: it is refused as transient again, and left as it was. + let (recorder, stored, after) = + repropose_unplaceable(&stale_proposal(FIXTURE_BURN_HEIGHT, 1), |info| { + info.mark_locally_rejected().unwrap(); + info.reject_reason = Some(RejectReason::NoSortitionView); + }); + assert_eq!(recorder.response_count, 1); + assert_eq!( + recorder + .first_response + .as_ref() + .and_then(|response| response.as_block_rejection()) + .map(|rejection| rejection.response_data.reject_reason.clone()), + Some(RejectReason::NoSortitionView) + ); + assert_eq!(after, stored); + } +}