Skip to content

Commit 83a032f

Browse files
committed
Added block_proposal_max_age_secs signer configuration to drop old proposals without processing
Signed-off-by: Jacinta Ferrant <[email protected]>
1 parent 025af8e commit 83a032f

File tree

7 files changed

+152
-8
lines changed

7 files changed

+152
-8
lines changed

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ jobs:
127127
- tests::signer::v0::continue_after_fast_block_no_sortition
128128
- tests::signer::v0::block_validation_response_timeout
129129
- tests::signer::v0::tenure_extend_after_bad_commit
130+
- tests::signer::v0::block_proposal_max_age_rejections
130131
- tests::nakamoto_integrations::burn_ops_integration_test
131132
- tests::nakamoto_integrations::check_block_heights
132133
- tests::nakamoto_integrations::clarity_burn_state

stacks-signer/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
77

88
## [Unreleased]
99

10+
- Introduced the `block_proposal_max_age_secs` configuration option for signers, enabling them to automatically ignore block proposals that exceed the specified age in seconds.
11+
1012
### Added
1113

1214
### Changed

stacks-signer/src/client/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ pub(crate) mod tests {
413413
block_proposal_timeout: config.block_proposal_timeout,
414414
tenure_last_block_proposal_timeout: config.tenure_last_block_proposal_timeout,
415415
block_proposal_validation_timeout: config.block_proposal_validation_timeout,
416+
block_proposal_max_age_secs: config.block_proposal_max_age_secs,
416417
}
417418
}
418419

stacks-signer/src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const BLOCK_PROPOSAL_TIMEOUT_MS: u64 = 600_000;
3838
const BLOCK_PROPOSAL_VALIDATION_TIMEOUT_MS: u64 = 120_000;
3939
const DEFAULT_FIRST_PROPOSAL_BURN_BLOCK_TIMING_SECS: u64 = 60;
4040
const DEFAULT_TENURE_LAST_BLOCK_PROPOSAL_TIMEOUT_SECS: u64 = 30;
41+
const DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS: u64 = 600;
4142

4243
#[derive(thiserror::Error, Debug)]
4344
/// An error occurred parsing the provided configuration
@@ -135,6 +136,8 @@ pub struct SignerConfig {
135136
pub tenure_last_block_proposal_timeout: Duration,
136137
/// How much time to wait for a block proposal validation response before marking the block invalid
137138
pub block_proposal_validation_timeout: Duration,
139+
/// The maximum age of a block proposal in seconds that will be processed by the signer
140+
pub block_proposal_max_age_secs: u64,
138141
}
139142

140143
/// The parsed configuration for the signer
@@ -171,6 +174,8 @@ pub struct GlobalConfig {
171174
/// How long to wait for a response from a block proposal validation response from the node
172175
/// before marking that block as invalid and rejecting it
173176
pub block_proposal_validation_timeout: Duration,
177+
/// The maximum age of a block proposal that will be processed by the signer
178+
pub block_proposal_max_age_secs: u64,
174179
}
175180

176181
/// Internal struct for loading up the config file
@@ -206,6 +211,8 @@ struct RawConfigFile {
206211
/// How long to wait (in millisecs) for a response from a block proposal validation response from the node
207212
/// before marking that block as invalid and rejecting it
208213
pub block_proposal_validation_timeout_ms: Option<u64>,
214+
/// The maximum age of a block proposal (in secs) that will be processed by the signer.
215+
pub block_proposal_max_age_secs: Option<u64>,
209216
}
210217

211218
impl RawConfigFile {
@@ -297,6 +304,10 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
297304
.unwrap_or(BLOCK_PROPOSAL_VALIDATION_TIMEOUT_MS),
298305
);
299306

307+
let block_proposal_max_age_secs = raw_data
308+
.block_proposal_max_age_secs
309+
.unwrap_or(DEFAULT_BLOCK_PROPOSAL_MAX_AGE_SECS);
310+
300311
Ok(Self {
301312
node_host: raw_data.node_host,
302313
endpoint,
@@ -312,6 +323,7 @@ impl TryFrom<RawConfigFile> for GlobalConfig {
312323
chain_id: raw_data.chain_id,
313324
tenure_last_block_proposal_timeout,
314325
block_proposal_validation_timeout,
326+
block_proposal_max_age_secs,
315327
})
316328
}
317329
}

