Skip to content

Commit 982ca9c

Browse files
authored
Merge pull request #5452 from stacks-network/feat/tenure-extend-no-blocks
Tenure extend when the previous tenure is bad
2 parents c79fa6c + ecae254 commit 982ca9c

File tree

13 files changed

+2018
-225
lines changed

13 files changed

+2018
-225
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ jobs:
126126
- tests::signer::v0::block_commit_delay
127127
- tests::signer::v0::continue_after_fast_block_no_sortition
128128
- tests::signer::v0::block_validation_response_timeout
129+
- tests::signer::v0::tenure_extend_after_bad_commit
129130
- tests::nakamoto_integrations::burn_ops_integration_test
130131
- tests::nakamoto_integrations::check_block_heights
131132
- tests::nakamoto_integrations::clarity_burn_state

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
1414
- Add `block_commit_delay_ms` to the config file to control the time to wait after seeing a new burn block, before submitting a block commit, to allow time for the first Nakamoto block of the new tenure to be mined, allowing this miner to avoid the need to RBF the block commit.
1515
- Add `tenure_cost_limit_per_block_percentage` to the miner config file to control the percentage remaining tenure cost limit to consume per nakamoto block.
1616
- Add `/v3/blocks/height/:block_height` rpc endpoint
17+
- If the winning miner of a sortition is committed to the wrong parent tenure, the previous miner can immediately tenure extend and continue mining since the winning miner would never be able to propose a valid block. (#5361)
1718

1819
## [3.0.0.0.1]
1920

stacks-signer/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
1111

1212
### Changed
1313

14+
- Allow a miner to extend their tenure immediately if the winner of the next tenure has committed to the wrong parent tenure (#5361)
15+
1416
## [3.0.0.0.1.0]
1517

1618
### Changed

stacks-signer/src/chainstate.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,40 @@ impl SortitionsView {
203203
"current_sortition_consensus_hash" => ?self.cur_sortition.consensus_hash,
204204
);
205205
self.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedBeforeFirstBlock;
206+
} else if let Some(tip) = signer_db.get_canonical_tip()? {
207+
// Check if the current sortition is aligned with the expected tenure:
208+
// - If the tip is in the current tenure, we are in the process of mining this tenure.
209+
// - If the tip is not in the current tenure, then we’re starting a new tenure,
210+
// and the current sortition's parent tenure must match the tenure of the tip.
211+
// - If the tip is not building off of the current sortition's parent tenure, then
212+
// check to see if the tip's parent is within the first proposal burn block timeout,
213+
// which allows for forks when a burn block arrives quickly.
214+
// - Else the miner of the current sortition has committed to an incorrect parent tenure.
215+
let consensus_hash_match =
216+
self.cur_sortition.consensus_hash == tip.block.header.consensus_hash;
217+
let parent_tenure_id_match =
218+
self.cur_sortition.parent_tenure_id == tip.block.header.consensus_hash;
219+
if !consensus_hash_match && !parent_tenure_id_match {
220+
// More expensive check, so do it only if we need to.
221+
let is_valid_parent_tenure = Self::check_parent_tenure_choice(
222+
&self.cur_sortition,
223+
block,
224+
signer_db,
225+
client,
226+
&self.config.first_proposal_burn_block_timing,
227+
)?;
228+
if !is_valid_parent_tenure {
229+
warn!(
230+
"Current sortition does not build off of canonical tip tenure, marking as invalid";
231+
"current_sortition_parent" => ?self.cur_sortition.parent_tenure_id,
232+
"tip_consensus_hash" => ?tip.block.header.consensus_hash,
233+
);
234+
self.cur_sortition.miner_status =
235+
SortitionMinerStatus::InvalidatedBeforeFirstBlock;
236+
}
237+
}
206238
}
239+
207240
if let Some(last_sortition) = self.last_sortition.as_mut() {
208241
if last_sortition.is_timed_out(self.config.block_proposal_timeout, signer_db)? {
209242
info!(
@@ -304,6 +337,7 @@ impl SortitionsView {
304337
"Miner block proposal is from last sortition winner, when the new sortition winner is still valid. Considering proposal invalid.";
305338
"proposed_block_consensus_hash" => %block.header.consensus_hash,
306339
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
340+
"current_sortition_miner_status" => ?self.cur_sortition.miner_status,
307341
);
308342
return Ok(false);
309343
}
@@ -439,6 +473,8 @@ impl SortitionsView {
439473
"violating_tenure_proposed_time" => local_block_info.proposed_time,
440474
"new_tenure_received_time" => sortition_state_received_time,
441475
"new_tenure_burn_timestamp" => sortition_state.burn_header_timestamp,
476+
"first_proposal_burn_block_timing_secs" => first_proposal_burn_block_timing.as_secs(),
477+
"proposal_to_sortition" => proposal_to_sortition,
442478
);
443479
continue;
444480
}

stacks-signer/src/signerdb.rs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ static CREATE_INDEXES_3: &str = r#"
324324
CREATE INDEX IF NOT EXISTS block_rejection_signer_addrs_on_block_signature_hash ON block_rejection_signer_addrs(signer_signature_hash);
325325
"#;
326326

327+
static CREATE_INDEXES_4: &str = r#"
328+
CREATE INDEX IF NOT EXISTS blocks_state ON blocks ((json_extract(block_info, '$.state')));
329+
CREATE INDEX IF NOT EXISTS blocks_signed_group ON blocks ((json_extract(block_info, '$.signed_group')));
330+
"#;
331+
327332
static CREATE_SIGNER_STATE_TABLE: &str = "
328333
CREATE TABLE IF NOT EXISTS signer_states (
329334
reward_cycle INTEGER PRIMARY KEY,
@@ -421,9 +426,14 @@ static SCHEMA_3: &[&str] = &[
421426
"INSERT INTO db_config (version) VALUES (3);",
422427
];
423428

429+
static SCHEMA_4: &[&str] = &[
430+
CREATE_INDEXES_4,
431+
"INSERT OR REPLACE INTO db_config (version) VALUES (4);",
432+
];
433+
424434
impl SignerDb {
425435
/// The current schema version used in this build of the signer binary.
426-
pub const SCHEMA_VERSION: u32 = 3;
436+
pub const SCHEMA_VERSION: u32 = 4;
427437

428438
/// Create a new `SignerState` instance.
429439
/// This will create a new SQLite database at the given path
@@ -443,7 +453,7 @@ impl SignerDb {
443453
return Ok(0);
444454
}
445455
let result = conn
446-
.query_row("SELECT version FROM db_config LIMIT 1", [], |row| {
456+
.query_row("SELECT MAX(version) FROM db_config LIMIT 1", [], |row| {
447457
row.get(0)
448458
})
449459
.optional();
@@ -495,6 +505,20 @@ impl SignerDb {
495505
Ok(())
496506
}
497507

508+
/// Migrate from schema 3 to schema 4
509+
fn schema_4_migration(tx: &Transaction) -> Result<(), DBError> {
510+
if Self::get_schema_version(tx)? >= 4 {
511+
// no migration necessary
512+
return Ok(());
513+
}
514+
515+
for statement in SCHEMA_4.iter() {
516+
tx.execute_batch(statement)?;
517+
}
518+
519+
Ok(())
520+
}
521+
498522
/// Either instantiate a new database, or migrate an existing one
499523
/// If the detected version of the existing database is 0 (i.e., a pre-migration
500524
/// logic DB, the DB will be dropped).
@@ -506,7 +530,8 @@ impl SignerDb {
506530
0 => Self::schema_1_migration(&sql_tx)?,
507531
1 => Self::schema_2_migration(&sql_tx)?,
508532
2 => Self::schema_3_migration(&sql_tx)?,
509-
3 => break,
533+
3 => Self::schema_4_migration(&sql_tx)?,
534+
4 => break,
510535
x => return Err(DBError::Other(format!(
511536
"Database schema is newer than supported by this binary. Expected version = {}, Database version = {x}",
512537
Self::SCHEMA_VERSION,
@@ -616,6 +641,15 @@ impl SignerDb {
616641
try_deserialize(result)
617642
}
618643

644+
/// Return the canonical tip -- the last globally accepted block.
645+
pub fn get_canonical_tip(&self) -> Result<Option<BlockInfo>, DBError> {
646+
let query = "SELECT block_info FROM blocks WHERE json_extract(block_info, '$.state') = ?1 ORDER BY stacks_height DESC, json_extract(block_info, '$.signed_group') DESC LIMIT 1";
647+
let args = params![&BlockState::GloballyAccepted.to_string()];
648+
let result: Option<String> = query_row(&self.db, query, args)?;
649+
650+
try_deserialize(result)
651+
}
652+
619653
/// Insert or replace a burn block into the database
620654
pub fn insert_burn_block(
621655
&mut self,
@@ -1242,4 +1276,45 @@ mod tests {
12421276
assert!(!block.check_state(BlockState::GloballyAccepted));
12431277
assert!(block.check_state(BlockState::GloballyRejected));
12441278
}
1279+
1280+
#[test]
1281+
fn test_get_canonical_tip() {
1282+
let db_path = tmp_db_path();
1283+
let mut db = SignerDb::new(db_path).expect("Failed to create signer db");
1284+
1285+
let (mut block_info_1, _block_proposal_1) = create_block_override(|b| {
1286+
b.block.header.miner_signature = MessageSignature([0x01; 65]);
1287+
b.block.header.chain_length = 1;
1288+
b.burn_height = 1;
1289+
});
1290+
1291+
let (mut block_info_2, _block_proposal_2) = create_block_override(|b| {
1292+
b.block.header.miner_signature = MessageSignature([0x02; 65]);
1293+
b.block.header.chain_length = 2;
1294+
b.burn_height = 2;
1295+
});
1296+
1297+
db.insert_block(&block_info_1)
1298+
.expect("Unable to insert block into db");
1299+
db.insert_block(&block_info_2)
1300+
.expect("Unable to insert block into db");
1301+
1302+
assert!(db.get_canonical_tip().unwrap().is_none());
1303+
1304+
block_info_1
1305+
.mark_globally_accepted()
1306+
.expect("Failed to mark block as globally accepted");
1307+
db.insert_block(&block_info_1)
1308+
.expect("Unable to insert block into db");
1309+
1310+
assert_eq!(db.get_canonical_tip().unwrap().unwrap(), block_info_1);
1311+
1312+
block_info_2
1313+
.mark_globally_accepted()
1314+
.expect("Failed to mark block as globally accepted");
1315+
db.insert_block(&block_info_2)
1316+
.expect("Unable to insert block into db");
1317+
1318+
assert_eq!(db.get_canonical_tip().unwrap().unwrap(), block_info_2);
1319+
}
12451320
}

stackslib/src/chainstate/burn/db/sortdb.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3690,6 +3690,12 @@ impl SortitionDB {
36903690
.try_into()
36913691
.ok()
36923692
}
3693+
3694+
/// Get the Stacks block ID for the canonical tip.
3695+
pub fn get_canonical_stacks_tip_block_id(&self) -> StacksBlockId {
3696+
let (ch, bh) = SortitionDB::get_canonical_stacks_chain_tip_hash(self.conn()).unwrap();
3697+
StacksBlockId::new(&ch, &bh)
3698+
}
36933699
}
36943700

36953701
impl<'a> SortitionDBTx<'a> {

testnet/stacks-node/src/event_dispatcher.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::collections::hash_map::Entry;
1818
use std::collections::{HashMap, HashSet};
1919
use std::path::PathBuf;
2020
use std::sync::mpsc::{channel, Receiver, Sender};
21-
use std::sync::Mutex;
21+
use std::sync::{Arc, Mutex};
2222
use std::thread::sleep;
2323
use std::time::Duration;
2424

@@ -107,17 +107,8 @@ pub const PATH_BLOCK_PROCESSED: &str = "new_block";
107107
pub const PATH_ATTACHMENT_PROCESSED: &str = "attachments/new";
108108
pub const PATH_PROPOSAL_RESPONSE: &str = "proposal_response";
109109

110-
pub static STACKER_DB_CHANNEL: StackerDBChannel = StackerDBChannel::new();
111-
112110
/// This struct receives StackerDB event callbacks without registering
113-
/// over the JSON/RPC interface. To ensure that any event observer
114-
/// uses the same channel, we use a lazy_static global for the channel (this
115-
/// implements a singleton using STACKER_DB_CHANNEL).
116-
///
117-
/// This is in place because a Nakamoto miner needs to receive
118-
/// StackerDB events. It could either poll the database (seems like a
119-
/// bad idea) or listen for events. Registering for RPC callbacks
120-
/// seems bad. So instead, it uses a singleton sync channel.
111+
/// over the JSON/RPC interface.
121112
pub struct StackerDBChannel {
122113
sender_info: Mutex<Option<InnerStackerDBChannel>>,
123114
}
@@ -923,6 +914,8 @@ pub struct EventDispatcher {
923914
/// Index into `registered_observers` that will receive block proposal events (Nakamoto and
924915
/// later)
925916
block_proposal_observers_lookup: HashSet<u16>,
917+
/// Channel for sending StackerDB events to the miner coordinator
918+
pub stackerdb_channel: Arc<Mutex<StackerDBChannel>>,
926919
}
927920

928921
/// This struct is used specifically for receiving proposal responses.
@@ -1115,6 +1108,7 @@ impl Default for EventDispatcher {
11151108
impl EventDispatcher {
11161109
pub fn new() -> EventDispatcher {
11171110
EventDispatcher {
1111+
stackerdb_channel: Arc::new(Mutex::new(StackerDBChannel::new())),
11181112
registered_observers: vec![],
11191113
contract_events_observers_lookup: HashMap::new(),
11201114
assets_observers_lookup: HashMap::new(),
@@ -1544,7 +1538,11 @@ impl EventDispatcher {
15441538

15451539
let interested_observers = self.filter_observers(&self.stackerdb_observers_lookup, false);
15461540

1547-
let interested_receiver = STACKER_DB_CHANNEL.is_active(&contract_id);
1541+
let stackerdb_channel = self
1542+
.stackerdb_channel
1543+
.lock()
1544+
.expect("FATAL: failed to lock StackerDB channel mutex");
1545+
let interested_receiver = stackerdb_channel.is_active(&contract_id);
15481546
if interested_observers.is_empty() && interested_receiver.is_none() {
15491547
return;
15501548
}

testnet/stacks-node/src/nakamoto_node.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ use stacks::monitoring::update_active_miners_count_gauge;
2828
use stacks::net::atlas::AtlasConfig;
2929
use stacks::net::relay::Relayer;
3030
use stacks::net::stackerdb::StackerDBs;
31+
use stacks::net::Error as NetError;
32+
use stacks::util_lib::db::Error as DBError;
3133
use stacks_common::types::chainstate::SortitionId;
3234
use stacks_common::types::StacksEpochId;
3335

@@ -71,46 +73,71 @@ pub struct StacksNode {
7173
}
7274

7375
/// Types of errors that can arise during Nakamoto StacksNode operation
74-
#[derive(Debug)]
76+
#[derive(thiserror::Error, Debug)]
7577
pub enum Error {
7678
/// Can't find the block sortition snapshot for the chain tip
79+
#[error("Can't find the block sortition snapshot for the chain tip")]
7780
SnapshotNotFoundForChainTip,
7881
/// The burnchain tip changed while this operation was in progress
82+
#[error("The burnchain tip changed while this operation was in progress")]
7983
BurnchainTipChanged,
8084
/// The Stacks tip changed while this operation was in progress
85+
#[error("The Stacks tip changed while this operation was in progress")]
8186
StacksTipChanged,
8287
/// Signers rejected a block
88+
#[error("Signers rejected a block")]
8389
SignersRejected,
8490
/// Error while spawning a subordinate thread
91+
#[error("Error while spawning a subordinate thread: {0}")]
8592
SpawnError(std::io::Error),
8693
/// Injected testing errors
94+
#[error("Injected testing errors")]
8795
FaultInjection,
8896
/// This miner was elected, but another sortition occurred before mining started
97+
#[error("This miner was elected, but another sortition occurred before mining started")]
8998
MissedMiningOpportunity,
9099
/// Attempted to mine while there was no active VRF key
100+
#[error("Attempted to mine while there was no active VRF key")]
91101
NoVRFKeyActive,
92102
/// The parent block or tenure could not be found
103+
#[error("The parent block or tenure could not be found")]
93104
ParentNotFound,
94105
/// Something unexpected happened (e.g., hash mismatches)
106+
#[error("Something unexpected happened (e.g., hash mismatches)")]
95107
UnexpectedChainState,
96108
/// A burnchain operation failed when submitting it to the burnchain
109+
#[error("A burnchain operation failed when submitting it to the burnchain: {0}")]
97110
BurnchainSubmissionFailed(BurnchainsError),
98111
/// A new parent has been discovered since mining started
112+
#[error("A new parent has been discovered since mining started")]
99113
NewParentDiscovered,
100114
/// A failure occurred while constructing a VRF Proof
115+
#[error("A failure occurred while constructing a VRF Proof")]
101116
BadVrfConstruction,
102-
CannotSelfSign,
103-
MiningFailure(ChainstateError),
117+
#[error("A failure occurred while mining: {0}")]
118+
MiningFailure(#[from] ChainstateError),
104119
/// The miner didn't accept their own block
120+
#[error("The miner didn't accept their own block: {0}")]
105121
AcceptFailure(ChainstateError),
122+
#[error("A failure occurred while signing a miner's block: {0}")]
106123
MinerSignatureError(&'static str),
124+
#[error("A failure occurred while signing a signer's block: {0}")]
107125
SignerSignatureError(String),
108126
/// A failure occurred while configuring the miner thread
127+
#[error("A failure occurred while configuring the miner thread: {0}")]
109128
MinerConfigurationFailed(&'static str),
110129
/// An error occurred while operating as the signing coordinator
130+
#[error("An error occurred while operating as the signing coordinator: {0}")]
111131
SigningCoordinatorFailure(String),
112132
// The thread that we tried to send to has closed
133+
#[error("The thread that we tried to send to has closed")]
113134
ChannelClosed,
135+
/// DBError wrapper
136+
#[error("DBError: {0}")]
137+
DBError(#[from] DBError),
138+
/// NetError wrapper
139+
#[error("NetError: {0}")]
140+
NetError(#[from] NetError),
114141
}
115142

116143
impl StacksNode {

0 commit comments

Comments
 (0)