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

stacks-network / stacks-core / 25394992339-1

05 May 2026 06:34PM UTC coverage: 85.691% (-0.02%) from 85.712%
25394992339-1

Pull #7183

github

6ed6d5
web-flow
Merge ffc2334e6 into 53ffba0ab
Pull Request #7183: Fix problematic transaction handling

115 of 135 new or added lines in 7 files covered. (85.19%)

4590 existing lines in 100 files now uncovered.

187724 of 219072 relevant lines covered (85.69%)

17710471.37 hits per line

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

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

17
use std::collections::VecDeque;
18
use std::hash::{DefaultHasher, Hash, Hasher};
19
#[cfg(any(test, feature = "testing"))]
20
use std::sync::LazyLock;
21
use std::thread::{self, JoinHandle};
22
use std::time::{Duration, Instant};
23

24
use clarity::vm::costs::ExecutionCost;
25
use clarity::vm::events::StacksTransactionEvent;
26
use clarity::vm::types::{ResponseData, TupleData};
27
use clarity::vm::Value;
28
use regex::{Captures, Regex};
29
use serde::Deserialize;
30
use stacks_common::codec::{Error as CodecError, StacksMessageCodec, MAX_PAYLOAD_LEN};
31
use stacks_common::consts::CHAIN_ID_MAINNET;
32
use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId};
33
use stacks_common::util::get_epoch_time_secs;
34
use stacks_common::util::hash::{hex_bytes, to_hex, Sha512Trunc256Sum};
35
#[cfg(any(test, feature = "testing"))]
36
use stacks_common::util::tests::TestFlag;
37

38
use crate::burnchains::Txid;
39
use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleConn};
40
use crate::chainstate::nakamoto::miner::{MinerTenureInfoCause, NakamotoBlockBuilder};
41
use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState, NAKAMOTO_BLOCK_VERSION};
42
use crate::chainstate::stacks::address::PoxAddress;
43
use crate::chainstate::stacks::boot::PoxVersions;
44
use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState, StacksHeaderInfo};
45
use crate::chainstate::stacks::miner::{
46
    BlockBuilder, BlockLimitFunction, TransactionError, TransactionProblematic, TransactionResult,
47
    TransactionSkipped,
48
};
49
use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload};
50
use crate::clarity_vm::clarity::ClarityError;
51
use crate::config::DEFAULT_MAX_TENURE_BYTES;
52
use crate::core::mempool::ProposalCallbackReceiver;
53
use crate::net::connection::ConnectionOptions;
54
use crate::net::http::{
55
    http_reason, parse_json, Error, HttpContentType, HttpRequest, HttpRequestContents,
56
    HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload,
57
    HttpResponsePreamble,
58
};
59
use crate::net::httpcore::RPCRequestHandler;
60
use crate::net::{Error as NetError, StacksNodeState};
61
use crate::util_lib::db::Error as db_error;
62

63
/// Test flag to stall block validation per endpoint with a matching passphrase
64
#[cfg(any(test, feature = "testing"))]
65
pub static TEST_VALIDATE_STALL: LazyLock<TestFlag<Vec<Option<String>>>> =
66
    LazyLock::new(TestFlag::default);
67

68
#[cfg(any(test, feature = "testing"))]
69
/// Artificial delay to add to block validation.
70
pub static TEST_VALIDATE_DELAY_DURATION_SECS: LazyLock<TestFlag<u64>> =
71
    LazyLock::new(TestFlag::default);
72

73
#[cfg(any(test, feature = "testing"))]
74
/// Mock for the set of transactions that must be replayed
75
pub static TEST_REPLAY_TRANSACTIONS: LazyLock<
76
    TestFlag<std::collections::VecDeque<StacksTransaction>>,
77
> = LazyLock::new(TestFlag::default);
78

79
#[cfg(any(test, feature = "testing"))]
80
/// Whether to reject any transaction while we're in a replay set.
81
pub static TEST_REJECT_REPLAY_TXS: LazyLock<TestFlag<bool>> = LazyLock::new(TestFlag::default);
82

83
// This enum is used to supply a `reason_code` for validation
84
//  rejection responses. This is serialized as an enum with string
85
//  type (in jsonschema terminology).
86
define_u8_enum![ValidateRejectCode {
87
    BadBlockHash = 0,
88
    BadTransaction = 1,
89
    InvalidBlock = 2,
90
    ChainstateError = 3,
91
    UnknownParent = 4,
92
    NonCanonicalTenure = 5,
93
    NoSuchTenure = 6,
94
    InvalidTransactionReplay = 7,
95
    InvalidParentBlock = 8,
96
    InvalidTimestamp = 9,
97
    NetworkChainMismatch = 10,
98
    NotFoundError = 11,
99
    ProblematicTransaction = 12
100
}];
101

102
pub static TOO_MANY_REQUESTS_STATUS: u16 = 429;
103

104
impl TryFrom<u8> for ValidateRejectCode {
105
    type Error = CodecError;
106
    fn try_from(value: u8) -> Result<Self, Self::Error> {
10,314✔
107
        Self::from_u8(value)
10,314✔
108
            .ok_or_else(|| CodecError::DeserializeError(format!("Unknown type prefix: {value}")))
10,314✔
109
    }
10,314✔
110
}
111

112
fn hex_ser_block<S: serde::Serializer>(b: &NakamotoBlock, s: S) -> Result<S::Ok, S::Error> {
32,626✔
113
    let inst = to_hex(&b.serialize_to_vec());
32,626✔
114
    s.serialize_str(inst.as_str())
32,626✔
115
}
32,626✔
116

117
fn hex_deser_block<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NakamotoBlock, D::Error> {
97,682✔
118
    let inst_str = String::deserialize(d)?;
97,682✔
119
    let bytes = hex_bytes(&inst_str).map_err(serde::de::Error::custom)?;
97,682✔
120
    NakamotoBlock::consensus_deserialize(&mut bytes.as_slice()).map_err(serde::de::Error::custom)
97,682✔
121
}
97,682✔
122

123
/// A response for block proposal validation
124
///  that the stacks-node thinks should be rejected.
125
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126
pub struct BlockValidateReject {
127
    pub signer_signature_hash: Sha512Trunc256Sum,
128
    pub reason: String,
129
    pub reason_code: ValidateRejectCode,
130
    /// The txid of the transaction that caused the block to be rejected, if any
131
    #[serde(default)]
132
    pub failed_txid: Option<Txid>,
133
}
134

135
#[derive(Debug, Clone, PartialEq)]
136
pub struct BlockValidateRejectReason {
137
    pub reason: String,
138
    pub reason_code: ValidateRejectCode,
139
    /// The txid of the transaction that caused the block to be rejected, if any
140
    pub failed_txid: Option<Txid>,
141
}
142

143
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144
pub enum BlockProposalResult {
145
    Accepted,
146
    Error,
147
}
148

149
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150
pub struct BlockProposalResponse {
151
    pub result: BlockProposalResult,
152
    pub message: String,
153
}
154

155
impl<T> From<T> for BlockValidateRejectReason
156
where
157
    T: Into<ChainError>,
158
{
159
    fn from(value: T) -> Self {
99✔
160
        let ce: ChainError = value.into();
99✔
161
        let reason_code = match ce {
99✔
162
            ChainError::DBError(db_error::NotFoundError) => ValidateRejectCode::NotFoundError,
81✔
163
            _ => ValidateRejectCode::ChainstateError,
18✔
164
        };
165
        Self {
99✔
166
            reason: format!("Chainstate Error: {ce}"),
99✔
167
            reason_code,
99✔
168
            failed_txid: None,
99✔
169
        }
99✔
170
    }
99✔
171
}
172

