Skip to content

Commit 67e254e

Browse files
committed
Add config option tenure_last_block_proposal_timeout_secs and add a test that ensures reorgs can't happen before timeout
Signed-off-by: Jacinta Ferrant <[email protected]>
1 parent c773444 commit 67e254e

File tree

8 files changed

+323
-18
lines changed

8 files changed

+323
-18
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ jobs:
112112
- tests::signer::v0::locally_accepted_blocks_overriden_by_global_rejection
113113
- tests::signer::v0::locally_rejected_blocks_overriden_by_global_acceptance
114114
- tests::signer::v0::reorg_locally_accepted_blocks_across_tenures_succeeds
115+
- tests::signer::v0::reorg_locally_accepted_blocks_across_tenures_fails
115116
- tests::signer::v0::miner_recovers_when_broadcast_block_delay_across_tenures_occurs
116117
- tests::signer::v0::multiple_miners_with_nakamoto_blocks
117118
- tests::signer::v0::partial_tenure_fork

stacks-signer/src/chainstate.rs

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ use blockstack_lib::chainstate::stacks::TenureChangePayload;
2020
use blockstack_lib::net::api::getsortition::SortitionInfo;
2121
use blockstack_lib::util_lib::db::Error as DBError;
2222
use clarity::types::chainstate::BurnchainHeaderHash;
23+
use clarity::util::get_epoch_time_secs;
2324
use slog::{slog_info, slog_warn};
2425
use stacks_common::types::chainstate::{ConsensusHash, StacksPublicKey};
2526
use stacks_common::util::hash::Hash160;
2627
use stacks_common::{info, warn};
2728

2829
use crate::client::{ClientError, CurrentAndLastSortition, StacksClient};
2930
use crate::config::SignerConfig;
30-
use crate::signerdb::{BlockState, SignerDb};
31+
use crate::signerdb::{BlockInfo, BlockState, SignerDb};
3132

3233
#[derive(thiserror::Error, Debug)]
3334
/// Error type for the signer chainstate module
@@ -119,13 +120,17 @@ pub struct ProposalEvalConfig {
119120
pub first_proposal_burn_block_timing: Duration,
120121
/// Time between processing a sortition and proposing a block before the block is considered invalid
121122
pub block_proposal_timeout: Duration,
123+
/// Time to wait for the last block of a tenure to be globally accepted or rejected before considering
124+
/// a new miner's block at the same height as valid.
125+
pub tenure_last_block_proposal_timeout: Duration,
122126
}
123127

124128
impl From<&SignerConfig> for ProposalEvalConfig {
125129
fn from(value: &SignerConfig) -> Self {
126130
Self {
127131
first_proposal_burn_block_timing: value.first_proposal_burn_block_timing,
128132
block_proposal_timeout: value.block_proposal_timeout,
133+
tenure_last_block_proposal_timeout: value.tenure_last_block_proposal_timeout,
129134
}
130135
}
131136
}
@@ -460,7 +465,35 @@ impl SortitionsView {
460465
Ok(true)
461466
}
462467

