Skip to content

Commit 74b0a38

Browse files
authored
Merge pull request #5003 from stacks-network/feature/timed-out-block-proposal-handling
[signer] time out an empty sortition and mark a miner as misbehaving if an initial block proposal fails to arrive in time
2 parents 409fb28 + f9ac9b2 commit 74b0a38

File tree

9 files changed

+350
-10
lines changed

9 files changed

+350
-10
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ jobs:
8989
- tests::signer::v0::end_of_tenure
9090
- tests::signer::v0::forked_tenure_okay
9191
- tests::signer::v0::forked_tenure_invalid
92+
- tests::signer::v0::empty_sortition
9293
- tests::signer::v0::bitcoind_forking_test
9394
- tests::nakamoto_integrations::stack_stx_burn_op_integration_test
9495
- tests::nakamoto_integrations::check_block_heights

stacks-signer/src/chainstate.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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;
16+
use std::time::{Duration, UNIX_EPOCH};
1717

1818
use blockstack_lib::chainstate::nakamoto::NakamotoBlock;
1919
use blockstack_lib::chainstate::stacks::TenureChangePayload;
@@ -78,18 +78,54 @@ pub struct SortitionState {
7878
pub burn_block_hash: BurnchainHeaderHash,
7979
}
8080

81+
impl SortitionState {
82+
/// Check if the sortition is timed out (i.e., the miner did not propose a block in time)
83+
pub fn is_timed_out(
84+
&self,
85+
timeout: Duration,
86+
signer_db: &SignerDb,
87+
) -> Result<bool, SignerChainstateError> {
88+
// if the miner has already been invalidated, we don't need to check if they've timed out.
89+
if self.miner_status != SortitionMinerStatus::Valid {
90+
return Ok(false);
91+
}
92+
// if we've already signed a block in this tenure, the miner can't have timed out.
93+
let has_blocks = signer_db
94+
.get_last_signed_block_in_tenure(&self.consensus_hash)?
95+
.is_some();
96+
if has_blocks {
97+
return Ok(false);
98+
}
99+
let Some(received_ts) = signer_db.get_burn_block_receive_time(&self.burn_block_hash)?
100+
else {
101+
return Ok(false);
102+
};
103+
let received_time = UNIX_EPOCH + Duration::from_secs(received_ts);
104+
let Ok(elapsed) = std::time::SystemTime::now().duration_since(received_time) else {
105+
return Ok(false);
106+
};
107+
if elapsed > timeout {
108+
return Ok(true);
109+
}
110+
Ok(false)
111+
}
112+
}
113+
81114
/// Captures the configuration settings used by the signer when evaluating block proposals.
82115
#[derive(Debug, Clone)]
83116
pub struct ProposalEvalConfig {
84117
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
85118
/// before a subsequent miner isn't allowed to reorg the tenure
86119
pub first_proposal_burn_block_timing: Duration,
120+
/// Time between processing a sortition and proposing a block before the block is considered invalid
121+
pub block_proposal_timeout: Duration,
87122
}
88123