173
/// A response for block proposal validation
174
///  that the stacks-node thinks is acceptable.
175
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176
pub struct BlockValidateOk {
177
    pub signer_signature_hash: Sha512Trunc256Sum,
178
    pub cost: ExecutionCost,
179
    pub size: u64,
180
    pub validation_time_ms: u64,
181
    /// If a block was validated by a transaction replay set,
182
    /// then this returns `Some` with the hash of the replay set.
183
    pub replay_tx_hash: Option<u64>,
184
    /// If a block was validated by a transaction replay set,
185
    /// then this is true if this block exhausted the set of transactions.
186
    pub replay_tx_exhausted: bool,
187
}
188

189
/// This enum is used for serializing the response to block
190
/// proposal validation.
191
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192
#[serde(tag = "result")]
193
pub enum BlockValidateResponse {
194
    Ok(BlockValidateOk),
195
    Reject(BlockValidateReject),
196
}
197

198
impl From<Result<BlockValidateOk, BlockValidateReject>> for BlockValidateResponse {
199
    fn from(value: Result<BlockValidateOk, BlockValidateReject>) -> Self {
7,796✔
200
        match value {
7,796✔
201
            Ok(o) => BlockValidateResponse::Ok(o),
7,668✔
202
            Err(e) => BlockValidateResponse::Reject(e),
128✔
203
        }
204
    }
7,796✔
205
}
206

207
impl BlockValidateResponse {
208
    /// Get the signer signature hash from the response
209
    pub fn signer_signature_hash(&self) -> &Sha512Trunc256Sum {
52,365✔
210
        match self {
52,365✔
211
            BlockValidateResponse::Ok(o) => &o.signer_signature_hash,
51,450✔
212
            BlockValidateResponse::Reject(r) => &r.signer_signature_hash,
915✔
213
        }
214
    }
52,365✔
215
}
216

217
#[cfg(any(test, feature = "testing"))]
218
fn fault_injection_validation_stall(auth_token: Option<String>) {
35,076✔
219
    if TEST_VALIDATE_STALL.get().contains(&auth_token) {
35,076✔
220
        // Do an extra check just so we don't log EVERY time.
221
        warn!("Block validation is stalled due to testing directive."; "auth_token" => ?auth_token);
81✔
222
        while TEST_VALIDATE_STALL.get().contains(&auth_token) {
102,294✔
223
            std::thread::sleep(std::time::Duration::from_millis(10));
102,213✔
224
        }
102,213✔
225
        info!(
81✔
226
            "Block validation is no longer stalled due to testing directive. Continuing..."; "auth_token" => ?auth_token
227
        );
228
    }
34,995✔
229
}
35,076✔
230

231
#[cfg(not(any(test, feature = "testing")))]
232
fn fault_injection_validation_stall(_auth_token: Option<String>) {}
233

234
#[cfg(any(test, feature = "testing"))]
235
fn fault_injection_validation_delay() {
35,076✔
236
    let delay = TEST_VALIDATE_DELAY_DURATION_SECS.get();
35,076✔
237
    if delay == 0 {
35,076✔
238
        return;
34,662✔
239
    }
414✔
240
    warn!("Sleeping for {} seconds to simulate slow processing", delay);
414✔
241
    thread::sleep(Duration::from_secs(delay));
414✔
242
}
35,076✔
243

244
#[cfg(not(any(test, feature = "testing")))]
245
fn fault_injection_validation_delay() {}
246

247
#[cfg(any(test, feature = "testing"))]
248
fn fault_injection_reject_replay_txs() -> Result<(), BlockValidateRejectReason> {
567✔
249
    let reject = TEST_REJECT_REPLAY_TXS.get();
567✔
250
    if reject {
567✔
251
        Err(BlockValidateRejectReason {
72✔
252
            reason_code: ValidateRejectCode::InvalidTransactionReplay,
72✔
253
            reason: "Rejected by test flag".into(),
72✔
254
            failed_txid: None,
72✔
255
        })
72✔
256
    } else {
257
        Ok(())
495✔
258
    }
259
}
567✔
260

261
#[cfg(not(any(test, feature = "testing")))]
262
fn fault_injection_reject_replay_txs() -> Result<(), BlockValidateRejectReason> {
263
    Ok(())
264
}
265

266
/// Represents a block proposed to the `v3/block_proposal` endpoint for validation
267
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268
pub struct NakamotoBlockProposal {
269
    /// Proposed block
270
    #[serde(serialize_with = "hex_ser_block", deserialize_with = "hex_deser_block")]
271
    pub block: NakamotoBlock,
272
    /// Identifies which chain block is for (Mainnet, Testnet, etc.)
273
    pub chain_id: u32,
274
    /// Optional transaction replay set
275
    pub replay_txs: Option<Vec<StacksTransaction>>,
276
}
277

278
fn match_result_ok(value: &Value) -> Option<&Value> {
771✔
279
    let Value::Response(ResponseData { committed, data }) = value else {
771✔
UNCOV
280
        return None;
×
281
    };
282
    if !committed {
771✔
UNCOV
283
        return None;
×
284
    }
771✔
285
    Some(data.as_ref())
771✔
286
}
771✔
287

288
fn match_tuple(value: &Value) -> Option<&TupleData> {
1,542✔
289
    if let Value::Tuple(data) = value {
1,542✔
290
        Some(data)
1,542✔
291
    } else {
UNCOV
292
        None
×
293
    }
294
}
1,542✔
295

296
pub fn is_event_pox_addr_valid(is_mainnet: bool, event: &StacksTransactionEvent) -> bool {
3,162,836✔
297
    let StacksTransactionEvent::SmartContractEvent(event) = event else {
3,162,836✔
298
        // only smart contract events are relevant, so everything else is "okay"
299
        return true;
3,161,369✔
300
    };
301
    if !event.key.0.is_boot() {
1,467✔
302
        // only boot code events are relevant, so everything else is "okay"
303
        return true;
678✔
304
    }
789✔
305
    if event.key.0.name.as_str() != PoxVersions::Pox4.get_name_str() {
789✔
306
        // only pox events are relevant
307
        return true;
18✔
308
    }
771✔
309
    if &event.key.1 != "print" {
771✔
310
        // only look at print events
UNCOV
311
        return true;
×
312
    }
771✔
313
    let Some(pox_event_tuple) = match_result_ok(&event.value) else {
771✔
314
        // only care about (okay ...) results
315
        return true;
×
316
    };
317
    let Some(outer_tuple_data) = match_tuple(&pox_event_tuple) else {
771✔
318
        // should be unreachable
UNCOV
319
        return true;
×
320
    };
321
    let Ok(data_tuple) = outer_tuple_data.get("data") else {
771✔
322
        // should be unreachable
323
        return true;
×
324
    };
325
    let Some(data_tuple_data) = match_tuple(&data_tuple) else {
771✔
326
        // should be unreachable
UNCOV
327
        return true;
×
328
    };
329
    let Ok(pox_addr_tuple) = data_tuple_data.get("pox-addr") else {
771✔
330
        // should be unreachable
331
        return true;
27✔
332
    };
333

334
    let pox_addr_value = if let Value::Optional(data) = pox_addr_tuple {
744✔
335
        match data.data {
42✔
336
            None => return true,
28✔
337
            Some(ref inner) => inner.as_ref(),
14✔
338
        }
339
    } else {
340
        pox_addr_tuple
702✔
341
    };
342

343
    PoxAddress::try_from_pox_tuple(is_mainnet, pox_addr_value).is_some()
716✔
344
}
3,162,836✔
345

