Skip to content

Commit c6b0866

Browse files
committed
feat: allow signers to reconsider some rejected blocks
Allows a signer to reconsider a block that it previously rejected if it was rejected for certain reasons that may resolve themselves, for example a testing directive, the parent block was unknown, or there was a communication failure with the stacks-node. Resolves #5856
1 parent 82b9b68 commit c6b0866

File tree

6 files changed

+207
-26
lines changed

6 files changed

+207
-26
lines changed

libsigner/src/v0/messages.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ use clarity::util::hash::Sha256Sum;
5151
use clarity::util::retry::BoundReader;
5252
use clarity::util::secp256k1::MessageSignature;
5353
use clarity::vm::types::serialization::SerializationError;
54-
use clarity::vm::types::{QualifiedContractIdentifier, TupleData};
54+
use clarity::vm::types::{QualifiedContractIdentifier, ResponseData, TupleData};
5555
use clarity::vm::Value;
5656
use hashbrown::{HashMap, HashSet};
5757
use serde::{Deserialize, Serialize};
@@ -875,11 +875,11 @@ impl BlockResponse {
875875
}
876876
}
877877

878-
/// The signer signature hash for the block response
879-
pub fn signer_signature_hash(&self) -> Sha512Trunc256Sum {
878+
/// Get the block response data from the block response
879+
pub fn get_response_data(&self) -> &BlockResponseData {
880880
match self {
881-
BlockResponse::Accepted(accepted) => accepted.signer_signature_hash,
882-
BlockResponse::Rejected(rejection) => rejection.signer_signature_hash,
881+
BlockResponse::Accepted(accepted) => &accepted.response_data,
882+
BlockResponse::Rejected(rejection) => &rejection.response_data,
883883
}
884884
}
885885

stacks-signer/src/signerdb.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ pub struct BlockInfo {
167167
pub validation_time_ms: Option<u64>,
168168
/// Extra data specific to v0, v1, etc.
169169
pub ext: ExtraBlockInfo,
170+
/// If this signer rejected this block, what was the reason
171+
pub reject_reason: Option<RejectReason>,
170172
}
171173

172174
impl From<BlockProposal> for BlockInfo {
@@ -184,6 +186,7 @@ impl From<BlockProposal> for BlockInfo {
184186
ext: ExtraBlockInfo::default(),
185187
state: BlockState::Unprocessed,
186188
validation_time_ms: None,
189+
reject_reason: None,
187190
}
188191
}
189192
}

stacks-signer/src/v0/signer.rs

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ use std::time::{Duration, Instant};
2121