89124
impl From<&SignerConfig> for ProposalEvalConfig {
90125
fn from(value: &SignerConfig) -> Self {
91126
Self {
92-
first_proposal_burn_block_timing: value.first_proposal_burn_block_timing.clone(),
127+
first_proposal_burn_block_timing: value.first_proposal_burn_block_timing,
128+
block_proposal_timeout: value.block_proposal_timeout,
93129
}
94130
}
95131
}
@@ -147,12 +183,23 @@ impl<'a> ProposedBy<'a> {
147183
impl SortitionsView {
148184
/// Apply checks from the SortitionsView on the block proposal.
149185
pub fn check_proposal(
150-
&self,
186+
&mut self,
151187
client: &StacksClient,
152188
signer_db: &SignerDb,
153189
block: &NakamotoBlock,
154190
block_pk: &StacksPublicKey,
155191
) -> Result<bool, SignerChainstateError> {
192+
if self
193+
.cur_sortition
194+
.is_timed_out(self.config.block_proposal_timeout, signer_db)?
195+
{
196+
self.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedBeforeFirstBlock;
197+
}
198+
if let Some(last_sortition) = self.last_sortition.as_mut() {
199+
if last_sortition.is_timed_out(self.config.block_proposal_timeout, signer_db)? {
200+
last_sortition.miner_status = SortitionMinerStatus::InvalidatedBeforeFirstBlock;
201+
}
202+
}
156203
let bitvec_all_1s = block.header.pox_treatment.iter().all(|entry| entry);
157204
if !bitvec_all_1s {
158205
warn!(

stacks-signer/src/client/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,8 @@ 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),
568+
first_proposal_burn_block_timing: config.first_proposal_burn_block_timing,
569+
block_proposal_timeout: config.block_proposal_timeout,
569570
}
570571
}
571572

stacks-signer/src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use wsts::curve::scalar::Scalar;
3636
use crate::client::SignerSlotID;
3737

3838
const EVENT_TIMEOUT_MS: u64 = 5000;
39+
const BLOCK_PROPOSAL_TIMEOUT_MS: u64 = 45_000;
3940
// Default transaction fee to use in microstacks (if unspecificed in the config file)
4041
const TX_FEE_USTX: u64 = 10_000;
4142

@@ -154,6 +155,8 @@ pub struct SignerConfig {
154155
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
155156
/// before a subsequent miner isn't allowed to reorg the tenure
156157
pub first_proposal_burn_block_timing: Duration,
158+
/// How much time to wait for a miner to propose a block following a sortition
159+
pub block_proposal_timeout: Duration,
157160
}
158161

159162
/// The parsed configuration for the signer
@@ -196,6 +199,8 @@ pub struct GlobalConfig {
196199
/// How much time between the first block proposal in a tenure and the next bitcoin block
197200
/// must pass before a subsequent miner isn't allowed to reorg the tenure
198201
pub first_proposal_burn_block_timing: Duration,
202+
/// How much time to wait for a miner to propose a block following a sortition
203+
pub block_proposal_timeout: Duration,
199204
}
200205

201206
/// Internal struct for loading up the config file
@@ -236,6 +241,8 @@ struct RawConfigFile {
236241
/// How much time must pass between the first block proposal in a tenure and the next bitcoin block
237242
/// before a subsequent miner isn't allowed to reorg the tenure
238243
pub first_proposal_burn_block_timing_secs: Option<u64>,
244+
/// How much time to wait for a miner to propose a block following a sortition in milliseconds
245+
pub block_proposal_timeout_ms: Option<u64>,
239246
}
240247

241248
impl RawConfigFile {
@@ -324,6 +331,12 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
324331
None => None,
325332
};
326333

334+
let block_proposal_timeout = Duration::from_millis(
335+
raw_data
336+
.block_proposal_timeout_ms
337+
.unwrap_or(BLOCK_PROPOSAL_TIMEOUT_MS),
338+
);
339+
327340
Ok(Self {
328341
node_host: raw_data.node_host,
329342
endpoint,
@@ -343,6 +356,7 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
343356
db_path,
344357
metrics_endpoint,
345358
first_proposal_burn_block_timing,
359+
block_proposal_timeout,
346360
})
347361
}
348362
}

stacks-signer/src/runloop.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ impl<Signer: SignerTrait<T>, T: StacksMessageCodec + Clone + Send + Debug> RunLo
270270
tx_fee_ustx: self.config.tx_fee_ustx,
271271
max_tx_fee_ustx: self.config.max_tx_fee_ustx,
272272
db_path: self.config.db_path.clone(),
273+
block_proposal_timeout: self.config.block_proposal_timeout,
273274
})
274275
}
275276

stacks-signer/src/signerdb.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ impl SignerDb {
339339
tenure: &ConsensusHash,
340340
) -> Result<Option<BlockInfo>, DBError> {
341341
let query = "SELECT block_info FROM blocks WHERE consensus_hash = ? AND signed_over = 1 ORDER BY stacks_height ASC LIMIT 1";
342-
let result: Option<String> = query_row(&self.db, query, &[tenure])?;
342+
let result: Option<String> = query_row(&self.db, query, [tenure])?;
343343

344344
try_deserialize(result)
345345
}

stacks-signer/src/tests/chainstate.rs

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ fn setup_test_environment(
8686
last_sortition,
8787
config: ProposalEvalConfig {
8888
first_proposal_burn_block_timing: Duration::from_secs(30),
89+
block_proposal_timeout: Duration::from_secs(5),
8990
},
9091
};
9192

