Skip to content

Commit a5f2fc6

Browse files
authored
Merge pull request #5817 from stacks-network/feat/tenure_boundary_heuristic
Feat/tenure boundary heuristic
2 parents 3a935b5 + 1b8cdbc commit a5f2fc6

File tree

4 files changed

+298
-20
lines changed

4 files changed

+298
-20
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
1717
- By default, miners will wait for a new tenure to start for a configurable amount of time after receiving a burn block before
1818
submitting a block commit. This will reduce the amount of RBF transactions miners are expected to need.
1919
- Add weight threshold and percentages to `StackerDBListener` logs
20+
- Signer will not allow reorg if more than one block in the current tenure has already been globally approved
2021

2122
## [3.1.0.0.6]
2223

stacks-signer/src/chainstate.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ impl SortitionsView {
427427
) -> Result<bool, SignerChainstateError> {
428428
// if the parent tenure is the last sortition, it is a valid choice.
429429
// if the parent tenure is a reorg, then all of the reorged sortitions
430-
// must either have produced zero blocks _or_ produced their first block
430+
// must either have produced zero blocks _or_ produced their first (and only) block
431431
// very close to the burn block transition.
432432
if sortition_state.prior_sortition == sortition_state.parent_tenure_id {
433433
return Ok(true);
@@ -464,6 +464,23 @@ impl SortitionsView {
464464
continue;
465465
}
466466

467+
// disallow reorg if more than one block has already been signed
468+
let globally_accepted_blocks =
469+
signer_db.get_globally_accepted_block_count_in_tenure(&tenure.consensus_hash)?;
470+
if globally_accepted_blocks > 1 {
471+
warn!(
472+
"Miner is not building off of most recent tenure, but a tenure they attempted to reorg has already more than one globally accepted block.";
473+
"proposed_block_consensus_hash" => %block.header.consensus_hash,
474+
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
475+
"parent_tenure" => %sortition_state.parent_tenure_id,
476+
"last_sortition" => %sortition_state.prior_sortition,
477+
"violating_tenure_id" => %tenure.consensus_hash,
478+
"violating_tenure_first_block_id" => ?tenure.first_block_mined,
479+
"globally_accepted_blocks" => globally_accepted_blocks,
480+
);
481+
return Ok(false);
482+
}
483+
467484
if tenure.first_block_mined.is_some() {
468485
let Some(local_block_info) =
469486
signer_db.get_first_signed_block_in_tenure(&tenure.consensus_hash)?

stacks-signer/src/signerdb.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,20 @@ impl SignerDb {
853853
try_deserialize(result)
854854
}
855855

856+
/// Return the count of globally accepted blocks in a tenure (identified by its consensus hash)
857+
pub fn get_globally_accepted_block_count_in_tenure(
858+
&self,
859+
tenure: &ConsensusHash,
860+
) -> Result<u64, DBError> {
861+
let query = "SELECT COALESCE((MAX(stacks_height) - MIN(stacks_height) + 1), 0) AS block_count FROM blocks WHERE consensus_hash = ?1 AND state = ?2";
862+
let args = params![tenure, &BlockState::GloballyAccepted.to_string()];
863+
let block_count_opt: Option<u64> = query_row(&self.db, query, args)?;
864+
match block_count_opt {
865+
Some(block_count) => Ok(block_count),
866+
None => Ok(0),
867+
}
868+
}
869+
856870
/// Return the last accepted block in a tenure (identified by its consensus hash).
857871
pub fn get_last_accepted_block(
858872
&self,
@@ -2045,6 +2059,74 @@ mod tests {
20452059
assert!(pendings.is_empty());
20462060
}
20472061

2062+
#[test]
2063+
fn check_globally_signed_block_count() {
2064+
let db_path = tmp_db_path();
2065+
let consensus_hash_1 = ConsensusHash([0x01; 20]);
2066+
let mut db = SignerDb::new(db_path).expect("Failed to create signer db");
2067+
let (mut block_info, _) = create_block_override(|b| {
2068+
b.block.header.consensus_hash = consensus_hash_1;
2069+
});
2070+
2071+
assert!(matches!(
2072+
db.get_globally_accepted_block_count_in_tenure(&consensus_hash_1)
2073+
.unwrap(),
2074+
0
2075+
));
2076+
2077+
// locally accepted still returns 0
2078+
block_info.signed_over = true;
2079+
block_info.state = BlockState::LocallyAccepted;
2080+
block_info.block.header.chain_length = 1;
2081+
db.insert_block(&block_info).unwrap();
2082+
2083+
assert_eq!(
2084+
db.get_globally_accepted_block_count_in_tenure(&consensus_hash_1)
2085+
.unwrap(),
2086+
0
2087+
);
2088+
2089+
block_info.signed_over = true;
2090+
block_info.state = BlockState::GloballyAccepted;
2091+
block_info.block.header.chain_length = 2;
2092+
db.insert_block(&block_info).unwrap();
2093+
2094+
block_info.signed_over = true;
2095+
block_info.state = BlockState::GloballyAccepted;
2096+
block_info.block.header.chain_length = 3;
2097+
db.insert_block(&block_info).unwrap();
2098+
2099+
assert_eq!(
2100+
db.get_globally_accepted_block_count_in_tenure(&consensus_hash_1)
2101+
.unwrap(),
2102+
2
2103+
);
2104+
2105+
// add an unsigned block
2106+
block_info.signed_over = false;
2107+
block_info.state = BlockState::GloballyAccepted;
2108+
block_info.block.header.chain_length = 4;
2109+
db.insert_block(&block_info).unwrap();
2110+
2111+
assert_eq!(
2112+
db.get_globally_accepted_block_count_in_tenure(&consensus_hash_1)
2113+
.unwrap(),
2114+
3
2115+
);
2116+
2117+
// add a locally signed block
2118+
block_info.signed_over = true;
2119+
block_info.state = BlockState::LocallyAccepted;
2120+
block_info.block.header.chain_length = 5;
2121+
db.insert_block(&block_info).unwrap();
2122+
2123+
assert_eq!(
2124+
db.get_globally_accepted_block_count_in_tenure(&consensus_hash_1)
2125+
.unwrap(),
2126+
3
2127+
);
2128+
}
2129+
20482130
#[test]
20492131
fn has_signed_block() {
20502132
let db_path = tmp_db_path();

testnet/stacks-node/src/tests/signer/v0.rs

Lines changed: 197 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,40 @@ fn wait_for_block_global_rejection(
10781078
})
10791079
}
10801080

1081+
/// Waits for >30% of num_signers block rejection to be observed in the test_observer stackerdb chunks for a block
1082+
/// with the provided signer signature hash and the specified reject_reason
1083+
fn wait_for_block_global_rejection_with_reject_reason(
1084+
timeout_secs: u64,
1085+
block_signer_signature_hash: Sha512Trunc256Sum,
1086+
num_signers: usize,
1087+
reject_reason: RejectReason,
1088+
) -> Result<(), String> {
1089+
let mut found_rejections = HashSet::new();
1090+
wait_for(timeout_secs, || {
1091+
let chunks = test_observer::get_stackerdb_chunks();
1092+
for chunk in chunks.into_iter().flat_map(|chunk| chunk.modified_slots) {
1093+
let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice())
1094+
else {
1095+
continue;
1096+
};
1097+
if let SignerMessage::BlockResponse(BlockResponse::Rejected(BlockRejection {
1098+
signer_signature_hash,
1099+
signature,
1100+
response_data,
1101+
..
1102+
})) = message
1103+
{
1104+
if signer_signature_hash == block_signer_signature_hash
1105+
&& response_data.reject_reason == reject_reason
1106+
{
1107+
found_rejections.insert(signature);
1108+
}
1109+
}
1110+
}
1111+
Ok(found_rejections.len() >= num_signers * 3 / 10)
1112+
})
1113+
}
1114+
10811115
/// Waits for the provided number of block rejections to be observed in the test_observer stackerdb chunks for a block
10821116
/// with the provided signer signature hash
10831117
fn wait_for_block_rejections(
@@ -10221,13 +10255,13 @@ fn block_proposal_timeout() {
1022110255
/// Signers accept and the stacks tip advances to N
1022210256
/// Miner 1's block commits are paused so it cannot confirm the next tenure.
1022310257
/// Sortition occurs. Miner 2 wins.
10224-
/// Miner 2 successfully mines blocks N+1, N+2, and N+3
10258+
/// Miner 2 successfully mines blocks N+1
1022510259
/// Sortition occurs quickly, within first_proposal_burn_block_timing_secs. Miner 1 wins.
1022610260
/// Miner 1 proposes block N+1'
1022710261
/// Signers approve N+1', saying "Miner is not building off of most recent tenure. A tenure they
1022810262
/// reorg has already mined blocks, but the block was poorly timed, allowing the reorg."
1022910263
/// Miner 1 proposes N+2' and it is accepted.
10230-
/// Miner 1 wins the next tenure and mines N+4, off of miner 2's tip.
10264+
/// Miner 1 wins the next tenure and mines N+3, off of miner 1's tip. (miner 2's N+1 gets reorg)
1023110265
#[test]
1023210266
#[ignore]
1023310267
fn allow_reorg_within_first_proposal_burn_block_timing_secs() {
@@ -10327,18 +10361,6 @@ fn allow_reorg_within_first_proposal_burn_block_timing_secs() {
1032710361
block_n_height + 1
1032810362
);
1032910363

10330-
info!("------------------------- Miner 2 Mines N+2 and N+3 -------------------------");
10331-
miners
10332-
.send_and_mine_transfer_tx(30)
10333-
.expect("Failed to send and mine transfer tx");
10334-
miners
10335-
.send_and_mine_transfer_tx(30)
10336-
.expect("Failed to send and mine transfer tx");
10337-
assert_eq!(
10338-
get_chain_info(&conf_1).stacks_tip_height,
10339-
block_n_height + 3
10340-
);
10341-
1034210364
info!("------------------------- Miner 1 Wins the Next Tenure, Mines N+1' -------------------------");
1034310365
miners
1034410366
.mine_bitcoin_blocks_and_confirm(&sortdb, 1, 30)
@@ -10359,18 +10381,174 @@ fn allow_reorg_within_first_proposal_burn_block_timing_secs() {
1035910381
let _ = wait_for_block_pushed_by_miner_key(30, block_n_height + 2, &miner_pk_1)
1036010382
.expect("Failed to get block N+2'");
1036110383

10362-
info!("------------------------- Miner 1 Mines N+4 in Next Tenure -------------------------");
10384+
info!("------------------------- Miner 1 Mines N+3 in Next Tenure -------------------------");
1036310385

1036410386
miners
1036510387
.mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 60)
10366-
.expect("Failed to mine BTC block followed by Block N+3");
10367-
let miner_1_block_n_4 = wait_for_block_pushed_by_miner_key(30, block_n_height + 4, &miner_pk_1)
10388+
.expect("Failed to mine BTC block followed by Block N+2");
10389+
let miner_1_block_n_3 = wait_for_block_pushed_by_miner_key(30, block_n_height + 3, &miner_pk_1)
1036810390
.expect("Failed to get block N+3");
1036910391

1037010392
let peer_info = miners.get_peer_info();
10371-
assert_eq!(peer_info.stacks_tip_height, block_n_height + 4);
10372-
assert_eq!(peer_info.stacks_tip, miner_1_block_n_4.header.block_hash());
10393+
assert_eq!(peer_info.stacks_tip_height, block_n_height + 3);
10394+
assert_eq!(peer_info.stacks_tip, miner_1_block_n_3.header.block_hash());
10395+
10396+
miners.shutdown();
10397+
}
10398+
10399+
/// Test a scenario where:
10400+
/// Two miners boot to Nakamoto.
10401+
/// Sortition occurs. Miner 1 wins.
10402+
/// Miner 1 proposes a block N
10403+
/// Signers accept and the stacks tip advances to N
10404+
/// Miner 1's block commits are paused so it cannot confirm the next tenure.
10405+
/// Sortition occurs. Miner 2 wins.
10406+
/// Miner 2 successfully mines blocks N+1, N+2, and N+3
10407+
/// Sortition occurs quickly, within first_proposal_burn_block_timing_secs. Miner 1 wins.
10408+
/// Miner 1 proposes block N+1' but gets rejected as more than one block has been mined in the current tenure (by miner2)
10409+
#[test]
10410+
#[ignore]
10411+
fn disallow_reorg_within_first_proposal_burn_block_timing_secs_but_more_than_one_block() {
10412+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
10413+
return;
10414+
}
10415+
10416+
let num_signers = 5;
10417+
let num_txs = 3;
10418+
10419+
let mut miners = MultipleMinerTest::new_with_config_modifications(
10420+
num_signers,
10421+
num_txs,
10422+
|signer_config| {
10423+
// Lets make sure we never time out since we need to stall some things to force our scenario
10424+
signer_config.block_proposal_validation_timeout = Duration::from_secs(1800);
10425+
signer_config.tenure_last_block_proposal_timeout = Duration::from_secs(1800);
10426+
signer_config.first_proposal_burn_block_timing = Duration::from_secs(1800);
10427+
},
10428+
|_| {},
10429+
|_| {},
10430+
);
10431+
let rl1_skip_commit_op = miners
10432+
.signer_test
10433+
.running_nodes
10434+
.counters
10435+
.naka_skip_commit_op
10436+
.clone();
10437+
let rl2_skip_commit_op = miners.rl2_counters.naka_skip_commit_op.clone();
10438+
10439+
let (conf_1, _) = miners.get_node_configs();
10440+
let (miner_pkh_1, miner_pkh_2) = miners.get_miner_public_key_hashes();
10441+
let (miner_pk_1, miner_pk_2) = miners.get_miner_public_keys();
1037310442

10443+
info!("------------------------- Pause Miner 2's Block Commits -------------------------");
10444+
10445+
// Make sure Miner 2 cannot win a sortition at first.
10446+
rl2_skip_commit_op.set(true);
10447+
10448+
miners.boot_to_epoch_3();
10449+
10450+
let burnchain = conf_1.get_burnchain();
10451+
let sortdb = burnchain.open_sortition_db(true).unwrap();
10452+
10453+
info!("------------------------- Pause Miner 1's Block Commits -------------------------");
10454+
rl1_skip_commit_op.set(true);
10455+
10456+
info!("------------------------- Miner 1 Mines a Nakamoto Block N -------------------------");
10457+
let stacks_height_before = miners.get_peer_stacks_tip_height();
10458+
miners
10459+
.mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 60)
10460+
.expect("Failed to mine BTC block followed by Block N");
10461+
10462+
let miner_1_block_n =
10463+
wait_for_block_pushed_by_miner_key(30, stacks_height_before + 1, &miner_pk_1)
10464+
.expect("Failed to get block N");
10465+
10466+
let block_n_height = miner_1_block_n.header.chain_length;
10467+
info!("Block N: {block_n_height}");
10468+
let info_after = get_chain_info(&conf_1);
10469+
assert_eq!(info_after.stacks_tip, miner_1_block_n.header.block_hash());
10470+
assert_eq!(info_after.stacks_tip_height, block_n_height);
10471+
assert_eq!(block_n_height, stacks_height_before + 1);
10472+
10473+
// assure we have a successful sortition that miner 1 won
10474+
verify_sortition_winner(&sortdb, &miner_pkh_1);
10475+
10476+
info!("------------------------- Miner 2 Submits a Block Commit -------------------------");
10477+
miners.submit_commit_miner_2(&sortdb);
10478+
10479+
info!("------------------------- Pause Miner 2's Block Mining -------------------------");
10480+
TEST_MINE_STALL.set(true);
10481+
10482+
info!("------------------------- Mine Tenure -------------------------");
10483+
miners
10484+
.mine_bitcoin_blocks_and_confirm(&sortdb, 1, 60)
10485+
.expect("Failed to mine BTC block");
10486+
10487+
info!("------------------------- Miner 1 Submits a Block Commit -------------------------");
10488+
miners.submit_commit_miner_1(&sortdb);
10489+
10490+
info!("------------------------- Miner 2 Mines Block N+1 -------------------------");
10491+
10492+
TEST_MINE_STALL.set(false);
10493+
let _ = wait_for_block_pushed_by_miner_key(30, block_n_height + 1, &miner_pk_2)
10494+
.expect("Failed to get block N+1");
10495+
10496+
// assure we have a successful sortition that miner 2 won
10497+
verify_sortition_winner(&sortdb, &miner_pkh_2);
10498+
10499+
assert_eq!(
10500+
get_chain_info(&conf_1).stacks_tip_height,
10501+
block_n_height + 1
10502+
);
10503+
10504+
info!("------------------------- Miner 2 Mines N+2 and N+3 -------------------------");
10505+
miners
10506+
.send_and_mine_transfer_tx(30)
10507+
.expect("Failed to send and mine transfer tx");
10508+
miners
10509+
.send_and_mine_transfer_tx(30)
10510+
.expect("Failed to send and mine transfer tx");
10511+
assert_eq!(
10512+
get_chain_info(&conf_1).stacks_tip_height,
10513+
block_n_height + 3
10514+
);
10515+
10516+
info!("------------------------- Miner 1 Wins the Next Tenure, Mines N+1', got rejected -------------------------");
10517+
miners.btc_regtest_controller_mut().build_next_block(1);
10518+
10519+
// wait for a block N+1' proposal from miner1
10520+
let proposed_block = wait_for_block_proposal(30, block_n_height + 1, &miner_pk_1)
10521+
.expect("Timed out waiting for block proposal");
10522+
// check it has been rejected
10523+
wait_for_block_global_rejection_with_reject_reason(
10524+
30,
10525+
proposed_block.header.signer_signature_hash(),
10526+
num_signers,
10527+
RejectReason::ReorgNotAllowed,
10528+
)
10529+
.expect("Timed out waiting for a block proposal to be rejected due to invalid reorg");
10530+
10531+
// check only 1 block from miner1 has been added after the epoch3 boot
10532+
let miner1_blocks_after_boot_to_epoch3 = get_nakamoto_headers(&conf_1)
10533+
.into_iter()
10534+
.filter(|block| {
10535+
// skip first nakamoto block
10536+
if block.stacks_block_height == stacks_height_before {
10537+
return false;
10538+
}
10539+
let nakamoto_block_header = block.anchored_header.as_stacks_nakamoto().unwrap();
10540+
miner_pk_1
10541+
.verify(
10542+
nakamoto_block_header.miner_signature_hash().as_bytes(),
10543+
&nakamoto_block_header.miner_signature,
10544+
)
10545+
.unwrap()
10546+
})
10547+
.count();
10548+
10549+
assert_eq!(miner1_blocks_after_boot_to_epoch3, 1);
10550+
10551+
info!("------------------------- Shutdown -------------------------");
1037410552
miners.shutdown();
1037510553
}
1037610554

0 commit comments

Comments
 (0)