463-
/// Check if the tenure change block confirms the expected parent block (i.e., the last globally accepted block in the parent tenure)
468+
/// Get the last block from the given tenure
469+
/// Returns the last locally accepted block if it is not timed out, otherwise it will return the last globally accepted block.
470+
fn get_tenure_last_block_info(
471+
consensus_hash: &ConsensusHash,
472+
signer_db: &SignerDb,
473+
tenure_last_block_proposal_timeout: Duration,
474+
) -> Result<Option<BlockInfo>, ClientError> {
475+
// Get the last known block in the previous tenure
476+
let last_locally_accepted_block = signer_db
477+
.get_last_accepted_block(consensus_hash)
478+
.map_err(|e| ClientError::InvalidResponse(e.to_string()))?;
479+
480+
if let Some(local_info) = last_locally_accepted_block {
481+
if let Some(signed_over_time) = local_info.signed_self {
482+
if signed_over_time + tenure_last_block_proposal_timeout.as_secs()
483+
> get_epoch_time_secs()
484+
{
485+
return Ok(Some(local_info));
486+
}
487+
}
488+
}
489+
490+
signer_db
491+
.get_last_globally_accepted_block(consensus_hash)
492+
.map_err(|e| ClientError::InvalidResponse(e.to_string()))
493+
}
494+
495+
/// Check if the tenure change block confirms the expected parent block
496+
/// (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)
464497
/// It checks the local DB first, and if the block is not present in the local DB, it asks the
465498
/// Stacks node for the highest processed block header in the given tenure (and then caches it
466499
/// in the DB).
@@ -473,24 +506,27 @@ impl SortitionsView {
473506
reward_cycle: u64,
474507
signer_db: &mut SignerDb,
475508
client: &StacksClient,
509+
tenure_last_block_proposal_timeout: Duration,
476510
) -> Result<bool, ClientError> {
477-
// 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.
478-
let last_globally_accepted_block = signer_db
479-
.get_last_globally_accepted_block(&tenure_change.prev_tenure_consensus_hash)
480-
.map_err(|e| ClientError::InvalidResponse(e.to_string()))?;
511+
// 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.
512+
let last_block_info = Self::get_tenure_last_block_info(
513+
&tenure_change.prev_tenure_consensus_hash,
514+
signer_db,
515+
tenure_last_block_proposal_timeout,
516+
)?;
481517

482-
if let Some(global_info) = last_globally_accepted_block {
518+
if let Some(info) = last_block_info {
483519
// N.B. this block might not be the last globally accepted block across the network;
484520
// it's just the highest one in this tenure that we know about. If this given block is
485521
// no higher than it, then it's definitely no higher than the last globally accepted
486522
// block across the network, so we can do an early rejection here.
487-
if block.header.chain_length <= global_info.block.header.chain_length {
523+
if block.header.chain_length <= info.block.header.chain_length {
488524
warn!(
489525
"Miner's block proposal does not confirm as many blocks as we expect";
490526
"proposed_block_consensus_hash" => %block.header.consensus_hash,
491527
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
492528
"proposed_chain_length" => block.header.chain_length,
493-
"expected_at_least" => global_info.block.header.chain_length + 1,
529+
"expected_at_least" => info.block.header.chain_length + 1,
494530
);
495531
return Ok(false);
496532
}
@@ -558,6 +594,7 @@ impl SortitionsView {
558594
reward_cycle,
559595
signer_db,
560596
client,
597+
self.config.tenure_last_block_proposal_timeout,
561598
)?;
562599
if !confirms_expected_parent {
563600
return Ok(false);
@@ -573,15 +610,15 @@ impl SortitionsView {
573610
if !is_valid_parent_tenure {
574611
return Ok(false);
575612
}
576-
let last_in_tenure = signer_db
613+
let last_in_current_tenure = signer_db
577614
.get_last_globally_accepted_block(&block.header.consensus_hash)
578615
.map_err(|e| ClientError::InvalidResponse(e.to_string()))?;
579-
if let Some(last_in_tenure) = last_in_tenure {
616+
if let Some(last_in_current_tenure) = last_in_current_tenure {
580617
warn!(
581618
"Miner block proposal contains a tenure change, but we've already signed a block in this tenure. Considering proposal invalid.";
582619
"proposed_block_consensus_hash" => %block.header.consensus_hash,
583620
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
584-
"last_in_tenure_signer_sighash" => %last_in_tenure.block.header.signer_signature_hash(),
621+
"last_in_tenure_signer_sighash" => %last_in_current_tenure.block.header.signer_signature_hash(),
585622
);
586623
return Ok(false);
587624
}

stacks-signer/src/client/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ pub(crate) mod tests {
411411
db_path: config.db_path.clone(),
412412
first_proposal_burn_block_timing: config.first_proposal_burn_block_timing,
413413
block_proposal_timeout: config.block_proposal_timeout,
414+
tenure_last_block_proposal_timeout: config.tenure_last_block_proposal_timeout,
414415
}
415416
}
416417

stacks-signer/src/config.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::client::SignerSlotID;
3636
const EVENT_TIMEOUT_MS: u64 = 5000;
3737
const BLOCK_PROPOSAL_TIMEOUT_MS: u64 = 600_000;
3838
const DEFAULT_FIRST_PROPOSAL_BURN_BLOCK_TIMING_SECS: u64 = 60;
39+
const DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS: u64 = 30;
3940

4041
#[derive(thiserror::Error, Debug)]
4142
/// An error occurred parsing the provided configuration
@@ -128,6 +129,9 @@ pub struct SignerConfig {
128129
pub first_proposal_burn_block_timing: Duration,
129130
/// How much time to wait for a miner to propose a block following a sortition
130131
pub block_proposal_timeout: Duration,
132+
/// Time to wait for the last block of a tenure to be globally accepted or rejected
133+
/// before considering a new miner's block at the same height as potentially valid.
134+
pub tenure_last_block_proposal_timeout: Duration,
131135
}
132136

133137
/// The parsed configuration for the signer
@@ -158,6 +162,9 @@ pub struct GlobalConfig {
158162
pub block_proposal_timeout: Duration,
159163
/// An optional custom Chain ID
160164
pub chain_id: Option<u32>,
165+
/// Time to wait for the last block of a tenure to be globally accepted or rejected
166+
/// before considering a new miner's block at the same height as potentially valid.
167+
pub tenure_last_block_proposal_timeout: Duration,
161168
}
162169

163170
/// Internal struct for loading up the config file
@@ -180,13 +187,16 @@ struct RawConfigFile {
180187
pub db_path: String,
181188
/// Metrics endpoint
182189
pub metrics_endpoint: Option<String>,
183-
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
190+
/// How much time must pass in seconds between the first block proposal in a tenure and the next bitcoin block
184191
/// before a subsequent miner isn't allowed to reorg the tenure
185192
pub first_proposal_burn_block_timing_secs: Option<u64>,
186193
/// How much time to wait for a miner to propose a block following a sortition in milliseconds
187194
pub block_proposal_timeout_ms: Option<u64>,
188195
/// An optional custom Chain ID
189196
pub chain_id: Option<u32>,
197+
/// Time in seconds to wait for the last block of a tenure to be globally accepted or rejected
198+
/// before considering a new miner's block at the same height as potentially valid.
199+
pub tenure_last_block_proposal_timeout_secs: Option<u64>,
190200
}
191201

192202
impl RawConfigFile {
@@ -266,6 +276,12 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
266276
.unwrap_or(BLOCK_PROPOSAL_TIMEOUT_MS),
267277
);
268278

279+
let tenure_last_block_proposal_timeout = Duration::from_secs(
280+
raw_data
281+
.tenure_last_block_proposal_timeout_secs
282+
.unwrap_or(DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS),
283+
);
284+
269285
Ok(Self {
270286
node_host: raw_data.node_host,
271287
endpoint,
@@ -279,6 +295,7 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
279295
first_proposal_burn_block_timing,
280296
block_proposal_timeout,
281297
chain_id: raw_data.chain_id,
298+
tenure_last_block_proposal_timeout,
282299
})
283300
}
284301
}

stacks-signer/src/runloop.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ impl<Signer: SignerTrait<T>, T: StacksMessageCodec + Clone + Send + Debug> RunLo
283283
mainnet: self.config.network.is_mainnet(),
284284
db_path: self.config.db_path.clone(),
285285
block_proposal_timeout: self.config.block_proposal_timeout,
286+
tenure_last_block_proposal_timeout: self.config.tenure_last_block_proposal_timeout,
286287
}))
287288
}
288289

