Skip to content

Commit d13bdb8

Browse files
authored
Merge pull request #5296 from stacks-network/fix/5285
Feat: Shadow block recovery mechanism
2 parents 982ca9c + ca5ab06 commit d13bdb8

File tree

40 files changed

+4518
-653
lines changed

40 files changed

+4518
-653
lines changed

.github/actions/dockerfiles/Dockerfile.debian-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ RUN --mount=type=tmpfs,target=${BUILD_DIR} cp -R /src/. ${BUILD_DIR}/ \
2424
&& cp -R ${BUILD_DIR}/target/${TARGET}/release/. /out
2525

2626
FROM --platform=${TARGETPLATFORM} debian:bookworm
27-
COPY --from=build /out/stacks-node /out/stacks-signer /bin/
27+
COPY --from=build /out/stacks-node /out/stacks-signer /out/stacks-inspect /bin/
2828
CMD ["stacks-node", "mainnet"]

.github/workflows/bitcoin-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ jobs:
140140
- tests::nakamoto_integrations::utxo_check_on_startup_panic
141141
- tests::nakamoto_integrations::utxo_check_on_startup_recover
142142
- tests::nakamoto_integrations::v3_signer_api_endpoint
143+
- tests::nakamoto_integrations::test_shadow_recovery
143144
- tests::nakamoto_integrations::signer_chainstate
144145
- tests::nakamoto_integrations::clarity_cost_spend_down
145146
- tests::nakamoto_integrations::v3_blockbyheight_api_endpoint

stacks-common/src/types/mod.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,21 @@ impl StacksEpochId {
170170
}
171171
}
172172

173+
/// Whether or not this epoch supports shadow blocks
174+
pub fn supports_shadow_blocks(&self) -> bool {
175+
match self {
176+
StacksEpochId::Epoch10
177+
| StacksEpochId::Epoch20
178+
| StacksEpochId::Epoch2_05
179+
| StacksEpochId::Epoch21
180+
| StacksEpochId::Epoch22
181+
| StacksEpochId::Epoch23
182+
| StacksEpochId::Epoch24
183+
| StacksEpochId::Epoch25 => false,
184+
StacksEpochId::Epoch30 => true,
185+
}
186+
}
187+
173188
/// Does this epoch support unlocking PoX contributors that miss a slot?
174189
///
175190
/// Epoch 2.0 - 2.05 didn't support this feature, but they weren't epoch-guarded on it. Instead,

stackslib/src/burnchains/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ impl PoxConstants {
450450
)
451451
}
452452

