Skip to content

Commit e4e5cd0

Browse files
committed
feat: on timeout, re-propose the same block
When a miner times out waiting for signatures, instead of proposing a new block, it should only re-propose the same block. Proposing a new block is guaranteed to fail because signers that approved the original block will reject any new block at the same height. This implements the miner side of #5856. A change is still needed on the signer side to allow a signer to accept a block that it previously rejected.
1 parent 512669b commit e4e5cd0

File tree

4 files changed

+220
-56
lines changed

4 files changed

+220
-56
lines changed

testnet/stacks-node/src/nakamoto_node.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ pub enum Error {
143143
/// NetError wrapper
144144
#[error("NetError: {0}")]
145145
NetError(#[from] NetError),
146+
#[error("Timed out waiting for signatures")]
147+
SignatureTimeout,
146148
}
147149

148150
impl StacksNode {

testnet/stacks-node/src/nakamoto_node/signer_coordinator.rs

Lines changed: 65 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -254,46 +254,54 @@ impl SignerCoordinator {
254254
};
255255

256256
let block_proposal_message = SignerMessageV0::BlockProposal(block_proposal);
257-
debug!("Sending block proposal message to signers";
258-
"signer_signature_hash" => %block.header.signer_signature_hash(),
259-
);
260-
Self::send_miners_message::<SignerMessageV0>(
261-
&self.message_key,
262-
sortdb,
263-
election_sortition,
264-
stackerdbs,
265-
block_proposal_message,
266-
MinerSlotID::BlockProposal,
267-
self.is_mainnet,
268-
&mut self.miners_session,
269-
&election_sortition.consensus_hash,
270-
)?;
271-
counters.bump_naka_proposed_blocks();
272257

273-
#[cfg(test)]
274-
{
275-
info!(
276-
"SignerCoordinator: sent block proposal to .miners, waiting for test signing channel"
258+
loop {
259+
debug!("Sending block proposal message to signers";
260+
"signer_signature_hash" => %block.header.signer_signature_hash(),
277261
);
278-
// In test mode, short-circuit waiting for the signers if the TEST_SIGNING
279-
// channel has been created. This allows integration tests for the stacks-node
280-
// independent of the stacks-signer.
281-
if let Some(signatures) =
282-
crate::tests::nakamoto_integrations::TestSigningChannel::get_signature()
262+
Self::send_miners_message::<SignerMessageV0>(
263+
&self.message_key,
264+
sortdb,
265+
election_sortition,
266+
stackerdbs,
267+
block_proposal_message.clone(),
268+
MinerSlotID::BlockProposal,
269+
self.is_mainnet,
270+
&mut self.miners_session,
271+
&election_sortition.consensus_hash,
272+
)?;
273+
counters.bump_naka_proposed_blocks();
274+
275+
#[cfg(test)]
283276
{
284-
debug!("Short-circuiting waiting for signers, using test signature");
285-
return Ok(signatures);
277+
info!(
278+
"SignerCoordinator: sent block proposal to .miners, waiting for test signing channel"
279+
);
280+
// In test mode, short-circuit waiting for the signers if the TEST_SIGNING
281+
// channel has been created. This allows integration tests for the stacks-node
282+
// independent of the stacks-signer.
283+
if let Some(signatures) =
284+
crate::tests::nakamoto_integrations::TestSigningChannel::get_signature()
285+
{
286+
debug!("Short-circuiting waiting for signers, using test signature");
287+
return Ok(signatures);
288+
}
286289
}
287-
}
288290

289-
self.get_block_status(
290-
&block.header.signer_signature_hash(),
291-
&block.block_id(),
292-
block.header.parent_block_id,
293-
chain_state,
294-
sortdb,
295-
counters,
296-
)
291+
let res = self.get_block_status(
292+
&block.header.signer_signature_hash(),
293+
&block.block_id(),
294+
block.header.parent_block_id,
295+
chain_state,
296+
sortdb,
297+
counters,
298+
);
299+
300+
match res {
301+
Err(NakamotoNodeError::SignatureTimeout) => continue,
302+
_ => return res,
303+
}
304+
}
297305
}
298306

299307
/// Get the block status for a given block hash.
@@ -340,7 +348,7 @@ impl SignerCoordinator {
340348
if rejections_timer.elapsed() > *rejections_timeout {
341349
return false;
342350
}
343-
// number or rejections changed?
351+
// number of rejections changed?
344352
if status.total_weight_rejected != rejections {
345353
return false;
346354
}
@@ -353,7 +361,7 @@ impl SignerCoordinator {
353361
// If we just received a timeout, we should check if the burnchain
354362
// tip has changed or if we received this signed block already in
355363
// the staging db.
356-
debug!("SignerCoordinator: Timeout waiting for block signatures");
364+
debug!("SignerCoordinator: Intermediate timeout waiting for block status");
357365

358366
// Look in the nakamoto staging db -- a block can only get stored there
359367
// if it has enough signing weight to clear the threshold.
@@ -380,15 +388,17 @@ impl SignerCoordinator {
380388
}
381389

382390
if rejections_timer.elapsed() > *rejections_timeout {
383-
warn!("Timed out while waiting for responses from signers";
384-
"elapsed" => rejections_timer.elapsed().as_secs(),
385-
"rejections_timeout" => rejections_timeout.as_secs(),
386-
"rejections" => rejections,
387-
"rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
391+
warn!("Timed out while waiting for responses from signers, resending proposal";
392+
"elapsed" => rejections_timer.elapsed().as_secs(),
393+
"rejections_timeout" => rejections_timeout.as_secs(),
394+
"rejections" => rejections,
395+
"rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
388396
);
389-
return Err(NakamotoNodeError::SigningCoordinatorFailure(
390-
"Timed out while waiting for signatures".into(),
391-
));
397+
398+
// Reset the rejections in the stackerdb listener
399+
self.stackerdb_comms.reset_rejections(block_signer_sighash);
400+
401+
return Err(NakamotoNodeError::SignatureTimeout);
392402
}
393403

394404
// Check if a new Stacks block has arrived in the parent tenure
@@ -399,7 +409,7 @@ impl SignerCoordinator {
399409
)?
400410
.ok_or(NakamotoNodeError::UnexpectedChainState)?;
401411
if highest_in_tenure.index_block_hash() != parent_block_id {
402-
debug!("SignCoordinator: Exiting due to new stacks tip");
412+
info!("SignCoordinator: Exiting due to new stacks tip");
403413
return Err(NakamotoNodeError::StacksTipChanged);
404414
}
405415

@@ -448,14 +458,16 @@ impl SignerCoordinator {
448458
return Ok(block_status.gathered_signatures.values().cloned().collect());
449459
} else if rejections_timer.elapsed() > *rejections_timeout {
450460
warn!("Timed out while waiting for responses from signers";
451-
"elapsed" => rejections_timer.elapsed().as_secs(),
452-
"rejections_timeout" => rejections_timeout.as_secs(),
453-
"rejections" => rejections,
454-
"rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
461+
"elapsed" => rejections_timer.elapsed().as_secs(),
462+
"rejections_timeout" => rejections_timeout.as_secs(),
463+
"rejections" => rejections,
464+
"rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
455465
);
456-
return Err(NakamotoNodeError::SigningCoordinatorFailure(
457-
"Timed out while waiting for signatures".into(),
458-
));
466+
467+
// Reset the rejections in the stackerdb listener
468+
self.stackerdb_comms.reset_rejections(block_signer_sighash);
469+
470+
return Err(NakamotoNodeError::SignatureTimeout);
459471
} else {
460472
continue;
461473
}

testnet/stacks-node/src/nakamoto_node/stackerdb_listener.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,13 @@ pub static EVENT_RECEIVER_POLL: Duration = Duration::from_millis(500);
5252

5353
#[derive(Debug, Clone)]
5454
pub struct BlockStatus {
55-
pub responded_signers: HashSet<StacksPublicKey>,
55+
/// Set of the slot ids of signers who have responded
56+
pub responded_signers: HashSet<u32>,
57+
/// Map of the slot id of signers who have signed the block and their signature
5658
pub gathered_signatures: BTreeMap<u32, MessageSignature>,
59+
/// Total weight of signers who have signed the block
5760
pub total_weight_approved: u32,
61+
/// Total weight of signers who have rejected the block
5862
pub total_weight_rejected: u32,
5963
}
6064

@@ -342,7 +346,7 @@ impl StackerDBListener {
342346
"server_version" => metadata.server_version,
343347
);
344348
block.gathered_signatures.insert(slot_id, signature);
345-
block.responded_signers.insert(signer_pubkey);
349+
block.responded_signers.insert(slot_id);
346350

347351
if block.total_weight_approved >= self.weight_threshold {
348352
// Signal to anyone waiting on this block that we have enough signatures
@@ -385,7 +389,7 @@ impl StackerDBListener {
385389
}
386390
};
387391

388-
if block.responded_signers.insert(rejected_pubkey) {
392+
if block.responded_signers.insert(slot_id) {
389393
block.total_weight_rejected = block
390394
.total_weight_rejected
391395
.checked_add(signer_entry.weight)
@@ -498,6 +502,25 @@ impl StackerDBListenerComms {
498502
blocks.insert(block.signer_signature_hash(), block_status);
499503
}
500504

505+
/// Reset rejections for a block proposal.
506+
/// This is used when a block proposal times out and we need to retry it by
507+
/// clearing the block's rejections. Block approvals cannot be cleared
508+
/// because an old approval could always be used to make a block reach
509+
/// the approval threshold.
510+
pub fn reset_rejections(&self, signer_sighash: &Sha512Trunc256Sum) {
511+
let (lock, _cvar) = &*self.blocks;
512+
let mut blocks = lock.lock().expect("FATAL: failed to lock block status");
513+
if let Some(block) = blocks.get_mut(signer_sighash) {
514+
block.responded_signers.clear();
515+
block.total_weight_rejected = 0;
516+
517+
// Add approving signers back to the responded signers set
518+
for (slot_id, _) in block.gathered_signatures.iter() {
519+
block.responded_signers.insert(*slot_id);
520+
}
521+
}
522+
}
523+
501524
/// Get the status for `block` from the Stacker DB listener.
502525
/// If the block is not found in the map, return an error.
503526
/// If the block is found, call `condition` to check if the block status

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12170,3 +12170,130 @@ fn repeated_rejection() {
1217012170

1217112171
signer_test.shutdown();
1217212172
}
12173+
12174+
#[test]
12175+
#[ignore]
12176+
/// This test verifies that a miner will re-propose the same block if it times
12177+
/// out waiting for signers to reach consensus on the block.
12178+
fn retry_proposal() {
12179+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
12180+
return;
12181+
}
12182+
tracing_subscriber::registry()
12183+
.with(fmt::layer())
12184+
.with(EnvFilter::from_default_env())
12185+
.init();
12186+
12187+
info!("------------------------- Test Setup -------------------------");
12188+
let num_signers = 5;
12189+
let sender_sk = Secp256k1PrivateKey::random();
12190+
let sender_addr = tests::to_addr(&sender_sk);
12191+
let send_amt = 100;
12192+
let send_fee = 180;
12193+
let recipient = PrincipalData::from(StacksAddress::burn_address(false));
12194+
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
12195+
num_signers,
12196+
vec![(sender_addr, (send_amt + send_fee) * 3)],
12197+
|_| {},
12198+
|config| {
12199+
config.miner.block_rejection_timeout_steps.clear();
12200+
config
12201+
.miner
12202+
.block_rejection_timeout_steps
12203+
.insert(0, Duration::from_secs(123));
12204+
config
12205+
.miner
12206+
.block_rejection_timeout_steps
12207+
.insert(10, Duration::from_secs(20));
12208+
config
12209+
.miner
12210+
.block_rejection_timeout_steps
12211+
.insert(15, Duration::from_secs(10));
12212+
config
12213+
.miner
12214+
.block_rejection_timeout_steps
12215+
.insert(20, Duration::from_secs(30));
12216+
},
12217+
None,
12218+
None,
12219+
);
12220+
let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind);
12221+
signer_test.boot_to_epoch_3();
12222+
12223+
let proposed_blocks = signer_test
12224+
.running_nodes
12225+
.counters
12226+
.naka_proposed_blocks
12227+
.clone();
12228+
12229+
signer_test.mine_nakamoto_block(Duration::from_secs(60), true);
12230+
12231+
let info = get_chain_info(&signer_test.running_nodes.conf);
12232+
let block_height_before = info.stacks_tip_height;
12233+
12234+
// make signer[0] reject all proposals
12235+
let rejecting_signer =
12236+
StacksPublicKey::from_private(&signer_test.signer_stacks_private_keys[0]);
12237+
TEST_REJECT_ALL_BLOCK_PROPOSAL.set(vec![rejecting_signer]);
12238+
12239+
// make signer[1] ignore all proposals
12240+
let ignoring_signer = StacksPublicKey::from_private(&signer_test.signer_stacks_private_keys[1]);
12241+
TEST_IGNORE_ALL_BLOCK_PROPOSALS.set(vec![ignoring_signer]);
12242+
12243+
let proposals_before = proposed_blocks.load(Ordering::SeqCst);
12244+
12245+
// submit a tx so that the miner will mine a block
12246+
let transfer_tx = make_stacks_transfer(
12247+
&sender_sk,
12248+
0,
12249+
send_fee,
12250+
signer_test.running_nodes.conf.burnchain.chain_id,
12251+
&recipient,
12252+
send_amt,
12253+
);
12254+
submit_tx(&http_origin, &transfer_tx);
12255+
12256+
info!("Submitted transfer tx and waiting for block proposal");
12257+
wait_for(60, || {
12258+
if proposed_blocks.load(Ordering::SeqCst) > proposals_before {
12259+
return Ok(true);
12260+
}
12261+
Ok(false)
12262+
})
12263+
.expect("Timed out waiting for block proposal");
12264+
12265+
info!(
12266+
"Block proposed, submitting another transaction that should not get included in the block"
12267+
);
12268+
let transfer_tx = make_stacks_transfer(
12269+
&sender_sk,
12270+
1,
12271+
send_fee,
12272+
signer_test.running_nodes.conf.burnchain.chain_id,
12273+
&recipient,
12274+
send_amt,
12275+
);
12276+
submit_tx(&http_origin, &transfer_tx);
12277+
12278+
info!("Disable signer 1 from ignoring proposals");
12279+
test_observer::clear();
12280+
TEST_IGNORE_ALL_BLOCK_PROPOSALS.set(vec![]);
12281+
12282+
info!("Waiting for the block to be approved");
12283+
wait_for(60, || {
12284+
let info = get_chain_info(&signer_test.running_nodes.conf);
12285+
if info.stacks_tip_height > block_height_before {
12286+
return Ok(true);
12287+
}
12288+
Ok(false)
12289+
})
12290+
.expect("Timed out waiting for block");
12291+
12292+
// Ensure that the block was the original block with just 1 transfer
12293+
let blocks = test_observer::get_blocks();
12294+
let block = blocks.first().expect("No blocks found");
12295+
let transactions = block["transactions"].as_array().unwrap();
12296+
assert_eq!(transactions.len(), 1);
12297+
12298+
signer_test.shutdown();
12299+
}

0 commit comments

Comments
 (0)