346
impl NakamotoBlockProposal {
347
    fn spawn_validation_thread(
35,076✔
348
        self,
35,076✔
349
        sortdb: SortitionDB,
35,076✔
350
        mut chainstate: StacksChainState,
35,076✔
351
        receiver: Box<dyn ProposalCallbackReceiver>,
35,076✔
352
        connection_opts: &ConnectionOptions,
35,076✔
353
    ) -> Result<JoinHandle<()>, std::io::Error> {
35,076✔
354
        let timeout_secs = connection_opts.block_proposal_validation_timeout_secs;
35,076✔
355
        let max_tx_execution_time_secs = connection_opts.block_proposal_max_tx_execution_time_secs;
35,076✔
356
        let auth_token = connection_opts.auth_token.clone();
35,076✔
357
        thread::Builder::new()
35,076✔
358
            .name("block-proposal".into())
35,076✔
359
            .spawn(move || {
35,076✔
360
                let result = self
35,076✔
361
                    .validate(
35,076✔
362
                        &sortdb,
35,076✔
363
                        &mut chainstate,
35,076✔
364
                        timeout_secs,
35,076✔
365
                        max_tx_execution_time_secs,
35,076✔
366
                        auth_token,
35,076✔
367
                    )
368
                    .map_err(|reason| BlockValidateReject {
35,076✔
369
                        signer_signature_hash: self.block.header.signer_signature_hash(),
578✔
370
                        reason_code: reason.reason_code,
578✔
371
                        reason: reason.reason,
578✔
372
                        failed_txid: reason.failed_txid,
578✔
373
                    });
578✔
374
                receiver.notify_proposal_result(result);
35,076✔
375
            })
35,076✔
376
    }
35,076✔
377

378
    /// DO NOT CALL FROM CONSENSUS CODE
379
    ///
380
    /// Check to see if a block builds atop the highest block in a given tenure.
381
    /// That is:
382
    /// - its parent must exist, and
383
    /// - its parent must be as high as the highest block in the given tenure.
384
    fn check_block_builds_on_highest_block_in_tenure(
34,806✔
385
        chainstate: &StacksChainState,
34,806✔
386
        sortdb: &SortitionDB,
34,806✔
387
        tenure_id: &ConsensusHash,
34,806✔
388
        parent_block_id: &StacksBlockId,
34,806✔
389
    ) -> Result<(), BlockValidateRejectReason> {
34,806✔
390
        let Some(highest_header) = NakamotoChainState::find_highest_known_block_header_in_tenure(
34,806✔
391
            chainstate, sortdb, tenure_id,
34,806✔
392
        )
393
        .map_err(|e| BlockValidateRejectReason {
34,806✔
394
            reason_code: ValidateRejectCode::ChainstateError,
×
395
            reason: format!("Failed to query highest block in tenure ID: {:?}", &e),
×
396
            failed_txid: None,
×
UNCOV
397
        })?
×
398
        else {
UNCOV
399
            warn!(
×
400
                "Rejected block proposal";
401
                "reason" => "Block is not a tenure-start block, and has an unrecognized tenure consensus hash",
402
                "consensus_hash" => %tenure_id,
403
            );
UNCOV
404
            return Err(BlockValidateRejectReason {
×
UNCOV
405
                reason_code: ValidateRejectCode::NoSuchTenure,
×
UNCOV
406
                reason: "Block is not a tenure-start block, and has an unrecognized tenure consensus hash".into(),
×
UNCOV
407
                failed_txid: None,
×
UNCOV
408
            });
×
409
        };
410
        let Some(parent_header) =
34,806✔
411
            NakamotoChainState::get_block_header(chainstate.db(), parent_block_id).map_err(
34,806✔
412
                |e| BlockValidateRejectReason {
413
                    reason_code: ValidateRejectCode::ChainstateError,
×
UNCOV
414
                    reason: format!("Failed to query block header by block ID: {:?}", &e),
×
415
                    failed_txid: None,
×
416
                },
×
417
            )?
×
418
        else {
UNCOV
419
            warn!(
×
420
                "Rejected block proposal";
421
                "reason" => "Block has no parent",
422
                "parent_block_id" => %parent_block_id
423
            );
424
            return Err(BlockValidateRejectReason {
×
UNCOV
425
                reason_code: ValidateRejectCode::UnknownParent,
×
UNCOV
426
                reason: "Block has no parent".into(),
×
UNCOV
427
                failed_txid: None,
×
UNCOV
428
            });
×
429
        };
430
        if parent_header.anchored_header.height() != highest_header.anchored_header.height() {
34,806✔
431
            warn!(
180✔
432
                "Rejected block proposal";
433
                "reason" => "Block's parent is not the highest block in this tenure",
434
                "consensus_hash" => %tenure_id,
435
                "parent_header.height" => parent_header.anchored_header.height(),
180✔
436
                "highest_header.height" => highest_header.anchored_header.height(),
180✔
437
            );
438
            return Err(BlockValidateRejectReason {
180✔
439
                reason_code: ValidateRejectCode::InvalidParentBlock,
180✔
440
                reason: "Block is not higher than the highest block in its tenure".into(),
180✔
441
                failed_txid: None,
180✔
442
            });
180✔
443
        }
34,626✔
444
        Ok(())
34,626✔
445
    }
34,806✔
446

447
    /// Verify that the block we received builds upon a valid tenure.
448
    /// Implemented as a static function to facilitate testing.
449
    pub(crate) fn check_block_has_valid_tenure(
34,815✔
450
        db_handle: &SortitionHandleConn,
34,815✔
451
        tenure_id: &ConsensusHash,
34,815✔
452
    ) -> Result<(), BlockValidateRejectReason> {
34,815✔
453
        // Verify that the block's tenure is on the canonical sortition history
454
        if !db_handle.has_consensus_hash(tenure_id)? {
34,815✔
455
            warn!(
9✔
456
                "Rejected block proposal";
457
                "reason" => "Block's tenure consensus hash is not on the canonical Bitcoin fork",
458
                "consensus_hash" => %tenure_id,
459
            );
460
            return Err(BlockValidateRejectReason {
9✔
461
                reason_code: ValidateRejectCode::NonCanonicalTenure,
9✔
462
                reason: "Tenure consensus hash is not on the canonical Bitcoin fork".into(),
9✔
463
                failed_txid: None,
9✔
464
            });
9✔
465
        }
34,806✔
466
        Ok(())
34,806✔
467
    }
34,815✔
468

469
    /// Verify that the block we received builds on the highest block in its tenure.
470
    /// * For tenure-start blocks, the parent must be as high as the highest block in the parent
471
    /// block's tenure.
472
    /// * For all other blocks, the parent must be as high as the highest block in the tenure.
473
    ///
474
    /// Implemented as a static function to facilitate testing
475
    pub(crate) fn check_block_has_valid_parent(
34,806✔
476
        chainstate: &StacksChainState,
34,806✔
477
        sortdb: &SortitionDB,
34,806✔
478
        block: &NakamotoBlock,
34,806✔
479
    ) -> Result<(), BlockValidateRejectReason> {
34,806✔
480
        let is_tenure_start =
34,806✔
481
            block
34,806✔
482
                .is_wellformed_tenure_start_block()
34,806✔
483
                .map_err(|_| BlockValidateRejectReason {
34,806✔
UNCOV
484
                    reason_code: ValidateRejectCode::InvalidBlock,
×
UNCOV
485
                    reason: "Block is not well-formed".into(),
×
UNCOV
486
                    failed_txid: None,
×
UNCOV
487
                })?;
×
488

489
        if !is_tenure_start {
34,806✔
490
            // this is a well-formed block that is not the start of a tenure, so it must build
491
            // atop an existing block in its tenure.
492
            Self::check_block_builds_on_highest_block_in_tenure(
13,152✔
493
                chainstate,
13,152✔
494
                sortdb,
13,152✔
495
                &block.header.consensus_hash,
13,152✔
496
                &block.header.parent_block_id,
13,152✔
497
            )?;
171✔
498
        } else {
499
            // this is a tenure-start block, so it must build atop a parent which has the
500
            // highest height in the *previous* tenure.
501
            let parent_header = NakamotoChainState::get_block_header(
21,654✔
502
                chainstate.db(),
21,654✔
503
                &block.header.parent_block_id,
21,654✔
UNCOV
504
            )?
×
505
            .ok_or_else(|| BlockValidateRejectReason {
21,654✔
UNCOV
506
                reason_code: ValidateRejectCode::UnknownParent,
×
UNCOV
507
                reason: "No parent block".into(),
×
UNCOV
508
                failed_txid: None,
×
UNCOV
509
            })?;
×
510

511
            Self::check_block_builds_on_highest_block_in_tenure(
21,654✔
512
                chainstate,
21,654✔
513
                sortdb,
21,654✔
514
                &parent_header.consensus_hash,
21,654✔
515
                &block.header.parent_block_id,
21,654✔
516
            )?;
9✔
517
        }
518
        Ok(())
34,626✔
519
    }
34,806✔
520

521
    /// Test this block proposal against the current chain state and
522
    /// either accept or reject the proposal
523
    ///
524
    /// This is done in 3 stages:
525
    /// - Static validation of the block, which checks the following:
526
    ///   - Block header is well-formed
527
    ///   - Transactions are well-formed
528
    ///   - Miner signature is valid
529
    /// - Validation of transactions by executing them agains current chainstate.
530
    ///   This is resource intensive, and therefore done only if previous checks pass
531
    ///
532
    /// During transaction replay, we also check that the block only contains the unmined
533
    /// transactions that need to be replayed, up until either:
534
    /// - The set of transactions that must be replayed is exhausted
535
    /// - A cost limit is hit
536
    pub fn validate(
35,076✔
537
        &self,
35,076✔
538
        sortdb: &SortitionDB,
35,076✔
539
        chainstate: &mut StacksChainState, // not directly used; used as a handle to open other chainstates
35,076✔
540
        timeout_secs: u64,
35,076✔
541
        max_tx_execution_time_secs: u64,
35,076✔
542
        auth_token: Option<String>,
35,076✔
543
    ) -> Result<BlockValidateOk, BlockValidateRejectReason> {
35,076✔
544
        fault_injection_validation_stall(auth_token);
35,076✔
545
        let start = Instant::now();
35,076✔
546

547
        fault_injection_validation_delay();
35,076✔
548

549
        let mainnet = self.chain_id == CHAIN_ID_MAINNET;
35,076✔
550
        if self.chain_id != chainstate.chain_id || mainnet != chainstate.mainnet {
35,076✔
551
            warn!(
9✔
552
                "Rejected block proposal";
553
                "reason" => "Wrong network/chain_id",
554
                "expected_chain_id" => chainstate.chain_id,
9✔
555
                "expected_mainnet" => chainstate.mainnet,
9✔
556
                "received_chain_id" => self.chain_id,
9✔
557
                "received_mainnet" => mainnet,
9✔
558
            );
559
            return Err(BlockValidateRejectReason {
9✔
560
                reason_code: ValidateRejectCode::NetworkChainMismatch,
9✔
561
                reason: "Wrong network/chain_id".into(),
9✔
562
                failed_txid: None,
9✔
563
            });
9✔
564
        }
35,067✔
565

566
        // Check block version. If it's less than the compiled-in version, just emit a warning
567
        // because there's a new version of the node / signer binary available that really ought to
568
        // be used (hint, hint)
569
        if self.block.header.version != NAKAMOTO_BLOCK_VERSION {
35,067✔
UNCOV
570
            warn!("Proposed block has unexpected version. Upgrade your node and/or signer ASAP.";
×
571
                  "block.header.version" => %self.block.header.version,
572
                  "expected" => %NAKAMOTO_BLOCK_VERSION);
573
        }
35,067✔
574

575
        // open sortition view to the current burn view.
576
        // If the block has a TenureChange with an Extend cause, then the burn view is whatever is
577
        // indicated in the TenureChange.
578
        // Otherwise, it's the same as the block's parent's burn view.
579
        let parent_stacks_header = NakamotoChainState::get_block_header(
35,067✔
580
            chainstate.db(),
35,067✔
581
            &self.block.header.parent_block_id,
35,067✔
582
        )?
×
583
        .ok_or_else(|| BlockValidateRejectReason {
35,067✔
584
            reason_code: ValidateRejectCode::UnknownParent,
171✔
585
            reason: "Unknown parent block".into(),
171✔
586
            failed_txid: None,
171✔
587
        })?;
171✔
588

589
        let burn_view_consensus_hash =
34,815✔
590
            NakamotoChainState::get_block_burn_view(sortdb, &self.block, &parent_stacks_header)?;
34,896✔
591
        let sort_tip =
34,815✔
592
            SortitionDB::get_block_snapshot_consensus(sortdb.conn(), &burn_view_consensus_hash)?
34,815✔
593
                .ok_or_else(|| BlockValidateRejectReason {
34,815✔
UNCOV
594
                    reason_code: ValidateRejectCode::NoSuchTenure,
×
UNCOV
595
                    reason: "Failed to find sortition for block tenure".to_string(),
×
UNCOV
596
                    failed_txid: None,
×
UNCOV
597
                })?;
×
598

599
        let burn_dbconn: SortitionHandleConn = sortdb.index_handle(&sort_tip.sortition_id);
34,815✔
600
        let db_handle = sortdb.index_handle(&sort_tip.sortition_id);
34,815✔
601

602
        // (For the signer)
603
        // Verify that the block's tenure is on the canonical sortition history
604
        Self::check_block_has_valid_tenure(&db_handle, &self.block.header.consensus_hash)?;
34,815✔
605

606
        // (For the signer)
607
        // Verify that this block's parent is the highest such block we can build off of
608
        Self::check_block_has_valid_parent(chainstate, sortdb, &self.block)?;
34,806✔
609

610
        // get the burnchain tokens spent for this block. There must be a record of this (i.e.
611
        // there must be a block-commit for this), or otherwise this block doesn't correspond to
612
        // any burnchain chainstate.
613
        let expected_burn_opt =
34,626✔
614
            NakamotoChainState::get_expected_burns(&db_handle, chainstate.db(), &self.block)?;
34,626✔
615
        if expected_burn_opt.is_none() {
34,626✔
UNCOV
616
            warn!(
×
617
                "Rejected block proposal";
618
                "reason" => "Failed to find parent expected burns",
619
            );
UNCOV
620
            return Err(BlockValidateRejectReason {
×
UNCOV
621
                reason_code: ValidateRejectCode::UnknownParent,
×
UNCOV
622
                reason: "Failed to find parent expected burns".into(),
×
UNCOV
623
                failed_txid: None,
×
UNCOV
624
            });
×
625
        };
34,626✔
626

627
        // Static validation checks
628
        NakamotoChainState::validate_normal_nakamoto_block_burnchain(
34,626✔
629
            chainstate.nakamoto_blocks_db(),
34,626✔
630
            &db_handle,
34,626✔
631
            expected_burn_opt,
34,626✔
632
            &self.block,
34,626✔
633
            mainnet,
34,626✔
634
            self.chain_id,
34,626✔
635
        )?;
18✔
636

637
        // Validate txs against chainstate
638

639
        // Validate the block's timestamp. It must be:
640
        // - Greater than the parent block's timestamp
641
        // - At most 15 seconds into the future
642
        if let StacksBlockHeaderTypes::Nakamoto(parent_nakamoto_header) =
30,108✔
643
            &parent_stacks_header.anchored_header
34,608✔
644
        {
645
            if self.block.header.timestamp <= parent_nakamoto_header.timestamp {
30,108✔
646
                warn!(
1✔
647
                    "Rejected block proposal";
648
                    "reason" => "Block timestamp is not greater than parent block",
649
                    "block_timestamp" => self.block.header.timestamp,
1✔
650
                    "parent_block_timestamp" => parent_nakamoto_header.timestamp,
1✔
651
                );
652
                return Err(BlockValidateRejectReason {
1✔
653
                    reason_code: ValidateRejectCode::InvalidTimestamp,
1✔
654
                    reason: "Block timestamp is not greater than parent block".into(),
1✔
655
                    failed_txid: None,
1✔
656
                });
1✔
657
            }
30,107✔
658
        }
4,500✔
659
        if self.block.header.timestamp > get_epoch_time_secs() + 15 {
34,607✔
660
            warn!(
1✔
661
                "Rejected block proposal";
662
                "reason" => "Block timestamp is too far into the future",
663
                "block_timestamp" => self.block.header.timestamp,
1✔
664
                "current_time" => get_epoch_time_secs(),
1✔
665
            );
666
            return Err(BlockValidateRejectReason {
1✔
667
                reason_code: ValidateRejectCode::InvalidTimestamp,
1✔
668
                reason: "Block timestamp is too far into the future".into(),
1✔
669
                failed_txid: None,
1✔
670
            });
1✔
671
        }
34,606✔
672

673
        if self.block.header.chain_length
34,606✔
674
            != parent_stacks_header.stacks_block_height.saturating_add(1)
34,606✔
675
        {
UNCOV
676
            warn!(
×
677
                "Rejected block proposal";
678
                "reason" => "Block height is non-contiguous with parent",
UNCOV
679
                "block_height" => self.block.header.chain_length,
×
UNCOV
680
                "parent_block_height" => parent_stacks_header.stacks_block_height,
×
681
            );
UNCOV
682
            return Err(BlockValidateRejectReason {
×
UNCOV
683
                reason_code: ValidateRejectCode::InvalidBlock,
×
UNCOV
684
                reason: "Block height is non-contiguous with parent".into(),
×
UNCOV
685
                failed_txid: None,
×
UNCOV
686
            });
×
687
        }
34,606✔
688

689
        let tenure_change = self
34,606✔
690
            .block
34,606✔
691
            .txs
34,606✔
692
            .iter()
34,606✔
693
            .find(|tx| matches!(tx.payload, TransactionPayload::TenureChange(..)));
1,285,786✔
694
        let coinbase = self
34,606✔
695
            .block
34,606✔
696
            .txs
34,606✔
697
            .iter()
34,606✔
698
            .find(|tx| matches!(tx.payload, TransactionPayload::Coinbase(..)));
1,308,583✔
699
        let tenure_cause = tenure_change
34,606✔
700
            .and_then(|tx| match &tx.payload {
34,606✔
701
                TransactionPayload::TenureChange(tc) => Some(MinerTenureInfoCause::from(tc)),
23,904✔
UNCOV
702
                _ => None,
×
703
            })
23,904✔
704
            .unwrap_or_else(|| MinerTenureInfoCause::NoTenureChange);
34,606✔
705

706
        let replay_tx_exhausted = self.validate_replay(
34,606✔
707
            &parent_stacks_header,
34,606✔
708
            tenure_change,
34,606✔
709
            coinbase,
34,606✔
710
            tenure_cause,
34,606✔
711
            chainstate,
34,606✔
712
            &burn_dbconn,
34,606✔
713
        )?;
108✔
714

715
        let mut builder = NakamotoBlockBuilder::new(
34,498✔
716
            &parent_stacks_header,
34,498✔
717
            &self.block.header.consensus_hash,
34,498✔
718
            self.block.header.burn_spent,
34,498✔
719
            tenure_change,
34,498✔
720
            coinbase,
34,498✔
721
            self.block.header.pox_treatment.len(),
34,498✔
722
            None,
34,498✔
723
            None,
34,498✔
724
            Some(self.block.header.timestamp),
34,498✔
725
            u64::from(DEFAULT_MAX_TENURE_BYTES),
34,498✔
UNCOV
726
        )?;
×
727

728
        let mut miner_tenure_info =
34,498✔
729
            builder.load_tenure_info(chainstate, &burn_dbconn, tenure_cause)?;
34,498✔
730
        let burn_chain_height = miner_tenure_info.burn_tip_height;
34,498✔
731
        let mut tenure_tx = builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info)?;
34,498✔
732

733
        let block_deadline = Instant::now() + Duration::from_secs(timeout_secs);
34,498✔
734
        let per_tx_max_execution_time = Duration::from_secs(max_tx_execution_time_secs);
34,498✔
735
        let mut receipts_total = 0u64;
34,498✔
736
        for (i, tx) in self.block.txs.iter().enumerate() {
1,308,331✔
737
            // Enforce the overall block validation budget between txs. A tx
738
            // running over its own per-tx limit is the tx's fault and is
739
            // handled below; running out of overall budget is the block's
740
            // fault and shouldn't flag any specific tx as problematic.
741
            if Instant::now() >= block_deadline {
1,308,331✔
NEW
742
                warn!(
×
743
                    "Rejected block proposal";
744
                    "reason" => "Block validation timed out",
NEW
745
                    "next_tx_index" => i,
×
746
                );
NEW
747
                return Err(BlockValidateRejectReason {
×
NEW
748
                    reason: format!("Block validation timed out before tx {i} could be processed"),
×
NEW
749
                    reason_code: ValidateRejectCode::InvalidBlock,
×
NEW
750
                    failed_txid: None,
×
NEW
751
                });
×
752
            }
1,308,331✔
753

754
            let tx_len = tx.tx_len();
1,308,331✔
755

756
            let tx_result = builder.try_mine_tx_with_len(
1,308,331✔
757
                &mut tenure_tx,
1,308,331✔
758
                tx,
1,308,331✔
759
                tx_len,
1,308,331✔
760
                &BlockLimitFunction::NO_LIMIT_HIT,
1,308,331✔
761
                Some(per_tx_max_execution_time),
1,308,331✔
762
                &mut receipts_total,
1,308,331✔
763
            );
764
            let reason = match tx_result {
1,308,331✔
765
                TransactionResult::Success(success_result) => {
1,308,331✔
766
                    let all_events_valid = success_result
1,308,331✔
767
                        .receipt
1,308,331✔
768
                        .events
1,308,331✔
769
                        .iter()
1,308,331✔
770
                        .all(|event| is_event_pox_addr_valid(mainnet, event));
1,308,331✔
771
                    if !all_events_valid {
1,308,331✔
UNCOV
772
                        Some((
×
UNCOV
773
                            format!("Problematic tx {i}: contains invalid pox address"),
×
UNCOV
774
                            ValidateRejectCode::ProblematicTransaction,
×
UNCOV
775
                        ))
×
776
                    } else {
777
                        None
1,308,331✔
778
                    }
779
                }
UNCOV
780
                TransactionResult::Skipped(s) => Some((
×
UNCOV
781
                    format!("tx {i} skipped: {}", s.error),
×
UNCOV
782
                    ValidateRejectCode::BadTransaction,
×
UNCOV
783
                )),
×
UNCOV
784
                TransactionResult::ProcessingError(e) => Some((
×
UNCOV
785
                    format!("Error processing tx {i}: {}", e.error),
×
UNCOV
786
                    ValidateRejectCode::BadTransaction,
×
UNCOV
787
                )),
×
UNCOV
788
                TransactionResult::Problematic(p) => Some((
×
UNCOV
789
                    format!("Problematic tx {i}: {}", p.error),
×
UNCOV
790
                    ValidateRejectCode::ProblematicTransaction,
×
UNCOV
791
                )),
×
792
            };
793
            if let Some((reason, reject_code)) = reason {
1,308,331✔
UNCOV
794
                warn!(
×
795
                    "Rejected block proposal";
796
                    "reason" => %reason,
797
                    "tx" => ?tx,
798
                );
799
                return Err(BlockValidateRejectReason {
×
UNCOV
800
                    reason,
×
UNCOV
801
                    reason_code: reject_code,
×
UNCOV
802
                    failed_txid: Some(tx.txid()),
×
UNCOV
803
                });
×
804
            }
1,308,331✔
805
        }
806

807
        let mut block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height);
