Skip to content

Commit bd11355

Browse files
committed
feat: handle burn reorgs during tenure extends
* update signer logic to check node's tenure info after local state times out (do not consult local state for global acceptance status) * update node get_tenuretip endpoint to return highest tip in tenure with canonical burn_view * update proposal validation and mining logic to do the same * update miner to be more defensive with burn_view check
1 parent 9dd349d commit bd11355

File tree

8 files changed

+417
-145
lines changed

8 files changed

+417
-145
lines changed

stacks-signer/src/chainstate.rs

Lines changed: 70 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,9 @@ impl SortitionsView {
389389
)?;
390390
} else {
391391
// check if the new block confirms the last block in the current tenure
392-
let confirms_latest_in_tenure =
393-
Self::confirms_latest_block_in_same_tenure(block, signer_db)
394-
.map_err(SignerChainstateError::from)?;
392+
let confirms_latest_in_tenure = self
393+
.confirms_latest_block_in_same_tenure(block, signer_db, client)
394+
.map_err(SignerChainstateError::from)?;
395395
if !confirms_latest_in_tenure {
396396
return Err(RejectReason::InvalidParentBlock);
397397
}
@@ -562,8 +562,7 @@ impl SortitionsView {
562562
Ok(true)
563563
}
564564

565-
/// Get the last block from the given tenure
566-
/// Returns the last locally accepted block if it is not timed out, otherwise it will return the last globally accepted block.
565+
/// Get the last locally signed block from the given tenure if it has not timed out
567566
pub fn get_tenure_last_block_info(
568567
consensus_hash: &ConsensusHash,
569568
signer_db: &SignerDb,
@@ -573,43 +572,41 @@ impl SortitionsView {
573572
let last_locally_accepted_block = signer_db
574573
.get_last_accepted_block(consensus_hash)
575574
.map_err(|e| ClientError::InvalidResponse(e.to_string()))?;
575+
let Some(local_info) = last_locally_accepted_block else {
576+
return Ok(None);
577+
};
576578

577-
if let Some(local_info) = last_locally_accepted_block {
578-
if let Some(signed_over_time) = local_info.signed_self {
579-
if signed_over_time.saturating_add(tenure_last_block_proposal_timeout.as_secs())
580-
> get_epoch_time_secs()
581-
{
582-
// The last locally accepted block is not timed out, return it
583-
return Ok(Some(local_info));
584-
}
585-
}
579+
let Some(signed_over_time) = local_info.signed_self else {
580+
return Ok(None);
581+
};
582+
583+
if signed_over_time.saturating_add(tenure_last_block_proposal_timeout.as_secs())
584+
> get_epoch_time_secs()
585+
{
586+
// The last locally accepted block is not timed out, return it
587+
Ok(Some(local_info))
588+
} else {
589+
// The last locally accepted block is timed out, get the last globally accepted block
590+
Ok(None)
586591
}
587-
// The last locally accepted block is timed out, get the last globally accepted block
588-
signer_db
589-
.get_last_globally_accepted_block(consensus_hash)
590-
.map_err(|e| ClientError::InvalidResponse(e.to_string()))
591592
}
592593

593-
/// Check if the tenure change block confirms the expected parent block
594-
/// (i.e., the last locally accepted block in the parent tenure, or if that block is timed out, the last globally accepted block in the parent tenure)
595-
/// It checks the local DB first, and if the block is not present in the local DB, it asks the
596-
/// Stacks node for the highest processed block header in the given tenure (and then caches it
597-
/// in the DB).
594+
/// Check whether or not `block` is higher than the highest block in `tenure_id`.
595+
/// returns `Ok(true)` if `block` is higher, `Ok(false)` if not.
598596
///
599-
/// The rationale here is that the signer DB can be out-of-sync with the node. For example,
600-
/// the signer may have been added to an already-running node.
601-
pub fn check_tenure_change_confirms_parent(
602-
tenure_change: &TenureChangePayload,
597+
/// If we can't look up `tenure_id`, assume `block` is higher.
598+
///
599+
/// This updates the activity timer for the miner of `block`.
600+
pub fn check_latest_block_in_tenure(
601+
tenure_id: &ConsensusHash,
603602
block: &NakamotoBlock,
604603
signer_db: &mut SignerDb,
605604
client: &StacksClient,
606605
tenure_last_block_proposal_timeout: Duration,
607606
reorg_attempts_activity_timeout: Duration,
608607
) -> Result<bool, ClientError> {
609-
// If the tenure change block confirms the expected parent block, it should confirm at least one more block than the last accepted block in the parent tenure.
610-
// NOTE: returns the locally accepted block if it is not timed out, otherwise it will return the last globally accepted block.
611608
let last_block_info = Self::get_tenure_last_block_info(
612-
&tenure_change.prev_tenure_consensus_hash,
609+
tenure_id,
613610
signer_db,
614611
tenure_last_block_proposal_timeout,
615612
)?;
@@ -636,7 +633,7 @@ impl SortitionsView {
636633
// to give the miner some extra buffer time to wait for its chain tip to advance
637634
// The miner may just be slow, so count this invalid block proposal towards valid miner activity.
638635
if let Err(e) = signer_db.update_last_activity_time(
639-
&tenure_change.tenure_consensus_hash,
636+
&block.header.consensus_hash,
640637
get_epoch_time_secs(),
641638
) {
642639
warn!("Failed to update last activity time: {e}");
@@ -646,16 +643,16 @@ impl SortitionsView {
646643
}
647644
}
648645

649-
let tip = match client.get_tenure_tip(&tenure_change.prev_tenure_consensus_hash) {
646+
let tip = match client.get_tenure_tip(tenure_id) {
650647
Ok(tip) => tip,
651648
Err(e) => {
652649
warn!(
653-
"Miner block proposal contains a tenure change, but failed to fetch the tenure tip for the parent tenure: {e:?}. Considering proposal invalid.";
650+
"Failed to fetch the tenure tip for the parent tenure: {e:?}. Assuming proposal is higher than the parent tenure for now.";
654651
"proposed_block_consensus_hash" => %block.header.consensus_hash,
655652
"signer_signature_hash" => %block.header.signer_signature_hash(),
656-
"parent_tenure" => %tenure_change.prev_tenure_consensus_hash,
653+
"parent_tenure" => %tenure_id,
657654
);
658-
return Ok(false);
655+
return Ok(true);
659656
}
660657
};
661658
if let Some(nakamoto_tip) = tip.as_stacks_nakamoto() {
@@ -673,19 +670,33 @@ impl SortitionsView {
673670
}
674671
}
675672
}
676-
let tip_height = tip.height();
677-
if block.header.chain_length > tip_height {
678-
Ok(true)
679-
} else {
680-
warn!(
681-
"Miner's block proposal does not confirm as many blocks as we expect";
682-
"proposed_block_consensus_hash" => %block.header.consensus_hash,
683-
"signer_signature_hash" => %block.header.signer_signature_hash(),
684-
"proposed_chain_length" => block.header.chain_length,
685-
"expected_at_least" => tip_height + 1,
686-
);
687-
Ok(false)
688-
}
673+
return Ok(tip.height() < block.header.chain_length);
674+
}
675+
676+
/// Check if the tenure change block confirms the expected parent block
677+
/// (i.e., the last locally accepted block in the parent tenure, or if that block is timed out, the last globally accepted block in the parent tenure)
678+
/// It checks the local DB first, and if the block is not present in the local DB, it asks the
679+
/// Stacks node for the highest processed block header in the given tenure (and then caches it
680+
/// in the DB).
681+
///
682+
/// The rationale here is that the signer DB can be out-of-sync with the node. For example,
683+
/// the signer may have been added to an already-running node.
684+
pub fn check_tenure_change_confirms_parent(
685+
tenure_change: &TenureChangePayload,
686+
block: &NakamotoBlock,
687+
signer_db: &mut SignerDb,
688+
client: &StacksClient,
689+
tenure_last_block_proposal_timeout: Duration,
690+
reorg_attempts_activity_timeout: Duration,
691+
) -> Result<bool, ClientError> {
692+
Self::check_latest_block_in_tenure(
693+
&tenure_change.prev_tenure_consensus_hash,
694+
block,
695+
signer_db,
696+
client,
697+
tenure_last_block_proposal_timeout,
698+
reorg_attempts_activity_timeout,
699+
)
689700
}
690701

691702
/// in tenure changes, we need to check:
@@ -742,33 +753,19 @@ impl SortitionsView {
742753
}
743754

744755
fn confirms_latest_block_in_same_tenure(
756+
&self,
745757
block: &NakamotoBlock,
746-
signer_db: &SignerDb,
758+
signer_db: &mut SignerDb,
759+
client: &StacksClient,
747760
) -> Result<bool, ClientError> {
748-
let Some(last_known_block) = signer_db
749-
.get_last_accepted_block(&block.header.consensus_hash)
750-
.map_err(|e| ClientError::InvalidResponse(e.to_string()))?
751-
else {
752-
info!(
753-
"Have no accepted blocks in the tenure, assuming block confirmation is correct";
754-
"proposed_block_consensus_hash" => %block.header.consensus_hash,
755-
"signer_signature_hash" => %block.header.signer_signature_hash(),
756-
"proposed_block_height" => block.header.chain_length,
757-
);
758-
return Ok(true);
759-
};
760-
if block.header.chain_length > last_known_block.block.header.chain_length {
761-
Ok(true)
762-
} else {
763-
warn!(
764-
"Miner's block proposal does not confirm as many blocks as we expect";
765-
"proposed_block_consensus_hash" => %block.header.consensus_hash,
766-
"signer_signature_hash" => %block.header.signer_signature_hash(),
767-
"proposed_chain_length" => block.header.chain_length,
768-
"expected_at_least" => last_known_block.block.header.chain_length + 1,
769-
);
770-
Ok(false)
771-
}
761+
Self::check_latest_block_in_tenure(
762+
&block.header.consensus_hash,
763+
block,
764+
signer_db,
765+
client,
766+
self.config.tenure_last_block_proposal_timeout,
767+
self.config.reorg_attempts_activity_timeout,
768+
)
772769
}
773770

774771
/// Fetch a new view of the recent sortitions

stacks-signer/src/v0/signer.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,6 @@ impl Signer {
945945
proposed_block: &NakamotoBlock,
946946
) -> Option<BlockResponse> {
947947
let signer_signature_hash = proposed_block.header.signer_signature_hash();
948-
let proposed_block_consensus_hash = proposed_block.header.consensus_hash;
949948
// If this is a tenure change block, ensure that it confirms the correct number of blocks from the parent tenure.
950949
if let Some(tenure_change) = proposed_block.get_tenure_change_tx_payload() {
951950
// Ensure that the tenure change block confirms the expected parent block
@@ -957,7 +956,7 @@ impl Signer {
957956
self.proposal_config.tenure_last_block_proposal_timeout,
958957
self.proposal_config.reorg_attempts_activity_timeout,
959958
) {
960-
Ok(true) => {}
959+
Ok(true) => return None,
961960
Ok(false) => {
962961
return Some(self.create_block_rejection(
963962
RejectReason::SortitionViewMismatch,
@@ -980,26 +979,30 @@ impl Signer {
980979
}
981980

982981
// Ensure that the block is the last block in the chain of its current tenure.
983-
match self
984-
.signer_db
985-
.get_last_accepted_block(&proposed_block_consensus_hash)
986-
{
987-
Ok(Some(last_block_info)) => {
988-
if proposed_block.header.chain_length <= last_block_info.block.header.chain_length {
982+
match SortitionsView::check_latest_block_in_tenure(
983+
&proposed_block.header.consensus_hash,
984+
proposed_block,
985+
&mut self.signer_db,
986+
stacks_client,
987+
self.proposal_config.tenure_last_block_proposal_timeout,
988+
self.proposal_config.reorg_attempts_activity_timeout,
989+
) {
990+
Ok(is_latest) => {
991+
if !is_latest {
989992
warn!(
990993
"Miner's block proposal does not confirm as many blocks as we expect";
991-
"proposed_block_consensus_hash" => %proposed_block_consensus_hash,
994+
"proposed_block_consensus_hash" => %proposed_block.header.consensus_hash,
992995
"proposed_block_signer_signature_hash" => %signer_signature_hash,
993996
"proposed_chain_length" => proposed_block.header.chain_length,
994-
"expected_at_least" => last_block_info.block.header.chain_length + 1,
995997
);
996998
return Some(self.create_block_rejection(
997999
RejectReason::SortitionViewMismatch,
9981000
proposed_block,
9991001
));
1002+
} else {
1003+
return None;
10001004
}
10011005
}
1002-
Ok(_) => {}
10031006
Err(e) => {
10041007
warn!("{self}: Failed to check block against signer db: {e}";
10051008
"signer_signature_hash" => %signer_signature_hash,
@@ -1013,7 +1016,6 @@ impl Signer {
10131016
));
10141017
}
10151018
}
1016-
None
10171019
}
10181020

10191021
/// Handle the block validate ok response. Returns our block response if we have one

stackslib/src/chainstate/nakamoto/mod.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2887,6 +2887,75 @@ impl NakamotoChainState {
28872887
Ok(StacksChainState::get_stacks_block_header_info_by_consensus_hash(db, consensus_hash)?)
28882888
}
28892889

2890+
/// DO NOT USE IN CONSENSUS CODE. Different nodes can have different blocks for the same
2891+
/// tenure.
2892+
///
2893+
/// Get the highest block in a given tenure (identified by its consensus hash) with a canonical
2894+
/// burn_view (i.e., burn_view on the canonical sortition fork)
2895+
pub fn find_highest_known_block_header_in_tenure(
2896+
chainstate: &StacksChainState,
2897+
sort_db: &SortitionDB,
2898+
tenure_id: &ConsensusHash,
2899+
) -> Result<Option<StacksHeaderInfo>, ChainstateError> {
2900+
let chainstate_db_conn = chainstate.db();
2901+
2902+
let candidates = Self::get_highest_known_block_header_in_tenure_at_each_burnview(
2903+
chainstate_db_conn,
2904+
tenure_id,
2905+
)?;
2906+
2907+
let canonical_sortition_handle = sort_db.index_handle_at_tip();
2908+
for candidate in candidates.into_iter() {
2909+
let Some(ref candidate_ch) = candidate.burn_view else {
2910+
// this is an epoch 2.x header, no burn view to check
2911+
return Ok(Some(candidate));
2912+
};
2913+
let in_canonical_fork = canonical_sortition_handle.processed_block(&candidate_ch)?;
2914+
if in_canonical_fork {
2915+
return Ok(Some(candidate));
2916+
}
2917+
}
2918+
2919+
// did not find any blocks in the tenure
2920+
Ok(None)
2921+
}
2922+
2923+
/// DO NOT USE IN CONSENSUS CODE. Different nodes can have different blocks for the same
2924+
/// tenure.
2925+
///
2926+
/// Get the highest blocks in a given tenure (identified by its consensus hash) at each burn view
2927+
/// active in that tenure. If there are ties at a given burn view, they will both be returned
2928+
fn get_highest_known_block_header_in_tenure_at_each_burnview(
2929+
db: &Connection,
2930+
tenure_id: &ConsensusHash,
2931+
) -> Result<Vec<StacksHeaderInfo>, ChainstateError> {
2932+
// see if we have a nakamoto block in this tenure
2933+
let qry = "
2934+
SELECT h.*
2935+
FROM nakamoto_block_headers h
2936+
JOIN (
2937+
SELECT burn_view, MAX(block_height) AS max_height
2938+
FROM nakamoto_block_headers
2939+
WHERE consensus_hash = ?1
2940+
GROUP BY burn_view
2941+
) maxed
2942+
ON h.burn_view = maxed.burn_view
2943+
AND h.block_height = maxed.max_height
2944+
WHERE h.consensus_hash = ?1
2945+
ORDER BY h.block_height DESC, h.timestamp
2946+
";
2947+
let args = params![tenure_id];
2948+
let out = query_rows(db, qry, args)?;
2949+
if !out.is_empty() {
2950+
return Ok(out);
2951+
}
2952+
2953+
// see if this is an epoch2 header. If it exists, then there will only be one.
2954+
let epoch2_x =
2955+
StacksChainState::get_stacks_block_header_info_by_consensus_hash(db, tenure_id)?;
2956+
Ok(Vec::from_iter(epoch2_x))
2957+
}
2958+
28902959
/// Get the VRF proof for a Stacks block.
28912960
/// For Nakamoto blocks, this is the VRF proof contained in the coinbase of the tenure-start
28922961
/// block of the given tenure identified by the consensus hash.

0 commit comments

Comments
 (0)