• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacks-network / stacks-core / 26250451051-1

21 May 2026 08:11PM UTC coverage: 85.585% (-0.1%) from 85.712%
26250451051-1

Pull #7215

github

ec9d4c
web-flow
Merge 9487bf852 into af1280aac
Pull Request #7215: Chore: fix flake in non_blocking_minority_configured_to_favour_...

188844 of 220651 relevant lines covered (85.58%)

18975267.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.46
/stacks-node/src/nakamoto_node/signer_coordinator.rs
1
// Copyright (C) 2024-2026 Stacks Open Internet Foundation
2
//
3
// This program is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// This program is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
15

16
use std::collections::{BTreeMap, HashMap, HashSet};
17
use std::ops::Bound::Included;
18
use std::sync::atomic::AtomicBool;
19
use std::sync::{Arc, Mutex};
20
use std::thread::JoinHandle;
21
use std::time::{Duration, Instant};
22

23
use libsigner::v0::messages::{MinerSlotID, SignerMessage as SignerMessageV0};
24
use libsigner::v0::signer_state::SignerStateMachine;
25
use libsigner::{BlockProposal, BlockProposalData, SignerSession, StackerDBSession};
26
use stacks::burnchains::Burnchain;
27
use stacks::chainstate::burn::db::sortdb::SortitionDB;
28
use stacks::chainstate::burn::{BlockSnapshot, ConsensusHash};
29
use stacks::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState};
30
use stacks::chainstate::stacks::boot::{RewardSet, MINERS_NAME};
31
use stacks::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState};
32
use stacks::chainstate::stacks::Error as ChainstateError;
33
use stacks::codec::StacksMessageCodec;
34
use stacks::libstackerdb::StackerDBChunkData;
35
use stacks::net::stackerdb::StackerDBs;
36
use stacks::types::chainstate::{StacksBlockId, StacksPrivateKey, StacksPublicKey};
37
use stacks::types::MinerDiagnosticData;
38
use stacks::util::hash::Sha512Trunc256Sum;
39
use stacks::util::secp256k1::MessageSignature;
40
use stacks::util_lib::boot::boot_code_id;
41

42
use super::miner_db::MinerDB;
43
use super::stackerdb_listener::StackerDBListenerComms;
44
use super::Error as NakamotoNodeError;
45
use crate::event_dispatcher::StackerDBChannel;
46
use crate::nakamoto_node::stackerdb_listener::{StackerDBListener, EVENT_RECEIVER_POLL};
47
use crate::neon::Counters;
48
use crate::Config;
49

50
/// The state of the signer database listener, used by the miner thread to
51
/// interact with the signer listener.
52
pub struct SignerCoordinator {
53
    /// The private key used to sign messages from the miner
54
    message_key: StacksPrivateKey,
55
    /// Is this mainnet?
56
    is_mainnet: bool,
57
    /// The session for writing to the miners contract in the stackerdb
58
    miners_session: StackerDBSession,
59
    /// The total weight of all signers
60
    total_weight: u32,
61
    /// The weight threshold for block approval
62
    weight_threshold: u32,
63
    /// Interface to the StackerDB listener thread's data
64
    stackerdb_comms: StackerDBListenerComms,
65
    /// Keep running flag for the signer DB listener thread
66
    keep_running: Arc<AtomicBool>,
67
    /// Handle for the signer DB listener thread
68
    listener_thread: Option<JoinHandle<()>>,
69
    /// The current tip when this miner thread was started.
70
    /// This *should not* be passed into any block building code, as it
71
    ///  is not necessarily the burn view for the block being constructed.
72
    /// Rather, this burn block is used to determine whether or not a new
73
    ///  burn block has arrived since this thread started.
74
    burn_tip_at_start: ConsensusHash,
75
    /// The timeout configuration based on the percentage of rejections
76
    block_rejection_timeout_steps: BTreeMap<u32, Duration>,
77
}
78