@@ -110,7 +111,7 @@ fn setup_test_environment(
110111
parent_block_id: StacksBlockId([0; 32]),
111112
tx_merkle_root: Sha512Trunc256Sum([0; 32]),
112113
state_index_root: TrieHash([0; 32]),
113-
timestamp: 11,
114+
timestamp: 3,
114115
miner_signature: MessageSignature::empty(),
115116
signer_signature: vec![],
116117
pox_treatment: BitVec::ones(1).unwrap(),
@@ -139,7 +140,7 @@ fn check_proposal_units() {
139140

140141
#[test]
141142
fn check_proposal_miner_pkh_mismatch() {
142-
let (stacks_client, signer_db, _block_pk, view, mut block) =
143+
let (stacks_client, signer_db, _block_pk, mut view, mut block) =
143144
setup_test_environment("miner_pkh_mismatch");
144145
block.header.consensus_hash = view.cur_sortition.consensus_hash;
145146
let different_block_pk = StacksPublicKey::from_private(&StacksPrivateKey::from_seed(&[2, 3]));
@@ -327,7 +328,7 @@ fn make_tenure_change_tx(payload: TenureChangePayload) -> StacksTransaction {
327328

328329
#[test]
329330
fn check_proposal_tenure_extend_invalid_conditions() {
330-
let (stacks_client, signer_db, block_pk, view, mut block) =
331+
let (stacks_client, signer_db, block_pk, mut view, mut block) =
331332
setup_test_environment("tenure_extend");
332333
block.header.consensus_hash = view.cur_sortition.consensus_hash;
333334
let mut extend_payload = make_tenure_change_payload();
@@ -350,3 +351,116 @@ fn check_proposal_tenure_extend_invalid_conditions() {
350351
.check_proposal(&stacks_client, &signer_db, &block, &block_pk)
351352
.unwrap());
352353
}
354+
355+
#[test]
356+
fn check_block_proposal_timeout() {
357+
let (stacks_client, mut signer_db, block_pk, mut view, mut curr_sortition_block) =
358+
setup_test_environment("block_proposal_timeout");
359+
curr_sortition_block.header.consensus_hash = view.cur_sortition.consensus_hash;
360+
let mut last_sortition_block = curr_sortition_block.clone();
361+
last_sortition_block.header.consensus_hash =
362+
view.last_sortition.as_ref().unwrap().consensus_hash;
363+
364+
// Ensure we have a burn height to compare against
365+
let burn_hash = view.cur_sortition.burn_block_hash;
366+
let burn_height = 1;
367+
let received_time = SystemTime::now();
368+
signer_db
369+
.insert_burn_block(&burn_hash, burn_height, &received_time)
370+
.unwrap();
371+
372+
assert!(view
373+
.check_proposal(&stacks_client, &signer_db, &curr_sortition_block, &block_pk)
374+
.unwrap());
375+
376+
assert!(!view
377+
.check_proposal(&stacks_client, &signer_db, &last_sortition_block, &block_pk)
378+
.unwrap());
379+
380+
// Sleep a bit to time out the block proposal
381+
std::thread::sleep(Duration::from_secs(5));
382+
assert!(!view
383+
.check_proposal(&stacks_client, &signer_db, &curr_sortition_block, &block_pk)
384+
.unwrap());
385+
386+
assert!(view
387+
.check_proposal(&stacks_client, &signer_db, &last_sortition_block, &block_pk)
388+
.unwrap());
389+
}
390+
391+
#[test]
392+
fn check_sortition_timeout() {
393+
let signer_db_dir = "/tmp/stacks-node-tests/signer-units/";
394+
let signer_db_path = format!(
395+
"{signer_db_dir}/sortition_timeout.{}.sqlite",
396+
get_epoch_time_secs()
397+
);
398+
fs::create_dir_all(signer_db_dir).unwrap();
399+
let mut signer_db = SignerDb::new(signer_db_path).unwrap();
400+
401+
let mut sortition = SortitionState {
402+
miner_pkh: Hash160([0; 20]),
403+
miner_pubkey: None,
404+
prior_sortition: ConsensusHash([0; 20]),
405+
parent_tenure_id: ConsensusHash([0; 20]),
406+
consensus_hash: ConsensusHash([1; 20]),
407+
miner_status: SortitionMinerStatus::Valid,
408+
burn_header_timestamp: 2,
409+
burn_block_hash: BurnchainHeaderHash([1; 32]),
410+
};
411+
// Ensure we have a burn height to compare against
412+
let burn_hash = sortition.burn_block_hash;
413+
let burn_height = 1;
414+
let received_time = SystemTime::now();
415+
signer_db
416+
.insert_burn_block(&burn_hash, burn_height, &received_time)
417+
.unwrap();
418+
419+
std::thread::sleep(Duration::from_secs(1));
420+
// We have not yet timed out
421+
assert!(!sortition
422+
.is_timed_out(Duration::from_secs(10), &signer_db)
423+
.unwrap());
424+
// We are a valid sortition, have an empty tenure, and have now timed out
425+
assert!(sortition
426+
.is_timed_out(Duration::from_secs(1), &signer_db)
427+
.unwrap());
428+
// This will not be marked as timed out as the status is no longer valid
429+
sortition.miner_status = SortitionMinerStatus::InvalidatedAfterFirstBlock;
430+
assert!(!sortition
431+
.is_timed_out(Duration::from_secs(1), &signer_db)
432+
.unwrap());
433+
434+
// Revert the status to continue other checks
435+
sortition.miner_status = SortitionMinerStatus::Valid;
436+
// Insert a signed over block so its no longer an empty tenure
437+
let block_proposal = BlockProposal {
438+
block: NakamotoBlock {
439+
header: NakamotoBlockHeader {
440+
version: 1,
441+
chain_length: 10,
442+
burn_spent: 10,
443+
consensus_hash: sortition.consensus_hash,
444+
parent_block_id: StacksBlockId([0; 32]),
445+
tx_merkle_root: Sha512Trunc256Sum([0; 32]),
446+
state_index_root: TrieHash([0; 32]),
447+
timestamp: 11,
448+
miner_signature: MessageSignature::empty(),
449+
signer_signature: vec![],
450+
pox_treatment: BitVec::ones(1).unwrap(),
451+
},
452+
txs: vec![],
453+
},
454+
burn_height: 2,
455+
reward_cycle: 1,
456+
};
457+
458+
let mut block_info = BlockInfo::from(block_proposal);
459+
block_info.signed_over = true;
460+
signer_db.insert_block(&block_info).unwrap();
461+
462+
// This will no longer be timed out as we have a non-empty tenure
463+
assert!(!sortition
464+
.is_timed_out(Duration::from_secs(1), &signer_db)
465+
.unwrap());
466+
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4982,8 +4982,10 @@ fn signer_chainstate() {
49824982
// this config disallows any reorg due to poorly timed block commits
49834983
let proposal_conf = ProposalEvalConfig {
49844984
first_proposal_burn_block_timing: Duration::from_secs(0),
4985+
block_proposal_timeout: Duration::from_secs(100),
49854986
};
4986-
let sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
4987+
let mut sortitions_view =
4988+
SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
49874989

49884990
// check the prior tenure's proposals again, confirming that the sortitions_view
49894991
// will reject them.
@@ -5095,8 +5097,10 @@ fn signer_chainstate() {
50955097
// this config disallows any reorg due to poorly timed block commits
50965098
let proposal_conf = ProposalEvalConfig {
50975099
first_proposal_burn_block_timing: Duration::from_secs(0),
5100+
block_proposal_timeout: Duration::from_secs(100),
50985101
};
5099-
let sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
5102+
let mut sortitions_view =
5103+
SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
51005104
let valid = sortitions_view
51015105
.check_proposal(
51025106
&signer_client,
@@ -5161,6 +5165,7 @@ fn signer_chainstate() {
51615165
// this config disallows any reorg due to poorly timed block commits
51625166
let proposal_conf = ProposalEvalConfig {
51635167
first_proposal_burn_block_timing: Duration::from_secs(0),
5168+
block_proposal_timeout: Duration::from_secs(100),
51645169
};
51655170
let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap();
51665171

0 commit comments

Comments
 (0)