stacks-signer/src/tests/chainstate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ fn setup_test_environment(
8989
config: ProposalEvalConfig {
9090
first_proposal_burn_block_timing: Duration::from_secs(30),
9191
block_proposal_timeout: Duration::from_secs(5),
92+
tenure_last_block_proposal_timeout: Duration::from_secs(30),
9293
},
9394
};
9495

testnet/stacks-node/src/tests/nakamoto_integrations.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6369,6 +6369,7 @@ fn signer_chainstate() {
63696369
let proposal_conf = ProposalEvalConfig {
63706370
first_proposal_burn_block_timing: Duration::from_secs(0),
63716371
block_proposal_timeout: Duration::from_secs(100),
6372+
tenure_last_block_proposal_timeout: Duration::from_secs(30),
63726373
};
63736374
let mut sortitions_view =
63746375
SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
@@ -6507,6 +6508,7 @@ fn signer_chainstate() {
65076508
let proposal_conf = ProposalEvalConfig {
65086509
first_proposal_burn_block_timing: Duration::from_secs(0),
65096510
block_proposal_timeout: Duration::from_secs(100),
6511+
tenure_last_block_proposal_timeout: Duration::from_secs(30),
65106512
};
65116513
let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn())
65126514
.unwrap()
@@ -6584,6 +6586,7 @@ fn signer_chainstate() {
65846586
let proposal_conf = ProposalEvalConfig {
65856587
first_proposal_burn_block_timing: Duration::from_secs(0),
65866588
block_proposal_timeout: Duration::from_secs(100),
6589+
tenure_last_block_proposal_timeout: Duration::from_secs(30),
65876590
};
65886591
let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
65896592
let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn())

0 commit comments

Comments
 (0)