79
/// Helper function to build block_rejection_timeout_steps BTreeMap from config.
80
///
81
/// When multiple percentages truncate to the same rejections_amount
82
/// (common with small total_weight), keeps the longest timeout.
83
fn build_block_rejection_timeout_steps(
1,346✔
84
    total_weight: u32,
1,346✔
85
    config_steps: &HashMap<u32, Duration>,
1,346✔
86
) -> BTreeMap<u32, Duration> {
1,346✔
87
    let mut block_rejection_timeout_steps = BTreeMap::<u32, Duration>::new();
1,346✔
88
    for (percentage, duration) in config_steps.iter() {
5,348✔
89
        let rejections_amount = ((f64::from(total_weight) / 100.0) * f64::from(*percentage)) as u32;
5,348✔
90
        // If multiple percentages collapse to the same weight, keep the longest timeout.
91
        if let Some(existing) = block_rejection_timeout_steps.get_mut(&rejections_amount) {
5,348✔
92
            warn!(
93✔
93
                "block_rejection_timeout_steps collision: percentages map to same rejection weight";
94
                "rejections_amount" => rejections_amount,
93✔
95
                "existing_timeout_secs" => existing.as_secs(),
93✔
96
                "new_timeout_secs" => duration.as_secs(),
93✔
97
            );
98
            *existing = (*existing).max(*duration);
93✔
99
        } else {
5,255✔
100
            block_rejection_timeout_steps.insert(rejections_amount, *duration);
5,255✔
101
        }
5,255✔
102
    }
103

104
    block_rejection_timeout_steps
1,346✔
105
}
1,346✔
106