34,498✔
808
        // Override the block version with the one from the proposal. This must be
809
        // done before computing the block hash, because the block hash includes the
810
        // version in its computation.
811
        block.header.version = self.block.header.version;
34,498✔
812
        let size = builder.get_bytes_so_far();
34,498✔
813
        let cost = builder.tenure_finish(tenure_tx)?;
34,498✔
814

815
        // Clone signatures from block proposal
816
        // These have already been validated by `validate_nakamoto_block_burnchain()``
817
        block.header.miner_signature = self.block.header.miner_signature.clone();
34,498✔
818
        block
34,498✔
819
            .header
34,498✔
820
            .signer_signature
34,498✔
821
            .clone_from(&self.block.header.signer_signature);
34,498✔
822

823
        // Assuming `tx_merkle_root` has been checked we don't need to hash the whole block
824
        let expected_block_header_hash = self.block.header.block_hash();
34,498✔
825
        let computed_block_header_hash = block.header.block_hash();
34,498✔
826

827
        if computed_block_header_hash != expected_block_header_hash {
34,498✔
828
            warn!(
×
829
                "Rejected block proposal";
830
                "reason" => "Block hash is not as expected",
831
                "expected_block_header_hash" => %expected_block_header_hash,
832
                "computed_block_header_hash" => %computed_block_header_hash,
833
                "expected_block" => ?self.block,
834
                "computed_block" => ?block,
835
            );
UNCOV
836
            return Err(BlockValidateRejectReason {
×
UNCOV
837
                reason: "Block hash is not as expected".into(),
×
UNCOV
838
                reason_code: ValidateRejectCode::BadBlockHash,
×
UNCOV
839
                failed_txid: None,
×
UNCOV
840
            });
×
841
        }