stacks-signer/src/runloop.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ impl<Signer: SignerTrait<T>, T: StacksMessageCodec + Clone + Send + Debug> RunLo
285285
block_proposal_timeout: self.config.block_proposal_timeout,
286286
tenure_last_block_proposal_timeout: self.config.tenure_last_block_proposal_timeout,
287287
block_proposal_validation_timeout: self.config.block_proposal_validation_timeout,
288+
block_proposal_max_age_secs: self.config.block_proposal_max_age_secs,
288289
}))
289290
}
290291

stacks-signer/src/v0/signer.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ pub struct Signer {
9292
pub block_proposal_validation_timeout: Duration,
9393
/// The current submitted block proposal and its submission time
9494
pub submitted_block_proposal: Option<(BlockProposal, Instant)>,
95+
/// Maximum age of a block proposal in seconds before it is dropped without processing
96+
pub block_proposal_max_age_secs: u64,
9597
}
9698

9799
impl std::fmt::Display for Signer {
@@ -284,6 +286,7 @@ impl From<SignerConfig> for Signer {
284286
proposal_config,
285287
submitted_block_proposal: None,
286288
block_proposal_validation_timeout: signer_config.block_proposal_validation_timeout,
289+
block_proposal_max_age_secs: signer_config.block_proposal_max_age_secs,
287290
}
288291
}
289292
}
@@ -331,6 +334,23 @@ impl Signer {
331334
return;
332335
}
333336