2222
use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader};
2323
use blockstack_lib::net::api::postblock_proposal::{
24-
BlockValidateOk, BlockValidateReject, BlockValidateResponse, TOO_MANY_REQUESTS_STATUS,
24+
BlockValidateOk, BlockValidateReject, BlockValidateResponse, ValidateRejectCode,
25+
TOO_MANY_REQUESTS_STATUS,
2526
};
2627
use blockstack_lib::util_lib::db::Error as DBError;
2728
use clarity::types::chainstate::StacksPrivateKey;
@@ -391,6 +392,7 @@ impl Signer {
391392
),
392393
)
393394
}
395+
394396
/// Create a block rejection response for a block with the given reject code
395397
pub fn create_block_rejection(
396398
&self,
@@ -411,6 +413,7 @@ impl Signer {
411413
),
412414
)
413415
}
416+
414417
/// Check if block should be rejected based on sortition state
415418
/// Will return a BlockResponse::Rejection if the block is invalid, none otherwise.
416419
fn check_block_against_sortition_state(
@@ -561,32 +564,37 @@ impl Signer {
561564
// the signer needs to be able to determine whether or not the block they're about to sign would conflict with an already-signed Stacks block
562565
let signer_signature_hash = block_proposal.block.header.signer_signature_hash();
563566
if let Some(block_info) = self.block_lookup_by_reward_cycle(&signer_signature_hash) {
564-
let Some(block_response) = self.determine_response(&block_info) else {
565-
// We are still waiting for a response for this block. Do nothing.
566-
debug!("{self}: Received a block proposal for a block we are already validating.";
567-
"signer_sighash" => %signer_signature_hash,
568-
"block_id" => %block_proposal.block.block_id()
569-
);
570-
return;
571-
};
572-
// Submit a proposal response to the .signers contract for miners
573-
debug!("{self}: Broadcasting a block response to stacks node: {block_response:?}");
574-
let accepted = matches!(block_response, BlockResponse::Accepted(..));
575-
match self
576-
.stackerdb
577-
.send_message_with_retry::<SignerMessage>(block_response.into())
578-
{
579-
Ok(_) => {
567+
if should_reevaluate_block(&block_info) {
568+
// Treat this case the same as if no block info was found
569+
} else {
570+
let Some(block_response) = self.determine_response(&block_info) else {
571+
// We are still waiting for a response for this block. Do nothing.
572+
debug!(
573+
"{self}: Received a block proposal for a block we are already validating.";
574+
"signer_sighash" => %signer_signature_hash,
575+
"block_id" => %block_proposal.block.block_id()
576+
);
577+
return;
578+
};
579+
580+
// Submit a proposal response to the .signers contract for miners
581+
debug!("{self}: Broadcasting a block response to stacks node: {block_response:?}");
582+
583+
let accepted = matches!(block_response, BlockResponse::Accepted(..));
584+
if let Err(e) = self
585+
.stackerdb
586+
.send_message_with_retry::<SignerMessage>(block_response.into())
587+
{
588+
warn!("{self}: Failed to send block response to stacker-db: {e:?}");
589+
} else {
580590
crate::monitoring::actions::increment_block_responses_sent(accepted);
581591
crate::monitoring::actions::record_block_response_latency(
582592
&block_proposal.block,
583593
);
584594
}
585-
Err(e) => {
586-
warn!("{self}: Failed to send block response to stacker-db: {e:?}",);
587-
}
595+
596+
return;
588597
}
589-
return;
590598
}
591599

592600
info!(
@@ -890,6 +898,8 @@ impl Signer {
890898
false,
891899
),
892900
);
901+
902+
block_info.reject_reason = Some(block_rejection.response_data.reject_reason.clone());
893903
self.signer_db
894904
.insert_block(&block_info)
895905
.unwrap_or_else(|e| self.handle_insert_block_error(e));
@@ -1019,6 +1029,7 @@ impl Signer {
10191029
),
10201030
&block_info.block,
10211031
);
1032+
block_info.reject_reason = Some(rejection.get_response_data().reject_reason.clone());
10221033
if let Err(e) = block_info.mark_locally_rejected() {
10231034
if !block_info.has_reached_consensus() {
10241035
warn!("{self}: Failed to mark block as locally rejected: {e:?}");
@@ -1039,6 +1050,7 @@ impl Signer {
10391050
),
10401051
Ok(_) => debug!("{self}: Block rejection accepted by stacker-db"),
10411052
}
1053+
10421054
self.signer_db
10431055
.insert_block(&block_info)
10441056
.unwrap_or_else(|e| self.handle_insert_block_error(e));
@@ -1132,6 +1144,7 @@ impl Signer {
11321144
) {
11331145
warn!("{self}: Failed to save block rejection signature: {e:?}",);
11341146
}
1147+
block_info.reject_reason = Some(rejection.response_data.reject_reason.clone());
11351148

11361149
// do we have enough signatures to mark a block a globally rejected?
11371150
// i.e. is (set-size) - (threshold) + 1 reached.
@@ -1412,3 +1425,33 @@ impl Signer {
14121425
}
14131426
}
14141427
}
1428+
1429+
/// Determine if a block should be re-evaluated based on its rejection reason˝
1430+
fn should_reevaluate_block(block_info: &BlockInfo) -> bool {
1431+
if let Some(reject_reason) = &block_info.reject_reason {
1432+
match reject_reason {
1433+
RejectReason::ValidationFailed(ValidateRejectCode::UnknownParent)
1434+
| RejectReason::NoSortitionView
1435+
| RejectReason::ConnectivityIssues(_)
1436+
| RejectReason::TestingDirective
1437+
| RejectReason::NotRejected
1438+
| RejectReason::Unknown(_) => true,
1439+
RejectReason::ValidationFailed(_)
1440+
| RejectReason::RejectedInPriorRound
1441+
| RejectReason::SortitionViewMismatch
1442+
| RejectReason::ReorgNotAllowed
1443+
| RejectReason::InvalidBitvec
1444+
| RejectReason::PubkeyHashMismatch
1445+
| RejectReason::InvalidMiner
1446+
| RejectReason::NotLatestSortitionWinner
1447+
| RejectReason::InvalidParentBlock
1448+
| RejectReason::DuplicateBlockFound
1449+
| RejectReason::InvalidTenureExtend => {
1450+
// No need to re-validate these types of rejections.
1451+
false
1452+
}
1453+
}
1454+
} else {
1455+
false
1456+
}
1457+
}

stacks-signer/src/v0/tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ impl Signer {
9292
warn!("{self}: Failed to mark block as locally rejected: {e:?}");
9393
}
9494
};
95+
96+
block_info.reject_reason = Some(RejectReason::TestingDirective);
97+
9598
// We must insert the block into the DB to prevent subsequent repeat proposals being accepted (should reject
9699
// as invalid since we rejected in a prior round if this crops up again)
97100
// in case this is the first time we saw this block. Safe to do since this is testing case only.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6642,6 +6642,7 @@ fn signer_chainstate() {
66426642
ext: ExtraBlockInfo::None,
66436643
state: BlockState::Unprocessed,
66446644
validation_time_ms: None,
6645+
reject_reason: None,
66456646
})
66466647
.unwrap();
66476648

