Skip to content

Commit e70fe0a

Browse files
authored
Merge pull request #4956 from stacks-network/feat/signer-block-timing
Nakamoto: Allow miner to reorg in poorly timed tenures
2 parents 34325ed + 8614c20 commit e70fe0a

File tree

18 files changed

+897
-73
lines changed

18 files changed

+897
-73
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ jobs:
8787
- tests::signer::v0::miner_gather_signatures
8888
- tests::signer::v0::mine_2_nakamoto_reward_cycles
8989
- tests::signer::v0::end_of_tenure
90+
- tests::signer::v0::forked_tenure_okay
91+
- tests::signer::v0::forked_tenure_invalid
9092
- tests::nakamoto_integrations::stack_stx_burn_op_integration_test
9193
- tests::nakamoto_integrations::check_block_heights
9294
- tests::nakamoto_integrations::clarity_burn_state

libsigner/src/events.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use std::net::{SocketAddr, TcpListener, TcpStream};
2020
use std::sync::atomic::{AtomicBool, Ordering};
2121
use std::sync::mpsc::Sender;
2222
use std::sync::Arc;
23+
use std::time::SystemTime;
2324

2425
use blockstack_lib::chainstate::nakamoto::NakamotoBlock;
2526
use blockstack_lib::chainstate::stacks::boot::{MINERS_NAME, SIGNERS_NAME};
@@ -111,7 +112,14 @@ pub enum SignerEvent<T: SignerEventTrait> {
111112
/// Status endpoint request
112113
StatusCheck,
113114
/// A new burn block event was received with the given burnchain block height
114-
NewBurnBlock(u64),
115+
NewBurnBlock {
116+
/// the burn height for the newly processed burn block
117+
burn_height: u64,
118+
/// the burn hash for the newly processed burn block
119+
burn_header_hash: BurnchainHeaderHash,
120+
/// the time at which this event was received by the signer's event processor
121+
received_time: SystemTime,
122+
},
115123
}
116124

117125
/// Trait to implement a stop-signaler for the event receiver thread.
@@ -516,7 +524,19 @@ fn process_new_burn_block_event<T: SignerEventTrait>(
516524
}
517525
let temp: TempBurnBlockEvent = serde_json::from_slice(body.as_bytes())
518526
.map_err(|e| EventError::Deserialize(format!("Could not decode body to JSON: {:?}", &e)))?;
519-
let event = SignerEvent::NewBurnBlock(temp.burn_block_height);
527+
let burn_header_hash = temp
528+
.burn_block_hash
529+
.get(2..)
530+
.ok_or_else(|| EventError::Deserialize("Hex string should be 0x prefixed".into()))
531+
.and_then(|hex| {
532+
BurnchainHeaderHash::from_hex(hex)
533+
.map_err(|e| EventError::Deserialize(format!("Invalid hex string: {e}")))
534+
})?;
535+
let event = SignerEvent::NewBurnBlock {
536+
burn_height: temp.burn_block_height,
537+
received_time: SystemTime::now(),
538+
burn_header_hash,
539+
};
520540
if let Err(e) = request.respond(HttpResponse::empty(200u16)) {
521541
error!("Failed to respond to request: {:?}", &e);
522542
}

stacks-signer/src/chainstate.rs

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,33 @@
1313
// You should have received a copy of the GNU General Public License
1414
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1515

16+
use std::time::Duration;
17+
1618
use blockstack_lib::chainstate::nakamoto::NakamotoBlock;
1719
use blockstack_lib::chainstate::stacks::TenureChangePayload;
1820
use blockstack_lib::net::api::getsortition::SortitionInfo;
21+
use blockstack_lib::util_lib::db::Error as DBError;
22+
use clarity::types::chainstate::BurnchainHeaderHash;
1923
use slog::{slog_info, slog_warn};
2024
use stacks_common::types::chainstate::{ConsensusHash, StacksPublicKey};
2125
use stacks_common::util::hash::Hash160;
2226
use stacks_common::{info, warn};
2327

2428
use crate::client::{ClientError, StacksClient};
29+
use crate::config::SignerConfig;
2530
use crate::signerdb::SignerDb;
2631

