diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 675fa0bf6c..ae465bbd44 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -75,6 +75,7 @@ jobs: - tests::nakamoto_integrations::mine_multiple_per_tenure_integration - tests::nakamoto_integrations::block_proposal_api_endpoint - tests::nakamoto_integrations::miner_writes_proposed_block_to_stackerdb + - tests::nakamoto_integrations::correct_burn_outs - tests::signer::stackerdb_dkg_sign - tests::signer::stackerdb_block_proposal steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b27ce83f..ffba4b176f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ### Added +- New RPC endpoint `/v2/stacker_set/{cycle_number}` to fetch stacker sets in PoX-4 - New `/new_pox_anchor` endpoint for broadcasting PoX anchor block processing. - Stacker bitvec in NakamotoBlock diff --git a/clarity/src/vm/errors.rs b/clarity/src/vm/errors.rs index fb8808936a..55977ec6aa 100644 --- a/clarity/src/vm/errors.rs +++ b/clarity/src/vm/errors.rs @@ -102,6 +102,7 @@ pub enum RuntimeErrorType { UnwrapFailure, DefunctPoxContract, PoxAlreadyLocked, + MetadataAlreadySet, } #[derive(Debug, PartialEq)] diff --git a/docs/rpc/api/core-node/get_stacker_set.400.example.json b/docs/rpc/api/core-node/get_stacker_set.400.example.json new file mode 100644 index 0000000000..263129a1c6 --- /dev/null +++ b/docs/rpc/api/core-node/get_stacker_set.400.example.json @@ -0,0 +1,4 @@ +{ + "response": "error", + "err_msg": "Could not read reward set. Prepare phase may not have started for this cycle yet. Cycle = 22, Err = PoXAnchorBlockRequired" +} diff --git a/docs/rpc/api/core-node/get_stacker_set.example.json b/docs/rpc/api/core-node/get_stacker_set.example.json new file mode 100644 index 0000000000..1bcd3fad59 --- /dev/null +++ b/docs/rpc/api/core-node/get_stacker_set.example.json @@ -0,0 +1,25 @@ +{ + "stacker_set": { + "rewarded_addresses": [ + { + "Standard": [ + { + "bytes": "dc5f18421006ee2b98ab972edfa7268a981e3f00", + "version": 26 + }, + "SerializeP2PKH" + ] + } + ], + "signers": [ + { + "signing_key": "02d0a27e4f1bf186b4391eecfcc4d4a0d403684ad089b477b8548a69dd6378bf26", + "slots": 1, + "stacked_amt": 2143020000000000 + } + ], + "start_cycle_state": { + "missed_reward_slots": [] + } + } +} diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d554b96242..ceaf0e4a9d 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -583,3 +583,27 @@ paths: application/json: example: $ref: ./api/core-node/post-block-proposal-req.example.json + + /v2/stacker_set/{cycle_number}: + get: + summary: Fetch the stacker and signer set information for a given cycle. + tags: + - Mining + operationId: get_stacker_set + description: | + Used to get stacker and signer set information for a given cycle. + + This will only return information for cycles started in Epoch-2.5 where PoX-4 was active and subsequent cycles. + responses: + 200: + description: Information for the given reward cycle + content: + application/json: + example: + $ref: ./api/core-node/get_stacker_set.example.json + 400: + description: Could not fetch the given reward set + content: + application/json: + example: + $ref: ./api/core-node/get_stacker_set.400.example.json diff --git a/stackslib/src/burnchains/burnchain.rs b/stackslib/src/burnchains/burnchain.rs index c7f85471ff..d4d936b332 100644 --- a/stackslib/src/burnchains/burnchain.rs +++ b/stackslib/src/burnchains/burnchain.rs @@ -489,6 +489,18 @@ impl Burnchain { .reward_cycle_to_block_height(self.first_block_height, reward_cycle) } + pub fn next_reward_cycle(&self, block_height: u64) -> Option { + let cycle = self.block_height_to_reward_cycle(block_height)?; + let effective_height = block_height.checked_sub(self.first_block_height)?; + let next_bump = if effective_height % u64::from(self.pox_constants.reward_cycle_length) == 0 + { + 0 + } else { + 1 + }; + Some(cycle + next_bump) + } + pub fn block_height_to_reward_cycle(&self, block_height: u64) -> Option { self.pox_constants .block_height_to_reward_cycle(self.first_block_height, block_height) diff --git a/stackslib/src/chainstate/coordinator/mod.rs b/stackslib/src/chainstate/coordinator/mod.rs index 85bfc83b48..beb29b5661 100644 --- a/stackslib/src/chainstate/coordinator/mod.rs +++ b/stackslib/src/chainstate/coordinator/mod.rs @@ -286,6 +286,15 @@ pub trait RewardSetProvider { sortdb: &SortitionDB, block_id: &StacksBlockId, ) -> Result; + + fn get_reward_set_nakamoto( + &self, + cycle_start_burn_height: u64, + chainstate: &mut StacksChainState, + burnchain: &Burnchain, + sortdb: &SortitionDB, + block_id: &StacksBlockId, + ) -> Result; } pub struct OnChainRewardSetProvider<'a, T: BlockEventDispatcher>(pub Option<&'a T>); @@ -312,6 +321,16 @@ impl<'a, T: BlockEventDispatcher> RewardSetProvider for OnChainRewardSetProvider let cycle = burnchain .block_height_to_reward_cycle(cycle_start_burn_height) .expect("FATAL: no reward cycle for burn height"); + // `self.get_reward_set_nakamoto` reads the reward set from data written during + // updates to .signers + // `self.get_reward_set_epoch2` reads the reward set from the `.pox-*` contract + // + // Data **cannot** be read from `.signers` in epoch 2.5 because the write occurs + // in the first block of the prepare phase, but the PoX anchor block is *before* + // the prepare phase. Therefore, we fetch the reward set in the 2.x style, and then + // apply the necessary nakamoto assertions if the reward set is going to be + // active in Nakamoto (i.e., check for signer set existence). + let is_nakamoto_reward_set = match SortitionDB::get_stacks_epoch_by_epoch_id( sortdb.conn(), &StacksEpochId::Epoch30, @@ -325,26 +344,22 @@ impl<'a, T: BlockEventDispatcher> RewardSetProvider for OnChainRewardSetProvider // if epoch-3.0 isn't defined, then never use a nakamoto reward set. None => false, }; - let reward_set = if !is_nakamoto_reward_set { - // Stacks 2.x epoch - self.get_reward_set_epoch2( - cycle_start_burn_height, - chainstate, - burnchain, - sortdb, - block_id, - cur_epoch, - )? - } else { - // Nakamoto epoch - self.get_reward_set_nakamoto( - cycle_start_burn_height, - chainstate, - burnchain, - sortdb, - block_id, - )? - }; + + let reward_set = self.get_reward_set_epoch2( + cycle_start_burn_height, + chainstate, + burnchain, + sortdb, + block_id, + cur_epoch, + )?; + + if is_nakamoto_reward_set { + if reward_set.signers.is_none() || reward_set.signers == Some(vec![]) { + error!("FATAL: Signer sets are empty in a reward set that will be used in nakamoto"; "reward_set" => ?reward_set); + return Err(Error::PoXAnchorBlockRequired); + } + } if let Some(dispatcher) = self.0 { dispatcher.announce_reward_set(&reward_set, block_id, cycle); @@ -352,6 +367,24 @@ impl<'a, T: BlockEventDispatcher> RewardSetProvider for OnChainRewardSetProvider Ok(reward_set) } + + fn get_reward_set_nakamoto( + &self, + cycle_start_burn_height: u64, + chainstate: &mut StacksChainState, + burnchain: &Burnchain, + sortdb: &SortitionDB, + block_id: &StacksBlockId, + ) -> Result { + self.read_reward_set_nakamoto( + cycle_start_burn_height, + chainstate, + burnchain, + sortdb, + block_id, + false, + ) + } } impl<'a, T: BlockEventDispatcher> OnChainRewardSetProvider<'a, T> { diff --git a/stackslib/src/chainstate/coordinator/tests.rs b/stackslib/src/chainstate/coordinator/tests.rs index b823545f30..2882bd2cd0 100644 --- a/stackslib/src/chainstate/coordinator/tests.rs +++ b/stackslib/src/chainstate/coordinator/tests.rs @@ -520,6 +520,17 @@ impl RewardSetProvider for StubbedRewardSetProvider { signers: None, }) } + + fn get_reward_set_nakamoto( + &self, + cycle_start_burn_height: u64, + chainstate: &mut StacksChainState, + burnchain: &Burnchain, + sortdb: &SortitionDB, + block_id: &StacksBlockId, + ) -> Result { + panic!("Stubbed reward set provider cannot be invoked in nakamoto") + } } fn make_reward_set_coordinator<'a>( diff --git a/stackslib/src/chainstate/nakamoto/coordinator/mod.rs b/stackslib/src/chainstate/nakamoto/coordinator/mod.rs index de145b6eec..15973cd291 100644 --- a/stackslib/src/chainstate/nakamoto/coordinator/mod.rs +++ b/stackslib/src/chainstate/nakamoto/coordinator/mod.rs @@ -17,6 +17,7 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +use clarity::vm::clarity::ClarityConnection; use clarity::vm::database::BurnStateDB; use clarity::vm::types::PrincipalData; use stacks_common::types::chainstate::{ @@ -40,7 +41,7 @@ use crate::chainstate::coordinator::{ RewardSetProvider, }; use crate::chainstate::nakamoto::NakamotoChainState; -use crate::chainstate::stacks::boot::RewardSet; +use crate::chainstate::stacks::boot::{RewardSet, SIGNERS_NAME}; use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState}; use crate::chainstate::stacks::miner::{signal_mining_blocked, signal_mining_ready, MinerStatus}; use crate::chainstate::stacks::Error as ChainstateError; @@ -52,60 +53,119 @@ use crate::util_lib::db::Error as DBError; #[cfg(test)] pub mod tests; +macro_rules! err_or_debug { + ($debug_bool:expr, $($arg:tt)*) => ({ + if $debug_bool { + debug!($($arg)*) + } else { + error!($($arg)*) + } + }) +} + +macro_rules! inf_or_debug { + ($debug_bool:expr, $($arg:tt)*) => ({ + if $debug_bool { + debug!($($arg)*) + } else { + info!($($arg)*) + } + }) +} + impl<'a, T: BlockEventDispatcher> OnChainRewardSetProvider<'a, T> { - pub fn get_reward_set_nakamoto( + /// Read a reward_set written while updating .signers + /// `debug_log` should be set to true if the reward set loading should + /// log messages as `debug!` instead of `error!` or `info!`. This allows + /// RPC endpoints to expose this without flooding loggers. + pub fn read_reward_set_nakamoto( &self, cycle_start_burn_height: u64, chainstate: &mut StacksChainState, burnchain: &Burnchain, sortdb: &SortitionDB, block_id: &StacksBlockId, + debug_log: bool, ) -> Result { - // TODO: this method should read the .signers contract to get the reward set entries. - // they will have been set via `NakamotoChainState::check_and_handle_prepare_phase_start()`. let cycle = burnchain .block_height_to_reward_cycle(cycle_start_burn_height) .expect("FATAL: no reward cycle for burn height"); - let registered_addrs = - chainstate.get_reward_addresses_in_cycle(burnchain, sortdb, cycle, block_id)?; - - let liquid_ustx = chainstate.get_liquid_ustx(block_id); + // figure out the block ID + let Some(coinbase_height_of_calculation) = chainstate + .eval_boot_code_read_only( + sortdb, + block_id, + SIGNERS_NAME, + &format!("(map-get? cycle-set-height u{})", cycle), + )? + .expect_optional() + .map_err(|e| Error::ChainstateError(e.into()))? + .map(|x| { + let as_u128 = x.expect_u128()?; + Ok(u64::try_from(as_u128).expect("FATAL: block height exceeded u64")) + }) + .transpose() + .map_err(|e| Error::ChainstateError(ChainstateError::ClarityError(e)))? + else { + err_or_debug!( + debug_log, + "The reward set was not written to .signers before it was needed by Nakamoto"; + "cycle_number" => cycle, + ); + return Err(Error::PoXAnchorBlockRequired); + }; - let (threshold, participation) = StacksChainState::get_reward_threshold_and_participation( - &burnchain.pox_constants, - ®istered_addrs[..], - liquid_ustx, - ); + let Some(reward_set_block) = NakamotoChainState::get_header_by_coinbase_height( + &mut chainstate.index_tx_begin()?, + block_id, + coinbase_height_of_calculation, + )? + else { + err_or_debug!( + debug_log, + "Failed to find the block in which .signers was written" + ); + return Err(Error::PoXAnchorBlockRequired); + }; - let cur_epoch = SortitionDB::get_stacks_epoch(sortdb.conn(), cycle_start_burn_height)? - .expect(&format!( - "FATAL: no epoch defined for burn height {}", - cycle_start_burn_height - )); + let Some(reward_set) = NakamotoChainState::get_reward_set( + chainstate.db(), + &reward_set_block.index_block_hash(), + )? + else { + err_or_debug!( + debug_log, + "No reward set stored at the block in which .signers was written"; + "checked_block" => %reward_set_block.index_block_hash(), + "coinbase_height_of_calculation" => coinbase_height_of_calculation, + ); + return Err(Error::PoXAnchorBlockRequired); + }; // This method should only ever called if the current reward cycle is a nakamoto reward cycle // (i.e., its reward set is fetched for determining signer sets (and therefore agg keys). // Non participation is fatal. - if participation == 0 { + if reward_set.rewarded_addresses.is_empty() { // no one is stacking - error!("No PoX participation"); + err_or_debug!(debug_log, "No PoX participation"); return Err(Error::PoXAnchorBlockRequired); } - info!("PoX reward cycle threshold computed"; - "burn_height" => cycle_start_burn_height, - "threshold" => threshold, - "participation" => participation, - "liquid_ustx" => liquid_ustx, - "registered_addrs" => registered_addrs.len()); + inf_or_debug!( + debug_log, + "PoX reward set loaded from written block state"; + "reward_set_block_id" => %reward_set_block.index_block_hash(), + ); - let reward_set = - StacksChainState::make_reward_set(threshold, registered_addrs, cur_epoch.epoch_id); if reward_set.signers.is_none() { - error!("FATAL: PoX reward set did not specify signer set in Nakamoto"); + err_or_debug!( + debug_log, + "FATAL: PoX reward set did not specify signer set in Nakamoto" + ); return Err(Error::PoXAnchorBlockRequired); } + Ok(reward_set) } } @@ -185,9 +245,8 @@ pub fn get_nakamoto_reward_cycle_info( // calculating the reward set for the _next_ reward cycle let reward_cycle = burnchain - .block_height_to_reward_cycle(burn_height) - .expect("FATAL: no reward cycle for burn height") - + 1; + .next_reward_cycle(burn_height) + .expect("FATAL: no reward cycle for burn height"); let reward_start_height = burnchain.reward_cycle_to_block_height(reward_cycle); debug!("Processing reward set for Nakamoto reward cycle"; @@ -286,7 +345,7 @@ pub fn get_nakamoto_reward_cycle_info( "first_prepare_sortition_id" => %first_sortition_id ); - let reward_set = provider.get_reward_set( + let reward_set = provider.get_reward_set_nakamoto( reward_start_height, chain_state, burnchain, diff --git a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs index 551348bffc..8dae7a39db 100644 --- a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs +++ b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs @@ -56,7 +56,7 @@ use crate::util_lib::boot::boot_code_id; fn advance_to_nakamoto( peer: &mut TestPeer, test_signers: &TestSigners, - test_stackers: Vec, + test_stackers: &[TestStacker], ) { let mut peer_nonce = 0; let private_key = peer.config.private_key.clone(); @@ -75,6 +75,8 @@ fn advance_to_nakamoto( test_stackers .iter() .map(|test_stacker| { + let signing_key = + StacksPublicKey::from_private(&test_stacker.signer_private_key); make_pox_4_lockup( &test_stacker.stacker_private_key, 0, @@ -84,7 +86,7 @@ fn advance_to_nakamoto( addr.bytes.clone(), ), 12, - StacksPublicKey::from_private(&test_stacker.signer_private_key), + signing_key, 34, ) }) @@ -104,7 +106,7 @@ pub fn boot_nakamoto<'a>( test_name: &str, mut initial_balances: Vec<(PrincipalData, u64)>, test_signers: &TestSigners, - test_stackers: Option>, + test_stackers: &[TestStacker], observer: Option<&'a TestEventObserver>, ) -> TestPeer<'a> { let aggregate_public_key = test_signers.aggregate_public_key.clone(); @@ -129,23 +131,6 @@ pub fn boot_nakamoto<'a>( peer_config.epochs = Some(StacksEpoch::unit_test_3_0_only(37)); peer_config.initial_balances = vec![(addr.to_account_principal(), 1_000_000_000_000_000_000)]; - let test_stackers: Vec = if let Some(stackers) = test_stackers { - stackers.into_iter().cloned().collect() - } else { - // Create a list of test Stackers and their signer keys - (0..test_signers.num_keys) - .map(|index| { - let stacker_private_key = StacksPrivateKey::from_seed(&index.to_be_bytes()); - let signer_private_key = StacksPrivateKey::from_seed(&index.to_be_bytes()); - TestStacker { - stacker_private_key, - signer_private_key, - amount: 1_000_000_000_000_000_000, - } - }) - .collect() - }; - // Create some balances for test Stackers let mut stacker_balances = test_stackers .iter() @@ -163,7 +148,7 @@ pub fn boot_nakamoto<'a>( peer_config.burnchain.pox_constants.pox_3_activation_height = 26; peer_config.burnchain.pox_constants.v3_unlock_height = 27; peer_config.burnchain.pox_constants.pox_4_activation_height = 31; - peer_config.test_stackers = Some(test_stackers.clone()); + peer_config.test_stackers = Some(test_stackers.to_vec()); let mut peer = TestPeer::new_with_observer(peer_config, observer); advance_to_nakamoto(&mut peer, &test_signers, test_stackers); @@ -182,7 +167,11 @@ fn make_replay_peer<'a>(peer: &mut TestPeer<'a>) -> TestPeer<'a> { let test_stackers = replay_config.test_stackers.clone().unwrap_or(vec![]); let mut replay_peer = TestPeer::new(replay_config); let observer = TestEventObserver::new(); - advance_to_nakamoto(&mut replay_peer, &TestSigners::default(), test_stackers); + advance_to_nakamoto( + &mut replay_peer, + &TestSigners::default(), + test_stackers.as_slice(), + ); // sanity check let replay_tip = { @@ -297,7 +286,14 @@ fn replay_reward_cycle( #[test] fn test_simple_nakamoto_coordinator_bootup() { let mut test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); + let test_stackers = TestStacker::common_signing_set(&test_signers); + let mut peer = boot_nakamoto( + function_name!(), + vec![], + &test_signers, + &test_stackers, + None, + ); let (burn_ops, mut tenure_change, miner_key) = peer.begin_nakamoto_tenure(TenureChangeCause::BlockFound); @@ -353,11 +349,12 @@ fn test_simple_nakamoto_coordinator_1_tenure_10_blocks() { .unwrap(); let mut test_signers = TestSigners::default(); + let test_stackers = TestStacker::common_signing_set(&test_signers); let mut peer = boot_nakamoto( function_name!(), vec![(addr.into(), 100_000_000)], &test_signers, - None, + &test_stackers, None, ); @@ -476,11 +473,12 @@ fn test_nakamoto_chainstate_getters() { ) .unwrap(); let mut test_signers = TestSigners::default(); + let test_stackers = TestStacker::common_signing_set(&test_signers); let mut peer = boot_nakamoto( function_name!(), vec![(addr.into(), 100_000_000)], &test_signers, - None, + &test_stackers, None, ); @@ -966,11 +964,12 @@ pub fn simple_nakamoto_coordinator_10_tenures_10_sortitions<'a>() -> TestPeer<'a .unwrap(); let mut test_signers = TestSigners::default(); + let test_stackers = TestStacker::common_signing_set(&test_signers); let mut peer = boot_nakamoto( function_name!(), vec![(addr.into(), 100_000_000)], &test_signers, - None, + &test_stackers, None, ); @@ -1295,11 +1294,12 @@ pub fn simple_nakamoto_coordinator_2_tenures_3_sortitions<'a>() -> TestPeer<'a> ) .unwrap(); let mut test_signers = TestSigners::default(); + let test_stackers = TestStacker::common_signing_set(&test_signers); let mut peer = boot_nakamoto( function_name!(), vec![(addr.into(), 100_000_000)], &test_signers, - None, + &test_stackers, None, ); @@ -1631,11 +1631,12 @@ pub fn simple_nakamoto_coordinator_10_extended_tenures_10_sortitions() -> TestPe ) .unwrap(); let mut test_signers = TestSigners::default(); + let test_stackers = TestStacker::common_signing_set(&test_signers); let mut peer = boot_nakamoto( function_name!(), vec![(addr.into(), 100_000_000)], &test_signers, - None, + &test_stackers, None, ); diff --git a/stackslib/src/chainstate/nakamoto/mod.rs b/stackslib/src/chainstate/nakamoto/mod.rs index 78af526912..638bbb48d0 100644 --- a/stackslib/src/chainstate/nakamoto/mod.rs +++ b/stackslib/src/chainstate/nakamoto/mod.rs @@ -47,13 +47,14 @@ use stacks_common::util::secp256k1::MessageSignature; use stacks_common::util::vrf::{VRFProof, VRFPublicKey, VRF}; use wsts::curve::point::Point; +use self::signer_set::SignerCalculation; use super::burn::db::sortdb::{ get_ancestor_sort_id, get_ancestor_sort_id_tx, get_block_commit_by_txid, SortitionHandle, SortitionHandleConn, SortitionHandleTx, }; use super::burn::operations::{DelegateStxOp, StackStxOp, TransferStxOp}; use super::stacks::boot::{ - PoxVersions, RawRewardSetEntry, BOOT_TEST_POX_4_AGG_KEY_CONTRACT, + PoxVersions, RawRewardSetEntry, RewardSet, BOOT_TEST_POX_4_AGG_KEY_CONTRACT, BOOT_TEST_POX_4_AGG_KEY_FNAME, SIGNERS_MAX_LIST_SIZE, SIGNERS_NAME, SIGNERS_PK_LEN, }; use super::stacks::db::accounts::MinerReward; @@ -72,6 +73,7 @@ use crate::chainstate::burn::db::sortdb::SortitionDB; use crate::chainstate::burn::operations::{LeaderBlockCommitOp, LeaderKeyRegisterOp}; use crate::chainstate::burn::{BlockSnapshot, SortitionHash}; use crate::chainstate::coordinator::{BlockEventDispatcher, Error}; +use crate::chainstate::nakamoto::signer_set::NakamotoSigners; use crate::chainstate::nakamoto::tenure::NAKAMOTO_TENURES_SCHEMA; use crate::chainstate::stacks::address::PoxAddress; use crate::chainstate::stacks::boot::{POX_4_NAME, SIGNERS_UPDATE_STATE}; @@ -99,6 +101,7 @@ pub mod coordinator; pub mod miner; pub mod tenure; +pub mod signer_set; #[cfg(test)] pub mod tests; @@ -161,6 +164,14 @@ lazy_static! { PRIMARY KEY(block_hash,consensus_hash) );"#.into(), + r#" + -- Table for storing calculated reward sets. This must be in the Chainstate DB because calculation occurs + -- during block processing. + CREATE TABLE nakamoto_reward_sets ( + index_block_hash TEXT NOT NULL, + reward_set TEXT NOT NULL, + PRIMARY KEY (index_block_hash) + );"#.into(), NAKAMOTO_TENURES_SCHEMA.into(), r#" -- Table for Nakamoto block headers @@ -297,6 +308,8 @@ pub struct SetupBlockResult<'a, 'b> { pub burn_delegate_stx_ops: Vec, /// STX auto-unlock events from PoX pub auto_unlock_events: Vec, + /// Result of a signer set calculation if one occurred + pub signer_set_calc: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1828,278 +1841,6 @@ impl NakamotoChainState { } } - fn get_reward_slots( - clarity: &mut ClarityTransactionConnection, - reward_cycle: u64, - pox_contract: &str, - ) -> Result, ChainstateError> { - let is_mainnet = clarity.is_mainnet(); - if !matches!( - PoxVersions::lookup_by_name(pox_contract), - Some(PoxVersions::Pox4) - ) { - error!("Invoked Nakamoto reward-set fetch on non-pox-4 contract"); - return Err(ChainstateError::DefunctPoxContract); - } - let pox_contract = &boot_code_id(pox_contract, is_mainnet); - - let list_length = clarity - .eval_method_read_only( - pox_contract, - "get-reward-set-size", - &[SymbolicExpression::atom_value(Value::UInt( - reward_cycle.into(), - ))], - )? - .expect_u128()?; - - let mut slots = vec![]; - for index in 0..list_length { - let entry = clarity - .eval_method_read_only( - pox_contract, - "get-reward-set-pox-address", - &[ - SymbolicExpression::atom_value(Value::UInt(reward_cycle.into())), - SymbolicExpression::atom_value(Value::UInt(index)), - ], - )? - .expect_optional()? - .expect(&format!( - "FATAL: missing PoX address in slot {} out of {} in reward cycle {}", - index, list_length, reward_cycle - )) - .expect_tuple()?; - - let pox_addr_tuple = entry - .get("pox-addr") - .expect(&format!("FATAL: no `pox-addr` in return value from (get-reward-set-pox-address u{} u{})", reward_cycle, index)) - .to_owned(); - - let reward_address = PoxAddress::try_from_pox_tuple(is_mainnet, &pox_addr_tuple) - .expect(&format!( - "FATAL: not a valid PoX address: {:?}", - &pox_addr_tuple - )); - - let total_ustx = entry - .get("total-ustx") - .expect(&format!("FATAL: no 'total-ustx' in return value from (get-reward-set-pox-address u{} u{})", reward_cycle, index)) - .to_owned() - .expect_u128()?; - - let stacker_opt = entry - .get("stacker") - .expect(&format!( - "FATAL: no 'stacker' in return value from (get-reward-set-pox-address u{} u{})", - reward_cycle, index - )) - .to_owned() - .expect_optional()?; - - let stacker = match stacker_opt { - Some(stacker_value) => Some(stacker_value.expect_principal()?), - None => None, - }; - - let signer = entry - .get("signer") - .expect(&format!( - "FATAL: no 'signer' in return value from (get-reward-set-pox-address u{} u{})", - reward_cycle, index - )) - .to_owned() - .expect_buff(SIGNERS_PK_LEN)?; - // (buff 33) only enforces max size, not min size, so we need to do a len check - let pk_bytes = if signer.len() == SIGNERS_PK_LEN { - let mut bytes = [0; SIGNERS_PK_LEN]; - bytes.copy_from_slice(signer.as_slice()); - bytes - } else { - [0; SIGNERS_PK_LEN] - }; - - slots.push(RawRewardSetEntry { - reward_address, - amount_stacked: total_ustx, - stacker, - signer: Some(pk_bytes), - }) - } - - Ok(slots) - } - - pub fn handle_signer_stackerdb_update( - clarity: &mut ClarityTransactionConnection, - pox_constants: &PoxConstants, - reward_cycle: u64, - pox_contract: &str, - ) -> Result, ChainstateError> { - let is_mainnet = clarity.is_mainnet(); - let sender_addr = PrincipalData::from(boot::boot_code_addr(is_mainnet)); - let signers_contract = &boot_code_id(SIGNERS_NAME, is_mainnet); - - let liquid_ustx = clarity.with_clarity_db_readonly(|db| db.get_total_liquid_ustx())?; - let reward_slots = Self::get_reward_slots(clarity, reward_cycle, pox_contract)?; - let (threshold, participation) = StacksChainState::get_reward_threshold_and_participation( - &pox_constants, - &reward_slots[..], - liquid_ustx, - ); - let reward_set = - StacksChainState::make_reward_set(threshold, reward_slots, StacksEpochId::Epoch30); - - let signers_list = if participation == 0 { - vec![] - } else { - reward_set - .signers - .ok_or(ChainstateError::PoxNoRewardCycle)? - .iter() - .map(|signer| { - let signer_hash = Hash160::from_data(&signer.signing_key); - let signing_address = StacksAddress::p2pkh_from_hash(is_mainnet, signer_hash); - Value::Tuple( - TupleData::from_data(vec![ - ( - "signer".into(), - Value::Principal(PrincipalData::from(signing_address)), - ), - ("num-slots".into(), Value::UInt(signer.slots.into())), - ]) - .expect( - "BUG: Failed to construct `{ signer: principal, num-slots: u64 }` tuple", - ), - ) - }) - .collect() - }; - if signers_list.len() > SIGNERS_MAX_LIST_SIZE { - panic!( - "FATAL: signers list returned by reward set calculations longer than maximum ({} > {})", - signers_list.len(), - SIGNERS_MAX_LIST_SIZE, - ); - } - - let args = [ - SymbolicExpression::atom_value(Value::cons_list_unsanitized(signers_list).expect( - "BUG: Failed to construct `(list 4000 { signer: principal, num-slots: u64 })` list", - )), - SymbolicExpression::atom_value(Value::UInt(reward_cycle.into())), - ]; - - let (value, _, events, _) = clarity - .with_abort_callback( - |vm_env| { - vm_env.execute_in_env(sender_addr.clone(), None, None, |env| { - env.execute_contract_allow_private( - &signers_contract, - "stackerdb-set-signer-slots", - &args, - false, - ) - }) - }, - |_, _| false, - ) - .expect("FATAL: failed to update signer stackerdb"); - - if let Value::Response(ref data) = value { - if !data.committed { - error!( - "Error while updating .signers contract"; - "reward_cycle" => reward_cycle, - "cc_response" => %value, - ); - panic!(); - } - } - - Ok(events) - } - - pub fn check_and_handle_prepare_phase_start( - clarity_tx: &mut ClarityTx, - first_block_height: u64, - pox_constants: &PoxConstants, - burn_tip_height: u64, - ) -> Result, ChainstateError> { - let current_epoch = clarity_tx.get_epoch(); - if current_epoch < StacksEpochId::Epoch25 { - // before Epoch-2.5, no need for special handling - return Ok(vec![]); - } - // now, determine if we are in a prepare phase, and we are the first - // block in this prepare phase in our fork - if !pox_constants.is_in_prepare_phase(first_block_height, burn_tip_height) { - // if we're not in a prepare phase, don't need to do anything - return Ok(vec![]); - } - - let Some(cycle_of_prepare_phase) = - pox_constants.reward_cycle_of_prepare_phase(first_block_height, burn_tip_height) - else { - // if we're not in a prepare phase, don't need to do anything - return Ok(vec![]); - }; - - let active_pox_contract = pox_constants.active_pox_contract(burn_tip_height); - if !matches!( - PoxVersions::lookup_by_name(active_pox_contract), - Some(PoxVersions::Pox4) - ) { - debug!( - "Active PoX contract is not PoX-4, skipping .signers updates until PoX-4 is active" - ); - return Ok(vec![]); - } - - let signers_contract = &boot_code_id(SIGNERS_NAME, clarity_tx.config.mainnet); - - // are we the first block in the prepare phase in our fork? - let needs_update = clarity_tx.connection().with_clarity_db_readonly(|clarity_db| { - if !clarity_db.has_contract(signers_contract) { - // if there's no signers contract, no need to update anything. - return Ok::<_, ChainstateError>(false); - } - let Ok(value) = clarity_db.lookup_variable_unknown_descriptor( - signers_contract, - SIGNERS_UPDATE_STATE, - ¤t_epoch, - ) else { - error!("FATAL: Failed to read `{SIGNERS_UPDATE_STATE}` variable from .signers contract"); - panic!(); - }; - let cycle_number = value.expect_u128().map_err(|e| ChainstateError::ClarityError(ClarityError::Interpreter(e)))?; - // if the cycle_number is less than `cycle_of_prepare_phase`, we need to update - // the .signers state. - Ok::<_, ChainstateError>(cycle_number < cycle_of_prepare_phase.into()) - })?; - - if !needs_update { - debug!("Current cycle has already been setup in .signers or .signers is not initialized yet"); - return Ok(vec![]); - } - - info!( - "Performing .signers state update"; - "burn_height" => burn_tip_height, - "for_cycle" => cycle_of_prepare_phase, - "signers_contract" => %signers_contract, - ); - - clarity_tx.connection().as_free_transaction(|clarity| { - Self::handle_signer_stackerdb_update( - clarity, - &pox_constants, - cycle_of_prepare_phase, - active_pox_contract, - ) - }) - } - /// Get the aggregate public key for a block. /// TODO: The block at which the aggregate public key is queried needs to be better defined. /// See https://github.com/stacks-network/stacks-core/issues/4109 @@ -2653,6 +2394,33 @@ impl NakamotoChainState { Ok(new_tip_info) } + pub fn write_reward_set( + tx: &mut ChainstateTx, + block_id: &StacksBlockId, + reward_set: &RewardSet, + ) -> Result<(), ChainstateError> { + let sql = "INSERT INTO nakamoto_reward_sets (index_block_hash, reward_set) VALUES (?, ?)"; + let args = rusqlite::params![block_id, &reward_set.metadata_serialize(),]; + tx.execute(sql, args)?; + Ok(()) + } + + pub fn get_reward_set( + chainstate_db: &Connection, + block_id: &StacksBlockId, + ) -> Result, ChainstateError> { + let sql = "SELECT reward_set FROM nakamoto_reward_sets WHERE index_block_hash = ?"; + chainstate_db + .query_row(sql, &[block_id], |row| { + let reward_set: String = row.get(0)?; + let reward_set = RewardSet::metadata_deserialize(&reward_set) + .map_err(|s| FromSqlError::Other(s.into()))?; + Ok(reward_set) + }) + .optional() + .map_err(ChainstateError::from) + } + /// Begin block-processing and return all of the pre-processed state within a /// `SetupBlockResult`. /// @@ -2843,13 +2611,17 @@ impl NakamotoChainState { } // Handle signer stackerdb updates + let signer_set_calc; if evaluated_epoch >= StacksEpochId::Epoch25 { - let _events = Self::check_and_handle_prepare_phase_start( + signer_set_calc = NakamotoSigners::check_and_handle_prepare_phase_start( &mut clarity_tx, first_block_height, &pox_constants, burn_header_height.into(), + coinbase_height, )?; + } else { + signer_set_calc = None; } debug!( @@ -2868,6 +2640,7 @@ impl NakamotoChainState { burn_transfer_stx_ops: transfer_burn_ops, auto_unlock_events, burn_delegate_stx_ops: delegate_burn_ops, + signer_set_calc, }) } @@ -3162,6 +2935,7 @@ impl NakamotoChainState { burn_transfer_stx_ops, burn_delegate_stx_ops, mut auto_unlock_events, + signer_set_calc, } = Self::setup_block( chainstate_tx, clarity_instance, @@ -3329,6 +3103,13 @@ impl NakamotoChainState { let new_block_id = new_tip.index_block_hash(); chainstate_tx.log_transactions_processed(&new_block_id, &tx_receipts); + // store the reward set calculated during this block if it happened + // NOTE: miner and proposal evaluation should not invoke this because + // it depends on knowing the StacksBlockId. + if let Some(signer_calculation) = signer_set_calc { + Self::write_reward_set(chainstate_tx, &new_block_id, &signer_calculation.reward_set)? + } + monitoring::set_last_block_transaction_count(u64::try_from(block.txs.len()).unwrap()); monitoring::set_last_execution_cost_observed(&block_execution_cost, &block_limit); diff --git a/stackslib/src/chainstate/nakamoto/signer_set.rs b/stackslib/src/chainstate/nakamoto/signer_set.rs new file mode 100644 index 0000000000..c0bfbfe078 --- /dev/null +++ b/stackslib/src/chainstate/nakamoto/signer_set.rs @@ -0,0 +1,387 @@ +// Copyright (C) 2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ops::DerefMut; + +use clarity::vm::ast::ASTRules; +use clarity::vm::costs::{ExecutionCost, LimitedCostTracker}; +use clarity::vm::database::{BurnStateDB, ClarityDatabase}; +use clarity::vm::events::StacksTransactionEvent; +use clarity::vm::types::{PrincipalData, StacksAddressExtensions, TupleData}; +use clarity::vm::{ClarityVersion, SymbolicExpression, Value}; +use lazy_static::{__Deref, lazy_static}; +use rusqlite::types::{FromSql, FromSqlError}; +use rusqlite::{params, Connection, OptionalExtension, ToSql, NO_PARAMS}; +use sha2::{Digest as Sha2Digest, Sha512_256}; +use stacks_common::bitvec::BitVec; +use stacks_common::codec::{ + read_next, write_next, Error as CodecError, StacksMessageCodec, MAX_MESSAGE_LEN, + MAX_PAYLOAD_LEN, +}; +use stacks_common::consts::{ + FIRST_BURNCHAIN_CONSENSUS_HASH, FIRST_STACKS_BLOCK_HASH, MINER_REWARD_MATURITY, +}; +use stacks_common::types::chainstate::{ + BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, StacksAddress, StacksBlockId, + StacksPrivateKey, StacksPublicKey, TrieHash, VRFSeed, +}; +use stacks_common::types::{PrivateKey, StacksEpochId}; +use stacks_common::util::get_epoch_time_secs; +use stacks_common::util::hash::{to_hex, Hash160, MerkleHashFunc, MerkleTree, Sha512Trunc256Sum}; +use stacks_common::util::retry::BoundReader; +use stacks_common::util::secp256k1::MessageSignature; +use stacks_common::util::vrf::{VRFProof, VRFPublicKey, VRF}; +use wsts::curve::point::Point; + +use crate::burnchains::{Burnchain, PoxConstants, Txid}; +use crate::chainstate::burn::db::sortdb::{ + get_ancestor_sort_id, get_ancestor_sort_id_tx, get_block_commit_by_txid, SortitionDB, + SortitionHandle, SortitionHandleConn, SortitionHandleTx, +}; +use crate::chainstate::burn::operations::{ + DelegateStxOp, LeaderBlockCommitOp, LeaderKeyRegisterOp, StackStxOp, TransferStxOp, +}; +use crate::chainstate::burn::{BlockSnapshot, SortitionHash}; +use crate::chainstate::coordinator::{BlockEventDispatcher, Error}; +use crate::chainstate::nakamoto::tenure::NAKAMOTO_TENURES_SCHEMA; +use crate::chainstate::stacks::address::PoxAddress; +use crate::chainstate::stacks::boot::{ + PoxVersions, RawRewardSetEntry, RewardSet, BOOT_TEST_POX_4_AGG_KEY_CONTRACT, + BOOT_TEST_POX_4_AGG_KEY_FNAME, POX_4_NAME, SIGNERS_MAX_LIST_SIZE, SIGNERS_NAME, SIGNERS_PK_LEN, + SIGNERS_UPDATE_STATE, +}; +use crate::chainstate::stacks::db::blocks::StagingUserBurnSupport; +use crate::chainstate::stacks::db::{ + ChainstateTx, ClarityTx, DBConfig as ChainstateConfig, MinerPaymentSchedule, + MinerPaymentTxFees, MinerRewardInfo, StacksBlockHeaderTypes, StacksChainState, StacksDBTx, + StacksEpochReceipt, StacksHeaderInfo, +}; +use crate::chainstate::stacks::events::{StacksTransactionReceipt, TransactionOrigin}; +use crate::chainstate::stacks::{ + Error as ChainstateError, StacksBlock, StacksBlockHeader, StacksMicroblock, StacksTransaction, + TenureChangeCause, TenureChangeError, TenureChangePayload, ThresholdSignature, + TransactionPayload, MINER_BLOCK_CONSENSUS_HASH, MINER_BLOCK_HEADER_HASH, +}; +use crate::clarity::vm::clarity::{ClarityConnection, TransactionConnection}; +use crate::clarity_vm::clarity::{ + ClarityInstance, ClarityTransactionConnection, PreCommitClarityBlock, +}; +use crate::clarity_vm::database::SortitionDBRef; +use crate::core::BOOT_BLOCK_HASH; +use crate::net::stackerdb::StackerDBConfig; +use crate::net::Error as net_error; +use crate::util_lib::boot; +use crate::util_lib::boot::boot_code_id; +use crate::util_lib::db::{ + query_int, query_row, query_row_panic, query_rows, u64_to_sql, DBConn, Error as DBError, + FromRow, +}; +use crate::{chainstate, monitoring}; + +pub struct NakamotoSigners(); + +pub struct SignerCalculation { + pub reward_set: RewardSet, + pub events: Vec, +} + +impl RawRewardSetEntry { + pub fn from_pox_4_tuple(is_mainnet: bool, tuple: TupleData) -> Result { + let mut tuple_data = tuple.data_map; + + let pox_addr_tuple = tuple_data + .remove("pox-addr") + .expect("FATAL: no `pox-addr` in return value from (get-reward-set-pox-address)"); + + let reward_address = PoxAddress::try_from_pox_tuple(is_mainnet, &pox_addr_tuple) + .expect(&format!("FATAL: not a valid PoX address: {pox_addr_tuple}")); + + let total_ustx = tuple_data + .remove("total-ustx") + .expect( + "FATAL: no 'total-ustx' in return value from (pox-4.get-reward-set-pox-address)", + ) + .expect_u128()?; + + let stacker = tuple_data + .remove("stacker") + .expect("FATAL: no 'stacker' in return value from (pox-4.get-reward-set-pox-address)") + .expect_optional()? + .map(|value| value.expect_principal()) + .transpose()?; + + let signer = tuple_data + .remove("signer") + .expect("FATAL: no 'signer' in return value from (pox-4.get-reward-set-pox-address)") + .expect_buff(SIGNERS_PK_LEN)?; + + // (buff 33) only enforces max size, not min size, so we need to do a len check + let pk_bytes = if signer.len() == SIGNERS_PK_LEN { + let mut bytes = [0; SIGNERS_PK_LEN]; + bytes.copy_from_slice(signer.as_slice()); + bytes + } else { + [0; SIGNERS_PK_LEN] + }; + + debug!( + "Parsed PoX reward address"; + "stacked_ustx" => total_ustx, + "reward_address" => %reward_address, + "stacker" => ?stacker, + "signer" => to_hex(&signer), + ); + + Ok(Self { + reward_address, + amount_stacked: total_ustx, + stacker, + signer: Some(pk_bytes), + }) + } +} + +impl NakamotoSigners { + fn get_reward_slots( + clarity: &mut ClarityTransactionConnection, + reward_cycle: u64, + pox_contract: &str, + ) -> Result, ChainstateError> { + let is_mainnet = clarity.is_mainnet(); + if !matches!( + PoxVersions::lookup_by_name(pox_contract), + Some(PoxVersions::Pox4) + ) { + error!("Invoked Nakamoto reward-set fetch on non-pox-4 contract"); + return Err(ChainstateError::DefunctPoxContract); + } + let pox_contract = &boot_code_id(pox_contract, is_mainnet); + + let list_length = clarity + .eval_method_read_only( + pox_contract, + "get-reward-set-size", + &[SymbolicExpression::atom_value(Value::UInt( + reward_cycle.into(), + ))], + )? + .expect_u128()?; + + let mut slots = vec![]; + for index in 0..list_length { + let tuple = clarity + .eval_method_read_only( + pox_contract, + "get-reward-set-pox-address", + &[ + SymbolicExpression::atom_value(Value::UInt(reward_cycle.into())), + SymbolicExpression::atom_value(Value::UInt(index)), + ], + )? + .expect_optional()? + .expect(&format!( + "FATAL: missing PoX address in slot {} out of {} in reward cycle {}", + index, list_length, reward_cycle + )) + .expect_tuple()?; + + let entry = RawRewardSetEntry::from_pox_4_tuple(is_mainnet, tuple)?; + + slots.push(entry) + } + + Ok(slots) + } + + pub fn handle_signer_stackerdb_update( + clarity: &mut ClarityTransactionConnection, + pox_constants: &PoxConstants, + reward_cycle: u64, + pox_contract: &str, + coinbase_height: u64, + ) -> Result { + let is_mainnet = clarity.is_mainnet(); + let sender_addr = PrincipalData::from(boot::boot_code_addr(is_mainnet)); + let signers_contract = &boot_code_id(SIGNERS_NAME, is_mainnet); + + let liquid_ustx = clarity.with_clarity_db_readonly(|db| db.get_total_liquid_ustx())?; + let reward_slots = Self::get_reward_slots(clarity, reward_cycle, pox_contract)?; + let (threshold, participation) = StacksChainState::get_reward_threshold_and_participation( + &pox_constants, + &reward_slots[..], + liquid_ustx, + ); + let reward_set = + StacksChainState::make_reward_set(threshold, reward_slots, StacksEpochId::Epoch30); + + let signers_list = if participation == 0 { + vec![] + } else { + reward_set + .signers + .as_ref() + .ok_or(ChainstateError::PoxNoRewardCycle)? + .iter() + .map(|signer| { + let signer_hash = Hash160::from_data(&signer.signing_key); + let signing_address = StacksAddress::p2pkh_from_hash(is_mainnet, signer_hash); + Value::Tuple( + TupleData::from_data(vec![ + ( + "signer".into(), + Value::Principal(PrincipalData::from(signing_address)), + ), + ("num-slots".into(), Value::UInt(signer.slots.into())), + ]) + .expect( + "BUG: Failed to construct `{ signer: principal, num-slots: u64 }` tuple", + ), + ) + }) + .collect() + }; + if signers_list.len() > SIGNERS_MAX_LIST_SIZE { + panic!( + "FATAL: signers list returned by reward set calculations longer than maximum ({} > {})", + signers_list.len(), + SIGNERS_MAX_LIST_SIZE, + ); + } + + let args = [ + SymbolicExpression::atom_value(Value::cons_list_unsanitized(signers_list).expect( + "BUG: Failed to construct `(list 4000 { signer: principal, num-slots: u64 })` list", + )), + SymbolicExpression::atom_value(Value::UInt(reward_cycle.into())), + SymbolicExpression::atom_value(Value::UInt(coinbase_height.into())), + ]; + + let (value, _, events, _) = clarity + .with_abort_callback( + |vm_env| { + vm_env.execute_in_env(sender_addr.clone(), None, None, |env| { + env.execute_contract_allow_private( + &signers_contract, + "stackerdb-set-signer-slots", + &args, + false, + ) + }) + }, + |_, _| false, + ) + .expect("FATAL: failed to update signer stackerdb"); + + if let Value::Response(ref data) = value { + if !data.committed { + error!( + "Error while updating .signers contract"; + "reward_cycle" => reward_cycle, + "cc_response" => %value, + ); + panic!(); + } + } + + Ok(SignerCalculation { events, reward_set }) + } + + pub fn check_and_handle_prepare_phase_start( + clarity_tx: &mut ClarityTx, + first_block_height: u64, + pox_constants: &PoxConstants, + burn_tip_height: u64, + coinbase_height: u64, + ) -> Result, ChainstateError> { + let current_epoch = clarity_tx.get_epoch(); + if current_epoch < StacksEpochId::Epoch25 { + // before Epoch-2.5, no need for special handling + return Ok(None); + } + // now, determine if we are in a prepare phase, and we are the first + // block in this prepare phase in our fork + if !pox_constants.is_in_prepare_phase(first_block_height, burn_tip_height) { + // if we're not in a prepare phase, don't need to do anything + return Ok(None); + } + + let Some(cycle_of_prepare_phase) = + pox_constants.reward_cycle_of_prepare_phase(first_block_height, burn_tip_height) + else { + // if we're not in a prepare phase, don't need to do anything + return Ok(None); + }; + + let active_pox_contract = pox_constants.active_pox_contract(burn_tip_height); + if !matches!( + PoxVersions::lookup_by_name(active_pox_contract), + Some(PoxVersions::Pox4) + ) { + debug!( + "Active PoX contract is not PoX-4, skipping .signers updates until PoX-4 is active" + ); + return Ok(None); + } + + let signers_contract = &boot_code_id(SIGNERS_NAME, clarity_tx.config.mainnet); + + // are we the first block in the prepare phase in our fork? + let needs_update: Result<_, ChainstateError> = clarity_tx.connection().with_clarity_db_readonly(|clarity_db| { + if !clarity_db.has_contract(signers_contract) { + // if there's no signers contract, no need to update anything. + return Ok(false) + } + let Ok(value) = clarity_db.lookup_variable_unknown_descriptor( + signers_contract, + SIGNERS_UPDATE_STATE, + ¤t_epoch, + ) else { + error!("FATAL: Failed to read `{SIGNERS_UPDATE_STATE}` variable from .signers contract"); + panic!(); + }; + let cycle_number = value.expect_u128()?; + // if the cycle_number is less than `cycle_of_prepare_phase`, we need to update + // the .signers state. + Ok(cycle_number < cycle_of_prepare_phase.into()) + }); + + if !needs_update? { + debug!("Current cycle has already been setup in .signers or .signers is not initialized yet"); + return Ok(None); + } + + info!( + "Performing .signers state update"; + "burn_height" => burn_tip_height, + "for_cycle" => cycle_of_prepare_phase, + "coinbase_height" => coinbase_height, + "signers_contract" => %signers_contract, + ); + + clarity_tx + .connection() + .as_free_transaction(|clarity| { + Self::handle_signer_stackerdb_update( + clarity, + &pox_constants, + cycle_of_prepare_phase, + active_pox_contract, + coinbase_height, + ) + }) + .map(|calculation| Some(calculation)) + } +} diff --git a/stackslib/src/chainstate/nakamoto/tests/mod.rs b/stackslib/src/chainstate/nakamoto/tests/mod.rs index 9df80e73f9..d2de8b67dc 100644 --- a/stackslib/src/chainstate/nakamoto/tests/mod.rs +++ b/stackslib/src/chainstate/nakamoto/tests/mod.rs @@ -52,7 +52,7 @@ use crate::chainstate::coordinator::tests::{ use crate::chainstate::nakamoto::coordinator::tests::boot_nakamoto; use crate::chainstate::nakamoto::miner::NakamotoBlockBuilder; use crate::chainstate::nakamoto::tenure::NakamotoTenure; -use crate::chainstate::nakamoto::tests::node::TestSigners; +use crate::chainstate::nakamoto::tests::node::{TestSigners, TestStacker}; use crate::chainstate::nakamoto::{ NakamotoBlock, NakamotoBlockHeader, NakamotoChainState, FIRST_STACKS_BLOCK_ID, }; @@ -1502,7 +1502,14 @@ fn make_fork_run_with_arrivals( #[test] pub fn test_get_highest_nakamoto_tenure() { let test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); + let test_stackers = TestStacker::common_signing_set(&test_signers); + let mut peer = boot_nakamoto( + function_name!(), + vec![], + &test_signers, + &test_stackers, + None, + ); // extract chainstate and sortdb -- we don't need the peer anymore let chainstate = &mut peer.stacks_node.as_mut().unwrap().chainstate; @@ -1644,7 +1651,14 @@ pub fn test_get_highest_nakamoto_tenure() { #[test] fn test_make_miners_stackerdb_config() { let test_signers = TestSigners::default(); - let mut peer = boot_nakamoto(function_name!(), vec![], &test_signers, None, None); + let test_stackers = TestStacker::common_signing_set(&test_signers); + let mut peer = boot_nakamoto( + function_name!(), + vec![], + &test_signers, + &test_stackers, + None, + ); let naka_miner_hash160 = peer.miner.nakamoto_miner_hash160(); let miner_keys: Vec<_> = (0..10).map(|_| StacksPrivateKey::new()).collect(); diff --git a/stackslib/src/chainstate/nakamoto/tests/node.rs b/stackslib/src/chainstate/nakamoto/tests/node.rs index df70c57e98..1b4828c024 100644 --- a/stackslib/src/chainstate/nakamoto/tests/node.rs +++ b/stackslib/src/chainstate/nakamoto/tests/node.rs @@ -74,6 +74,7 @@ pub struct TestStacker { } impl TestStacker { + pub const DEFAULT_STACKER_AMOUNT: u128 = 1_000_000_000_000_000_000; pub fn from_seed(seed: &[u8]) -> TestStacker { let stacker_private_key = StacksPrivateKey::from_seed(seed); let mut signer_seed = seed.to_vec(); @@ -89,6 +90,21 @@ impl TestStacker { pub fn signer_public_key(&self) -> StacksPublicKey { StacksPublicKey::from_private(&self.signer_private_key) } + + /// make a set of stackers who will share a single signing key and stack with + /// `Self::DEFAULT_STACKER_AMOUNT` + pub fn common_signing_set(test_signers: &TestSigners) -> Vec { + let mut signing_key_seed = test_signers.num_keys.to_be_bytes().to_vec(); + signing_key_seed.extend_from_slice(&[1, 1, 1, 1]); + let signing_key = StacksPrivateKey::from_seed(signing_key_seed.as_slice()); + (0..test_signers.num_keys) + .map(|index| TestStacker { + signer_private_key: signing_key.clone(), + stacker_private_key: StacksPrivateKey::from_seed(&index.to_be_bytes()), + amount: Self::DEFAULT_STACKER_AMOUNT, + }) + .collect() + } } #[derive(Debug, Clone)] diff --git a/stackslib/src/chainstate/stacks/boot/mod.rs b/stackslib/src/chainstate/stacks/boot/mod.rs index 1294091f5e..a99a15b032 100644 --- a/stackslib/src/chainstate/stacks/boot/mod.rs +++ b/stackslib/src/chainstate/stacks/boot/mod.rs @@ -257,6 +257,16 @@ impl RewardSet { signers: None, } } + + /// Serialization used when stored as ClarityDB metadata + pub fn metadata_serialize(&self) -> String { + serde_json::to_string(self).expect("FATAL: failure to serialize RewardSet struct") + } + + /// Deserializer corresponding to `RewardSet::metadata_serialize` + pub fn metadata_deserialize(from: &str) -> Result { + serde_json::from_str(from).map_err(|e| e.to_string()) + } } impl StacksChainState { @@ -526,7 +536,7 @@ impl StacksChainState { Ok(total_events) } - fn eval_boot_code_read_only( + pub fn eval_boot_code_read_only( &mut self, sortdb: &SortitionDB, stacks_block_id: &StacksBlockId, @@ -1201,67 +1211,8 @@ impl StacksChainState { )) .expect_tuple()?; - let pox_addr_tuple = tuple - .get("pox-addr") - .expect(&format!("FATAL: no `pox-addr` in return value from (get-reward-set-pox-address u{} u{})", reward_cycle, i)) - .to_owned(); - - let reward_address = PoxAddress::try_from_pox_tuple(self.mainnet, &pox_addr_tuple) - .expect(&format!( - "FATAL: not a valid PoX address: {:?}", - &pox_addr_tuple - )); - - let total_ustx = tuple - .get("total-ustx") - .expect(&format!("FATAL: no 'total-ustx' in return value from (get-reward-set-pox-address u{} u{})", reward_cycle, i)) - .to_owned() - .expect_u128()?; - - let stacker_opt = tuple - .get("stacker") - .expect(&format!( - "FATAL: no 'stacker' in return value from (get-reward-set-pox-address u{} u{})", - reward_cycle, i - )) - .to_owned() - .expect_optional()?; - - let stacker = match stacker_opt { - Some(stacker_value) => Some(stacker_value.expect_principal()?), - None => None, - }; - - let signer = tuple - .get("signer") - .expect(&format!( - "FATAL: no 'signer' in return value from (get-reward-set-pox-address u{} u{})", - reward_cycle, i - )) - .to_owned() - .expect_buff(SIGNERS_PK_LEN)?; - // (buff 33) only enforces max size, not min size, so we need to do a len check - let pk_bytes = if signer.len() == SIGNERS_PK_LEN { - let mut bytes = [0; SIGNERS_PK_LEN]; - bytes.copy_from_slice(signer.as_slice()); - bytes - } else { - [0; SIGNERS_PK_LEN] - }; - - debug!( - "Parsed PoX reward address"; - "stacked_ustx" => total_ustx, - "reward_address" => %reward_address, - "stacker" => ?stacker, - "signer" => ?signer - ); - ret.push(RawRewardSetEntry { - reward_address, - amount_stacked: total_ustx, - stacker, - signer: Some(pk_bytes), - }) + let entry = RawRewardSetEntry::from_pox_4_tuple(self.mainnet, tuple)?; + ret.push(entry) } Ok(ret) diff --git a/stackslib/src/chainstate/stacks/boot/signers.clar b/stackslib/src/chainstate/stacks/boot/signers.clar index a901dc0f94..71adb33bd7 100644 --- a/stackslib/src/chainstate/stacks/boot/signers.clar +++ b/stackslib/src/chainstate/stacks/boot/signers.clar @@ -1,12 +1,15 @@ (define-data-var last-set-cycle uint u0) (define-data-var stackerdb-signer-slots (list 4000 { signer: principal, num-slots: uint }) (list)) +(define-map cycle-set-height uint uint) (define-constant MAX_WRITES u340282366920938463463374607431768211455) (define-constant CHUNK_SIZE (* u2 u1024 u1024)) (define-private (stackerdb-set-signer-slots (signer-slots (list 4000 { signer: principal, num-slots: uint })) - (reward-cycle uint)) + (reward-cycle uint) + (set-at-height uint)) (begin + (map-set cycle-set-height reward-cycle set-at-height) (var-set last-set-cycle reward-cycle) (ok (var-set stackerdb-signer-slots signer-slots)))) diff --git a/stackslib/src/chainstate/stacks/boot/signers_tests.rs b/stackslib/src/chainstate/stacks/boot/signers_tests.rs index 004e437dfb..45090fa63f 100644 --- a/stackslib/src/chainstate/stacks/boot/signers_tests.rs +++ b/stackslib/src/chainstate/stacks/boot/signers_tests.rs @@ -202,7 +202,7 @@ fn signers_get_signer_keys_from_stackerdb() { let (mut peer, test_signers, latest_block_id, _) = prepare_signers_test( function_name!(), vec![], - Some(vec![&stacker_1, &stacker_2]), + &[stacker_1.clone(), stacker_2.clone()], None, ); @@ -249,7 +249,7 @@ fn signers_get_signer_keys_from_stackerdb() { pub fn prepare_signers_test<'a>( test_name: &str, initial_balances: Vec<(PrincipalData, u64)>, - stackers: Option>, + stackers: &[TestStacker], observer: Option<&'a TestEventObserver>, ) -> (TestPeer<'a>, TestSigners, StacksBlockId, u128) { let mut test_signers = TestSigners::default(); diff --git a/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs b/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs index d7ac4912ac..370eea72df 100644 --- a/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs +++ b/stackslib/src/chainstate/stacks/boot/signers_voting_tests.rs @@ -150,7 +150,7 @@ fn vote_for_aggregate_public_key_in_first_block() { let (mut peer, mut test_signers, latest_block_id, current_reward_cycle) = prepare_signers_test( function_name!(), vec![(signer, 1000)], - Some(vec![&stacker_1, &stacker_2]), + &[stacker_1.clone(), stacker_2.clone()], Some(&observer), ); @@ -237,7 +237,7 @@ fn vote_for_aggregate_public_key_in_last_block() { let (mut peer, mut test_signers, latest_block_id, current_reward_cycle) = prepare_signers_test( function_name!(), vec![(signer_1, 1000), (signer_2, 1000)], - Some(vec![&stacker_1, &stacker_2]), + &[stacker_1.clone(), stacker_2.clone()], Some(&observer), ); diff --git a/stackslib/src/chainstate/stacks/db/blocks.rs b/stackslib/src/chainstate/stacks/db/blocks.rs index cfc22cdd95..fc3920902d 100644 --- a/stackslib/src/chainstate/stacks/db/blocks.rs +++ b/stackslib/src/chainstate/stacks/db/blocks.rs @@ -52,6 +52,7 @@ use crate::chainstate::burn::db::sortdb::*; use crate::chainstate::burn::operations::*; use crate::chainstate::burn::BlockSnapshot; use crate::chainstate::coordinator::BlockEventDispatcher; +use crate::chainstate::nakamoto::signer_set::{NakamotoSigners, SignerCalculation}; use crate::chainstate::nakamoto::NakamotoChainState; use crate::chainstate::stacks::address::{PoxAddress, StacksAddressExtensions}; use crate::chainstate::stacks::db::accounts::MinerReward; @@ -168,6 +169,8 @@ pub struct SetupBlockResult<'a, 'b> { pub burn_transfer_stx_ops: Vec, pub auto_unlock_events: Vec, pub burn_delegate_stx_ops: Vec, + /// Result of a signer set calculation if one occurred + pub signer_set_calc: Option, } pub struct DummyEventDispatcher; @@ -5090,6 +5093,24 @@ impl StacksChainState { let evaluated_epoch = clarity_tx.get_epoch(); + // Handle signer stackerdb updates + // this must happen *before* any state transformations from burn ops, rewards unlocking, etc. + // this ensures that the .signers updates will match the PoX anchor block calculation in Epoch 2.5 + let first_block_height = burn_dbconn.get_burn_start_height(); + let signer_set_calc; + if evaluated_epoch >= StacksEpochId::Epoch25 { + signer_set_calc = NakamotoSigners::check_and_handle_prepare_phase_start( + &mut clarity_tx, + first_block_height.into(), + &pox_constants, + burn_tip_height.into(), + // this is the block height that the write occurs *during* + chain_tip.stacks_block_height + 1, + )?; + } else { + signer_set_calc = None; + } + let auto_unlock_events = if evaluated_epoch >= StacksEpochId::Epoch21 { let unlock_events = Self::check_and_handle_reward_start( burn_tip_height.into(), @@ -5147,17 +5168,6 @@ impl StacksChainState { ); } - // Handle signer stackerdb updates - let first_block_height = burn_dbconn.get_burn_start_height(); - if evaluated_epoch >= StacksEpochId::Epoch25 { - let _events = NakamotoChainState::check_and_handle_prepare_phase_start( - &mut clarity_tx, - first_block_height.into(), - &pox_constants, - burn_tip_height.into(), - )?; - } - debug!( "Setup block: ready to go for {}/{}", &chain_tip.consensus_hash, @@ -5177,6 +5187,7 @@ impl StacksChainState { burn_transfer_stx_ops: transfer_burn_ops, auto_unlock_events, burn_delegate_stx_ops: delegate_burn_ops, + signer_set_calc, }) } @@ -5372,6 +5383,7 @@ impl StacksChainState { burn_transfer_stx_ops, mut auto_unlock_events, burn_delegate_stx_ops, + signer_set_calc, } = StacksChainState::setup_block( chainstate_tx, clarity_instance, @@ -5671,6 +5683,18 @@ impl StacksChainState { chainstate_tx.log_transactions_processed(&new_tip.index_block_hash(), &tx_receipts); + // store the reward set calculated during this block if it happened + // NOTE: miner and proposal evaluation should not invoke this because + // it depends on knowing the StacksBlockId. + if let Some(signer_calculation) = signer_set_calc { + let new_block_id = new_tip.index_block_hash(); + NakamotoChainState::write_reward_set( + chainstate_tx, + &new_block_id, + &signer_calculation.reward_set, + )? + } + set_last_block_transaction_count( u64::try_from(block.txs.len()).expect("more than 2^64 txs"), ); diff --git a/stackslib/src/net/api/getstackers.rs b/stackslib/src/net/api/getstackers.rs new file mode 100644 index 0000000000..2c5c1240d1 --- /dev/null +++ b/stackslib/src/net/api/getstackers.rs @@ -0,0 +1,225 @@ +// Copyright (C) 2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +use regex::{Captures, Regex}; +use serde_json::json; +use stacks_common::types::chainstate::StacksBlockId; +use stacks_common::types::net::PeerHost; +use stacks_common::util::hash::Sha256Sum; + +use crate::burnchains::Burnchain; +use crate::chainstate::burn::db::sortdb::SortitionDB; +use crate::chainstate::coordinator::OnChainRewardSetProvider; +use crate::chainstate::stacks::boot::{ + PoxVersions, RewardSet, POX_1_NAME, POX_2_NAME, POX_3_NAME, POX_4_NAME, +}; +use crate::chainstate::stacks::db::StacksChainState; +use crate::chainstate::stacks::Error as ChainError; +use crate::core::mempool::MemPoolDB; +use crate::net::http::{ + parse_json, Error, HttpBadRequest, HttpNotFound, HttpRequest, HttpRequestContents, + HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, + HttpResponsePreamble, HttpServerError, +}; +use crate::net::httpcore::{ + HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, StacksHttp, + StacksHttpRequest, StacksHttpResponse, +}; +use crate::net::p2p::PeerNetwork; +use crate::net::{Error as NetError, StacksNodeState, TipRequest}; +use crate::util_lib::boot::boot_code_id; +use crate::util_lib::db::Error as DBError; + +#[derive(Clone, Default)] +pub struct GetStackersRequestHandler { + cycle_number: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetStackersResponse { + pub stacker_set: RewardSet, +} + +impl GetStackersResponse { + pub fn load( + sortdb: &SortitionDB, + chainstate: &mut StacksChainState, + tip: &StacksBlockId, + burnchain: &Burnchain, + cycle_number: u64, + ) -> Result { + let cycle_start_height = burnchain.reward_cycle_to_block_height(cycle_number); + + let pox_contract_name = burnchain + .pox_constants + .active_pox_contract(cycle_start_height); + let pox_version = PoxVersions::lookup_by_name(pox_contract_name) + .ok_or("Failed to lookup PoX contract version at tip")?; + if !matches!(pox_version, PoxVersions::Pox4) { + return Err( + "Active PoX contract version at tip is Pre-PoX-4, the signer set is not fetchable" + .into(), + ); + } + + let provider = OnChainRewardSetProvider::new(); + let stacker_set = provider.read_reward_set_nakamoto( + cycle_start_height, + chainstate, + burnchain, + sortdb, + tip, + true, + ).map_err( + |e| format!("Could not read reward set. Prepare phase may not have started for this cycle yet. Cycle = {cycle_number}, Err = {e:?}") + )?; + + Ok(Self { stacker_set }) + } +} + +/// Decode the HTTP request +impl HttpRequest for GetStackersRequestHandler { + fn verb(&self) -> &'static str { + "GET" + } + + fn path_regex(&self) -> Regex { + Regex::new(r#"^/v2/stacker_set/(?P[0-9]{1,20})$"#).unwrap() + } + + /// Try to decode this request. + /// There's nothing to load here, so just make sure the request is well-formed. + fn try_parse_request( + &mut self, + preamble: &HttpRequestPreamble, + captures: &Captures, + query: Option<&str>, + _body: &[u8], + ) -> Result { + if preamble.get_content_length() != 0 { + return Err(Error::DecodeError( + "Invalid Http request: expected 0-length body".into(), + )); + } + + let Some(cycle_num_str) = captures.name("cycle_num") else { + return Err(Error::DecodeError( + "Missing in request path: `cycle_num`".into(), + )); + }; + let cycle_num = u64::from_str_radix(cycle_num_str.into(), 10) + .map_err(|e| Error::DecodeError(format!("Failed to parse cycle number: {e}")))?; + + self.cycle_number = Some(cycle_num); + + Ok(HttpRequestContents::new().query_string(query)) + } +} + +impl RPCRequestHandler for GetStackersRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.cycle_number = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + let tip = match node.load_stacks_chain_tip(&preamble, &contents) { + Ok(tip) => tip, + Err(error_resp) => { + return error_resp.try_into_contents().map_err(NetError::from); + } + }; + let Some(cycle_number) = self.cycle_number.clone() else { + return StacksHttpResponse::new_error( + &preamble, + &HttpBadRequest::new_json(json!({"response": "error", "err_msg": "Failed to read cycle number in request"})) + ) + .try_into_contents() + .map_err(NetError::from); + }; + + let stacker_response = + node.with_node_state(|network, sortdb, chainstate, _mempool, _rpc_args| { + GetStackersResponse::load( + sortdb, + chainstate, + &tip, + network.get_burnchain(), + cycle_number, + ) + }); + + let response = match stacker_response { + Ok(response) => response, + Err(err_str) => { + return StacksHttpResponse::new_error( + &preamble, + &HttpBadRequest::new_json(json!({"response": "error", "err_msg": err_str})), + ) + .try_into_contents() + .map_err(NetError::from) + } + }; + + let mut preamble = HttpResponsePreamble::ok_json(&preamble); + preamble.set_canonical_stacks_tip_height(Some(node.canonical_stacks_tip_height())); + let body = HttpResponseContents::try_from_json(&response)?; + Ok((preamble, body)) + } +} + +impl HttpResponse for GetStackersRequestHandler { + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let response: GetStackersResponse = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(response)?) + } +} + +impl StacksHttpRequest { + /// Make a new getinfo request to this endpoint + pub fn new_getstackers( + host: PeerHost, + cycle_num: u64, + tip_req: TipRequest, + ) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "GET".into(), + format!("/v2/stacker_set/{cycle_num}"), + HttpRequestContents::new().for_tip(tip_req), + ) + .expect("FATAL: failed to construct request from infallible data") + } +} + +impl StacksHttpResponse { + pub fn decode_stacker_set(self) -> Result { + let contents = self.get_http_payload_ok()?; + let response_json: serde_json::Value = contents.try_into()?; + let response: GetStackersResponse = serde_json::from_value(response_json) + .map_err(|_e| Error::DecodeError("Failed to decode JSON".to_string()))?; + Ok(response) + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index c2efdb3402..e55e309374 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -53,6 +53,7 @@ pub mod getneighbors; pub mod getpoxinfo; pub mod getstackerdbchunk; pub mod getstackerdbmetadata; +pub mod getstackers; pub mod getstxtransfercost; pub mod gettransaction_unconfirmed; pub mod liststackerdbreplicas; @@ -105,6 +106,7 @@ impl StacksHttp { self.register_rpc_endpoint( getstackerdbmetadata::RPCGetStackerDBMetadataRequestHandler::new(), ); + self.register_rpc_endpoint(getstackers::GetStackersRequestHandler::default()); self.register_rpc_endpoint( gettransaction_unconfirmed::RPCGetTransactionUnconfirmedRequestHandler::new(), ); diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs index a2129475fe..c4656febf3 100644 --- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs +++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs @@ -38,6 +38,7 @@ use stacks::core::{ PEER_VERSION_EPOCH_2_1, PEER_VERSION_EPOCH_2_2, PEER_VERSION_EPOCH_2_3, PEER_VERSION_EPOCH_2_4, PEER_VERSION_EPOCH_2_5, PEER_VERSION_EPOCH_3_0, }; +use stacks::net::api::getstackers::GetStackersResponse; use stacks::net::api::postblock_proposal::{ BlockValidateReject, BlockValidateResponse, NakamotoBlockProposal, ValidateRejectCode, }; @@ -133,6 +134,20 @@ lazy_static! { ]; } +pub fn get_stacker_set(http_origin: &str, cycle: u64) -> GetStackersResponse { + let client = reqwest::blocking::Client::new(); + let path = format!("{http_origin}/v2/stacker_set/{cycle}"); + let res = client + .get(&path) + .send() + .unwrap() + .json::() + .unwrap(); + info!("Stacker set response: {res}"); + let res = serde_json::from_value(res).unwrap(); + res +} + pub fn add_initial_balances( conf: &mut Config, accounts: usize, @@ -938,6 +953,21 @@ fn correct_burn_outs() { info!("Bootstrapped to Epoch-3.0 boundary, Epoch2x miner should stop"); + // we should already be able to query the stacker set via RPC + let burnchain = naka_conf.get_burnchain(); + let first_epoch_3_cycle = burnchain + .block_height_to_reward_cycle(epoch_3.start_height) + .unwrap(); + + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + let stacker_response = get_stacker_set(&http_origin, first_epoch_3_cycle); + assert!(stacker_response.stacker_set.signers.is_some()); + assert_eq!( + stacker_response.stacker_set.signers.as_ref().unwrap().len(), + 1 + ); + assert_eq!(stacker_response.stacker_set.rewarded_addresses.len(), 1); + // first block wakes up the run loop, wait until a key registration has been submitted. next_block_and(&mut btc_regtest_controller, 60, || { let vrf_count = vrfs_submitted.load(Ordering::SeqCst); @@ -954,7 +984,6 @@ fn correct_burn_outs() { info!("Bootstrapped to Epoch-3.0 boundary, mining nakamoto blocks"); - let burnchain = naka_conf.get_burnchain(); let sortdb = burnchain.open_sortition_db(true).unwrap(); // Mine nakamoto tenures @@ -1000,9 +1029,6 @@ fn correct_burn_outs() { "Stacker set should be sorted by cycle number already" ); - let first_epoch_3_cycle = burnchain - .block_height_to_reward_cycle(epoch_3.start_height) - .unwrap(); for (_, cycle_number, reward_set) in stacker_sets.iter() { if *cycle_number < first_epoch_3_cycle { assert!(reward_set.signers.is_none());