diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index e618eedebe..18288b11f1 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -78,6 +78,7 @@ jobs: - tests::epoch_25::microblocks_disabled - tests::should_succeed_handling_malformed_and_valid_txs - tests::nakamoto_integrations::simple_neon_integration + - tests::nakamoto_integrations::simple_neon_integration_with_flash_blocks_on_epoch_3 - tests::nakamoto_integrations::mine_multiple_per_tenure_integration - tests::nakamoto_integrations::block_proposal_api_endpoint - tests::nakamoto_integrations::miner_writes_proposed_block_to_stackerdb diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index 97938e873c..86099d6cfd 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -929,6 +929,161 @@ pub fn boot_to_epoch_3( info!("Bootstrapped to Epoch-3.0 boundary, Epoch2x miner should stop"); } +/// Boot the chain to just before the Epoch 3.0 boundary to allow for flash blocks +/// This function is similar to `boot_to_epoch_3`, but it stops at epoch 3 start height - 2, +/// allowing for flash blocks to occur when the epoch changes. +/// +/// * `stacker_sks` - private keys for sending large `stack-stx` transactions to activate pox-4 +/// * `signer_sks` - corresponding signer keys for the stackers +pub fn boot_to_pre_epoch_3_boundary( + naka_conf: &Config, + blocks_processed: &Arc, + stacker_sks: &[StacksPrivateKey], + signer_sks: &[StacksPrivateKey], + self_signing: &mut Option<&mut TestSigners>, + btc_regtest_controller: &mut BitcoinRegtestController, +) { + assert_eq!(stacker_sks.len(), signer_sks.len()); + + let epochs = naka_conf.burnchain.epochs.clone().unwrap(); + let epoch_3 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch30).unwrap()]; + let current_height = btc_regtest_controller.get_headers_height(); + info!( + "Chain bootstrapped to bitcoin block {current_height:?}, starting Epoch 2x miner"; + "Epoch 3.0 Boundary" => (epoch_3.start_height - 1), + ); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + next_block_and_wait(btc_regtest_controller, &blocks_processed); + next_block_and_wait(btc_regtest_controller, &blocks_processed); + // first mined stacks block + next_block_and_wait(btc_regtest_controller, &blocks_processed); + + let start_time = Instant::now(); + loop { + if start_time.elapsed() > Duration::from_secs(20) { + panic!("Timed out waiting for the stacks height to increment") + } + let stacks_height = get_chain_info(&naka_conf).stacks_tip_height; + if stacks_height >= 1 { + break; + } + thread::sleep(Duration::from_millis(100)); + } + // stack enough to activate pox-4 + + let block_height = btc_regtest_controller.get_headers_height(); + let reward_cycle = btc_regtest_controller + .get_burnchain() + .block_height_to_reward_cycle(block_height) + .unwrap(); + + for (stacker_sk, signer_sk) in stacker_sks.iter().zip(signer_sks.iter()) { + let pox_addr = PoxAddress::from_legacy( + AddressHashMode::SerializeP2PKH, + tests::to_addr(&stacker_sk).bytes, + ); + let pox_addr_tuple: clarity::vm::Value = + pox_addr.clone().as_clarity_tuple().unwrap().into(); + let signature = make_pox_4_signer_key_signature( + &pox_addr, + &signer_sk, + reward_cycle.into(), + &Pox4SignatureTopic::StackStx, + CHAIN_ID_TESTNET, + 12_u128, + u128::MAX, + 1, + ) + .unwrap() + .to_rsv(); + + let signer_pk = StacksPublicKey::from_private(signer_sk); + + let stacking_tx = tests::make_contract_call( + &stacker_sk, + 0, + 1000, + &StacksAddress::burn_address(false), + "pox-4", + "stack-stx", + &[ + clarity::vm::Value::UInt(POX_4_DEFAULT_STACKER_STX_AMT), + pox_addr_tuple.clone(), + clarity::vm::Value::UInt(block_height as u128), + clarity::vm::Value::UInt(12), + clarity::vm::Value::some(clarity::vm::Value::buff_from(signature).unwrap()) + .unwrap(), + clarity::vm::Value::buff_from(signer_pk.to_bytes_compressed()).unwrap(), + clarity::vm::Value::UInt(u128::MAX), + clarity::vm::Value::UInt(1), + ], + ); + submit_tx(&http_origin, &stacking_tx); + } + + // Update TestSigner with `signer_sks` if self-signing + if let Some(ref mut signers) = self_signing { + signers.signer_keys = signer_sks.to_vec(); + } + + let prepare_phase_start = btc_regtest_controller + .get_burnchain() + .pox_constants + .prepare_phase_start( + btc_regtest_controller.get_burnchain().first_block_height, + reward_cycle, + ); + + // Run until the prepare phase + run_until_burnchain_height( + btc_regtest_controller, + &blocks_processed, + prepare_phase_start, + &naka_conf, + ); + + // We need to vote on the aggregate public key if this test is self signing + if let Some(signers) = self_signing { + // Get the aggregate key + let aggregate_key = signers.clone().generate_aggregate_key(reward_cycle + 1); + let aggregate_public_key = + clarity::vm::Value::buff_from(aggregate_key.compress().data.to_vec()) + .expect("Failed to serialize aggregate public key"); + let signer_sks_unique: HashMap<_, _> = signer_sks.iter().map(|x| (x.to_hex(), x)).collect(); + let signer_set = get_stacker_set(&http_origin, reward_cycle + 1); + // Vote on the aggregate public key + for signer_sk in signer_sks_unique.values() { + let signer_index = + get_signer_index(&signer_set, &Secp256k1PublicKey::from_private(signer_sk)) + .unwrap(); + let voting_tx = tests::make_contract_call( + signer_sk, + 0, + 300, + &StacksAddress::burn_address(false), + SIGNERS_VOTING_NAME, + SIGNERS_VOTING_FUNCTION_NAME, + &[ + clarity::vm::Value::UInt(u128::try_from(signer_index).unwrap()), + aggregate_public_key.clone(), + clarity::vm::Value::UInt(0), + clarity::vm::Value::UInt(reward_cycle as u128 + 1), + ], + ); + submit_tx(&http_origin, &voting_tx); + } + } + + run_until_burnchain_height( + btc_regtest_controller, + &blocks_processed, + epoch_3.start_height - 2, + &naka_conf, + ); + + info!("Bootstrapped to one block before Epoch 3.0 boundary, Epoch 2.x miner should continue for one more block"); +} + fn get_signer_index( stacker_set: &GetStackersResponse, signer_key: &Secp256k1PublicKey, @@ -1520,6 +1675,287 @@ fn simple_neon_integration() { run_loop_thread.join().unwrap(); } +#[test] +#[ignore] +/// This test spins up a nakamoto-neon node. +/// It starts in Epoch 2.0, mines with `neon_node` to Epoch 3.0, +/// having flash blocks when epoch updates and expects everything to work normally, +/// then switches to Nakamoto operation (activating pox-4 by submitting a stack-stx tx). The BootLoop +/// struct handles the epoch-2/3 tear-down and spin-up. +/// This test makes three assertions: +/// * 30 blocks are mined after 3.0 starts. This is enough to mine across 2 reward cycles +/// * A transaction submitted to the mempool in 3.0 will be mined in 3.0 +/// * The final chain tip is a nakamoto block +fn simple_neon_integration_with_flash_blocks_on_epoch_3() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); + let prom_bind = format!("{}:{}", "127.0.0.1", 6000); + naka_conf.node.prometheus_bind = Some(prom_bind.clone()); + naka_conf.miner.wait_on_interim_blocks = Duration::from_secs(1000); + let sender_sk = Secp256k1PrivateKey::new(); + // setup sender + recipient for a test stx transfer + let sender_addr = tests::to_addr(&sender_sk); + let send_amt = 1000; + let send_fee = 100; + naka_conf.add_initial_balance( + PrincipalData::from(sender_addr.clone()).to_string(), + send_amt * 2 + send_fee, + ); + let sender_signer_sk = Secp256k1PrivateKey::new(); + let sender_signer_addr = tests::to_addr(&sender_signer_sk); + let mut signers = TestSigners::new(vec![sender_signer_sk.clone()]); + naka_conf.add_initial_balance( + PrincipalData::from(sender_signer_addr.clone()).to_string(), + 100000, + ); + let recipient = PrincipalData::from(StacksAddress::burn_address(false)); + let stacker_sk = setup_stacker(&mut naka_conf); + + test_observer::spawn(); + let observer_port = test_observer::EVENT_OBSERVER_PORT; + naka_conf.events_observers.insert(EventObserverConfig { + endpoint: format!("localhost:{observer_port}"), + events_keys: vec![EventKeyType::AnyEvent], + }); + + let mut btcd_controller = BitcoinCoreController::new(naka_conf.clone()); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); + btc_regtest_controller.bootstrap_chain(201); + + let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); + let run_loop_stopper = run_loop.get_termination_switch(); + let Counters { + blocks_processed, + naka_submitted_commits: commits_submitted, + naka_proposed_blocks: proposals_submitted, + .. + } = run_loop.counters(); + + let coord_channel = run_loop.coordinator_channels(); + + let run_loop_thread = thread::spawn(move || run_loop.start(None, 0)); + wait_for_runloop(&blocks_processed); + boot_to_pre_epoch_3_boundary( + &naka_conf, + &blocks_processed, + &[stacker_sk], + &[sender_signer_sk], + &mut Some(&mut signers), + &mut btc_regtest_controller, + ); + + let burnchain = naka_conf.get_burnchain(); + let sortdb = burnchain.open_sortition_db(true).unwrap(); + let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); + let block_height_before_mining = tip.block_height; + + // Mine 3 Bitcoin blocks rapidly without waiting for Stacks blocks to be processed. + // These blocks won't be considered "mined" until the next_block_and_wait call. + for _i in 0..3 { + btc_regtest_controller.build_next_block(1); + let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); + + // Verify that the canonical burn chain tip hasn't advanced yet + assert_eq!( + tip.block_height, + btc_regtest_controller.get_headers_height() - 1 + ); + assert_eq!(tip.block_height, block_height_before_mining); + } + + info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner"); + + // Mine a new block and wait for it to be processed. + // This should update the canonical burn chain tip to include all 4 new blocks. + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); + // Verify that the burn chain tip has advanced by 4 blocks + assert_eq!( + tip.block_height, + block_height_before_mining + 4, + "Burn chain tip should have advanced by 4 blocks" + ); + + assert_eq!( + tip.block_height, + btc_regtest_controller.get_headers_height() - 1 + ); + + let burnchain = naka_conf.get_burnchain(); + let sortdb = burnchain.open_sortition_db(true).unwrap(); + let (mut chainstate, _) = StacksChainState::open( + naka_conf.is_mainnet(), + naka_conf.burnchain.chain_id, + &naka_conf.get_chainstate_path_str(), + None, + ) + .unwrap(); + + let block_height_pre_3_0 = + NakamotoChainState::get_canonical_block_header(chainstate.db(), &sortdb) + .unwrap() + .unwrap() + .stacks_block_height; + + info!("Nakamoto miner started..."); + blind_signer(&naka_conf, &signers, proposals_submitted); + + wait_for_first_naka_block_commit(60, &commits_submitted); + + // Mine 15 nakamoto tenures + for _i in 0..15 { + next_block_and_mine_commit( + &mut btc_regtest_controller, + 60, + &coord_channel, + &commits_submitted, + ) + .unwrap(); + + signer_vote_if_needed( + &btc_regtest_controller, + &naka_conf, + &[sender_signer_sk], + &signers, + ); + } + + // Submit a TX + let transfer_tx = make_stacks_transfer(&sender_sk, 0, send_fee, &recipient, send_amt); + let transfer_tx_hex = format!("0x{}", to_hex(&transfer_tx)); + + let tip = NakamotoChainState::get_canonical_block_header(chainstate.db(), &sortdb) + .unwrap() + .unwrap(); + + let mut mempool = naka_conf + .connect_mempool_db() + .expect("Database failure opening mempool"); + + mempool + .submit_raw( + &mut chainstate, + &sortdb, + &tip.consensus_hash, + &tip.anchored_header.block_hash(), + transfer_tx.clone(), + &ExecutionCost::max_value(), + &StacksEpochId::Epoch30, + ) + .unwrap(); + + // Mine 15 more nakamoto tenures + for _i in 0..15 { + next_block_and_mine_commit( + &mut btc_regtest_controller, + 60, + &coord_channel, + &commits_submitted, + ) + .unwrap(); + + signer_vote_if_needed( + &btc_regtest_controller, + &naka_conf, + &[sender_signer_sk], + &signers, + ); + } + + // load the chain tip, and assert that it is a nakamoto block and at least 30 blocks have advanced in epoch 3 + let tip = NakamotoChainState::get_canonical_block_header(chainstate.db(), &sortdb) + .unwrap() + .unwrap(); + info!( + "Latest tip"; + "height" => tip.stacks_block_height, + "is_nakamoto" => tip.anchored_header.as_stacks_nakamoto().is_some(), + ); + + // assert that the transfer tx was observed + let transfer_tx_included = test_observer::get_blocks() + .into_iter() + .find(|block_json| { + block_json["transactions"] + .as_array() + .unwrap() + .iter() + .find(|tx_json| tx_json["raw_tx"].as_str() == Some(&transfer_tx_hex)) + .is_some() + }) + .is_some(); + + assert!( + transfer_tx_included, + "Nakamoto node failed to include the transfer tx" + ); + + assert!(tip.anchored_header.as_stacks_nakamoto().is_some()); + assert!(tip.stacks_block_height >= block_height_pre_3_0 + 30); + + // Check that we have the expected burn blocks + // We expect to have around the blocks 220-230 and 234 onwards, with a gap of 3 blocks for the flash blocks + let bhh = u64::from(tip.burn_header_height); + + // Get the Epoch 3.0 activation height (in terms of Bitcoin block height) + let epochs = naka_conf.burnchain.epochs.clone().unwrap(); + let epoch_3 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch30).unwrap()]; + let epoch_3_start_height = epoch_3.start_height; + + // Find the gap in burn blocks + let mut gap_start = 0; + let mut gap_end = 0; + for i in 220..=bhh { + if test_observer::contains_burn_block_range(i..=i).is_err() { + if gap_start == 0 { + gap_start = i; + } + gap_end = i; + } else if gap_start != 0 { + break; + } + } + + // Verify that there's a gap of exactly 3 blocks + assert_eq!( + gap_end - gap_start + 1, + 3, + "Expected a gap of exactly 3 burn blocks due to flash blocks, found gap from {} to {}", + gap_start, + gap_end + ); + + // Verify that the gap includes the Epoch 3.0 activation height + assert!( + gap_start <= epoch_3_start_height && epoch_3_start_height <= gap_end, + "Expected the gap ({}..={}) to include the Epoch 3.0 activation height ({})", + gap_start, + gap_end, + epoch_3_start_height + ); + + // Verify blocks before and after the gap + test_observer::contains_burn_block_range(220..=(gap_start - 1)).unwrap(); + test_observer::contains_burn_block_range((gap_end + 1)..=bhh).unwrap(); + + info!("Verified burn block ranges, including expected gap for flash blocks"); + info!("Confirmed that the gap includes the Epoch 3.0 activation height (Bitcoin block height): {}", epoch_3_start_height); + + coord_channel + .lock() + .expect("Mutex poisoned") + .stop_chains_coordinator(); + run_loop_stopper.store(false, Ordering::SeqCst); + + run_loop_thread.join().unwrap(); +} + #[test] #[ignore] /// This test spins up a nakamoto-neon node.