34,498✔
842

843
        let validation_time_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
34,498✔
844

845
        info!(
34,498✔
846
            "Participant: validated anchored block";
847
            "block_header_hash" => %computed_block_header_hash,
848
            "height" => block.header.chain_length,
34,498✔
849
            "tx_count" => block.txs.len(),
34,498✔
850
            "parent_stacks_block_id" => %block.header.parent_block_id,
851
            "block_size" => size,
34,498✔
852
            "execution_cost" => %cost,
853
            "validation_time_ms" => validation_time_ms,
34,498✔
854
            "tx_fees_microstacks" => block.txs.iter().fold(0, |agg: u64, tx| {
1,308,331✔
855
                agg.saturating_add(tx.get_tx_fee())
1,308,331✔
856
            })
1,308,331✔
857
        );
858

859
        let replay_tx_hash = Self::tx_replay_hash(&self.replay_txs);
34,498✔
860

861
        Ok(BlockValidateOk {
34,498✔
862
            signer_signature_hash: block.header.signer_signature_hash(),
34,498✔
863
            cost,
34,498✔
864
            size,
34,498✔
865
            validation_time_ms,
34,498✔
866
            replay_tx_hash,
34,498✔
867
            replay_tx_exhausted,
34,498✔
868
        })
34,498✔
869
    }
