diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 5b5cd320cd..489d978c02 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -91,6 +91,7 @@ jobs: - tests::signer::v0::forked_tenure_invalid - tests::signer::v0::empty_sortition - tests::signer::v0::bitcoind_forking_test + - tests::signer::v0::mock_sign_epoch_25 - tests::nakamoto_integrations::stack_stx_burn_op_integration_test - tests::nakamoto_integrations::check_block_heights - tests::nakamoto_integrations::clarity_burn_state diff --git a/libsigner/src/v0/messages.rs b/libsigner/src/v0/messages.rs index d15f566e16..f16dd6d4ed 100644 --- a/libsigner/src/v0/messages.rs +++ b/libsigner/src/v0/messages.rs @@ -34,16 +34,27 @@ use blockstack_lib::chainstate::nakamoto::signer_set::NakamotoSigners; use blockstack_lib::chainstate::nakamoto::NakamotoBlock; use blockstack_lib::chainstate::stacks::events::StackerDBChunksEvent; use blockstack_lib::chainstate::stacks::StacksTransaction; +use blockstack_lib::net::api::getinfo::RPCPeerInfoData; use blockstack_lib::net::api::postblock_proposal::{ BlockValidateReject, BlockValidateResponse, ValidateRejectCode, }; use blockstack_lib::util_lib::boot::boot_code_id; +use blockstack_lib::util_lib::signed_structured_data::{ + make_structured_data_domain, structured_data_message_hash, +}; +use clarity::types::chainstate::{ + BlockHeaderHash, ConsensusHash, StacksPrivateKey, StacksPublicKey, +}; +use clarity::types::PrivateKey; +use clarity::util::hash::Sha256Sum; use clarity::util::retry::BoundReader; use clarity::util::secp256k1::MessageSignature; use clarity::vm::types::serialization::SerializationError; -use clarity::vm::types::QualifiedContractIdentifier; +use clarity::vm::types::{QualifiedContractIdentifier, TupleData}; +use clarity::vm::Value; use hashbrown::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512_256}; use stacks_common::codec::{ read_next, read_next_at_most, read_next_exact, write_next, Error as CodecError, StacksMessageCodec, @@ -55,6 +66,7 @@ use tiny_http::{ }; use crate::http::{decode_http_body, decode_http_request}; +use crate::stacks_common::types::PublicKey; use crate::{ BlockProposal, EventError, MessageSlotID as MessageSlotIDTrait, SignerMessage as SignerMessageTrait, @@ -65,7 +77,9 @@ define_u8_enum!( /// the contract index in the signers contracts (i.e., X in signers-0-X) MessageSlotID { /// Block Response message from signers - BlockResponse = 1 + BlockResponse = 1, + /// Mock Signature message from Epoch 2.5 signers + MockSignature = 2 }); define_u8_enum!( @@ -100,7 +114,9 @@ SignerMessageTypePrefix { /// Block Response message from signers BlockResponse = 1, /// Block Pushed message from miners - BlockPushed = 2 + BlockPushed = 2, + /// Mock Signature message from Epoch 2.5 signers + MockSignature = 3 }); #[cfg_attr(test, mutants::skip)] @@ -143,6 +159,7 @@ impl From<&SignerMessage> for SignerMessageTypePrefix { SignerMessage::BlockProposal(_) => SignerMessageTypePrefix::BlockProposal, SignerMessage::BlockResponse(_) => SignerMessageTypePrefix::BlockResponse, SignerMessage::BlockPushed(_) => SignerMessageTypePrefix::BlockPushed, + SignerMessage::MockSignature(_) => SignerMessageTypePrefix::MockSignature, } } } @@ -156,6 +173,8 @@ pub enum SignerMessage { BlockResponse(BlockResponse), /// A block pushed from miners to the signers set BlockPushed(NakamotoBlock), + /// A mock signature from the epoch 2.5 signers + MockSignature(MockSignature), } impl SignerMessage { @@ -167,6 +186,7 @@ impl SignerMessage { match self { Self::BlockProposal(_) | Self::BlockPushed(_) => None, Self::BlockResponse(_) => Some(MessageSlotID::BlockResponse), + Self::MockSignature(_) => Some(MessageSlotID::MockSignature), } } } @@ -180,6 +200,7 @@ impl StacksMessageCodec for SignerMessage { SignerMessage::BlockProposal(block_proposal) => block_proposal.consensus_serialize(fd), SignerMessage::BlockResponse(block_response) => block_response.consensus_serialize(fd), SignerMessage::BlockPushed(block) => block.consensus_serialize(fd), + SignerMessage::MockSignature(signature) => signature.consensus_serialize(fd), }?; Ok(()) } @@ -201,6 +222,10 @@ impl StacksMessageCodec for SignerMessage { let block = StacksMessageCodec::consensus_deserialize(fd)?; SignerMessage::BlockPushed(block) } + SignerMessageTypePrefix::MockSignature => { + let signature = StacksMessageCodec::consensus_deserialize(fd)?; + SignerMessage::MockSignature(signature) + } }; Ok(message) } @@ -214,6 +239,178 @@ pub trait StacksMessageCodecExtensions: Sized { fn inner_consensus_deserialize(fd: &mut R) -> Result; } +/// A snapshot of the signer view of the stacks node to be used for mock signing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MockSignData { + /// The stacks tip consensus hash at the time of the mock signature + pub stacks_tip_consensus_hash: ConsensusHash, + /// The stacks tip header hash at the time of the mock signature + pub stacks_tip: BlockHeaderHash, + /// The server version + pub server_version: String, + /// The burn block height that triggered the mock signature + pub burn_block_height: u64, + /// The burn block height of the peer view at the time of the mock signature. Note + /// that this may be different from the burn_block_height if the peer view is stale. + pub peer_burn_block_height: u64, + /// The POX consensus hash at the time of the mock signature + pub pox_consensus: ConsensusHash, + /// The chain id for the mock signature + pub chain_id: u32, +} + +impl MockSignData { + fn new(peer_view: RPCPeerInfoData, burn_block_height: u64, chain_id: u32) -> Self { + Self { + stacks_tip_consensus_hash: peer_view.stacks_tip_consensus_hash, + stacks_tip: peer_view.stacks_tip, + server_version: peer_view.server_version, + burn_block_height, + peer_burn_block_height: peer_view.burn_block_height, + pox_consensus: peer_view.pox_consensus, + chain_id, + } + } +} + +impl StacksMessageCodec for MockSignData { + fn consensus_serialize(&self, fd: &mut W) -> Result<(), CodecError> { + write_next(fd, self.stacks_tip_consensus_hash.as_bytes())?; + write_next(fd, &self.stacks_tip)?; + write_next(fd, &(self.server_version.as_bytes().len() as u8))?; + fd.write_all(self.server_version.as_bytes()) + .map_err(CodecError::WriteError)?; + write_next(fd, &self.burn_block_height)?; + write_next(fd, &self.peer_burn_block_height)?; + write_next(fd, &self.pox_consensus)?; + write_next(fd, &self.chain_id)?; + Ok(()) + } + + fn consensus_deserialize(fd: &mut R) -> Result { + let stacks_tip_consensus_hash = read_next::(fd)?; + let stacks_tip = read_next::(fd)?; + let len_byte: u8 = read_next(fd)?; + let mut bytes = vec![0u8; len_byte as usize]; + fd.read_exact(&mut bytes).map_err(CodecError::ReadError)?; + // must encode a valid string + let server_version = String::from_utf8(bytes).map_err(|_e| { + CodecError::DeserializeError( + "Failed to parse server version name: could not contruct from utf8".to_string(), + ) + })?; + let burn_block_height = read_next::(fd)?; + let peer_burn_block_height = read_next::(fd)?; + let pox_consensus = read_next::(fd)?; + let chain_id = read_next::(fd)?; + Ok(Self { + stacks_tip_consensus_hash, + stacks_tip, + server_version, + burn_block_height, + peer_burn_block_height, + pox_consensus, + chain_id, + }) + } +} + +/// A mock signature for the stacks node to be used for mock signing. +/// This is only used by Epoch 2.5 signers to simulate the signing of a block for every sortition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MockSignature { + /// The signature of the mock signature + signature: MessageSignature, + /// The data that was signed across + pub sign_data: MockSignData, +} + +impl MockSignature { + /// Create a new mock sign data struct from the provided peer info, burn block height, chain id, and private key. + pub fn new( + peer_view: RPCPeerInfoData, + burn_block_height: u64, + chain_id: u32, + stacks_private_key: &StacksPrivateKey, + ) -> Self { + let mut sig = Self { + signature: MessageSignature::empty(), + sign_data: MockSignData::new(peer_view, burn_block_height, chain_id), + }; + sig.sign(stacks_private_key) + .expect("Failed to sign MockSignature"); + sig + } + + /// The signature hash for the mock signature + pub fn signature_hash(&self) -> Sha256Sum { + let domain_tuple = + make_structured_data_domain("mock-signer", "1.0.0", self.sign_data.chain_id); + let data_tuple = Value::Tuple( + TupleData::from_data(vec![ + ( + "stacks-tip-consensus-hash".into(), + Value::buff_from(self.sign_data.stacks_tip_consensus_hash.as_bytes().into()) + .unwrap(), + ), + ( + "stacks-tip".into(), + Value::buff_from(self.sign_data.stacks_tip.as_bytes().into()).unwrap(), + ), + ( + "server-version".into(), + Value::string_ascii_from_bytes(self.sign_data.server_version.clone().into()) + .unwrap(), + ), + ( + "burn-block-height".into(), + Value::UInt(self.sign_data.burn_block_height.into()), + ), + ( + "pox-consensus".into(), + Value::buff_from(self.sign_data.pox_consensus.as_bytes().into()).unwrap(), + ), + ]) + .expect("Error creating signature hash"), + ); + structured_data_message_hash(data_tuple, domain_tuple) + } + + /// Sign the mock signature and set the internal signature field + fn sign(&mut self, private_key: &StacksPrivateKey) -> Result<(), String> { + let signature_hash = self.signature_hash(); + self.signature = private_key.sign(signature_hash.as_bytes())?; + Ok(()) + } + /// Verify the mock signature against the provided public key + pub fn verify(&self, public_key: &StacksPublicKey) -> Result { + if self.signature == MessageSignature::empty() { + return Ok(false); + } + let signature_hash = self.signature_hash(); + public_key + .verify(&signature_hash.0, &self.signature) + .map_err(|e| e.to_string()) + } +} + +impl StacksMessageCodec for MockSignature { + fn consensus_serialize(&self, fd: &mut W) -> Result<(), CodecError> { + write_next(fd, &self.signature)?; + self.sign_data.consensus_serialize(fd)?; + Ok(()) + } + + fn consensus_deserialize(fd: &mut R) -> Result { + let signature = read_next::(fd)?; + let sign_data = read_next::(fd)?; + Ok(Self { + signature, + sign_data, + }) + } +} + define_u8_enum!( /// Enum representing the reject code type prefix RejectCodeTypePrefix { @@ -507,7 +704,9 @@ mod test { TransactionPostConditionMode, TransactionSmartContract, TransactionVersion, }; use blockstack_lib::util_lib::strings::StacksString; + use clarity::consts::CHAIN_ID_MAINNET; use clarity::types::chainstate::{ConsensusHash, StacksBlockId, TrieHash}; + use clarity::types::PrivateKey; use clarity::util::hash::MerkleTree; use clarity::util::secp256k1::MessageSignature; use rand::{thread_rng, Rng, RngCore}; @@ -622,4 +821,74 @@ mod test { .expect("Failed to deserialize SignerMessage"); assert_eq!(signer_message, deserialized_signer_message); } + + fn random_mock_sign_data() -> MockSignData { + let stacks_tip_consensus_byte: u8 = thread_rng().gen(); + let stacks_tip_byte: u8 = thread_rng().gen(); + let pox_consensus_byte: u8 = thread_rng().gen(); + let chain_byte: u8 = thread_rng().gen_range(0..=1); + let chain_id = if chain_byte == 1 { + CHAIN_ID_TESTNET + } else { + CHAIN_ID_MAINNET + }; + MockSignData { + stacks_tip_consensus_hash: ConsensusHash([stacks_tip_consensus_byte; 20]), + stacks_tip: BlockHeaderHash([stacks_tip_byte; 32]), + server_version: "0.0.0".to_string(), + burn_block_height: thread_rng().next_u64(), + peer_burn_block_height: thread_rng().next_u64(), + pox_consensus: ConsensusHash([pox_consensus_byte; 20]), + chain_id, + } + } + + #[test] + fn verify_sign_mock_signature() { + let private_key = StacksPrivateKey::new(); + let public_key = StacksPublicKey::from_private(&private_key); + + let bad_private_key = StacksPrivateKey::new(); + let bad_public_key = StacksPublicKey::from_private(&bad_private_key); + + let mut mock_signature = MockSignature { + signature: MessageSignature::empty(), + sign_data: random_mock_sign_data(), + }; + assert!(!mock_signature + .verify(&public_key) + .expect("Failed to verify MockSignature")); + + mock_signature + .sign(&private_key) + .expect("Failed to sign MockSignature"); + + assert!(mock_signature + .verify(&public_key) + .expect("Failed to verify MockSignature")); + assert!(!mock_signature + .verify(&bad_public_key) + .expect("Failed to verify MockSignature")); + } + + #[test] + fn serde_mock_signature() { + let mock_signature = MockSignature { + signature: MessageSignature::empty(), + sign_data: random_mock_sign_data(), + }; + let serialized_signature = mock_signature.serialize_to_vec(); + let deserialized_signature = read_next::(&mut &serialized_signature[..]) + .expect("Failed to deserialize MockSignature"); + assert_eq!(mock_signature, deserialized_signature); + } + + #[test] + fn serde_sign_data() { + let sign_data = random_mock_sign_data(); + let serialized_data = sign_data.serialize_to_vec(); + let deserialized_data = read_next::(&mut &serialized_data[..]) + .expect("Failed to deserialize MockSignData"); + assert_eq!(sign_data, deserialized_data); + } } diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs index a4716e4c80..ad2a459b96 100644 --- a/stacks-signer/src/v0/signer.rs +++ b/stacks-signer/src/v0/signer.rs @@ -16,11 +16,14 @@ use std::fmt::Debug; use std::sync::mpsc::Sender; use blockstack_lib::net::api::postblock_proposal::BlockValidateResponse; +use clarity::consts::{CHAIN_ID_MAINNET, CHAIN_ID_TESTNET}; use clarity::types::chainstate::StacksPrivateKey; -use clarity::types::PrivateKey; +use clarity::types::{PrivateKey, StacksEpochId}; use clarity::util::hash::MerkleHashFunc; use clarity::util::secp256k1::Secp256k1PublicKey; -use libsigner::v0::messages::{BlockResponse, MessageSlotID, RejectCode, SignerMessage}; +use libsigner::v0::messages::{ + BlockResponse, MessageSlotID, MockSignature, RejectCode, SignerMessage, +}; use libsigner::{BlockProposal, SignerEvent}; use slog::{slog_debug, slog_error, slog_info, slog_warn}; use stacks_common::types::chainstate::StacksAddress; @@ -84,7 +87,7 @@ impl SignerTrait for Signer { sortition_state: &mut Option, event: Option<&SignerEvent>, _res: Sender>, - _current_reward_cycle: u64, + current_reward_cycle: u64, ) { let event_parity = match event { // Block proposal events do have reward cycles, but each proposal has its own cycle, @@ -166,6 +169,12 @@ impl SignerTrait for Signer { ); } *sortition_state = None; + if let Ok(StacksEpochId::Epoch25) = stacks_client.get_node_epoch() { + if self.reward_cycle == current_reward_cycle { + // We are in epoch 2.5, so we should mock mine to prove we are still alive. + self.mock_sign(*burn_height, stacks_client); + } + }; } } } @@ -462,4 +471,34 @@ impl Signer { .insert_block(&block_info) .unwrap_or_else(|_| panic!("{self}: Failed to insert block in DB")); } + + /// Send a mock signature to stackerdb to prove we are still alive + fn mock_sign(&mut self, burn_block_height: u64, stacks_client: &StacksClient) { + let Ok(peer_view) = stacks_client.get_peer_info() else { + warn!("{self}: Failed to get peer info. Cannot mock sign."); + return; + }; + let chain_id = if self.mainnet { + CHAIN_ID_MAINNET + } else { + CHAIN_ID_TESTNET + }; + info!("Mock signing for burn block {burn_block_height:?}"; + "stacks_tip_consensus_hash" => ?peer_view.stacks_tip_consensus_hash.clone(), + "stacks_tip" => ?peer_view.stacks_tip.clone(), + "peer_burn_block_height" => peer_view.burn_block_height, + "pox_consensus" => ?peer_view.pox_consensus.clone(), + "server_version" => peer_view.server_version.clone(), + "chain_id" => chain_id + ); + let mock_signature = + MockSignature::new(peer_view, burn_block_height, chain_id, &self.private_key); + let message = SignerMessage::MockSignature(mock_signature); + if let Err(e) = self + .stackerdb + .send_message_with_retry::(message) + { + warn!("{self}: Failed to send mock signature to stacker-db: {e:?}",); + } + } } diff --git a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs index b6e42b87ee..60f61c0f6a 100644 --- a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs +++ b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs @@ -768,6 +768,10 @@ impl SignCoordinator { debug!("Received block pushed message. Ignoring."); continue; } + SignerMessageV0::MockSignature(_) => { + debug!("Received mock signature message. Ignoring."); + continue; + } }; let block_sighash = block.header.signer_signature_hash(); if block_sighash != response_hash { diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index 2441380b2b..3ab7248db6 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -111,7 +111,7 @@ use crate::tests::{ use crate::{tests, BitcoinRegtestController, BurnchainController, Config, ConfigFile, Keychain}; pub static POX_4_DEFAULT_STACKER_BALANCE: u64 = 100_000_000_000_000; -static POX_4_DEFAULT_STACKER_STX_AMT: u128 = 99_000_000_000_000; +pub static POX_4_DEFAULT_STACKER_STX_AMT: u128 = 99_000_000_000_000; lazy_static! { pub static ref NAKAMOTO_INTEGRATION_EPOCHS: [StacksEpoch; 9] = [ @@ -167,13 +167,13 @@ lazy_static! { StacksEpoch { epoch_id: StacksEpochId::Epoch25, start_height: 201, - end_height: 231, + end_height: 251, block_limit: HELIUM_BLOCK_LIMIT_20.clone(), network_epoch: PEER_VERSION_EPOCH_2_5 }, StacksEpoch { epoch_id: StacksEpochId::Epoch30, - start_height: 231, + start_height: 251, end_height: STACKS_EPOCH_MAX, block_limit: HELIUM_BLOCK_LIMIT_20.clone(), network_epoch: PEER_VERSION_EPOCH_3_0 @@ -735,9 +735,9 @@ pub fn boot_to_epoch_3( 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 201, starting Epoch 2x miner"; + "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); @@ -1120,6 +1120,47 @@ pub fn boot_to_epoch_3_reward_set_calculation_boundary( info!("Bootstrapped to Epoch 3.0 reward set calculation boundary height: {epoch_3_reward_set_calculation_boundary}."); } +/// +/// * `stacker_sks` - must be a private key for sending a large `stack-stx` transaction in order +/// for pox-4 to activate +/// * `signer_pks` - must be the same size as `stacker_sks` +pub fn boot_to_epoch_25( + naka_conf: &Config, + blocks_processed: &Arc, + btc_regtest_controller: &mut BitcoinRegtestController, +) { + let epochs = naka_conf.burnchain.epochs.clone().unwrap(); + let epoch_25 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch25).unwrap()]; + let reward_cycle_len = naka_conf.get_burnchain().pox_constants.reward_cycle_length as u64; + let prepare_phase_len = naka_conf.get_burnchain().pox_constants.prepare_length as u64; + + let epoch_25_start_height = epoch_25.start_height; + assert!( + epoch_25_start_height > 0, + "Epoch 2.5 start height must be greater than 0" + ); + // 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(); + debug!("Test Cycle Info"; + "prepare_phase_len" => {prepare_phase_len}, + "reward_cycle_len" => {reward_cycle_len}, + "block_height" => {block_height}, + "reward_cycle" => {reward_cycle}, + "epoch_25_start_height" => {epoch_25_start_height}, + ); + run_until_burnchain_height( + btc_regtest_controller, + &blocks_processed, + epoch_25_start_height, + &naka_conf, + ); + info!("Bootstrapped to Epoch 2.5: {epoch_25_start_height}."); +} + /// /// * `stacker_sks` - must be a private key for sending a large `stack-stx` transaction in order /// for pox-4 to activate @@ -1901,9 +1942,9 @@ fn correct_burn_outs() { let epochs = naka_conf.burnchain.epochs.clone().unwrap(); let epoch_3 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch30).unwrap()]; let epoch_25 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch25).unwrap()]; - + let current_height = btc_regtest_controller.get_headers_height(); info!( - "Chain bootstrapped to bitcoin block 201, starting Epoch 2x miner"; + "Chain bootstrapped to bitcoin block {current_height:?}, starting Epoch 2x miner"; "Epoch 3.0 Boundary" => (epoch_3.start_height - 1), ); diff --git a/testnet/stacks-node/src/tests/signer/mod.rs b/testnet/stacks-node/src/tests/signer/mod.rs index 12584ab89a..bad0b499ea 100644 --- a/testnet/stacks-node/src/tests/signer/mod.rs +++ b/testnet/stacks-node/src/tests/signer/mod.rs @@ -604,7 +604,8 @@ fn setup_stx_btc_node( let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); info!("Bootstraping..."); - btc_regtest_controller.bootstrap_chain(201); + // Bootstrap the chain to BEFORE epoch 2.5 to enable mock mining of blocks in Epoch 2.5 tests + btc_regtest_controller.bootstrap_chain(195); info!("Chain bootstrapped..."); diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs index 5ebb893717..e1f57097e9 100644 --- a/testnet/stacks-node/src/tests/signer/v0.rs +++ b/testnet/stacks-node/src/tests/signer/v0.rs @@ -19,20 +19,27 @@ use std::time::{Duration, Instant}; use std::{env, thread}; use clarity::vm::types::PrincipalData; +use clarity::vm::StacksEpoch; use libsigner::v0::messages::{ BlockRejection, BlockResponse, MessageSlotID, RejectCode, SignerMessage, }; use libsigner::{BlockProposal, SignerSession, StackerDBSession}; +use stacks::address::AddressHashMode; use stacks::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader, NakamotoChainState}; +use stacks::chainstate::stacks::address::PoxAddress; use stacks::chainstate::stacks::boot::MINERS_NAME; use stacks::chainstate::stacks::db::{StacksChainState, StacksHeaderInfo}; use stacks::codec::StacksMessageCodec; +use stacks::core::{StacksEpochId, CHAIN_ID_TESTNET}; use stacks::libstackerdb::StackerDBChunkData; use stacks::net::api::postblock_proposal::TEST_VALIDATE_STALL; -use stacks::types::chainstate::{StacksAddress, StacksPrivateKey}; +use stacks::types::chainstate::{StacksAddress, StacksPrivateKey, StacksPublicKey}; use stacks::types::PublicKey; use stacks::util::secp256k1::{Secp256k1PrivateKey, Secp256k1PublicKey}; use stacks::util_lib::boot::boot_code_id; +use stacks::util_lib::signed_structured_data::pox4::{ + make_pox_4_signer_key_signature, Pox4SignatureTopic, +}; use stacks_common::bitvec::BitVec; use stacks_signer::chainstate::{ProposalEvalConfig, SortitionsView}; use stacks_signer::client::{SignerSlotID, StackerDB}; @@ -45,14 +52,191 @@ use super::SignerTest; use crate::event_dispatcher::MinedNakamotoBlockEvent; use crate::nakamoto_node::miner::TEST_BROADCAST_STALL; use crate::nakamoto_node::relayer::TEST_SKIP_COMMIT_OP; -use crate::tests::nakamoto_integrations::{boot_to_epoch_3_reward_set, next_block_and}; +use crate::tests::nakamoto_integrations::{ + boot_to_epoch_25, boot_to_epoch_3_reward_set, next_block_and, POX_4_DEFAULT_STACKER_STX_AMT, +}; use crate::tests::neon_integrations::{ - get_account, get_chain_info, next_block_and_wait, submit_tx, test_observer, + get_account, get_chain_info, next_block_and_wait, run_until_burnchain_height, submit_tx, + test_observer, }; use crate::tests::{self, make_stacks_transfer}; use crate::{nakamoto_node, BurnchainController, Keychain}; impl SignerTest { + /// Run the test until the first epoch 2.5 reward cycle. + /// Will activate pox-4 and register signers for the first full Epoch 2.5 reward cycle. + fn boot_to_epoch_25_reward_cycle(&mut self) { + boot_to_epoch_25( + &self.running_nodes.conf, + &self.running_nodes.blocks_processed, + &mut self.running_nodes.btc_regtest_controller, + ); + + next_block_and_wait( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + ); + + let http_origin = format!("http://{}", &self.running_nodes.conf.node.rpc_bind); + let lock_period = 12; + + let epochs = self.running_nodes.conf.burnchain.epochs.clone().unwrap(); + let epoch_25 = + &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch25).unwrap()]; + let epoch_25_start_height = epoch_25.start_height; + // stack enough to activate pox-4 + let block_height = self + .running_nodes + .btc_regtest_controller + .get_headers_height(); + let reward_cycle = self + .running_nodes + .btc_regtest_controller + .get_burnchain() + .block_height_to_reward_cycle(block_height) + .unwrap(); + for stacker_sk in self.signer_stacks_private_keys.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, + &stacker_sk, + reward_cycle.into(), + &Pox4SignatureTopic::StackStx, + CHAIN_ID_TESTNET, + lock_period, + u128::MAX, + 1, + ) + .unwrap() + .to_rsv(); + + let signer_pk = StacksPublicKey::from_private(stacker_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(lock_period), + 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); + } + next_block_and_wait( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + ); + next_block_and_wait( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + ); + + let reward_cycle_len = self + .running_nodes + .conf + .get_burnchain() + .pox_constants + .reward_cycle_length as u64; + let prepare_phase_len = self + .running_nodes + .conf + .get_burnchain() + .pox_constants + .prepare_length as u64; + + let epoch_25_reward_cycle_boundary = + epoch_25_start_height.saturating_sub(epoch_25_start_height % reward_cycle_len); + let epoch_25_reward_set_calculation_boundary = epoch_25_reward_cycle_boundary + .saturating_sub(prepare_phase_len) + .wrapping_add(reward_cycle_len) + .wrapping_add(1); + + let next_reward_cycle_boundary = epoch_25_reward_cycle_boundary + .wrapping_add(reward_cycle_len) + .saturating_sub(1); + run_until_burnchain_height( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + epoch_25_reward_set_calculation_boundary, + &self.running_nodes.conf, + ); + debug!("Waiting for signer set calculation."); + let mut reward_set_calculated = false; + let short_timeout = Duration::from_secs(30); + let now = std::time::Instant::now(); + // Make sure the signer set is calculated before continuing or signers may not + // recognize that they are registered signers in the subsequent burn block event + let reward_cycle = self.get_current_reward_cycle().wrapping_add(1); + while !reward_set_calculated { + let reward_set = self + .stacks_client + .get_reward_set_signers(reward_cycle) + .expect("Failed to check if reward set is calculated"); + reward_set_calculated = reward_set.is_some(); + if reward_set_calculated { + debug!("Signer set: {:?}", reward_set.unwrap()); + } + std::thread::sleep(Duration::from_secs(1)); + assert!( + now.elapsed() < short_timeout, + "Timed out waiting for reward set calculation" + ); + } + debug!("Signer set calculated"); + // Manually consume one more block to ensure signers refresh their state + debug!("Waiting for signers to initialize."); + next_block_and_wait( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + ); + let now = std::time::Instant::now(); + loop { + self.send_status_request(); + let states = self.wait_for_states(short_timeout); + if states + .iter() + .all(|state_info| state_info.runloop_state == State::RegisteredSigners) + { + break; + } + assert!( + now.elapsed() < short_timeout, + "Timed out waiting for signers to be registered" + ); + std::thread::sleep(Duration::from_secs(1)); + } + debug!("Signers initialized"); + + info!("Advancing to the first full Epoch 2.5 reward cycle boundary..."); + run_until_burnchain_height( + &mut self.running_nodes.btc_regtest_controller, + &self.running_nodes.blocks_processed, + next_reward_cycle_boundary, + &self.running_nodes.conf, + ); + + let current_burn_block_height = self + .running_nodes + .btc_regtest_controller + .get_headers_height(); + info!("At burn block height {current_burn_block_height}. Ready to mine the first Epoch 2.5 reward cycle!"); + } + /// Run the test until the epoch 3 boundary fn boot_to_epoch_3(&mut self) { boot_to_epoch_3_reward_set( @@ -1373,3 +1557,172 @@ fn empty_sortition() { } signer_test.shutdown(); } + +#[test] +#[ignore] +/// This test checks that Epoch 2.5 signers will issue a mock signature per burn block they receive. +fn mock_sign_epoch_25() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env()) + .init(); + + info!("------------------------- Test Setup -------------------------"); + let num_signers = 5; + let sender_sk = Secp256k1PrivateKey::new(); + let sender_addr = tests::to_addr(&sender_sk); + let send_amt = 100; + let send_fee = 180; + + let mut signer_test: SignerTest = SignerTest::new( + num_signers, + vec![(sender_addr.clone(), send_amt + send_fee)], + Some(Duration::from_secs(5)), + ); + + let epochs = signer_test + .running_nodes + .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; + + signer_test.boot_to_epoch_25_reward_cycle(); + + info!("------------------------- Test Processing Epoch 2.5 Tenures -------------------------"); + + // Mine until epoch 3.0 and ensure that no more mock signatures are received + + let mut reward_cycle = signer_test.get_current_reward_cycle(); + let mut stackerdb = StackerDB::new( + &signer_test.running_nodes.conf.node.rpc_bind, + StacksPrivateKey::new(), // We are just reading so don't care what the key is + false, + reward_cycle, + SignerSlotID(0), // We are just reading so again, don't care about index. + ); + let mut signer_slot_ids: Vec<_> = signer_test + .get_signer_indices(reward_cycle) + .iter() + .map(|id| id.0) + .collect(); + assert_eq!(signer_slot_ids.len(), num_signers); + // Mine until epoch 3.0 and ensure we get a new mock signature per epoch 2.5 sortition + let main_poll_time = Instant::now(); + let mut current_burn_block_height = signer_test + .running_nodes + .btc_regtest_controller + .get_headers_height(); + while current_burn_block_height + 1 < epoch_3_start_height { + current_burn_block_height = signer_test + .running_nodes + .btc_regtest_controller + .get_headers_height(); + let current_reward_cycle = signer_test.get_current_reward_cycle(); + if current_reward_cycle != reward_cycle { + debug!("Rolling over reward cycle to {:?}", current_reward_cycle); + reward_cycle = current_reward_cycle; + stackerdb = StackerDB::new( + &signer_test.running_nodes.conf.node.rpc_bind, + StacksPrivateKey::new(), // We are just reading so don't care what the key is + false, + reward_cycle, + SignerSlotID(0), // We are just reading so again, don't care about index. + ); + signer_slot_ids = signer_test + .get_signer_indices(reward_cycle) + .iter() + .map(|id| id.0) + .collect(); + assert_eq!(signer_slot_ids.len(), num_signers); + } + next_block_and( + &mut signer_test.running_nodes.btc_regtest_controller, + 60, + || Ok(true), + ) + .unwrap(); + let mut mock_signatures = vec![]; + let mock_poll_time = Instant::now(); + debug!("Waiting for mock signatures for burn block height {current_burn_block_height}"); + while mock_signatures.len() != num_signers { + std::thread::sleep(Duration::from_millis(100)); + let messages: Vec = StackerDB::get_messages( + stackerdb + .get_session_mut(&MessageSlotID::MockSignature) + .expect("Failed to get BlockResponse stackerdb session"), + &signer_slot_ids, + ) + .expect("Failed to get message from stackerdb"); + for message in messages { + if let SignerMessage::MockSignature(mock_signature) = message { + if mock_signature.sign_data.burn_block_height == current_burn_block_height { + if !mock_signatures.contains(&mock_signature) { + mock_signatures.push(mock_signature); + } + } + } + } + assert!( + mock_poll_time.elapsed() <= Duration::from_secs(15), + "Failed to find mock signatures within timeout" + ); + } + assert!( + main_poll_time.elapsed() <= Duration::from_secs(45), + "Timed out waiting to advance epoch 3.0" + ); + } + + info!("------------------------- Test Processing Epoch 3.0 Tenure -------------------------"); + let old_messages: Vec = StackerDB::get_messages( + stackerdb + .get_session_mut(&MessageSlotID::MockSignature) + .expect("Failed to get BlockResponse stackerdb session"), + &signer_slot_ids, + ) + .expect("Failed to get message from stackerdb"); + let old_signatures = old_messages + .iter() + .filter_map(|message| { + if let SignerMessage::MockSignature(mock_signature) = message { + Some(mock_signature) + } else { + None + } + }) + .collect::>(); + next_block_and( + &mut signer_test.running_nodes.btc_regtest_controller, + 60, + || Ok(true), + ) + .unwrap(); + // Wait a bit to ensure no new mock signatures show up + std::thread::sleep(Duration::from_secs(5)); + let new_messages: Vec = StackerDB::get_messages( + stackerdb + .get_session_mut(&MessageSlotID::MockSignature) + .expect("Failed to get BlockResponse stackerdb session"), + &signer_slot_ids, + ) + .expect("Failed to get message from stackerdb"); + let new_signatures = new_messages + .iter() + .filter_map(|message| { + if let SignerMessage::MockSignature(mock_signature) = message { + Some(mock_signature) + } else { + None + } + }) + .collect::>(); + assert_eq!(old_signatures, new_signatures); +}