337+
if block_proposal
338+
.block
339+
.header
340+
.timestamp
341+
.saturating_add(self.block_proposal_max_age_secs)
342+
< get_epoch_time_secs()
343+
{
344+
// Block is too old. Drop it with a warning. Don't even bother broadcasting to the node.
345+
warn!("{self}: Received a block proposal that is more than {} secs old. Ignoring...", self.block_proposal_max_age_secs;
346+
"block_id" => %block_proposal.block.block_id(),
347+
"block_height" => block_proposal.block.header.chain_length,
348+
"burn_height" => block_proposal.burn_height,
349+
"timestamp" => block_proposal.block.header.timestamp,
350+
);
351+
return;
352+
}
353+
334354
// TODO: should add a check to ignore an old burn block height if we know its outdated. Would require us to store the burn block height we last saw on the side.
335355
// 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
336356
let signer_signature_hash = block_proposal.block.header.signer_signature_hash();

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

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ use stacks::net::api::postblock_proposal::{ValidateRejectCode, TEST_VALIDATE_STA
4343
use stacks::net::relay::fault_injection::set_ignore_block;
4444
use stacks::types::chainstate::{StacksAddress, StacksBlockId, StacksPrivateKey, StacksPublicKey};
4545
use stacks::types::PublicKey;
46+
use stacks::util::get_epoch_time_secs;
4647
use stacks::util::hash::{hex_bytes, Hash160, MerkleHashFunc};
4748
use stacks::util::secp256k1::{Secp256k1PrivateKey, Secp256k1PublicKey};
4849
use stacks::util_lib::boot::boot_code_id;
@@ -811,14 +812,8 @@ fn reloads_signer_set_in() {
811812
let sender_addr = tests::to_addr(&sender_sk);
812813
let send_amt = 100;
813814
let send_fee = 180;
814-
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
815-
num_signers,
816-
vec![(sender_addr, send_amt + send_fee)],
817-
|_config| {},
818-
|_| {},
819-
None,
820-
None,
821-
);
815+
let mut signer_test: SignerTest<SpawnedSigner> =
816+
SignerTest::new(num_signers, vec![(sender_addr, send_amt + send_fee)]);
822817

823818
setup_epoch_3_reward_set(
824819
&signer_test.running_nodes.conf,
@@ -8574,3 +8569,115 @@ fn tenure_extend_after_2_bad_commits() {
85748569
run_loop_2_thread.join().unwrap();
85758570
signer_test.shutdown();
85768571
}
8572+
8573+
#[test]
8574+
#[ignore]
8575+
/// Test the block_proposal_max_age_secs signer configuration option. It should reject blocks that are
8576+
/// invalid but within the max age window, otherwise it should simply drop the block without further processing.
8577+
///
8578+
/// Test Setup:
8579+
/// The test spins up five stacks signers, one miner Nakamoto node, and a corresponding bitcoind.
8580+
///
8581+
/// Test Execution:
8582+
/// The stacks node is advanced to epoch 3.0 reward set calculation to ensure the signer set is determined.
8583+
/// An invalid block proposal with a recent timestamp is forcibly written to the miner's slot to simulate the miner proposing a block.
8584+
/// The signers process the invalid block and broadcast a block response rejection to the respective .signers-XXX-YYY contract.
8585+
/// A second block proposal with an outdated timestamp is then submitted to the miner's slot to simulate the miner proposing a very old block.
8586+
/// The test confirms no further block rejection response is submitted to the .signers-XXX-YYY contract.
8587+
///
8588+
/// Test Assertion:
8589+
/// - Each signer successfully rejects the recent invalid block proposal.
8590+
/// - No signer submits a block proposal response for the outdated block proposal.
8591+
/// - The stacks tip does not advance
8592+
fn block_proposal_max_age_rejections() {
8593+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
8594+
return;
8595+
}
8596+
8597+
tracing_subscriber::registry()
8598+
.with(fmt::layer())
8599+
.with(EnvFilter::from_default_env())
8600+
.init();
8601+
8602+
info!("------------------------- Test Setup -------------------------");
8603+
let num_signers = 5;
8604+
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
8605+
num_signers,
8606+
vec![],
8607+
|config| {
8608+
config.block_proposal_max_age_secs = 30;
8609+
},
8610+
|_| {},
8611+
None,
8612+
None,
8613+
);
8614+
signer_test.boot_to_epoch_3();
8615+
let short_timeout = Duration::from_secs(30);
8616+
8617+
// Make sure no other block approvals are in the system.
8618+
test_observer::clear();
8619+
info!("------------------------- Send Block Proposal To Signers -------------------------");
8620+
let info_before = get_chain_info(&signer_test.running_nodes.conf);
8621+
let mut block = NakamotoBlock {
8622+
header: NakamotoBlockHeader::empty(),
8623+
txs: vec![],
8624+
};
8625+
// First propose a stale block that is older than the block_proposal_max_age_secs
8626+
block.header.timestamp = get_epoch_time_secs().saturating_sub(
8627+
signer_test.signer_configs[0]
8628+
.block_proposal_max_age_secs
8629+
.saturating_add(1),
8630+
);
8631+
let _block_signer_signature_hash_1 = block.header.signer_signature_hash();
8632+
signer_test.propose_block(block.clone(), short_timeout);
8633+
8634+
// Next propose a recent invalid block
8635+
block.header.timestamp = get_epoch_time_secs();
8636+
let block_signer_signature_hash_2 = block.header.signer_signature_hash();
8637+
signer_test.propose_block(block, short_timeout);
8638+
8639+
info!("------------------------- Test Block Proposal Rejected -------------------------");
8640+
// Verify the signers rejected only the SECOND block proposal. The first was not even processed.
8641+
wait_for(30, || {
8642+
let rejections: Vec<_> = test_observer::get_stackerdb_chunks()
8643+
.into_iter()
8644+
.flat_map(|chunk| chunk.modified_slots)
8645+
.map(|chunk| {
8646+
let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice())
8647+
else {
8648+
return None;
8649+
};
8650+
assert!(matches!(
8651+
message,
8652+
SignerMessage::BlockResponse(BlockResponse::Rejected(_))
8653+
));
8654+
let SignerMessage::BlockResponse(BlockResponse::Rejected(BlockRejection {
8655+
reason_code,
8656+
signer_signature_hash,
8657+
signature,
8658+
..
8659+
})) = message
8660+
else {
8661+
panic!("Received an unexpected block approval from the signer");
8662+
};
8663+
assert_eq!(
8664+
signer_signature_hash, block_signer_signature_hash_2,
8665+
"Received a rejection for an unexpected block: {signer_signature_hash}"
8666+
);
8667+
assert!(
8668+
matches!(reason_code, RejectCode::SortitionViewMismatch),
8669+
"Received a rejection for an unexpected reason: {reason_code}"
8670+
);
8671+
Some(signature)
8672+
})
8673+
.collect();
8674+
Ok(rejections.len() == num_signers)
8675+
})
8676+
.expect("Timed out waiting for block rejections");
8677+
8678+
info!("------------------------- Test Peer Info-------------------------");
8679+
assert_eq!(info_before, get_chain_info(&signer_test.running_nodes.conf));
8680+
8681+
info!("------------------------- Test Shutdown-------------------------");
8682+
signer_test.shutdown();
8683+
}

0 commit comments

Comments
 (0)