35,076✔
870

871
    pub fn tx_replay_hash(replay_txs: &Option<Vec<StacksTransaction>>) -> Option<u64> {
79,642✔
872
        replay_txs.as_ref().map(|txs| {
79,642✔
873
            let mut hasher = DefaultHasher::new();
1,971✔
874
            txs.hash(&mut hasher);
1,971✔
875
            hasher.finish()
1,971✔
876
        })
1,971✔
877
    }
79,642✔
878

879
    /// Validate the block against the replay set.
880
    ///
881
    /// Returns a boolean indicating whether this block exhausts the replay set.
882
    ///
883
    /// Returns `false` if there is no replay set.
884
    fn validate_replay(
34,606✔
885
        &self,
34,606✔
886
        parent_stacks_header: &StacksHeaderInfo,
34,606✔
887
        tenure_change: Option<&StacksTransaction>,
34,606✔
888
        coinbase: Option<&StacksTransaction>,
34,606✔
889
        tenure_cause: MinerTenureInfoCause,
34,606✔
890
        // not directly used; used as a handle to open other chainstates
34,606✔
891
        chainstate_handle: &StacksChainState,
34,606✔
892
        burn_dbconn: &SortitionHandleConn,
34,606✔
893
    ) -> Result<bool, BlockValidateRejectReason> {
34,606✔
894
        let mut replay_txs_maybe: Option<VecDeque<StacksTransaction>> =
34,606✔
895
            self.replay_txs.clone().map(|txs| txs.into());
34,606✔
896

897
        let Some(ref mut replay_txs) = replay_txs_maybe else {
34,606✔
898
            return Ok(false);
33,724✔
899
        };
900

901
        let mut replay_builder = NakamotoBlockBuilder::new(
882✔
902
            &parent_stacks_header,
882✔
903
            &self.block.header.consensus_hash,
882✔
904
            self.block.header.burn_spent,
882✔
905
            tenure_change,
882✔
906
            coinbase,
882✔
907
            self.block.header.pox_treatment.len(),
882✔
908
            None,
882✔
909
            None,
882✔
910
            Some(self.block.header.timestamp),
882✔
911
            u64::from(DEFAULT_MAX_TENURE_BYTES),
882✔
UNCOV
912
        )?;
×
913
        let (mut replay_chainstate, _) = chainstate_handle.reopen()?;
882✔
914
        let mut replay_miner_tenure_info =
882✔
915
            replay_builder.load_tenure_info(&mut replay_chainstate, &burn_dbconn, tenure_cause)?;
882✔
916
        let mut replay_tenure_tx =
882✔
917
            replay_builder.tenure_begin(&burn_dbconn, &mut replay_miner_tenure_info)?;
882✔
918

919
        let mut total_receipts = 0;
882✔
920
        for (i, tx) in self.block.txs.iter().enumerate() {
1,683✔
921
            let tx_len = tx.tx_len();
1,683✔
922

923
            // If a list of replay transactions is set, this transaction must be the next
924
            // mineable transaction from this list.
925
            loop {
926
                if matches!(
567✔
927
                    tx.payload,
1,737✔
928
                    TransactionPayload::TenureChange(..) | TransactionPayload::Coinbase(..)
929
                ) {
930
                    // Allow this to happen, tenure extend checks happen elsewhere.
931
                    break;
1,170✔
932
                }
567✔
933
                fault_injection_reject_replay_txs()?;
567✔
934
                let Some(replay_tx) = replay_txs.pop_front() else {
495✔
935
                    // During transaction replay, we expect that the block only
936
                    // contains transactions from the replay set. Thus, if we're here,
937
                    // the block contains a transaction that is not in the replay set,
938
                    // and we should reject the block.
UNCOV
939
                    warn!("Rejected block proposal. Block contains transactions beyond the replay set.";
×
UNCOV
940
                        "txid" => %tx.txid(),
×
UNCOV
941
                        "tx_index" => i,
×
942
                    );
UNCOV
943
                    return Err(BlockValidateRejectReason {
×
UNCOV
944
                        reason_code: ValidateRejectCode::InvalidTransactionReplay,
×
UNCOV
945
                        reason: "Block contains transactions beyond the replay set".into(),
×
UNCOV
946
                        failed_txid: Some(tx.txid()),
×
UNCOV
947
                    });
×
948
                };
949
                if replay_tx.txid() == tx.txid() {
495✔
950
                    break;
405✔
951
                }
90✔
952

953
                // The included tx doesn't match the next tx in the
954
                // replay set. Check to see if the tx is skipped because
955
                // it was unmineable.
956
                let tx_result = replay_builder.try_mine_tx_with_len(
90✔
957
                    &mut replay_tenure_tx,
90✔
958
                    &replay_tx,
90✔
959
                    replay_tx.tx_len(),
90✔
960
                    &BlockLimitFunction::NO_LIMIT_HIT,
90✔
961
                    None,
90✔
962
                    &mut total_receipts,
90✔
963
                );
964
                match tx_result {
90✔
UNCOV
965
                    TransactionResult::Skipped(TransactionSkipped { error, .. })
×
966
                    | TransactionResult::ProcessingError(TransactionError { error, .. })
54✔
UNCOV
967
                    | TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
×
968
                        // The tx wasn't able to be mined. Check the underlying error, to
969
                        // see if we should reject the block or allow the tx to be
970
                        // dropped from the replay set.
971

UNCOV
972
                        match error {
×
973
                            ChainError::CostOverflowError(..)
974
                            | ChainError::BlockTooBigError
975
                            | ChainError::BlockCostLimitError
976
                            | ChainError::ClarityError(ClarityError::CostError(..)) => {
977
                                // block limit reached; add tx back to replay set.
978
                                // BUT we know that the block should have ended at this point, so
979
                                // return an error.
UNCOV
980
                                let txid = replay_tx.txid();
×
UNCOV
981
                                replay_txs.push_front(replay_tx);
×
982

UNCOV
983
                                warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block.";
×
984
                                    "error" => %error,
985
                                    "txid" => %txid,
986
                                );
987

UNCOV
988
                                return Err(BlockValidateRejectReason {
×
UNCOV
989
                                    reason_code: ValidateRejectCode::InvalidTransactionReplay,
×
UNCOV
990
                                    reason: "Next replay tx exceeds cost limits, so should have been in the next block.".into(),
×
UNCOV
991
                                    failed_txid: None,
×
UNCOV
992
                                });
×
993
                            }
994
                            _ => {
995
                                info!("During replay block validation, allowing problematic tx to be dropped";
54✔
996
                                    "txid" => %replay_tx.txid(),
54✔
997
                                    "error" => %error,
998
                                );
999
                                // it's ok, drop it
1000
                                continue;
54✔
1001
                            }
1002
                        }
1003
                    }
1004
                    TransactionResult::Success(_) => {
1005
                        // Tx should have been included
1006
                        warn!("Rejected block proposal. Block doesn't contain replay transaction that should have been included.";
36✔
1007
                            "block_txid" => %tx.txid(),
36✔
1008
                            "block_tx_index" => i,
36✔
1009
                            "replay_txid" => %replay_tx.txid(),
36✔
1010
                        );
1011
                        return Err(BlockValidateRejectReason {
36✔
1012
                            reason_code: ValidateRejectCode::InvalidTransactionReplay,
36✔
1013
                            reason: "Transaction is not in the replay set".into(),
36✔
1014
                            failed_txid: Some(tx.txid()),
36✔
1015
                        });
36✔
1016
                    }
1017
                };
1018
            }