32+
#[derive(thiserror::Error, Debug)]
33+
/// Error type for the signer chainstate module
34+
pub enum SignerChainstateError {
35+
/// Error resulting from database interactions
36+
#[error("Database error: {0}")]
37+
DBError(#[from] DBError),
38+
/// Error resulting from crate::client interactions
39+
#[error("Client error: {0}")]
40+
ClientError(#[from] ClientError),
41+
}
42+
2743
/// Captures this signer's current view of a sortition's miner.
2844
#[derive(PartialEq, Eq, Debug)]
2945
pub enum SortitionMinerStatus {
@@ -56,6 +72,26 @@ pub struct SortitionState {
5672
pub consensus_hash: ConsensusHash,
5773
/// what is this signer's view of the this sortition's miner? did they misbehave?
5874
pub miner_status: SortitionMinerStatus,
75+
/// the timestamp in the burn block that performed this sortition
76+
pub burn_header_timestamp: u64,
77+
/// the burn header hash of the burn block that performed this sortition
78+
pub burn_block_hash: BurnchainHeaderHash,
79+
}
80+
81+
/// Captures the configuration settings used by the signer when evaluating block proposals.
82+
#[derive(Debug, Clone)]
83+
pub struct ProposalEvalConfig {
84+
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
85+
/// before a subsequent miner isn't allowed to reorg the tenure
86+
pub first_proposal_burn_block_timing: Duration,
87+
}
88+
89+
impl From<&SignerConfig> for ProposalEvalConfig {
90+
fn from(value: &SignerConfig) -> Self {
91+
Self {
92+
first_proposal_burn_block_timing: value.first_proposal_burn_block_timing.clone(),
93+
}
94+
}
5995
}
6096

6197
/// The signer's current view of the stacks chain's sortition
@@ -68,6 +104,8 @@ pub struct SortitionsView {
68104
pub cur_sortition: SortitionState,
69105
/// the hash at which the sortitions view was fetched
70106
pub latest_consensus_hash: ConsensusHash,
107+
/// configuration settings for evaluating proposals
108+
pub config: ProposalEvalConfig,
71109
}
72110

73111
impl TryFrom<SortitionInfo> for SortitionState {
@@ -85,6 +123,8 @@ impl TryFrom<SortitionInfo> for SortitionState {
85123
parent_tenure_id: value
86124
.stacks_parent_ch
87125
.ok_or_else(|| ClientError::UnexpectedSortitionInfo)?,
126+
burn_header_timestamp: value.burn_header_timestamp,
127+
burn_block_hash: value.burn_block_hash,
88128
miner_status: SortitionMinerStatus::Valid,
89129
})
90130
}
@@ -112,7 +152,7 @@ impl SortitionsView {
112152
signer_db: &SignerDb,
113153
block: &NakamotoBlock,
114154
block_pk: &StacksPublicKey,
115-
) -> Result<bool, ClientError> {
155+
) -> Result<bool, SignerChainstateError> {
116156
let bitvec_all_1s = block.header.pox_treatment.iter().all(|entry| entry);
117157
if !bitvec_all_1s {
118158
warn!(
@@ -203,8 +243,13 @@ impl SortitionsView {
203243
return Ok(false);
204244
}
205245
// now, we have to check if the parent tenure was a valid choice.
206-
let is_valid_parent_tenure =
207-
Self::check_parent_tenure_choice(proposed_by.state(), block, client)?;
246+
let is_valid_parent_tenure = Self::check_parent_tenure_choice(
247+
proposed_by.state(),
248+
block,
249+
signer_db,
250+
client,
251+
&self.config.first_proposal_burn_block_timing,
252+
)?;
208253
if !is_valid_parent_tenure {
209254
return Ok(false);
210255
}
@@ -251,8 +296,10 @@ impl SortitionsView {
251296
fn check_parent_tenure_choice(
252297
sortition_state: &SortitionState,
253298
block: &NakamotoBlock,
299+
signer_db: &SignerDb,
254300
client: &StacksClient,
255-
) -> Result<bool, ClientError> {
301+
first_proposal_burn_block_timing: &Duration,
302+
) -> Result<bool, SignerChainstateError> {
256303
// if the parent tenure is the last sortition, it is a valid choice.
257304
// if the parent tenure is a reorg, then all of the reorged sortitions
258305
// must either have produced zero blocks _or_ produced their first block
@@ -264,6 +311,9 @@ impl SortitionsView {
264311
"Most recent miner's tenure does not build off the prior sortition, checking if this is valid behavior";
265312
"proposed_block_consensus_hash" => %block.header.consensus_hash,
266313
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
314+
"sortition_state.consensus_hash" => %sortition_state.consensus_hash,
315+
"sortition_state.prior_sortition" => %sortition_state.prior_sortition,
316+
"sortition_state.parent_tenure_id" => %sortition_state.parent_tenure_id,
267317
);
268318

269319
let tenures_reorged = client.get_tenure_forking_info(
@@ -277,9 +327,67 @@ impl SortitionsView {
277327
);
278328
return Ok(false);
279329
}
330+
331+
// this value *should* always be some, but try to do the best we can if it isn't
332+
let sortition_state_received_time =
333+
signer_db.get_burn_block_receive_time(&sortition_state.burn_block_hash)?;
334+
280335
for tenure in tenures_reorged.iter() {
336+
if tenure.consensus_hash == sortition_state.parent_tenure_id {
337+
// this was a built-upon tenure, no need to check this tenure as part of the reorg.
338+
continue;
339+
}
340+
281341
if tenure.first_block_mined.is_some() {
282-
// TODO: must check if the first block was poorly timed.
342+
let Some(local_block_info) =
343+
signer_db.get_first_signed_block_in_tenure(&tenure.consensus_hash)?
344+
else {
345+
warn!(
346+
"Miner is not building off of most recent tenure, but a tenure they attempted to reorg has already mined blocks, and there is no local knowledge for that tenure's block timing.";
347+
"proposed_block_consensus_hash" => %block.header.consensus_hash,
348+
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
349+
"parent_tenure" => %sortition_state.parent_tenure_id,
350+
"last_sortition" => %sortition_state.prior_sortition,
351+
"violating_tenure_id" => %tenure.consensus_hash,
352+
"violating_tenure_first_block_id" => ?tenure.first_block_mined,
353+
);
354+
return Ok(false);
355+
};
356+
357+
let checked_proposal_timing = if let Some(sortition_state_received_time) =
358+
sortition_state_received_time
359+
{
360+
// how long was there between when the proposal was received and the next sortition started?
361+
let proposal_to_sortition = if let Some(signed_at) =
362+
local_block_info.signed_self
363+
{
364+
sortition_state_received_time.saturating_sub(signed_at)
365+
} else {
366+
info!("We did not sign over the reorged tenure's first block, considering it as a late-arriving proposal");
367+
0
368+
};
369+
if Duration::from_secs(proposal_to_sortition)
370+
<= *first_proposal_burn_block_timing
371+
{
372+
info!(
373+
"Miner is not building off of most recent tenure. A tenure they reorg has already mined blocks, but the block was poorly timed, allowing the reorg.";
374+
"proposed_block_consensus_hash" => %block.header.consensus_hash,
375+
"proposed_block_signer_sighash" => %block.header.signer_signature_hash(),
376+
"parent_tenure" => %sortition_state.parent_tenure_id,
377+
"last_sortition" => %sortition_state.prior_sortition,
378+
"violating_tenure_id" => %tenure.consensus_hash,
379+
"violating_tenure_first_block_id" => ?tenure.first_block_mined,
380+
"violating_tenure_proposed_time" => local_block_info.proposed_time,
381+
"new_tenure_received_time" => sortition_state_received_time,
382+
"new_tenure_burn_timestamp" => sortition_state.burn_header_timestamp,
383+
);
384+
continue;
385+
}
386+
true
387+
} else {
388+
false
389+
};
390+
283391
warn!(
284392
"Miner is not building off of most recent tenure, but a tenure they attempted to reorg has already mined blocks.";
285393
"proposed_block_consensus_hash" => %block.header.consensus_hash,
@@ -288,6 +396,7 @@ impl SortitionsView {
288396
"last_sortition" => %sortition_state.prior_sortition,
289397
"violating_tenure_id" => %tenure.consensus_hash,
290398
"violating_tenure_first_block_id" => ?tenure.first_block_mined,
399+
"checked_proposal_timing" => checked_proposal_timing,
291400
);
292401
return Ok(false);
293402
}
@@ -346,7 +455,10 @@ impl SortitionsView {
346455
}
347456

348457
/// Fetch a new view of the recent sortitions
349-
pub fn fetch_view(client: &StacksClient) -> Result<Self, ClientError> {
458+
pub fn fetch_view(
459+
config: ProposalEvalConfig,
460+
client: &StacksClient,
461+
) -> Result<Self, ClientError> {
350462
let latest_state = client.get_latest_sortition()?;
351463
let latest_ch = latest_state.consensus_hash;
352464

@@ -383,6 +495,7 @@ impl SortitionsView {
383495
cur_sortition,
384496
last_sortition,
385497
latest_consensus_hash,
498+
config,
386499
})
387500
}
388501
}

stacks-signer/src/client/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ pub(crate) mod tests {
565565
tx_fee_ustx: config.tx_fee_ustx,
566566
max_tx_fee_ustx: config.max_tx_fee_ustx,
567567
db_path: config.db_path.clone(),
568+
first_proposal_burn_block_timing: Duration::from_secs(30),
568569
}
569570
}
570571

stacks-signer/src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ pub struct SignerConfig {
151151
pub max_tx_fee_ustx: Option<u64>,
152152
/// The path to the signer's database file
153153
pub db_path: PathBuf,
154+
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
155+
/// before a subsequent miner isn't allowed to reorg the tenure
156+
pub first_proposal_burn_block_timing: Duration,
154157
}
155158

156159
/// The parsed configuration for the signer
@@ -190,6 +193,9 @@ pub struct GlobalConfig {
190193
pub db_path: PathBuf,
191194
/// Metrics endpoint
192195
pub metrics_endpoint: Option<SocketAddr>,
196+
/// How much time between the first block proposal in a tenure and the next bitcoin block
197+
/// must pass before a subsequent miner isn't allowed to reorg the tenure
198+
pub first_proposal_burn_block_timing: Duration,
193199
}
194200

195201
/// Internal struct for loading up the config file
@@ -227,6 +233,9 @@ struct RawConfigFile {
227233
pub db_path: String,
228234
/// Metrics endpoint
229235
pub metrics_endpoint: Option<String>,
236+
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
237+
/// before a subsequent miner isn't allowed to reorg the tenure
238+
pub first_proposal_burn_block_timing_secs: Option<u64>,
230239
}
231240

232241
impl RawConfigFile {
@@ -298,6 +307,8 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
298307
let dkg_private_timeout = raw_data.dkg_private_timeout_ms.map(Duration::from_millis);
299308
let nonce_timeout = raw_data.nonce_timeout_ms.map(Duration::from_millis);
300309
let sign_timeout = raw_data.sign_timeout_ms.map(Duration::from_millis);
310+
let first_proposal_burn_block_timing =
311+
Duration::from_secs(raw_data.first_proposal_burn_block_timing_secs.unwrap_or(30));
301312
let db_path = raw_data.db_path.into();
302313

303314
let metrics_endpoint = match raw_data.metrics_endpoint {
@@ -331,6 +342,7 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
331342
auth_password: raw_data.auth_password,
332343
db_path,
333344
metrics_endpoint,
345+
first_proposal_burn_block_timing,
334346
})
335347
}
336348
}

stacks-signer/src/runloop.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ impl<Signer: SignerTrait<T>, T: StacksMessageCodec + Clone + Send + Debug> RunLo
257257
key_ids,
258258
signer_entries,
259259
signer_slot_ids: signer_slot_ids.into_values().collect(),
260+
first_proposal_burn_block_timing: self.config.first_proposal_burn_block_timing,
260261
ecdsa_private_key: self.config.ecdsa_private_key,
261262
stacks_private_key: self.config.stacks_private_key,
262263
node_host: self.config.node_host.to_string(),
@@ -434,8 +435,8 @@ impl<Signer: SignerTrait<T>, T: StacksMessageCodec + Clone + Send + Debug>
434435
}
435436
return None;
436437
}
437-
} else if let Some(SignerEvent::NewBurnBlock(current_burn_block_height)) = event {
438-
if let Err(e) = self.refresh_runloop(current_burn_block_height) {
438+
} else if let Some(SignerEvent::NewBurnBlock { burn_height, .. }) = event {
439+
if let Err(e) = self.refresh_runloop(burn_height) {
439440
error!("Failed to refresh signer runloop: {e}.");
440441
warn!("Signer may have an outdated view of the network.");
441442
}

0 commit comments

Comments
 (0)