453+
// NOTE: this is the *old* pre-Nakamoto testnet
453454
pub fn testnet_default() -> PoxConstants {
454455
PoxConstants::new(
455456
POX_REWARD_CYCLE_LENGTH / 2, // 1050
@@ -468,6 +469,10 @@ impl PoxConstants {
468469
) // total liquid supply is 40000000000000000 µSTX
469470
}
470471

472+
pub fn nakamoto_testnet_default() -> PoxConstants {
473+
PoxConstants::new(900, 100, 51, 100, 0, u64::MAX, u64::MAX, 242, 243, 246, 244)
474+
}
475+
471476
// TODO: add tests from mutation testing results #4838
472477
#[cfg_attr(test, mutants::skip)]
473478
pub fn regtest_default() -> PoxConstants {

stackslib/src/burnchains/tests/mod.rs

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,30 @@ impl TestMinerFactory {
351351

352352
impl TestBurnchainBlock {
353353
pub fn new(parent_snapshot: &BlockSnapshot, fork_id: u64) -> TestBurnchainBlock {
354+
let burn_header_hash = BurnchainHeaderHash::from_test_data(
355+
parent_snapshot.block_height + 1,
356+
&parent_snapshot.index_root,
357+
fork_id,
358+
);
354359
TestBurnchainBlock {
355360
parent_snapshot: parent_snapshot.clone(),
356361
block_height: parent_snapshot.block_height + 1,
357-
txs: vec![],
362+
txs: vec![
363+
// make sure that no block-commit gets vtxindex == 0 unless explicitly structured.
364+
// This prestx mocks a burnchain coinbase
365+
BlockstackOperationType::PreStx(PreStxOp {
366+
output: StacksAddress::burn_address(false),
367+
txid: Txid::from_test_data(
368+
parent_snapshot.block_height + 1,
369+
0,
370+
&burn_header_hash,
371+
128,
372+
),
373+
vtxindex: 0,
374+
block_height: parent_snapshot.block_height + 1,
375+
burn_header_hash,
376+
}),
377+
],
358378
fork_id: fork_id,
359379
timestamp: get_epoch_time_secs(),
360380
}
@@ -397,6 +417,7 @@ impl TestBurnchainBlock {
397417
parent_block_snapshot: Option<&BlockSnapshot>,
398418
new_seed: Option<VRFSeed>,
399419
epoch_marker: u8,
420+
parent_is_shadow: bool,
400421
) -> LeaderBlockCommitOp {
401422
let pubks = miner
402423
.privks
@@ -435,6 +456,13 @@ impl TestBurnchainBlock {
435456
)
436457
.expect("FATAL: failed to read block commit");
437458

459+
if parent_is_shadow {
460+
assert!(
461+
get_commit_res.is_none(),
462+
"FATAL: shadow parent should not have a block-commit"
463+
);
464+
}
465+
438466
let input = SortitionDB::get_last_block_commit_by_sender(ic.conn(), &apparent_sender)
439467
.unwrap()
440468
.map(|commit| (commit.txid.clone(), 1 + (commit.commit_outs.len() as u32)))
@@ -454,7 +482,8 @@ impl TestBurnchainBlock {
454482
block_hash,
455483
self.block_height,
456484
&new_seed,
457-
&parent,
485+
parent.block_height as u32,
486+
parent.vtxindex as u16,
458487
leader_key.block_height as u32,
459488
leader_key.vtxindex as u16,
460489
burn_fee,
@@ -464,16 +493,42 @@ impl TestBurnchainBlock {
464493
txop
465494
}
466495
None => {
467-
// initial
468-
let txop = LeaderBlockCommitOp::initial(
469-
block_hash,
470-
self.block_height,
471-
&new_seed,
472-
leader_key,
473-
burn_fee,
474-
&input,
475-
&apparent_sender,
476-
);
496+
let txop = if parent_is_shadow {
497+
test_debug!(
498+
"Block-commit for {} (burn height {}) builds on shadow sortition",
499+
block_hash,
500+
self.block_height
501+
);
502+
503+
LeaderBlockCommitOp::new(
504+
block_hash,
505+
self.block_height,
506+
&new_seed,
507+
last_snapshot_with_sortition.block_height as u32,
508+
0,
509+
leader_key.block_height as u32,
510+
leader_key.vtxindex as u16,
511+
burn_fee,
512+
&input,
513+
&apparent_sender,
514+
)
515+
} else {
516+
// initial
517+
test_debug!(
518+
"Block-commit for {} (burn height {}) builds on genesis",
519+
block_hash,
520+
self.block_height,
521+
);
522+
LeaderBlockCommitOp::initial(
523+
block_hash,
524+
self.block_height,
525+
&new_seed,
526+
leader_key,
527+
burn_fee,
528+
&input,
529+
&apparent_sender,
530+
)
531+
};
477532
txop
478533
}
479534
};
@@ -517,6 +572,7 @@ impl TestBurnchainBlock {
517572
parent_block_snapshot,
518573
None,
519574
STACKS_EPOCH_2_4_MARKER,
575+
false,
520576
)
521577
}
522578

stackslib/src/chainstate/burn/operations/leader_block_commit.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ impl LeaderBlockCommitOp {
136136
block_header_hash: &BlockHeaderHash,
137137
block_height: u64,
138138
new_seed: &VRFSeed,
139-
parent: &LeaderBlockCommitOp,
139+
parent_block_height: u32,
140+
parent_vtxindex: u16,
140141
key_block_ptr: u32,
141142
key_vtxindex: u16,
142143
burn_fee: u64,
@@ -148,8 +149,8 @@ impl LeaderBlockCommitOp {
148149
new_seed: new_seed.clone(),
149150
key_block_ptr: key_block_ptr,
150151
key_vtxindex: key_vtxindex,
151-
parent_block_ptr: parent.block_height as u32,
152-
parent_vtxindex: parent.vtxindex as u16,
152+
parent_block_ptr: parent_block_height,
153+
parent_vtxindex: parent_vtxindex,
153154
memo: vec![],
154155
burn_fee: burn_fee,
155156
input: input.clone(),
@@ -696,8 +697,19 @@ impl LeaderBlockCommitOp {
696697
// is descendant
697698
let directly_descended_from_anchor = epoch_id.block_commits_to_parent()
698699
&& self.block_header_hash == reward_set_info.anchor_block;
699-
let descended_from_anchor = directly_descended_from_anchor || tx
700-
.descended_from(parent_block_height, &reward_set_info.anchor_block)
700+
701+
// second, if we're in a nakamoto epoch, and the parent block has vtxindex 0 (i.e. the
702+
// coinbase of the burnchain block), then assume that this block descends from the anchor
703+
// block for the purposes of validating its PoX payouts. The block validation logic will
704+
// check that the parent block is indeed a shadow block, and that `self.parent_block_ptr`
705+
// points to the shadow block's tenure's burnchain block.
706+
let maybe_shadow_parent = epoch_id.supports_shadow_blocks()
707+
&& self.parent_block_ptr != 0
708+
&& self.parent_vtxindex == 0;
709+
710+
let descended_from_anchor = directly_descended_from_anchor
711+
|| maybe_shadow_parent
712+
|| tx.descended_from(parent_block_height, &reward_set_info.anchor_block)
701713
.map_err(|e| {
702714
error!("Failed to check whether parent (height={}) is descendent of anchor block={}: {}",
703715
parent_block_height, &reward_set_info.anchor_block, e);
@@ -1031,10 +1043,12 @@ impl LeaderBlockCommitOp {
10311043
return Err(op_error::BlockCommitNoParent);
10321044
} else if self.parent_block_ptr != 0 || self.parent_vtxindex != 0 {
10331045
// not building off of genesis, so the parent block must exist
1046+
// unless the parent is a shadow block
10341047
let has_parent = tx
10351048
.get_block_commit_parent(parent_block_height, self.parent_vtxindex.into(), &tx_tip)?
10361049
.is_some();
1037-
if !has_parent {
1050+
let maybe_shadow_block = self.parent_vtxindex == 0 && epoch_id.supports_shadow_blocks();
1051+
if !has_parent && !maybe_shadow_block {
10381052
warn!("Invalid block commit: no parent block in this fork";
10391053
"apparent_sender" => %apparent_sender_repr
10401054
);

stackslib/src/chainstate/nakamoto/coordinator/mod.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ use crate::monitoring::increment_stx_blocks_processed_counter;
5858
use crate::net::Error as NetError;
5959
use crate::util_lib::db::Error as DBError;
6060

61+
#[cfg(any(test, feature = "testing"))]
62+
pub static TEST_COORDINATOR_STALL: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
63+
6164
#[cfg(test)]
6265
pub mod tests;
6366

@@ -484,7 +487,14 @@ pub fn load_nakamoto_reward_set<U: RewardSetProvider>(
484487
let Some(anchor_block_header) = prepare_phase_sortitions
485488
.into_iter()
486489
.find_map(|sn| {
487-
if !sn.sortition {
490+
let shadow_tenure = match chain_state.nakamoto_blocks_db().is_shadow_tenure(&sn.consensus_hash) {
491+
Ok(x) => x,
492+
Err(e) => {
493+
return Some(Err(e));
494+
}
495+
};
496+
497+
if !sn.sortition && !shadow_tenure {
488498
return None
489499
}
490500

@@ -757,6 +767,21 @@ impl<
757767
true
758768
}
759769

770+
#[cfg(any(test, feature = "testing"))]
771+
fn fault_injection_pause_nakamoto_block_processing() {
772+
if *TEST_COORDINATOR_STALL.lock().unwrap() == Some(true) {
773+
// Do an extra check just so we don't log EVERY time.
774+
warn!("Coordinator is stalled due to testing directive");
775+
while *TEST_COORDINATOR_STALL.lock().unwrap() == Some(true) {
776+
std::thread::sleep(std::time::Duration::from_millis(10));
777+
}
778+
warn!("Coordinator is no longer stalled due to testing directive. Continuing...");
779+
}
780+
}
781+
782+
#[cfg(not(any(test, feature = "testing")))]
783+
fn fault_injection_pause_nakamoto_block_processing() {}
784+
760785
/// Handle one or more new Nakamoto Stacks blocks.
761786
/// If we process a PoX anchor block, then return its block hash. This unblocks processing the
762787
/// next reward cycle's burnchain blocks. Subsequent calls to this function will terminate
@@ -769,6 +794,8 @@ impl<
769794
);
770795

771796
loop {
797+
Self::fault_injection_pause_nakamoto_block_processing();
798+
772799
// process at most one block per loop pass
773800
let mut processed_block_receipt = match NakamotoChainState::process_next_nakamoto_block(
774801
&mut self.chain_state_db,

0 commit comments

Comments
 (0)