107
impl SignerCoordinator {
108
    /// Create a new `SignerCoordinator` instance.
109
    /// This will spawn a new thread to listen for messages from the signer DB.
110
    pub fn new(
1,344✔
111
        stackerdb_channel: Arc<Mutex<StackerDBChannel>>,
1,344✔
112
        node_keep_running: Arc<AtomicBool>,
1,344✔
113
        reward_set: &RewardSet,
1,344✔
114
        election_block: &BlockSnapshot,
1,344✔
115
        burnchain: &Burnchain,
1,344✔
116
        message_key: StacksPrivateKey,
1,344✔
117
        config: &Config,
1,344✔
118
        burn_tip_at_start: &ConsensusHash,
1,344✔
119
    ) -> Result<Self, ChainstateError> {
1,344✔
120
        info!("SignerCoordinator: starting up");
1,344✔
121
        let keep_running = Arc::new(AtomicBool::new(true));
1,344✔
122

123
        // Create the stacker DB listener
124
        let mut listener = StackerDBListener::new(
1,344✔
125
            stackerdb_channel,
1,344✔
126
            node_keep_running,
1,344✔
127
            keep_running.clone(),
1,344✔
128
            reward_set,
1,344✔
129
            election_block,
1,344✔
130
            burnchain,
1,344✔
131
            config,
1,344✔
132
        )?;
×
133
        let is_mainnet = config.is_mainnet();
1,344✔
134
        let rpc_socket = config
1,344✔
135
            .node
1,344✔
136
            .get_rpc_loopback()
1,344✔
137
            .ok_or_else(|| ChainstateError::MinerAborted)?;
1,344✔
138
        let miners_contract_id = boot_code_id(MINERS_NAME, is_mainnet);
1,344✔
139
        let miners_session = StackerDBSession::new(
1,344✔
140
            &rpc_socket.to_string(),
1,344✔
141
            miners_contract_id,
1,344✔
142
            config.miner.stackerdb_timeout,
1,344✔
143
        );
144

145
        // build a BTreeMap of the various timeout steps
146
        let block_rejection_timeout_steps = build_block_rejection_timeout_steps(
1,344✔
147
            listener.total_weight,
1,344✔
148
            &config.miner.block_rejection_timeout_steps,
1,344✔
149
        );
150

151
        let mut sc = Self {
1,344✔
152
            message_key,
1,344✔
153
            is_mainnet,
1,344✔
154
            miners_session,
1,344✔
155
            total_weight: listener.total_weight,
1,344✔
156
            weight_threshold: listener.weight_threshold,
1,344✔
157
            stackerdb_comms: listener.get_comms(),
1,344✔
158
            keep_running,
1,344✔
159
            listener_thread: None,
1,344✔
160
            burn_tip_at_start: burn_tip_at_start.clone(),
1,344✔
161
            block_rejection_timeout_steps,
1,344✔
162
        };
1,344✔
163

164
        // Spawn the signer DB listener thread
165
        let listener_thread = std::thread::Builder::new()
1,344✔
166
            .name(format!(
1,344✔
167
                "stackerdb_listener_{}",
168
                election_block.block_height
169
            ))
170
            .spawn(move || {
1,344✔
171
                if let Err(e) = listener.run() {
1,344✔
172
                    error!("StackerDBListener: exited with error: {e:?}");
18✔
173
                }
1,326✔
174
            })
1,344✔
175
            .map_err(|e| {
1,344✔
176
                error!("Failed to spawn stackerdb_listener thread: {e:?}");
×
177
                ChainstateError::MinerAborted
×
178
            })?;
×
179

180
        sc.listener_thread = Some(listener_thread);
1,344✔
181

182
        Ok(sc)
1,344✔
183
    }
1,344✔
184

185
    /// Send a message over the miners contract using a `StacksPrivateKey`
186
    #[allow(clippy::too_many_arguments)]
187
    pub fn send_miners_message<M: StacksMessageCodec>(
4,745✔
188
        miner_sk: &StacksPrivateKey,
4,745✔
189
        sortdb: &SortitionDB,
4,745✔
190
        tip: &BlockSnapshot,
4,745✔
191
        stackerdbs: &StackerDBs,
4,745✔
192
        message: M,
4,745✔
193
        miner_slot_id: MinerSlotID,
4,745✔
194
        is_mainnet: bool,
4,745✔
195
        miners_session: &mut StackerDBSession,
4,745✔
196
        election_sortition: &ConsensusHash,
4,745✔
197
        miner_db: &MinerDB,
4,745✔
198
    ) -> Result<(), NakamotoNodeError> {
4,745✔
199
        let Some(slot_range) = NakamotoChainState::get_miner_slot(sortdb, tip, election_sortition)
4,745✔
200
            .map_err(|e| {
4,745✔
201
                NakamotoNodeError::SigningCoordinatorFailure(format!(
×
202
                    "Failed to read miner slot information: {e:?}"
×
203
                ))
×
204
            })?
×
205
        else {
206
            return Err(NakamotoNodeError::SigningCoordinatorFailure(
×
207
                "No slot for miner".into(),
×
208
            ));
×
209
        };
210

211
        let slot_id = slot_range
4,745✔
212
            .start
4,745✔
213
            .saturating_add(miner_slot_id.to_u8().into());
4,745✔
214
        if !slot_range.contains(&slot_id) {
4,745✔
215
            return Err(NakamotoNodeError::SigningCoordinatorFailure(
×
216
                "Not enough slots for miner messages".into(),
×
217
            ));
×
218
        }
4,745✔
219
        // Get the LAST slot version number written to the DB. If not found, use 0.
220
        // Add 1 to get the NEXT version number
221
        // Note: we already check above for the slot's existence
222
        let miners_contract_id = boot_code_id(MINERS_NAME, is_mainnet);
4,745✔
223
        let mut slot_version = stackerdbs
4,745✔
224
            .get_slot_version(&miners_contract_id, slot_id)
4,745✔
225
            .map_err(|e| {
4,745✔
226
                NakamotoNodeError::SigningCoordinatorFailure(format!(
×
227
                    "Failed to read slot version: {e:?}"
×
228
                ))
×
229
            })?
×
230
            .unwrap_or(0)
4,745✔
231
            .saturating_add(1);
4,745✔
232
        let miner_pk = StacksPublicKey::from_private(miner_sk);
4,745✔
233
        if let Some(prior_version) = miner_db.get_latest_chunk_version(&miner_pk, slot_id)? {
4,745✔
234
            if slot_version <= prior_version {
3,969✔
235
                slot_version = prior_version.saturating_add(1);
24✔
236
            }
3,945✔
237
        }
776✔
238

239
        let mut chunk = StackerDBChunkData::new(slot_id, slot_version, message.serialize_to_vec());
4,745✔
240
        chunk.sign(miner_sk).map_err(|e| {
4,745✔
241
            NakamotoNodeError::SigningCoordinatorFailure(format!(
×
242
                "Failed to sign StackerDB chunk: {e:?}"
×
243
            ))
×
244
        })?;
×
245

246
        match miners_session.put_chunk(&chunk) {
4,745✔
247
            Ok(ack) => {
4,726✔
248
                if ack.accepted {
4,726✔
249
                    miner_db.set_latest_chunk_version(&miner_pk, slot_id, slot_version)?;
4,726✔
250
                    debug!("Wrote message to stackerdb: {ack:?}");
4,726✔
251
                    Ok(())
4,726✔
252
                } else {
253
                    Err(NakamotoNodeError::StackerDBUploadError(ack))
×
254
                }
255
            }
256
            Err(e) => Err(NakamotoNodeError::SigningCoordinatorFailure(format!(
19✔
257
                "{e:?}"
19✔
258
            ))),
19✔
259
        }
260
    }
4,745✔
261

262
    /// Propose a Nakamoto block and gather signatures for it.
263
    /// This function begins by sending a `BlockProposal` message to the
264
    /// signers, and then it waits for the signers to respond with their
265
    /// signatures. It does so in two ways, concurrently:
266
    /// * It waits for the signer DB listener to collect enough signatures to
267
    ///   accept or reject the block
268
    /// * It waits for the chainstate to contain the relayed block. If so, then its signatures are
269
    ///   loaded and returned. This can happen if the node receives the block via a signer who
270
    ///   fetched all signatures and assembled the signature vector, all before we could.
271
    // Mutants skip here: this function is covered via integration tests,
272
    //  which the mutation testing does not see.
273
    #[cfg_attr(test, mutants::skip)]
274
    #[allow(clippy::too_many_arguments)]
275
    pub fn propose_block(
2,346✔
276
        &mut self,
2,346✔
277
        block: &NakamotoBlock,
2,346✔
278
        burnchain: &Burnchain,
2,346✔
279
        sortdb: &SortitionDB,
2,346✔
280
        chain_state: &mut StacksChainState,
2,346✔
281
        stackerdbs: &StackerDBs,
2,346✔
282
        counters: &Counters,
2,346✔
283
        election_sortition: &BlockSnapshot,
2,346✔
284
        miner_db: &MinerDB,
2,346✔
285
        miner_diagnostic_data: MinerDiagnosticData,
2,346✔
286
    ) -> Result<Vec<MessageSignature>, NakamotoNodeError> {
2,346✔
287
        // Add this block to the block status map.
288
        self.stackerdb_comms.insert_block(&block.header);
2,346✔
289

290
        let reward_cycle_id = burnchain
2,346✔
291
            .block_height_to_reward_cycle(election_sortition.block_height)
2,346✔
292
            .expect("FATAL: tried to initialize coordinator before first burn block height");
2,346✔
293

294
        let block_proposal = BlockProposal {
2,346✔
295
            block: block.clone(),
2,346✔
296
            burn_height: election_sortition.block_height,
2,346✔
297
            reward_cycle: reward_cycle_id,
2,346✔
298
            block_proposal_data: BlockProposalData::from_current_version(miner_diagnostic_data),
2,346✔
299
        };
2,346✔
300

301
        let block_proposal_message = SignerMessageV0::BlockProposal(block_proposal);
2,346✔
302

303
        loop {
304
            debug!("Sending block proposal message to signers";
2,348✔
305
                "signer_signature_hash" => %block.header.signer_signature_hash(),
×
306
            );
307
            Self::send_miners_message::<SignerMessageV0>(
2,348✔
308
                &self.message_key,
2,348✔
309
                sortdb,
2,348✔
310
                election_sortition,
2,348✔
311
                stackerdbs,
2,348✔
312
                block_proposal_message.clone(),
2,348✔
313
                MinerSlotID::BlockProposal,
2,348✔
314
                self.is_mainnet,
2,348✔
315
                &mut self.miners_session,
2,348✔
316
                &election_sortition.consensus_hash,
2,348✔
317
                miner_db,
2,348✔
318
            )?;
16✔
319
            counters.bump_naka_proposed_blocks();
2,332✔
320

321
            #[cfg(test)]
322
            {
323
                info!(
2,332✔
324
                "SignerCoordinator: sent block proposal to .miners, waiting for test signing channel"
325
            );
326
                // In test mode, short-circuit waiting for the signers if the TEST_SIGNING
327
                //  channel has been created. This allows integration tests for the stacks-node
328
                //  independent of the stacks-signer.
329
                if let Some(signatures) =
1,244✔
330
                    crate::tests::nakamoto_integrations::TestSigningChannel::get_signature()
2,332✔
331
                {
332
                    debug!("Short-circuiting waiting for signers, using test signature");
1,244✔
333
                    return Ok(signatures);
1,244✔
334
                }
1,088✔
335
            }
336

337
            let res = self.get_block_status(
1,088✔
338
                &block.header.signer_signature_hash(),
1,088✔
339
                &block.block_id(),
1,088✔
340
                &block.header.parent_block_id,
1,088✔
341
                chain_state,
1,088✔
342
                sortdb,
1,088✔
343
                counters,
1,088✔
344
            );
345

346
            match res {
112✔
347
                Err(NakamotoNodeError::SignatureTimeout) => {
348
                    info!("Block proposal signing process timed out, resending the same proposal");
2✔
349
                    continue;
2✔
350
                }
351
                _ => return res,
1,086✔
352
            }
353
        }
354
    }
2,346✔
355

356
    /// Get the block status for a given block hash.
357
    /// If we have not yet received enough signatures for this block, this
358
    /// method will block until we do. If this block shows up in the staging DB
359
    /// before we have enough signatures, we will return the signatures from
360
    /// there. If a new burnchain tip is detected, we will return an error.
361
    fn get_block_status(
1,088✔
362
        &self,
1,088✔
363
        block_signer_sighash: &Sha512Trunc256Sum,
1,088✔
364
        block_id: &StacksBlockId,
1,088✔
365
        parent_block_id: &StacksBlockId,
1,088✔
366
        chain_state: &mut StacksChainState,
1,088✔
367
        sortdb: &SortitionDB,
1,088✔
368
        counters: &Counters,
1,088✔
369
    ) -> Result<Vec<MessageSignature>, NakamotoNodeError> {
1,088✔
370
        // the amount of current rejections (used to eventually modify the timeout)
371
        let mut rejections: u32 = 0;
1,088✔
372
        // default timeout (the 0 entry must be always present)
373
        let mut rejections_timeout = self
1,088✔
374
            .block_rejection_timeout_steps
1,088✔
375
            .get(&rejections)
1,088✔
376
            .ok_or_else(|| {
1,088✔
377
                NakamotoNodeError::SigningCoordinatorFailure(
×
378
                    "Invalid rejection timeout step function definition".into(),
×
379
                )
×
380
            })?;
×
381

382
        let parent_tenure_header =
1,088✔
383
            NakamotoChainState::get_block_header(chain_state.db(), parent_block_id)?
1,088✔
384
                .ok_or(NakamotoNodeError::UnexpectedChainState)?;
1,088✔
385

386
        // this is used to track the start of the waiting cycle
387
        let rejections_timer = Instant::now();
1,088✔
388
        loop {
389
            // At every iteration wait for the block_status.
390
            // Exit when the amount of confirmations/rejections reaches the threshold (or until timeout)
391
            // Based on the amount of rejections, eventually modify the timeout.
392
            let block_status = match self.stackerdb_comms.wait_for_block_status(
6,081✔
393
                block_signer_sighash,
6,081✔
394
                EVENT_RECEIVER_POLL,
6,081✔
395
                |status| {
12,143✔
396
                    // rejections-based timeout expired?
397
                    if rejections_timer.elapsed() > *rejections_timeout {
12,143✔
398
                        return false;
2✔
399
                    }
12,141✔
400
                    // number of rejections changed?
401
                    if status.total_weight_rejected != rejections {
12,141✔
402
                        return false;
125✔
403
                    }
12,016✔
404
                    // enough signatures?
405
                    return status.total_weight_approved < self.weight_threshold;
12,016✔
406
                },
12,143✔
407
            )? {
×
408
                Some(status) => status,
1,072✔
409
                None => {
410
                    // If we just received a timeout, we should check if the burnchain
411
                    // tip has changed or if we received this signed block already in
412
                    // the staging db.
413
                    debug!("SignerCoordinator: Intermediate timeout waiting for block status");
5,009✔
414

415
                    // Look in the nakamoto staging db -- a block can only get stored there
416
                    // if it has enough signing weight to clear the threshold.
417
                    if let Ok(Some((stored_block, _sz))) = chain_state
5,009✔
418
                        .nakamoto_blocks_db()
5,009✔
419
                        .get_nakamoto_block(block_id)
5,009✔
420
                        .map_err(|e| {
5,009✔
421
                            warn!(
×
422
                                "Failed to query chainstate for block: {e:?}";
423
                                "block_id" => %block_id,
424
                                "signer_signature_hash" => %block_signer_sighash,
425
                            );
426
                            e
×
427
                        })
×
428
                    {
429
                        debug!("SignCoordinator: Found signatures in relayed block");
31✔
430
                        counters.bump_naka_signer_pushed_blocks();
31✔
431
                        return Ok(stored_block.header.signer_signature);
31✔
432
                    }
4,978✔
433

434
                    if self.check_burn_tip_changed(sortdb) {
4,978✔
435
                        debug!("SignCoordinator: Exiting due to new burnchain tip");
17✔
436
                        return Err(NakamotoNodeError::BurnchainTipChanged);
17✔
437
                    }
4,961✔
438

439
                    if rejections_timer.elapsed() > *rejections_timeout {
4,961✔
440
                        warn!("Timed out while waiting for responses from signers, resending proposal";
×
441
                            "elapsed" => rejections_timer.elapsed().as_secs(),
×
442
                            "rejections_timeout" => rejections_timeout.as_secs(),
×
443
                            "rejections" => rejections,
×
444
                            "rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
×
445
                        );
446

447
                        // Reset the rejections in the stackerdb listener
448
                        self.stackerdb_comms.reset_rejections(block_signer_sighash);
×
449

450
                        return Err(NakamotoNodeError::SignatureTimeout);
×
451
                    }
4,961✔
452

453
                    // Check if a new Stacks block has arrived in the parent tenure
454
                    let highest_in_tenure =
4,961✔
455
                        NakamotoChainState::find_highest_known_block_header_in_tenure(
4,961✔
456
                            &chain_state,
4,961✔
457
                            &sortdb,
4,961✔
458
                            &parent_tenure_header.consensus_hash,
4,961✔
459
                        )?
×
460
                        .ok_or(NakamotoNodeError::UnexpectedChainState)?;
4,961✔
461
                    let highest_stacks_block_id = highest_in_tenure.index_block_hash();
4,961✔
462
                    if &highest_stacks_block_id == block_id {
4,961✔
463
                        // the block was included in the chainstate since we last checked!
464
                        let StacksBlockHeaderTypes::Nakamoto(stored_block) =
×
465
                            highest_in_tenure.anchored_header
×
466
                        else {
467
                            error!("Nakamoto miner produced a non-nakamoto block");
×
468
                            return Err(NakamotoNodeError::UnexpectedChainState);
×
469
                        };
470
                        return Ok(stored_block.signer_signature);
×
471
                    } else if &highest_stacks_block_id != parent_block_id {
4,961✔
472
                        info!("SignCoordinator: Exiting due to new stacks tip";
3✔
473
                              "new_block_hash" => %highest_in_tenure.anchored_header.block_hash(),
3✔
474
                              "new_block_height" => %highest_in_tenure.anchored_header.height(),
3✔
475
                        );
476
                        return Err(NakamotoNodeError::StacksTipChanged);
3✔
477
                    }
4,958✔
478

479
                    continue;
4,958✔
480
                }
481
            };
482

483
            if rejections != block_status.total_weight_rejected {
1,072✔
484
                rejections = block_status.total_weight_rejected;
125✔
485
                let (rejections_step, new_rejections_timeout) = self
125✔
486
                    .block_rejection_timeout_steps
125✔
487
                    .range((Included(0), Included(rejections)))
125✔
488
                    .last()
125✔
489
                    .ok_or_else(|| {
125✔
490
                        NakamotoNodeError::SigningCoordinatorFailure(
×
491
                            "Invalid rejection timeout step function definition".into(),
×
492
                        )
×
493
                    })?;
×
494
                rejections_timeout = new_rejections_timeout;
125✔
495
                info!("Number of received rejections updated, resetting timeout";
125✔
496
                                    "rejections" => rejections,
125✔
497
                                    "rejections_timeout" => rejections_timeout.as_secs(),
125✔
498
                                    "rejections_step" => rejections_step,
125✔
499
                                    "rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold));
125✔
500

501
                counters.set_miner_current_rejections_timeout_secs(rejections_timeout.as_secs());
125✔
502
                counters.set_miner_current_rejections(rejections);
125✔
503
            }
947✔
504

505
            if block_status
1,072✔
506
                .total_weight_rejected
1,072✔
507
                .saturating_add(self.weight_threshold)
1,072✔
508
                > self.total_weight
1,072✔
509
            {
510
                info!(
90✔
511
                    "{}/{} signer weight votes to reject block",
512
                    block_status.total_weight_rejected, self.total_weight;
513
                    "signer_signature_hash" => %block_signer_sighash,
514
                );
515
                counters.bump_naka_rejected_blocks();
90✔
516

517
                // Only act on failed txids that a blocking minority (>30% weight) agrees on
518
                let blocking_minority = self.total_weight.saturating_sub(self.weight_threshold);
90✔
519
                let mut temporarily_excluded_txids = HashSet::new();
90✔
520
                let mut permanently_excluded_txids = HashSet::new();
90✔
521
                for (txid, info) in &block_status.failed_txids {
90✔
522
                    if info.total_weight > blocking_minority {
2✔
523
                        // Do not perma ban txids that only a small minority of signers reported as problematic
524
                        // But make sure its removed from the next block proposal
525
                        if info.problematic_weight > blocking_minority {
2✔
526
                            permanently_excluded_txids.insert(txid.clone());
1✔
527
                        } else {
2✔
528
                            temporarily_excluded_txids.insert(txid.clone());
1✔
529
                        }
1✔
530
                    }
×
531
                }
532

533
                return Err(NakamotoNodeError::SignersRejected {
90✔
534
                    temporarily_excluded_txids,
90✔
535
                    permanently_excluded_txids,
90✔
536
                });
90✔
537
            } else if block_status.total_weight_approved >= self.weight_threshold {
982✔
538
                info!("Received enough signatures, block accepted";
945✔
539
                    "signer_signature_hash" => %block_signer_sighash,
540
                );
541
                return Ok(block_status.gathered_signatures.values().cloned().collect());
945✔
542
            } else if rejections_timer.elapsed() > *rejections_timeout {
37✔
543
                warn!("Timed out while waiting for responses from signers";
2✔
544
                    "elapsed" => rejections_timer.elapsed().as_secs(),
2✔
545
                    "rejections_timeout" => rejections_timeout.as_secs(),
2✔
546
                    "rejections" => rejections,
2✔
547
                    "rejections_threshold" => self.total_weight.saturating_sub(self.weight_threshold)
2✔
548
                );
549

550
                // Reset the rejections in the stackerdb listener
551
                self.stackerdb_comms.reset_rejections(block_signer_sighash);
2✔
552

553
                return Err(NakamotoNodeError::SignatureTimeout);
2✔
554
            } else {
555
                continue;
35✔
556
            }
557
        }
558
    }
1,088✔
559

560
    /// Get the timestamp at which at least 70% of the signing power should be
561
    /// willing to accept a time-based tenure extension.
562
    pub fn get_tenure_extend_timestamp(&self) -> u64 {
2,721✔
563
        self.stackerdb_comms
2,721✔
564
            .get_tenure_extend_timestamp(self.weight_threshold)
2,721✔
565
    }
2,721✔
566

567
    /// Get the timestamp at which at least 70% of the signing power should be
568
    /// willing to accept a time-based read-count extension.
569
    pub fn get_read_count_extend_timestamp(&self) -> u64 {
2,436✔
570
        self.stackerdb_comms
2,436✔
571
            .get_read_count_extend_timestamp(self.weight_threshold)
2,436✔
572
    }
2,436✔
573

574
    /// Get the signer global state view if there is one
575
    pub fn get_signer_global_state(&self) -> Option<SignerStateMachine> {
934✔
576
        self.stackerdb_comms.get_signer_global_state()
934✔
577
    }
934✔
578

579
    /// Check if the tenure needs to change
580
    fn check_burn_tip_changed(&self, sortdb: &SortitionDB) -> bool {
4,978✔
581
        let cur_burn_chain_tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn())
4,978✔
582
            .expect("FATAL: failed to query sortition DB for canonical burn chain tip");
4,978✔
583

584
        if cur_burn_chain_tip.consensus_hash != self.burn_tip_at_start {
4,978✔
585
            info!("SignCoordinator: Cancel signature aggregation; burnchain tip has changed");
17✔
586
            true
17✔
587
        } else {
588
            false
4,961✔
589
        }
590
    }
4,978✔
591

592
    pub fn shutdown(&mut self) {
1,142✔
593
        if let Some(listener_thread) = self.listener_thread.take() {
1,142✔
594
            info!("SignerCoordinator: shutting down stacker db listener thread");
1,142✔
595
            self.keep_running
1,142✔
596
                .store(false, std::sync::atomic::Ordering::Relaxed);
1,142✔
597
            if let Err(e) = listener_thread.join() {
1,142✔
598
                error!("Failed to join signer listener thread: {e:?}");
×
599
            }
1,142✔
600
            debug!("SignerCoordinator: stacker db listener thread has shut down");
1,142✔
601
        }
×
602
    }
1,142✔
603
}
604