@@ -6722,6 +6723,7 @@ fn signer_chainstate() {
67226723
ext: ExtraBlockInfo::None,
67236724
state: BlockState::GloballyAccepted,
67246725
validation_time_ms: Some(1000),
6726+
reject_reason: None,
67256727
})
67266728
.unwrap();
67276729

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12305,3 +12305,133 @@ fn retry_proposal() {
1230512305

1230612306
signer_test.shutdown();
1230712307
}
12308+
12309+
#[test]
12310+
#[ignore]
12311+
/// This test verifies that a a signer will accept a rejected block if it is
12312+
/// re-proposed and determined to be legitimate. This can happen if the block
12313+
/// is initially rejected due to a test flag or because the stacks-node had
12314+
/// not yet processed the block's parent.
12315+
fn signer_can_accept_rejected_block() {
12316+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
12317+
return;
12318+
}
12319+
tracing_subscriber::registry()
12320+
.with(fmt::layer())
12321+
.with(EnvFilter::from_default_env())
12322+
.init();
12323+
12324+
info!("------------------------- Test Setup -------------------------");
12325+
let num_signers = 5;
12326+
let sender_sk = Secp256k1PrivateKey::random();
12327+
let sender_addr = tests::to_addr(&sender_sk);
12328+
let send_amt = 100;
12329+
let send_fee = 180;
12330+
let recipient = PrincipalData::from(StacksAddress::burn_address(false));
12331+
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
12332+
num_signers,
12333+
vec![(sender_addr, (send_amt + send_fee) * 3)],
12334+
|_| {},
12335+
|config| {
12336+
config.miner.block_rejection_timeout_steps.clear();
12337+
config
12338+
.miner
12339+
.block_rejection_timeout_steps
12340+
.insert(0, Duration::from_secs(123));
12341+
config
12342+
.miner
12343+
.block_rejection_timeout_steps
12344+
.insert(10, Duration::from_secs(20));
12345+
config
12346+
.miner
12347+
.block_rejection_timeout_steps
12348+
.insert(15, Duration::from_secs(10));
12349+
config
12350+
.miner
12351+
.block_rejection_timeout_steps
12352+
.insert(20, Duration::from_secs(30));
12353+
},
12354+
None,
12355+
None,
12356+
);
12357+
let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind);
12358+
signer_test.boot_to_epoch_3();
12359+
12360+
let proposed_blocks = signer_test
12361+
.running_nodes
12362+
.counters
12363+
.naka_proposed_blocks
12364+
.clone();
12365+
12366+
signer_test.mine_nakamoto_block(Duration::from_secs(60), true);
12367+
12368+
let info = get_chain_info(&signer_test.running_nodes.conf);
12369+
let block_height_before = info.stacks_tip_height;
12370+
12371+
// make signer[0] reject all proposals
12372+
let rejecting_signer =
12373+
StacksPublicKey::from_private(&signer_test.signer_stacks_private_keys[0]);
12374+
TEST_REJECT_ALL_BLOCK_PROPOSAL.set(vec![rejecting_signer]);
12375+
12376+
// make signer[1] ignore all proposals
12377+
let ignoring_signer = StacksPublicKey::from_private(&signer_test.signer_stacks_private_keys[1]);
12378+
TEST_IGNORE_ALL_BLOCK_PROPOSALS.set(vec![ignoring_signer]);
12379+
12380+
let proposals_before = proposed_blocks.load(Ordering::SeqCst);
12381+
12382+
// submit a tx so that the miner will mine a block
12383+
let transfer_tx = make_stacks_transfer(
12384+
&sender_sk,
12385+
0,
12386+
send_fee,
12387+
signer_test.running_nodes.conf.burnchain.chain_id,
12388+
&recipient,
12389+
send_amt,
12390+
);
12391+
submit_tx(&http_origin, &transfer_tx);
12392+
12393+
info!("Submitted transfer tx and waiting for block proposal");
12394+
wait_for(60, || {
12395+
if proposed_blocks.load(Ordering::SeqCst) > proposals_before {
12396+
return Ok(true);
12397+
}
12398+
Ok(false)
12399+
})
12400+
.expect("Timed out waiting for block proposal");
12401+
12402+
info!(
12403+
"Block proposed, submitting another transaction that should not get included in the block"
12404+
);
12405+
let transfer_tx = make_stacks_transfer(
12406+
&sender_sk,
12407+
1,
12408+
send_fee,
12409+
signer_test.running_nodes.conf.burnchain.chain_id,
12410+
&recipient,
12411+
send_amt,
12412+
);
12413+
submit_tx(&http_origin, &transfer_tx);
12414+
12415+
info!("Disable signer 0 from rejecting proposals");
12416+
test_observer::clear();
12417+
TEST_REJECT_ALL_BLOCK_PROPOSAL.set(vec![]);
12418+
12419+
info!("Waiting for the block to be approved");
12420+
wait_for(60, || {
12421+
let blocks = test_observer::get_blocks();
12422+
let last_block = blocks.last().expect("No blocks found");
12423+
let height = last_block["block_height"].as_u64().unwrap();
12424+
if height > block_height_before {
12425+
return Ok(true);
12426+
}
12427+
Ok(false)
12428+
})
12429+
.expect("Timed out waiting for block");
12430+
12431+
// Ensure that the block was the original block with just 1 transfer
12432+
let blocks = test_observer::get_blocks();
12433+
let block = blocks.last().expect("No blocks found");
12434+
assert_eq!(transfers_in_block(block), 1);
12435+
12436+
signer_test.shutdown();
12437+
}

0 commit comments

Comments
 (0)