1019

1020
            // Apply the block's transaction to our block builder, but we don't
1021
            // actually care about the result - that happens in the main
1022
            // validation check.
1023
            let _tx_result = replay_builder.try_mine_tx_with_len(
1,575✔
1024
                &mut replay_tenure_tx,
1,575✔
1025
                tx,
1,575✔
1026
                tx_len,
1,575✔
1027
                &BlockLimitFunction::NO_LIMIT_HIT,
1,575✔
1028
                None,
1,575✔
1029
                &mut total_receipts,
1,575✔
1030
            );
1031
        }
1032

1033
        let no_replay_txs_remaining = replay_txs.is_empty();
774✔
1034

1035
        // Now, we need to check if the remaining replay transactions are unmineable.
1036
        let only_unmineable_remaining = !replay_txs.is_empty()
774✔
1037
            && replay_txs.iter().all(|tx| {
648✔
1038
                let tx_result = replay_builder.try_mine_tx_with_len(
648✔
1039
                    &mut replay_tenure_tx,
648✔
1040
                    &tx,
648✔
1041
                    tx.tx_len(),
648✔
1042
                    &BlockLimitFunction::NO_LIMIT_HIT,
648✔
1043
                    None,
648✔
1044
                    &mut total_receipts,
648✔
1045
                );
1046
                match tx_result {
648✔
1047
                    TransactionResult::Skipped(TransactionSkipped { error, .. })
54✔
1048
                    | TransactionResult::ProcessingError(TransactionError { error, .. })
297✔
1049
                    | TransactionResult::Problematic(TransactionProblematic { error, .. }) => {
18✔
1050
                        // If it's just a cost error, it's not unmineable.
1051
                        !matches!(
315✔
UNCOV
1052
                            error,
×
1053
                            ChainError::CostOverflowError(..)
1054
                                | ChainError::BlockTooBigError
1055
                                | ChainError::ClarityError(ClarityError::CostError(..))
1056
                                | ChainError::BlockCostLimitError
1057
                        )
1058
                    }
1059
                    TransactionResult::Success(_) => {
1060
                        // The tx could have been included, but wasn't. This is ok, but we
1061
                        // haven't exhausted the replay set.
1062
                        false
279✔
1063
                    }
1064
                }
1065
            });
648✔
1066

1067
        Ok(no_replay_txs_remaining || only_unmineable_remaining)
774✔
1068
    }
34,606✔
1069
}
1070

1071
#[derive(Clone, Default)]
1072
pub struct RPCBlockProposalRequestHandler {
1073
    pub block_proposal: Option<NakamotoBlockProposal>,
1074
    pub auth: Option<String>,
1075
}
1076

1077
impl RPCBlockProposalRequestHandler {
1078
    pub fn new(auth: Option<String>) -> Self {
1,026,395✔
1079
        Self {
1,026,395✔
1080
            block_proposal: None,
1,026,395✔
1081
            auth,
1,026,395✔
1082
        }
1,026,395✔
1083
    }
1,026,395✔
1084

1085
    /// Decode a JSON-encoded block proposal
1086
    fn parse_json(body: &[u8]) -> Result<NakamotoBlockProposal, Error> {
97,682✔
1087
        serde_json::from_slice(body)
97,682✔
1088
            .map_err(|e| Error::DecodeError(format!("Failed to parse body: {e}")))
97,682✔
1089
    }
97,682✔
1090
}
1091

1092
/// Decode the HTTP request
1093
impl HttpRequest for RPCBlockProposalRequestHandler {
1094
    fn verb(&self) -> &'static str {
1,026,394✔
1095
        "POST"
1,026,394✔
1096
    }
1,026,394✔
1097

