Skip to content

Commit bdc76d8

Browse files
authored
Merge pull request #5860 from stacks-network/fix/signerdb-pubkey
Fix: signer calculates miner pk with block header
2 parents bf1cc79 + fb39902 commit bdc76d8

File tree

5 files changed

+194
-18
lines changed

5 files changed

+194
-18
lines changed

libsigner/src/events.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,7 @@ impl StacksMessageCodec for BlockProposalData {
188188
pub enum SignerEvent<T: SignerEventTrait> {
189189
/// A miner sent a message over .miners
190190
/// The `Vec<T>` will contain any signer messages made by the miner.
191-
/// The `StacksPublicKey` is the message sender's public key.
192-
MinerMessages(Vec<T>, StacksPublicKey),
191+
MinerMessages(Vec<T>),
193192
/// The signer messages for other signers and miners to observe
194193
/// The u32 is the signer set to which the message belongs (either 0 or 1)
195194
SignerMessages(u32, Vec<T>),
@@ -513,20 +512,13 @@ impl<T: SignerEventTrait> TryFrom<StackerDBChunksEvent> for SignerEvent<T> {
513512
&& event.contract_id.is_boot()
514513
{
515514
let mut messages = vec![];
516-
let mut miner_pk = None;
517515
for chunk in event.modified_slots {
518516
let Ok(msg) = T::consensus_deserialize(&mut chunk.data.as_slice()) else {
519517
continue;
520518
};
521-
522-
miner_pk = Some(chunk.recover_pk().map_err(|e| {
523-
EventError::MalformedRequest(format!(
524-
"Failed to recover PK from StackerDB chunk: {e}"
525-
))
526-
})?);
527519
messages.push(msg);
528520
}
529-
SignerEvent::MinerMessages(messages, miner_pk.ok_or(EventError::EmptyChunksEvent)?)
521+
SignerEvent::MinerMessages(messages)
530522
} else if event.contract_id.name.starts_with(SIGNERS_NAME) && event.contract_id.is_boot() {
531523
let Some((signer_set, _)) =
532524
get_signers_db_signer_set_message_id(event.contract_id.name.as_str())

stacks-signer/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
2020
- Add new reject codes to the signer response for better visibility into why a block was rejected.
2121
- When allowing a reorg within the `reorg_attempts_activity_timeout_ms`, the signer will now watch the responses from other signers and if >30% of them reject this reorg attempt, then the signer will mark the miner as invalid, reject further attempts to reorg and allow the previous miner to extend their tenure.
2222

23+
### Fixed
24+
25+
- The signer runloop no longer relies on pubkey reports from the SignerDB event system. This previously led to improper proposal rejections via #5858.
26+
2327
## [3.1.0.0.5.0]
2428

2529
### Added

stacks-signer/src/v0/signer.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ impl SignerTrait<SignerMessage> for Signer {
184184
self.handle_block_response(stacks_client, block_response, sortition_state);
185185
}
186186
}
187-
SignerEvent::MinerMessages(messages, miner_pubkey) => {
187+
SignerEvent::MinerMessages(messages) => {
188188
debug!(
189189
"{self}: Received {} messages from the miner",
190190
messages.len();
@@ -196,11 +196,19 @@ impl SignerTrait<SignerMessage> for Signer {
196196
if self.test_ignore_all_block_proposals(block_proposal) {
197197
continue;
198198
}
199+
let Some(miner_pubkey) = block_proposal.block.header.recover_miner_pk()
200+
else {
201+
warn!("{self}: Failed to recover miner pubkey";
202+
"signer_sighash" => %block_proposal.block.header.signer_signature_hash(),
203+
"consensus_hash" => %block_proposal.block.header.consensus_hash);
204+
continue;
205+
};
206+
199207
self.handle_block_proposal(
200208
stacks_client,
201209
sortition_state,
202210
block_proposal,
203-
miner_pubkey,
211+
&miner_pubkey,
204212
);
205213
}
206214
SignerMessage::BlockPushed(b) => {

testnet/stacks-node/src/event_dispatcher.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,14 @@ lazy_static! {
8282
}
8383

8484
#[derive(Debug, Clone)]
85-
struct EventObserver {
85+
pub struct EventObserver {
8686
/// Path to the database where pending payloads are stored. If `None`, then
8787
/// the database is not used and events are not recoverable across restarts.
88-
db_path: Option<PathBuf>,
88+
pub db_path: Option<PathBuf>,
8989
/// URL to which events will be sent
90-
endpoint: String,
90+
pub endpoint: String,
9191
/// Timeout for sending events to this observer
92-
timeout: Duration,
92+
pub timeout: Duration,
9393
}
9494

9595
struct ReceiptPayloadInfo<'a> {
@@ -770,7 +770,7 @@ impl EventObserver {
770770
self.send_payload(payload, PATH_MINED_NAKAMOTO_BLOCK);
771771
}
772772

773-
fn send_stackerdb_chunks(&self, payload: &serde_json::Value) {
773+
pub fn send_stackerdb_chunks(&self, payload: &serde_json::Value) {
774774
self.send_payload(payload, PATH_STACKERDB_CHUNKS);
775775
}
776776

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

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ use tracing_subscriber::prelude::*;
7777
use tracing_subscriber::{fmt, EnvFilter};
7878

7979
use super::SignerTest;
80-
use crate::event_dispatcher::{MinedNakamotoBlockEvent, TEST_SKIP_BLOCK_ANNOUNCEMENT};
80+
use crate::event_dispatcher::{
81+
EventObserver, MinedNakamotoBlockEvent, TEST_SKIP_BLOCK_ANNOUNCEMENT,
82+
};
8183
use crate::nakamoto_node::miner::{
8284
TEST_BLOCK_ANNOUNCE_STALL, TEST_BROADCAST_PROPOSAL_STALL, TEST_MINE_STALL,
8385
TEST_P2P_BROADCAST_STALL,
@@ -396,6 +398,10 @@ impl SignerTest<SpawnedSigner> {
396398
}
397399
}
398400

401+
fn get_miner_key(&self) -> &Secp256k1PrivateKey {
402+
self.running_nodes.conf.miner.mining_key.as_ref().unwrap()
403+
}
404+
399405
/// Propose a block to the signers
400406
fn propose_block(&mut self, block: NakamotoBlock, timeout: Duration) {
401407
let miners_contract_id = boot_code_id(MINERS_NAME, false);
@@ -407,6 +413,9 @@ impl SignerTest<SpawnedSigner> {
407413
.get_headers_height();
408414
let reward_cycle = self.get_current_reward_cycle();
409415
let signer_signature_hash = block.header.signer_signature_hash();
416+
let signed_by = block.header.recover_miner_pk().expect(
417+
"FATAL: signer tests should only propose blocks that have been signed by the signer test miner. Otherwise, signers won't even consider them via this channel."
418+
);
410419
let message = SignerMessage::BlockProposal(BlockProposal {
411420
block,
412421
burn_height,
@@ -419,6 +428,9 @@ impl SignerTest<SpawnedSigner> {
419428
.miner
420429
.mining_key
421430
.expect("No mining key");
431+
assert_eq!(signed_by, Secp256k1PublicKey::from_private(&miner_sk),
432+
"signer tests should only propose blocks that have been signed by the signer test miner. Otherwise, signers won't even consider them via this channel.");
433+
422434
// Submit the block proposal to the miner's slot
423435
let mut accepted = false;
424436
let mut version = 0;
@@ -1256,6 +1268,10 @@ fn block_proposal_rejection() {
12561268

12571269
// First propose a block to the signers that does not have the correct consensus hash or BitVec. This should be rejected BEFORE
12581270
// the block is submitted to the node for validation.
1271+
block
1272+
.header
1273+
.sign_miner(signer_test.get_miner_key())
1274+
.unwrap();
12591275
let block_signer_signature_hash_1 = block.header.signer_signature_hash();
12601276
signer_test.propose_block(block.clone(), short_timeout);
12611277

@@ -1268,6 +1284,10 @@ fn block_proposal_rejection() {
12681284
block.header.consensus_hash = view.cur_sortition.consensus_hash;
12691285
block.header.chain_length = 35; // We have mined 35 blocks so far.
12701286

1287+
block
1288+
.header
1289+
.sign_miner(signer_test.get_miner_key())
1290+
.unwrap();
12711291
let block_signer_signature_hash_2 = block.header.signer_signature_hash();
12721292
signer_test.propose_block(block, short_timeout);
12731293

@@ -1430,6 +1450,130 @@ fn mine_2_nakamoto_reward_cycles() {
14301450
signer_test.shutdown();
14311451
}
14321452

1453+
#[test]
1454+
#[ignore]
1455+
/// This test is a regression test for issue #5858 in which the signer runloop
1456+
/// used the signature from the stackerdb to determine the miner public key.
1457+
/// This does not work in cases where events get coalesced. The fix was to use
1458+
/// the signature in the proposal's block header instead.
1459+
///
1460+
/// This test covers the regression by adding a thread that interposes on the
1461+
/// stackerdb events sent to the test signers and mutating the signatures
1462+
/// so that the stackerdb chunks are signed by the wrong signer. After the
1463+
/// fix to #5848, signers are resilient to this behavior because they check
1464+
/// the signature on the block proposal (not the chunk).
1465+
fn regr_use_block_header_pk() {
1466+
if env::var("BITCOIND_TEST") != Ok("1".into()) {
1467+
return;
1468+
}
1469+
1470+
tracing_subscriber::registry()
1471+
.with(fmt::layer())
1472+
.with(EnvFilter::from_default_env())
1473+
.init();
1474+
1475+
info!("------------------------- Test Setup -------------------------");
1476+
let num_signers = 5;
1477+
let signer_listeners: Mutex<Vec<String>> = Mutex::default();
1478+
let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
1479+
num_signers,
1480+
vec![],
1481+
|_| {},
1482+
|node_config| {
1483+
node_config.events_observers = node_config
1484+
.events_observers
1485+
.clone()
1486+
.into_iter()
1487+
.map(|mut event_observer| {
1488+
if event_observer
1489+
.endpoint
1490+
.ends_with(&test_observer::EVENT_OBSERVER_PORT.to_string())
1491+
{
1492+
event_observer
1493+
} else if event_observer
1494+
.events_keys
1495+
.contains(&EventKeyType::StackerDBChunks)
1496+
{
1497+
event_observer
1498+
.events_keys
1499+
.retain(|key| *key != EventKeyType::StackerDBChunks);
1500+
let mut listeners_lock = signer_listeners.lock().unwrap();
1501+
listeners_lock.push(event_observer.endpoint.clone());
1502+
event_observer
1503+
} else {
1504+
event_observer
1505+
}
1506+
})
1507+
.collect();
1508+
},
1509+
None,
1510+
None,
1511+
);
1512+
1513+
let signer_listeners: Vec<_> = signer_listeners
1514+
.lock()
1515+
.unwrap()
1516+
.drain(..)
1517+
.map(|endpoint| EventObserver {
1518+
endpoint,
1519+
db_path: None,
1520+
timeout: Duration::from_secs(120),
1521+
})
1522+
.collect();
1523+
1524+
let bad_signer = Secp256k1PrivateKey::from_seed(&[0xde, 0xad, 0xbe, 0xef]);
1525+
let bad_signer_pk = Secp256k1PublicKey::from_private(&bad_signer);
1526+
1527+
let broadcast_thread_stopper = Arc::new(AtomicBool::new(true));
1528+
let broadcast_thread_flag = broadcast_thread_stopper.clone();
1529+
let broadcast_thread = thread::Builder::new()
1530+
.name("rebroadcast-thread".into())
1531+
.spawn(move || {
1532+
let mut last_sent = 0;
1533+
while broadcast_thread_flag.load(Ordering::SeqCst) {
1534+
thread::sleep(Duration::from_secs(1));
1535+
let mut signerdb_chunks = test_observer::get_stackerdb_chunks();
1536+
if last_sent >= signerdb_chunks.len() {
1537+
continue;
1538+
}
1539+
let mut to_send = signerdb_chunks.split_off(last_sent);
1540+
last_sent = signerdb_chunks.len();
1541+
for event in to_send.iter_mut() {
1542+
// mutilate the signature
1543+
event.modified_slots.iter_mut().for_each(|chunk_data| {
1544+
let pk = chunk_data.recover_pk().unwrap();
1545+
assert_ne!(pk, bad_signer_pk);
1546+
chunk_data.sign(&bad_signer).unwrap();
1547+
});
1548+
1549+
let payload = serde_json::to_value(event).unwrap();
1550+
for signer_listener in signer_listeners.iter() {
1551+
signer_listener.send_stackerdb_chunks(&payload);
1552+
}
1553+
}
1554+
}
1555+
})
1556+
.unwrap();
1557+
1558+
let timeout = Duration::from_secs(200);
1559+
signer_test.boot_to_epoch_3();
1560+
1561+
let prior_stacks_height = signer_test.get_peer_info().stacks_tip_height;
1562+
1563+
let tenures_to_mine = 2;
1564+
for _i in 0..tenures_to_mine {
1565+
signer_test.mine_nakamoto_block(timeout, false);
1566+
}
1567+
1568+
let current_stacks_height = signer_test.get_peer_info().stacks_tip_height;
1569+
1570+
assert!(current_stacks_height >= prior_stacks_height + tenures_to_mine);
1571+
1572+
broadcast_thread_stopper.store(false, Ordering::SeqCst);
1573+
broadcast_thread.join().unwrap();
1574+
signer_test.shutdown();
1575+
}
1576+
14331577
#[test]
14341578
#[ignore]
14351579
fn forked_tenure_invalid() {
@@ -7067,6 +7211,10 @@ fn block_validation_response_timeout() {
70677211
block.header.consensus_hash = view.cur_sortition.consensus_hash;
70687212
block.header.chain_length = info_before.stacks_tip_height + 1;
70697213

7214+
block
7215+
.header
7216+
.sign_miner(signer_test.get_miner_key())
7217+
.unwrap();
70707218
let block_signer_signature_hash_1 = block.header.signer_signature_hash();
70717219
signer_test.propose_block(block, timeout);
70727220

@@ -7352,6 +7500,10 @@ fn block_validation_pending_table() {
73527500
block.header.pox_treatment = BitVec::ones(1).unwrap();
73537501
block.header.consensus_hash = view.cur_sortition.consensus_hash;
73547502
block.header.chain_length = peer_info.stacks_tip_height + 1;
7503+
block
7504+
.header
7505+
.sign_miner(signer_test.get_miner_key())
7506+
.unwrap();
73557507
let block_signer_signature_hash = block.header.signer_signature_hash();
73567508
signer_test.propose_block(block.clone(), short_timeout);
73577509

@@ -8009,11 +8161,19 @@ fn block_proposal_max_age_rejections() {
80098161
.block_proposal_max_age_secs
80108162
.saturating_add(1),
80118163
);
8164+
block
8165+
.header
8166+
.sign_miner(signer_test.get_miner_key())
8167+
.unwrap();
80128168
let block_signer_signature_hash_1 = block.header.signer_signature_hash();
80138169
signer_test.propose_block(block.clone(), short_timeout);
80148170

80158171
// Next propose a recent invalid block
80168172
block.header.timestamp = get_epoch_time_secs();
8173+
block
8174+
.header
8175+
.sign_miner(signer_test.get_miner_key())
8176+
.unwrap();
80178177
let block_signer_signature_hash_2 = block.header.signer_signature_hash();
80188178
signer_test.propose_block(block, short_timeout);
80198179

@@ -8614,6 +8774,10 @@ fn incoming_signers_ignore_block_proposals() {
86148774
txs: vec![],
86158775
};
86168776
block.header.timestamp = get_epoch_time_secs();
8777+
block
8778+
.header
8779+
.sign_miner(signer_test.get_miner_key())
8780+
.unwrap();
86178781
let signer_signature_hash_1 = block.header.signer_signature_hash();
86188782

86198783
info!("------------------------- Test Attempt to Mine Invalid Block {signer_signature_hash_1} -------------------------");
@@ -8632,6 +8796,10 @@ fn incoming_signers_ignore_block_proposals() {
86328796
block.header.consensus_hash = view.cur_sortition.consensus_hash;
86338797
block.header.chain_length =
86348798
get_chain_info(&signer_test.running_nodes.conf).stacks_tip_height + 1;
8799+
block
8800+
.header
8801+
.sign_miner(signer_test.get_miner_key())
8802+
.unwrap();
86358803
let signer_signature_hash_2 = block.header.signer_signature_hash();
86368804

86378805
info!("------------------------- Test Attempt to Mine Invalid Block {signer_signature_hash_2} -------------------------");
@@ -8800,6 +8968,10 @@ fn outgoing_signers_ignore_block_proposals() {
88008968
block.header.consensus_hash = view.cur_sortition.consensus_hash;
88018969
block.header.chain_length =
88028970
get_chain_info(&signer_test.running_nodes.conf).stacks_tip_height + 1;
8971+
block
8972+
.header
8973+
.sign_miner(signer_test.get_miner_key())
8974+
.unwrap();
88038975
let signer_signature_hash = block.header.signer_signature_hash();
88048976

88058977
info!("------------------------- Test Attempt to Mine Invalid Block {signer_signature_hash} -------------------------");

0 commit comments

Comments
 (0)