Skip to content

Commit 3b2726e

Browse files
committed
feat: prevent multiple block proposal evals
1 parent 6e0bd5a commit 3b2726e

File tree

5 files changed

+168
-37
lines changed

5 files changed

+168
-37
lines changed

libsigner/src/v0/messages.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,14 @@ impl BlockResponse {
655655
) -> Self {
656656
Self::Rejected(BlockRejection::new(hash, reject_code, private_key, mainnet))
657657
}
658+
659+
/// The signer signature hash for the block response
660+
pub fn signer_signature_hash(&self) -> Sha512Trunc256Sum {
661+
match self {
662+
BlockResponse::Accepted(accepted) => accepted.signer_signature_hash,
663+
BlockResponse::Rejected(rejection) => rejection.signer_signature_hash,
664+
}
665+
}
658666
}
659667

660668
impl StacksMessageCodec for BlockResponse {

stacks-signer/src/signerdb.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ static CREATE_INDEXES_3: &str = r#"
308308
CREATE INDEX IF NOT EXISTS block_rejection_signer_addrs_on_block_signature_hash ON block_rejection_signer_addrs(signer_signature_hash);
309309
"#;
310310

311+
static CREATE_INDEXES_4: &str = r#"
312+
CREATE INDEX IF NOT EXISTS block_validations_pending_on_added_time ON block_validations_pending(added_time);
313+
"#;
314+
311315
static CREATE_SIGNER_STATE_TABLE: &str = "
312316
CREATE TABLE IF NOT EXISTS signer_states (
313317
reward_cycle INTEGER PRIMARY KEY,
@@ -369,6 +373,14 @@ CREATE TABLE IF NOT EXISTS block_rejection_signer_addrs (
369373
PRIMARY KEY (signer_addr)
370374
) STRICT;"#;
371375

376+
static CREATE_BLOCK_VALIDATION_PENDING_TABLE: &str = r#"
377+
CREATE TABLE IF NOT EXISTS block_validations_pending (
378+
signer_signature_hash TEXT NOT NULL,
379+
-- the time at which the block was added to the pending table
380+
added_time INTEGER NOT NULL,
381+
PRIMARY KEY (signer_signature_hash)
382+
) STRICT;"#;
383+
372384
static SCHEMA_1: &[&str] = &[
373385
DROP_SCHEMA_0,
374386
CREATE_DB_CONFIG,
@@ -405,9 +417,15 @@ static SCHEMA_3: &[&str] = &[
405417
"INSERT INTO db_config (version) VALUES (3);",
406418
];
407419

420+
static SCHEMA_4: &[&str] = &[
421+
CREATE_BLOCK_VALIDATION_PENDING_TABLE,
422+
CREATE_INDEXES_4,
423+
"INSERT OR REPLACE INTO db_config (version) VALUES (4);",
424+
];
425+
408426
impl SignerDb {
409427
/// The current schema version used in this build of the signer binary.
410-
pub const SCHEMA_VERSION: u32 = 3;
428+
pub const SCHEMA_VERSION: u32 = 4;
411429

412430
/// Create a new `SignerState` instance.
413431
/// This will create a new SQLite database at the given path
@@ -427,7 +445,7 @@ impl SignerDb {
427445
return Ok(0);
428446
}
429447
let result = conn
430-
.query_row("SELECT version FROM db_config LIMIT 1", [], |row| {
448+
.query_row("SELECT MAX(version) FROM db_config LIMIT 1", [], |row| {
431449
row.get(0)
432450
})
433451
.optional();
@@ -479,6 +497,20 @@ impl SignerDb {
479497
Ok(())
480498
}
481499

500+
/// Migrate from schema 3 to schema 4
501+
fn schema_4_migration(tx: &Transaction) -> Result<(), DBError> {
502+
if Self::get_schema_version(tx)? >= 4 {
503+
// no migration necessary
504+
return Ok(());
505+
}
506+
507+
for statement in SCHEMA_4.iter() {
508+
tx.execute_batch(statement)?;
509+
}
510+
511+
Ok(())
512+
}
513+
482514
/// Either instantiate a new database, or migrate an existing one
483515
/// If the detected version of the existing database is 0 (i.e., a pre-migration
484516
/// logic DB, the DB will be dropped).
@@ -490,7 +522,8 @@ impl SignerDb {
490522
0 => Self::schema_1_migration(&sql_tx)?,
491523
1 => Self::schema_2_migration(&sql_tx)?,
492524
2 => Self::schema_3_migration(&sql_tx)?,
493-
3 => break,
525+
3 => Self::schema_4_migration(&sql_tx)?,
526+
4 => break,
494527
x => return Err(DBError::Other(format!(
495528
"Database schema is newer than supported by this binary. Expected version = {}, Database version = {x}",
496529
Self::SCHEMA_VERSION,
@@ -809,6 +842,45 @@ impl SignerDb {
809842
BlockState::try_from(state.as_str()).map_err(|_| DBError::Corruption)?,
810843
))
811844
}
845+
846+
/// Get a pending block validation, sorted by the time at which it was added to the pending table.
847+
/// If found, remove it from the pending table.
848+
pub fn get_pending_block_validation(&self) -> Result<Option<Sha512Trunc256Sum>, DBError> {
849+
let qry =
850+
"SELECT signer_signature_hash FROM block_validations_pending ORDER BY added_time ASC";
851+
let sighash_opt: Option<String> = query_row(&self.db, qry, params![])?;
852+
if let Some(sighash) = sighash_opt {
853+
let sighash = Sha512Trunc256Sum::from_hex(&sighash).map_err(|_| DBError::Corruption)?;
854+
self.remove_pending_block_validation(&sighash)?;
855+
return Ok(Some(sighash));
856+
}
857+
Ok(None)
858+
}
859+
860+
/// Remove a pending block validation
861+
pub fn remove_pending_block_validation(
862+
&self,
863+
sighash: &Sha512Trunc256Sum,
864+
) -> Result<(), DBError> {
865+
self.db.execute(
866+
"DELETE FROM block_validations_pending WHERE signer_signature_hash = ?1",
867+
params![sighash.to_string()],
868+
)?;
869+
Ok(())
870+
}
871+
872+
/// Insert a pending block validation
873+
pub fn insert_pending_block_validation(
874+
&self,
875+
sighash: &Sha512Trunc256Sum,
876+
ts: u64,
877+
) -> Result<(), DBError> {
878+
self.db.execute(
879+
"INSERT INTO block_validations_pending (signer_signature_hash, added_time) VALUES (?1, ?2)",
880+
params![sighash.to_string(), u64_to_sql(ts)?],
881+
)?;
882+
Ok(())
883+
}
812884
}
813885

814886
fn try_deserialize<T>(s: Option<String>) -> Result<Option<T>, DBError>

stacks-signer/src/v0/signer.rs

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
1919

2020
use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader};
2121
use blockstack_lib::net::api::postblock_proposal::{
22-
BlockValidateOk, BlockValidateReject, BlockValidateResponse,
22+
BlockValidateOk, BlockValidateReject, BlockValidateResponse, TOO_MANY_REQUESTS_STATUS,
2323
};
2424
use clarity::types::chainstate::StacksPrivateKey;
2525
use clarity::types::{PrivateKey, StacksEpochId};
@@ -33,11 +33,12 @@ use libsigner::{BlockProposal, SignerEvent};
3333
use slog::{slog_debug, slog_error, slog_info, slog_warn};
3434
use stacks_common::types::chainstate::StacksAddress;
3535
use stacks_common::util::get_epoch_time_secs;
36+
use stacks_common::util::hash::Sha512Trunc256Sum;
3637
use stacks_common::util::secp256k1::MessageSignature;
3738
use stacks_common::{debug, error, info, warn};
3839

3940
use crate::chainstate::{ProposalEvalConfig, SortitionsView};
40-
use crate::client::{SignerSlotID, StackerDB, StacksClient};
41+
use crate::client::{ClientError, SignerSlotID, StackerDB, StacksClient};
4142
use crate::config::SignerConfig;
4243
use crate::runloop::SignerResult;
4344
use crate::signerdb::{BlockInfo, BlockState, SignerDb};
@@ -90,7 +91,7 @@ pub struct Signer {
9091
/// marking a submitted block as invalid
9192
pub block_proposal_validation_timeout: Duration,
9293
/// The current submitted block proposal and its submission time
93-
pub submitted_block_proposal: Option<(BlockProposal, Instant)>,
94+
pub submitted_block_proposal: Option<(Sha512Trunc256Sum, Instant)>,
9495
}
9596

9697
impl std::fmt::Display for Signer {
@@ -476,15 +477,8 @@ impl Signer {
476477
"block_height" => block_proposal.block.header.chain_length,
477478
"burn_height" => block_proposal.burn_height,
478479
);
479-
match stacks_client.submit_block_for_validation(block_info.block.clone()) {
480-
Ok(_) => {
481-
self.submitted_block_proposal =
482-
Some((block_proposal.clone(), Instant::now()));
483-
}
484-
Err(e) => {
485-
warn!("{self}: Failed to submit block for validation: {e:?}");
486-
}
487-
};
480+
481+
self.submit_block_for_validation(stacks_client, block_proposal.block.clone());
488482
} else {
489483
// Still store the block but log we can't submit it for validation. We may receive enough signatures/rejections
490484
// from other signers to push the proposed block into a global rejection/acceptance regardless of our participation.
@@ -509,12 +503,44 @@ impl Signer {
509503
match block_response {
510504
BlockResponse::Accepted(accepted) => {
511505
self.handle_block_signature(stacks_client, accepted);
506+
accepted.signer_signature_hash
512507
}
513508
BlockResponse::Rejected(block_rejection) => {
514509
self.handle_block_rejection(block_rejection);
510+
block_rejection.signer_signature_hash
515511
}
512+
};
513+
514+
// Remove this block validation from the pending table
515+
let signer_sig_hash = block_response.signer_signature_hash();
516+
self.signer_db
517+
.remove_pending_block_validation(&signer_sig_hash)
518+
.unwrap_or_else(|e| warn!("{self}: Failed to remove pending block validation: {e:?}"));
519+
520+
match self.signer_db.get_pending_block_validation() {
521+
Ok(Some(signer_sig_hash)) => {
522+
info!("{self}: Found a pending block validation: {signer_sig_hash:?}");
523+
match self
524+
.signer_db
525+
.block_lookup(self.reward_cycle, &signer_sig_hash)
526+
{
527+
Ok(Some(block_info)) => {
528+
self.submit_block_for_validation(stacks_client, block_info.block);
529+
}
530+
Ok(None) => {
531+
// This should never happen
532+
error!(
533+
"{self}: Pending block validation not found in DB: {signer_sig_hash:?}"
534+
);
535+
}
536+
Err(e) => error!("{self}: Failed to get block info: {e:?}"),
537+
}
538+
}
539+
Ok(None) => {}
540+
Err(e) => warn!("{self}: Failed to get pending block validation: {e:?}"),
516541
}
517542
}
543+
518544
/// Handle the block validate ok response. Returns our block response if we have one
519545
fn handle_block_validate_ok(
520546
&mut self,
@@ -525,10 +551,7 @@ impl Signer {
525551
let signer_signature_hash = block_validate_ok.signer_signature_hash;
526552
if self
527553
.submitted_block_proposal
528-
.as_ref()
529-
.map(|(proposal, _)| {
530-
proposal.block.header.signer_signature_hash() == signer_signature_hash
531-
})
554+
.map(|(proposal_hash, _)| proposal_hash == signer_signature_hash)
532555
.unwrap_or(false)
533556
{
534557
self.submitted_block_proposal = None;
@@ -584,10 +607,7 @@ impl Signer {
584607
let signer_signature_hash = block_validate_reject.signer_signature_hash;
585608
if self
586609
.submitted_block_proposal
587-
.as_ref()
588-
.map(|(proposal, _)| {
589-
proposal.block.header.signer_signature_hash() == signer_signature_hash
590-
})
610+
.map(|(proposal_hash, _)| proposal_hash == signer_signature_hash)
591611
.unwrap_or(false)
592612
{
593613
self.submitted_block_proposal = None;
@@ -670,20 +690,21 @@ impl Signer {
670690
/// Check the current tracked submitted block proposal to see if it has timed out.
671691
/// Broadcasts a rejection and marks the block locally rejected if it has.
672692
fn check_submitted_block_proposal(&mut self) {
673-
let Some((block_proposal, block_submission)) = self.submitted_block_proposal.take() else {
693+
let Some((proposal_signer_sighash, block_submission)) =
694+
self.submitted_block_proposal.take()
695+
else {
674696
// Nothing to check.
675697
return;
676698
};
677699
if block_submission.elapsed() < self.block_proposal_validation_timeout {
678700
// Not expired yet. Put it back!
679-
self.submitted_block_proposal = Some((block_proposal, block_submission));
701+
self.submitted_block_proposal = Some((proposal_signer_sighash, block_submission));
680702
return;
681703
}
682-
let signature_sighash = block_proposal.block.header.signer_signature_hash();
683704
// For mutability reasons, we need to take the block_info out of the map and add it back after processing
684705
let mut block_info = match self
685706
.signer_db
686-
.block_lookup(self.reward_cycle, &signature_sighash)
707+
.block_lookup(self.reward_cycle, &proposal_signer_sighash)
687708
{
688709
Ok(Some(block_info)) => {
689710
if block_info.state == BlockState::GloballyRejected
@@ -698,8 +719,7 @@ impl Signer {
698719
// This is weird. If this is reached, its probably an error in code logic or the db was flushed.
699720
// Why are we tracking a block submission for a block we have never seen / stored before.
700721
error!("{self}: tracking an unknown block validation submission.";
701-
"signer_sighash" => %signature_sighash,
702-
"block_id" => %block_proposal.block.block_id(),
722+
"signer_sighash" => %proposal_signer_sighash,
703723
);
704724
return;
705725
}
@@ -712,11 +732,10 @@ impl Signer {
712732
// Reject it so we aren't holding up the network because of our inaction.
713733
warn!(
714734
"{self}: Failed to receive block validation response within {} ms. Rejecting block.", self.block_proposal_validation_timeout.as_millis();
715-
"signer_sighash" => %signature_sighash,
716-
"block_id" => %block_proposal.block.block_id(),
735+
"signer_sighash" => %proposal_signer_sighash,
717736
);
718737
let rejection = BlockResponse::rejected(
719-
block_proposal.block.header.signer_signature_hash(),
738+
proposal_signer_sighash,
720739
RejectCode::ConnectivityIssues,
721740
&self.private_key,
722741
self.mainnet,
@@ -851,7 +870,7 @@ impl Signer {
851870
if self
852871
.submitted_block_proposal
853872
.as_ref()
854-
.map(|(proposal, _)| &proposal.block.header.signer_signature_hash() == block_hash)
873+
.map(|(proposal_signer_sighash, _)| proposal_signer_sighash == block_hash)
855874
.unwrap_or(false)
856875
{
857876
// Consensus reached! No longer bother tracking its validation submission to the node as we are too late to participate in the decision anyway.
@@ -1002,7 +1021,7 @@ impl Signer {
10021021
if self
10031022
.submitted_block_proposal
10041023
.as_ref()
1005-
.map(|(proposal, _)| &proposal.block.header.signer_signature_hash() == block_hash)
1024+
.map(|(proposal_hash, _)| proposal_hash == block_hash)
10061025
.unwrap_or(false)
10071026
{
10081027
// Consensus reached! No longer bother tracking its validation submission to the node as we are too late to participate in the decision anyway.
@@ -1046,6 +1065,36 @@ impl Signer {
10461065
}
10471066
}
10481067

1068+
/// Submit a block for validation, and mark it as pending if the node
1069+
fn submit_block_for_validation(&mut self, stacks_client: &StacksClient, block: NakamotoBlock) {
1070+
let signer_signature_hash = block.header.signer_signature_hash();
1071+
match stacks_client.submit_block_for_validation(block.clone()) {
1072+
Ok(_) => {
1073+
self.submitted_block_proposal = Some((signer_signature_hash, Instant::now()));
1074+
}
1075+
Err(ClientError::RequestFailure(status)) => {
1076+
if status.as_u16() == TOO_MANY_REQUESTS_STATUS {
1077+
info!("{self}: Received 429 from stacks node. Inserting pending block validation...";
1078+
"signer_signature_hash" => %signer_signature_hash,
1079+
);
1080+
self.signer_db
1081+
.insert_pending_block_validation(
1082+
&signer_signature_hash,
1083+
get_epoch_time_secs(),
1084+
)
1085+
.unwrap_or_else(|e| {
1086+
warn!("{self}: Failed to insert pending block validation: {e:?}")
1087+
});
1088+
} else {
1089+
warn!("{self}: Received non-429 status from stacks node: {status}");
1090+
}
1091+
}
1092+
Err(e) => {
1093+
warn!("{self}: Failed to submit block for validation: {e:?}");
1094+
}
1095+
}
1096+
}
1097+
10491098
#[cfg(any(test, feature = "testing"))]
10501099
fn test_skip_block_broadcast(&self, block: &NakamotoBlock) -> bool {
10511100
if *TEST_SKIP_BLOCK_BROADCAST.lock().unwrap() == Some(true) {

stackslib/src/net/api/postblock_proposal.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ define_u8_enum![ValidateRejectCode {
7979
NoSuchTenure = 6
8080
}];
8181

82+
pub static TOO_MANY_REQUESTS_STATUS: u16 = 429;
83+
8284
impl TryFrom<u8> for ValidateRejectCode {
8385
type Error = CodecError;
8486
fn try_from(value: u8) -> Result<Self, Self::Error> {
@@ -687,7 +689,7 @@ impl RPCRequestHandler for RPCBlockProposalRequestHandler {
687689
let res = node.with_node_state(|network, sortdb, chainstate, _mempool, rpc_args| {
688690
if network.is_proposal_thread_running() {
689691
return Err((
690-
429,
692+
TOO_MANY_REQUESTS_STATUS,
691693
NetError::SendError("Proposal currently being evaluated".into()),
692694
));
693695
}
@@ -708,7 +710,7 @@ impl RPCRequestHandler for RPCBlockProposalRequestHandler {
708710
.spawn_validation_thread(sortdb, chainstate, receiver)
709711
.map_err(|_e| {
710712
(
711-
429,
713+
TOO_MANY_REQUESTS_STATUS,
712714
NetError::SendError(
713715
"IO error while spawning proposal callback thread".into(),
714716
),

0 commit comments

Comments
 (0)