1098
    fn path_regex(&self) -> Regex {
2,052,790✔
1099
        Regex::new(r#"^/v3/block_proposal$"#).unwrap()
2,052,790✔
1100
    }
2,052,790✔
1101

1102
    fn metrics_identifier(&self) -> &str {
97,681✔
1103
        "/v3/block_proposal"
97,681✔
1104
    }
97,681✔
1105

1106
    /// Try to decode this request.
1107
    /// There's nothing to load here, so just make sure the request is well-formed.
1108
    fn try_parse_request(
97,692✔
1109
        &mut self,
97,692✔
1110
        preamble: &HttpRequestPreamble,
97,692✔
1111
        _captures: &Captures,
97,692✔
1112
        query: Option<&str>,
97,692✔
1113
        body: &[u8],
97,692✔
1114
    ) -> Result<HttpRequestContents, Error> {
97,692✔
1115
        // If no authorization is set, then the block proposal endpoint is not enabled
1116
        let Some(password) = &self.auth else {
97,692✔
UNCOV
1117
            return Err(Error::Http(400, "Bad Request.".into()));
×
1118
        };
1119
        let Some(auth_header) = preamble.headers.get("authorization") else {
97,692✔
1120
            return Err(Error::Http(401, "Unauthorized".into()));
10✔
1121
        };
1122
        if auth_header != password {
97,682✔
1123
            return Err(Error::Http(401, "Unauthorized".into()));
×
1124
        }
97,682✔
1125
        if preamble.get_content_length() == 0 {
97,682✔
1126
            return Err(Error::DecodeError(
×
1127
                "Invalid Http request: expected non-zero-length body for block proposal endpoint"
×
UNCOV
1128
                    .to_string(),
×
UNCOV
1129
            ));
×
1130
        }
97,682✔
1131
        if preamble.get_content_length() > MAX_PAYLOAD_LEN {
97,682✔
UNCOV
1132
            return Err(Error::DecodeError(
×
UNCOV
1133
                "Invalid Http request: BlockProposal body is too big".to_string(),
×
UNCOV
1134
            ));
×
1135
        }
97,682✔
1136

1137
        let block_proposal = match preamble.content_type {
97,682✔
1138
            Some(HttpContentType::JSON) => Self::parse_json(body)?,
97,682✔
1139
            Some(_) => {
UNCOV
1140
                return Err(Error::DecodeError(
×
UNCOV
1141
                    "Wrong Content-Type for block proposal; expected application/json".to_string(),
×
UNCOV
1142
                ))
×
1143
            }
1144
            None => {
UNCOV
1145
                return Err(Error::DecodeError(
×
UNCOV
1146
                    "Missing Content-Type for block proposal".to_string(),
×
UNCOV
1147
                ))
×
1148
            }
1149
        };
1150

1151
        if block_proposal.block.is_shadow_block() {
97,682✔
UNCOV
1152
            return Err(Error::DecodeError(
×
UNCOV
1153
                "Shadow blocks cannot be submitted for validation".to_string(),
×
UNCOV
1154
            ));
×
1155
        }
97,682✔
1156

1157
        self.block_proposal = Some(block_proposal);
97,682✔
1158
        Ok(HttpRequestContents::new().query_string(query))
97,682✔
1159
    }
97,692✔
1160
}
1161

1162
struct ProposalThreadInfo {
1163
    sortdb: SortitionDB,
1164
    chainstate: StacksChainState,
1165
    receiver: Box<dyn ProposalCallbackReceiver>,
1166
}
1167

1168
impl RPCRequestHandler for RPCBlockProposalRequestHandler {
1169
    /// Reset internal state
1170
    fn restart(&mut self) {
97,692✔
1171
        self.block_proposal = None
97,692✔
1172
    }
97,692✔
1173

1174
    /// Make the response
1175
    fn try_handle_request(
97,681✔
1176
        &mut self,
97,681✔
1177
        preamble: HttpRequestPreamble,
97,681✔
1178
        _contents: HttpRequestContents,
97,681✔
1179
        node: &mut StacksNodeState,
97,681✔
1180
    ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> {
97,681✔
1181
        let block_proposal = self
97,681✔
1182
            .block_proposal
97,681✔
1183
            .take()
97,681✔
1184
            .ok_or(NetError::SendError("`block_proposal` not set".into()))?;
97,681✔
1185

1186
        info!(
97,681✔
1187
            "Received block proposal request";
1188
            "signer_signature_hash" => %block_proposal.block.header.signer_signature_hash(),
97,681✔
1189
            "block_header_hash" => %block_proposal.block.header.block_hash(),
97,681✔
1190
            "height" => block_proposal.block.header.chain_length,
97,681✔
1191
            "tx_count" => block_proposal.block.txs.len(),
97,681✔
1192
            "parent_stacks_block_id" => %block_proposal.block.header.parent_block_id,
1193
        );
1194

1195
        let res = node.with_node_state(|network, sortdb, chainstate, _mempool, rpc_args| {
97,681✔
1196
            if network.is_proposal_thread_running() {
97,681✔
1197
                return Err((
62,595✔
1198
                    TOO_MANY_REQUESTS_STATUS,
62,595✔
1199
                    NetError::SendError("Proposal currently being evaluated".into()),
62,595✔
1200
                ));
62,595✔
1201
            }
35,086✔
1202

1203
            if block_proposal
35,086✔
1204
                .block
35,086✔
1205
                .header
35,086✔
1206
                .timestamp
35,086✔
1207
                .saturating_add(network.get_connection_opts().block_proposal_max_age_secs)
35,086✔
1208
                < get_epoch_time_secs()
35,086✔
1209
            {
1210
                return Err((
10✔
1211
                    422,
10✔
1212
                    NetError::SendError("Block proposal is too old to process.".into()),
10✔
1213
                ));
10✔
1214
            }
35,076✔
1215

1216
            let (chainstate, _) = chainstate.reopen().map_err(|e| (400, NetError::from(e)))?;
35,076✔
1217
            let sortdb = sortdb.reopen().map_err(|e| (400, NetError::from(e)))?;
35,076✔
1218
            let receiver = rpc_args
35,076✔
1219
                .event_observer
35,076✔
1220
                .and_then(|observer| observer.get_proposal_callback_receiver())
35,076✔
1221
                .ok_or_else(|| {
35,076✔
UNCOV
1222
                    (
×
UNCOV
1223
                        400,
×
UNCOV
1224
                        NetError::SendError(
×
UNCOV
1225
                            "No `observer` registered for receiving proposal callbacks".into(),
×
UNCOV
1226
                        ),
×
UNCOV
1227
                    )
×
UNCOV
1228
                })?;
×
1229
            let thread_info = block_proposal
35,076✔
1230
                .spawn_validation_thread(
35,076✔
1231
                    sortdb,
35,076✔
1232
                    chainstate,
35,076✔
1233
                    receiver,
35,076✔
1234
                    network.get_connection_opts(),
35,076✔
1235
                )
1236
                .map_err(|_e| {
35,076✔
UNCOV
1237
                    (
×
UNCOV
1238
                        TOO_MANY_REQUESTS_STATUS,
×
UNCOV
1239
                        NetError::SendError(
×
UNCOV
1240
                            "IO error while spawning proposal callback thread".into(),
×
UNCOV
1241
                        ),
×
UNCOV
1242
                    )
×
UNCOV
1243
                })?;
×
1244
            network.set_proposal_thread(thread_info);
35,076✔
1245
            Ok(())
35,076✔
1246
        });
97,681✔
1247

1248
        match res {
97,681✔
1249
            Ok(_) => {
1250
                let preamble = HttpResponsePreamble::accepted_json(&preamble);
35,076✔
1251
                let body = HttpResponseContents::try_from_json(&serde_json::json!({
35,076✔
1252
                    "result": "Accepted",
35,076✔
1253
                    "message": "Block proposal is processing, result will be returned via the event observer"
35,076✔
1254
                }))?;
35,076✔
1255
                Ok((preamble, body))
35,076✔
1256
            }
1257
            Err((code, err)) => {
62,605✔
1258
                let preamble = HttpResponsePreamble::error_json(code, http_reason(code));
62,605✔
1259
                let body = HttpResponseContents::try_from_json(&serde_json::json!({
62,605✔
1260
                    "result": "Error",
62,605✔
1261
                    "message": format!("Could not process block proposal request: {err}")
62,605✔
1262
                }))?;
62,605✔
1263
                Ok((preamble, body))
62,605✔
1264
            }
1265
        }
1266
    }
97,681✔
1267
}
1268

1269
/// Decode the HTTP response
1270
impl HttpResponse for RPCBlockProposalRequestHandler {
1271
    fn try_parse_response(
3✔
1272
        &self,
3✔
1273
        preamble: &HttpResponsePreamble,
3✔
1274
        body: &[u8],
3✔
1275
    ) -> Result<HttpResponsePayload, Error> {
3✔
1276
        let response: BlockProposalResponse = parse_json(preamble, body)?;
3✔
1277
        HttpResponsePayload::try_from_json(response)
3✔
1278
    }
3✔
1279
}
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