605
#[cfg(test)]
606
mod tests {
607
    use std::collections::HashMap;
608
    use std::time::Duration;
609

610
    use super::build_block_rejection_timeout_steps;
611

612
    #[test]
613
    fn timeout_steps_keep_longest_on_collisions() {
1✔
614
        let mut steps = HashMap::new();
1✔
615
        steps.insert(0, Duration::from_secs(180));
1✔
616
        steps.insert(10, Duration::from_secs(90));
1✔
617
        steps.insert(20, Duration::from_secs(45));
1✔
618
        steps.insert(30, Duration::from_secs(0));
1✔
619

620
        let built = build_block_rejection_timeout_steps(3, &steps);
1✔
621

622
        assert_eq!(built.len(), 1);
1✔
623
        assert_eq!(built.get(&0), Some(&Duration::from_secs(180)));
1✔
624
    }
1✔
625

626
    #[test]
627
    fn timeout_steps_distinct_with_normal_weight() {
1✔
628
        let mut steps = HashMap::new();
1✔
629
        steps.insert(0, Duration::from_secs(180));
1✔
630
        steps.insert(10, Duration::from_secs(90));
1✔
631
        steps.insert(20, Duration::from_secs(45));
1✔
632
        steps.insert(30, Duration::from_secs(0));
1✔
633

634
        let built = build_block_rejection_timeout_steps(100, &steps);
1✔
635

636
        assert_eq!(built.get(&0), Some(&Duration::from_secs(180)));
1✔
637
        assert_eq!(built.get(&10), Some(&Duration::from_secs(90)));
1✔
638
        assert_eq!(built.get(&20), Some(&Duration::from_secs(45)));
1✔
639
        assert_eq!(built.get(&30), Some(&Duration::from_secs(0)));
1✔
640
    }
1✔
641
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc