diff --git a/.github/actions/bitcoin-int-tests/Dockerfile.code-cov b/.github/actions/bitcoin-int-tests/Dockerfile.code-cov index 733f879b75..209b804731 100644 --- a/.github/actions/bitcoin-int-tests/Dockerfile.code-cov +++ b/.github/actions/bitcoin-int-tests/Dockerfile.code-cov @@ -4,13 +4,12 @@ WORKDIR /build ENV CARGO_MANIFEST_DIR="$(pwd)" -RUN rustup override set nightly-2022-01-14 && \ - rustup component add llvm-tools-preview && \ +RUN rustup component add llvm-tools-preview && \ cargo install grcov -ENV RUSTFLAGS="-Zinstrument-coverage" \ +ENV RUSTFLAGS="-Cinstrument-coverage" \ LLVM_PROFILE_FILE="stacks-blockchain-%p-%m.profraw" - + COPY . . RUN cargo build --workspace && \ diff --git a/.github/actions/bitcoin-int-tests/Dockerfile.generic.bitcoin-tests b/.github/actions/bitcoin-int-tests/Dockerfile.generic.bitcoin-tests index 42a0235cf8..2fd43a589e 100644 --- a/.github/actions/bitcoin-int-tests/Dockerfile.generic.bitcoin-tests +++ b/.github/actions/bitcoin-int-tests/Dockerfile.generic.bitcoin-tests @@ -6,11 +6,10 @@ COPY . . WORKDIR /src/testnet/stacks-node -RUN rustup override set nightly-2022-01-14 && \ - rustup component add llvm-tools-preview && \ +RUN rustup component add llvm-tools-preview && \ cargo install grcov -ENV RUSTFLAGS="-Zinstrument-coverage" \ +ENV RUSTFLAGS="-Cinstrument-coverage" \ LLVM_PROFILE_FILE="stacks-blockchain-%p-%m.profraw" RUN cargo test --no-run && \ diff --git a/.github/actions/bitcoin-int-tests/Dockerfile.large-genesis b/.github/actions/bitcoin-int-tests/Dockerfile.large-genesis index 4f96fd304b..1350a6ed86 100644 --- a/.github/actions/bitcoin-int-tests/Dockerfile.large-genesis +++ b/.github/actions/bitcoin-int-tests/Dockerfile.large-genesis @@ -9,11 +9,10 @@ RUN cd / && tar -xvzf bitcoin-0.20.0-x86_64-linux-gnu.tar.gz RUN ln -s /bitcoin-0.20.0/bin/bitcoind /bin/ -RUN rustup override set nightly-2022-01-14 && \ - rustup component add llvm-tools-preview && \ +RUN rustup component add llvm-tools-preview && \ cargo install grcov -ENV RUSTFLAGS="-Zinstrument-coverage" \ +ENV RUSTFLAGS="-Cinstrument-coverage" \ LLVM_PROFILE_FILE="stacks-blockchain-%p-%m.profraw" RUN cargo test --no-run --workspace && \ diff --git a/clarity/src/vm/clarity.rs b/clarity/src/vm/clarity.rs index eb9d1d9122..69667925c7 100644 --- a/clarity/src/vm/clarity.rs +++ b/clarity/src/vm/clarity.rs @@ -149,6 +149,14 @@ pub trait ClarityConnection { } pub trait TransactionConnection: ClarityConnection { + /// Do something with this connection's Clarity environment that can be aborted + /// with `abort_call_back`. + /// This returns the return value of `to_do`: + /// * the generic term `R` + /// * the asset changes during `to_do` in an `AssetMap` + /// * the Stacks events during the transaction + /// and a `bool` value which is `true` if the `abort_call_back` caused the changes to abort + /// If `to_do` returns an `Err` variant, then the changes are aborted. fn with_abort_callback( &mut self, to_do: F, diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index de013fe9b9..640301a700 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -1061,11 +1061,41 @@ impl<'a, 'b> Environment<'a, 'b> { } pub fn execute_contract( + &mut self, + contract: &QualifiedContractIdentifier, + tx_name: &str, + args: &[SymbolicExpression], + read_only: bool, + ) -> Result { + self.inner_execute_contract(contract, tx_name, args, read_only, false) + } + + /// This method is exposed for callers that need to invoke a private method directly. + /// For example, this is used by the Stacks chainstate for invoking private methods + /// on the pox-2 contract. This should not be called by user transaction processing. + pub fn execute_contract_allow_private( + &mut self, + contract: &QualifiedContractIdentifier, + tx_name: &str, + args: &[SymbolicExpression], + read_only: bool, + ) -> Result { + self.inner_execute_contract(contract, tx_name, args, read_only, true) + } + + /// This method handles actual execution of contract-calls on a contract. + /// + /// `allow_private` should always be set to `false` for user transactions: + /// this ensures that only `define-public` and `define-read-only` methods can + /// be invoked. The `allow_private` mode should only be used by + /// `Environment::execute_contract_allow_private`. + fn inner_execute_contract( &mut self, contract_identifier: &QualifiedContractIdentifier, tx_name: &str, args: &[SymbolicExpression], read_only: bool, + allow_private: bool, ) -> Result { let contract_size = self .global_context @@ -1080,7 +1110,7 @@ impl<'a, 'b> Environment<'a, 'b> { let func = contract.contract_context.lookup_function(tx_name) .ok_or_else(|| { CheckErrors::UndefinedFunction(tx_name.to_string()) })?; - if !func.is_public() { + if !allow_private && !func.is_public() { return Err(CheckErrors::NoSuchPublicFunction(contract_identifier.to_string(), tx_name.to_string()).into()); } else if read_only && !func.is_read_only() { return Err(CheckErrors::PublicFunctionNotReadOnly(contract_identifier.to_string(), tx_name.to_string()).into()); diff --git a/clarity/src/vm/database/clarity_db.rs b/clarity/src/vm/database/clarity_db.rs index e4bdc878dc..7871ba04e7 100644 --- a/clarity/src/vm/database/clarity_db.rs +++ b/clarity/src/vm/database/clarity_db.rs @@ -1215,6 +1215,7 @@ impl<'a> ClarityDatabase<'a> { self.fetch_entry(contract_identifier, map_name, key_value, &descriptor) } + /// Returns a Clarity optional type wrapping a found or not found result pub fn fetch_entry( &mut self, contract_identifier: &QualifiedContractIdentifier, diff --git a/clarity/src/vm/database/structures.rs b/clarity/src/vm/database/structures.rs index d8fafba1a3..f3727a7341 100644 --- a/clarity/src/vm/database/structures.rs +++ b/clarity/src/vm/database/structures.rs @@ -474,6 +474,32 @@ impl<'db, 'conn> STXBalanceSnapshot<'db, 'conn> { }; } + /// If this snapshot is locked, then alter the lock height to be + /// the next burn block (i.e., `self.burn_block_height + 1`) + pub fn accelerate_unlock(&mut self) { + let unlocked = self.unlock_available_tokens_if_any(); + if unlocked > 0 { + debug!("Consolidated after account-token-lock"); + } + + let new_unlock_height = self.burn_block_height + 1; + self.balance = match self.balance { + STXBalance::Unlocked { amount } => STXBalance::Unlocked { amount }, + STXBalance::LockedPoxOne { .. } => { + unreachable!("Attempted to accelerate the unlock of a lockup created by PoX-1") + } + STXBalance::LockedPoxTwo { + amount_unlocked, + amount_locked, + .. + } => STXBalance::LockedPoxTwo { + amount_unlocked, + amount_locked, + unlock_height: new_unlock_height, + }, + }; + } + /// Unlock any tokens that are unlockable at the current /// burn block height, and return the amount newly unlocked fn unlock_available_tokens_if_any(&mut self) -> u128 { diff --git a/src/burnchains/burnchain.rs b/src/burnchains/burnchain.rs index a11f87c571..10bcc96316 100644 --- a/src/burnchains/burnchain.rs +++ b/src/burnchains/burnchain.rs @@ -46,6 +46,7 @@ use crate::burnchains::{ BurnchainStateTransition, BurnchainStateTransitionOps, BurnchainTransaction, Error as burnchain_error, PoxConstants, }; +use crate::chainstate::burn::db::sortdb::SortitionHandle; use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleConn, SortitionHandleTx}; use crate::chainstate::burn::distribution::BurnSamplePoint; use crate::chainstate::burn::operations::{ @@ -72,11 +73,11 @@ use crate::util_lib::db::Error as db_error; use stacks_common::address::public_keys_to_address_hash; use stacks_common::address::AddressHashMode; use stacks_common::deps_common::bitcoin::util::hash::Sha256dHash as BitcoinSha256dHash; -use stacks_common::util::{get_epoch_time_ms, sleep_ms}; use stacks_common::util::get_epoch_time_secs; use stacks_common::util::hash::to_hex; use stacks_common::util::log; use stacks_common::util::vrf::VRFPublicKey; +use stacks_common::util::{get_epoch_time_ms, sleep_ms}; use crate::burnchains::bitcoin::indexer::BitcoinIndexer; use crate::chainstate::stacks::boot::POX_2_MAINNET_CODE; @@ -491,6 +492,23 @@ impl Burnchain { ) } + /// Is this block either the first block in a reward cycle or + /// right before the reward phase starts? This is the mod 0 or mod 1 + /// block. Reward cycle start events (like auto-unlocks) process *after* + /// the first reward block, so this function is used to determine when + /// that has passed. + pub fn is_before_reward_cycle( + first_block_ht: u64, + burn_ht: u64, + reward_cycle_length: u64, + ) -> bool { + let effective_height = burn_ht + .checked_sub(first_block_ht) + .expect("FATAL: attempted to check reward cycle start before first block height"); + // first block of the new reward cycle + (effective_height % reward_cycle_length) <= 1 + } + pub fn static_is_in_prepare_phase( first_block_height: u64, reward_cycle_length: u64, diff --git a/src/chainstate/burn/db/sortdb.rs b/src/chainstate/burn/db/sortdb.rs index 7a9fd32f38..596a881181 100644 --- a/src/chainstate/burn/db/sortdb.rs +++ b/src/chainstate/burn/db/sortdb.rs @@ -51,6 +51,7 @@ use crate::chainstate::coordinator::{ Error as CoordinatorError, PoxAnchorBlockStatus, RewardCycleInfo, }; use crate::chainstate::stacks::address::PoxAddress; +use crate::chainstate::stacks::boot::PoxStartCycleInfo; use crate::chainstate::stacks::db::{StacksChainState, StacksHeaderInfo}; use crate::chainstate::stacks::index::marf::MARFOpenOpts; use crate::chainstate::stacks::index::marf::MarfConnection; @@ -754,6 +755,10 @@ impl db_keys { "sortition_db::last_anchor_block" } + pub fn pox_reward_cycle_unlocks(cycle: u64) -> String { + format!("sortition_db::reward_set_unlocks::{}", cycle) + } + pub fn pox_reward_set_size() -> &'static str { "sortition_db::reward_set::size" } @@ -837,6 +842,81 @@ impl db_keys { } } +/// Trait for structs that provide a chaintip-indexed handle into the +/// SortitionDB (i.e., a MARF view from a particular SortitionId) +pub trait SortitionHandle { + /// Returns a connection to the SQLite db. If this handle is wrapping + /// a transaction, this should point to the open transaction. + fn sqlite(&self) -> &Connection; + + /// Returns the snapshot of the burnchain block at burnchain height `block_height`. + /// Returns None if there is no block at this height. + fn get_block_snapshot_by_height( + &mut self, + block_height: u64, + ) -> Result, db_error>; + + /// is the given block a descendant of `potential_ancestor`? + /// * block_at_burn_height: the burn height of the sortition that chose the stacks block to check + /// * potential_ancestor: the stacks block hash of the potential ancestor + fn descended_from( + &mut self, + block_at_burn_height: u64, + potential_ancestor: &BlockHeaderHash, + ) -> Result { + let earliest_block_height = self.sqlite().query_row( + "SELECT block_height FROM snapshots WHERE winning_stacks_block_hash = ? ORDER BY block_height ASC LIMIT 1", + &[potential_ancestor], + |row| Ok(u64::from_row(row).expect("Expected u64 in database")))?; + + let mut sn = self + .get_block_snapshot_by_height(block_at_burn_height)? + .ok_or_else(|| { + test_debug!("No snapshot at height {}", block_at_burn_height); + db_error::NotFoundError + })?; + + while sn.block_height >= earliest_block_height { + if !sn.sortition { + return Ok(false); + } + if &sn.winning_stacks_block_hash == potential_ancestor { + return Ok(true); + } + + // step back to the parent + match SortitionDB::get_block_commit_parent_sortition_id( + self.sqlite(), + &sn.winning_block_txid, + &sn.sortition_id, + )? { + Some(parent_sortition_id) => { + // we have the block_commit parent memoization data + test_debug!( + "Parent sortition of {} memoized as {}", + &sn.winning_block_txid, + &parent_sortition_id + ); + sn = SortitionDB::get_block_snapshot(self.sqlite(), &parent_sortition_id)? + .ok_or_else(|| db_error::NotFoundError)?; + } + None => { + // we do not have the block_commit parent memoization data + // step back to the parent + test_debug!("No parent sortition memo for {}", &sn.winning_block_txid); + let block_commit = + get_block_commit_by_txid(&self.sqlite(), &sn.winning_block_txid)? + .expect("CORRUPTION: winning block commit for snapshot not found"); + sn = self + .get_block_snapshot_by_height(block_commit.parent_block_ptr as u64)? + .ok_or_else(|| db_error::NotFoundError)?; + } + } + } + return Ok(false); + } +} + impl<'a> SortitionHandleTx<'a> { /// begin a MARF transaction with this connection /// this is used by _writing_ contexts @@ -1147,6 +1227,34 @@ impl<'a> SortitionHandleTx<'a> { } } +impl SortitionHandle for SortitionHandleTx<'_> { + fn get_block_snapshot_by_height( + &mut self, + block_height: u64, + ) -> Result, db_error> { + assert!(block_height < BLOCK_HEIGHT_MAX); + let chain_tip = self.context.chain_tip.clone(); + SortitionDB::get_ancestor_snapshot_tx(self, block_height, &chain_tip) + } + + fn sqlite(&self) -> &Connection { + self.tx() + } +} + +impl SortitionHandle for SortitionHandleConn<'_> { + fn get_block_snapshot_by_height( + &mut self, + block_height: u64, + ) -> Result, db_error> { + SortitionHandleConn::get_block_snapshot_by_height(self, block_height) + } + + fn sqlite(&self) -> &Connection { + self.conn() + } +} + impl<'a> SortitionHandleTx<'a> { pub fn set_stacks_block_accepted( &mut self, @@ -1201,9 +1309,9 @@ impl<'a> SortitionHandleTx<'a> { test_debug!( "Pick recipients for anchor block {} -- {} reward recipient(s)", anchor_block, - reward_set.len() + reward_set.rewarded_addresses.len() ); - if reward_set.len() == 0 { + if reward_set.rewarded_addresses.len() == 0 { return Ok(None); } @@ -1213,6 +1321,7 @@ impl<'a> SortitionHandleTx<'a> { let chosen_recipients = reward_set_vrf_seed.choose_two( reward_set + .rewarded_addresses .len() .try_into() .expect("BUG: u32 overflow in PoX outputs per commit"), @@ -1223,7 +1332,7 @@ impl<'a> SortitionHandleTx<'a> { recipients: chosen_recipients .into_iter() .map(|ix| { - let recipient = reward_set[ix as usize].clone(); + let recipient = reward_set.rewarded_addresses[ix as usize].clone(); info!("PoX recipient chosen"; "recipient" => recipient.to_burnchain_repr(), "block_height" => block_height); @@ -1315,75 +1424,6 @@ impl<'a> SortitionHandleTx<'a> { self.get_reward_set_size_at(&self.context.chain_tip.clone()) } - /// is the given block a descendant of `potential_ancestor`? - /// * block_at_burn_height: the burn height of the sortition that chose the stacks block to check - /// * potential_ancestor: the stacks block hash of the potential ancestor - pub fn descended_from( - &mut self, - block_at_burn_height: u64, - potential_ancestor: &BlockHeaderHash, - ) -> Result { - let earliest_block_height = self.tx().query_row( - "SELECT block_height FROM snapshots WHERE winning_stacks_block_hash = ? ORDER BY block_height ASC LIMIT 1", - &[potential_ancestor], - |row| Ok(u64::from_row(row).expect("Expected u64 in database")))?; - - let mut sn = self - .get_block_snapshot_by_height(block_at_burn_height)? - .ok_or_else(|| { - test_debug!("No snapshot at height {}", block_at_burn_height); - db_error::NotFoundError - })?; - - while sn.block_height >= earliest_block_height { - if !sn.sortition { - return Ok(false); - } - if &sn.winning_stacks_block_hash == potential_ancestor { - return Ok(true); - } - - // step back to the parent - match SortitionDB::get_block_commit_parent_sortition_id( - self.tx(), - &sn.winning_block_txid, - &sn.sortition_id, - )? { - Some(parent_sortition_id) => { - // we have the block_commit parent memoization data - test_debug!( - "Parent sortition of {} memoized as {}", - &sn.winning_block_txid, - &parent_sortition_id - ); - sn = SortitionDB::get_block_snapshot(self.tx(), &parent_sortition_id)? - .ok_or_else(|| db_error::NotFoundError)?; - } - None => { - // we do not have the block_commit parent memoization data - // step back to the parent - test_debug!("No parent sortition memo for {}", &sn.winning_block_txid); - let block_commit = - get_block_commit_by_txid(&self.tx(), &sn.winning_block_txid)? - .expect("CORRUPTION: winning block commit for snapshot not found"); - sn = self - .get_block_snapshot_by_height(block_commit.parent_block_ptr as u64)? - .ok_or_else(|| db_error::NotFoundError)?; - } - } - } - return Ok(false); - } - - pub fn get_block_snapshot_by_height( - &mut self, - block_height: u64, - ) -> Result, db_error> { - assert!(block_height < BLOCK_HEIGHT_MAX); - let chain_tip = self.context.chain_tip.clone(); - SortitionDB::get_ancestor_snapshot_tx(self, block_height, &chain_tip) - } - pub fn get_last_anchor_block_hash(&mut self) -> Result, db_error> { let chain_tip = self.context.chain_tip.clone(); let anchor_block_hash = SortitionDB::parse_last_anchor_block_hash( @@ -1554,7 +1594,6 @@ impl<'a> SortitionHandleConn<'a> { }) } - #[cfg(test)] pub fn get_last_anchor_block_hash(&self) -> Result, db_error> { let anchor_block_hash = SortitionDB::parse_last_anchor_block_hash( self.get_indexed(&self.context.chain_tip, &db_keys::pox_last_anchor())?, @@ -1562,6 +1601,19 @@ impl<'a> SortitionHandleConn<'a> { Ok(anchor_block_hash) } + pub fn get_reward_cycle_unlocks( + &mut self, + cycle: u64, + ) -> Result, db_error> { + let start_info = self + .get_tip_indexed(&db_keys::pox_reward_cycle_unlocks(cycle))? + .map(|x| { + PoxStartCycleInfo::deserialize(&x) + .expect("CORRUPTION: Failed to deserialize PoxStartCycleInfo from database") + }); + Ok(start_info) + } + pub fn get_pox_id(&self) -> Result { let pox_id = self .get_tip_indexed(db_keys::pox_identifier())? @@ -1635,9 +1687,6 @@ impl<'a> SortitionHandleConn<'a> { } } - /// Returns the snapshot of the burnchain block at burnchain height `block_height`. - /// - /// Returns None if there is no block at this height. pub fn get_block_snapshot_by_height( &self, block_height: u64, @@ -4442,7 +4491,7 @@ impl<'a> SortitionHandleTx<'a> { if let Some(mut reward_set) = reward_info.known_selected_anchor_block_owned() { // record payouts separately from the remaining addresses, since some of them // could have just been consumed. - if reward_set.len() > 0 { + if reward_set.rewarded_addresses.len() > 0 { // if we have a reward set, then we must also have produced a recipient // info for this block let mut recipients_to_remove: Vec<_> = recipient_info @@ -4456,7 +4505,7 @@ impl<'a> SortitionHandleTx<'a> { let mut addrs = vec![]; for (addr, ix) in recipients_to_remove.iter() { addrs.push(addr.clone()); - assert_eq!(reward_set.remove(*ix as usize).to_burnchain_repr(), addr.to_burnchain_repr(), + assert_eq!(reward_set.rewarded_addresses.remove(*ix as usize).to_burnchain_repr(), addr.to_burnchain_repr(), "BUG: Attempted to remove used address from reward set, but failed to do so safely"); } pox_payout_addrs = addrs; @@ -4466,15 +4515,29 @@ impl<'a> SortitionHandleTx<'a> { } keys.push(db_keys::pox_reward_set_size().to_string()); - values.push(db_keys::reward_set_size_to_string(reward_set.len())); + values.push(db_keys::reward_set_size_to_string( + reward_set.rewarded_addresses.len(), + )); // NOTE: the pox_addr _must_ come from the reward set (i.e. from the PoX // contract), since we _must_ know the hash modes for standard addresses. This // information cannot be learned from the burnchain alone. - for (ix, pox_addr) in reward_set.iter().enumerate() { + for (ix, pox_addr) in reward_set.rewarded_addresses.iter().enumerate() { keys.push(db_keys::pox_reward_set_entry(ix as u16)); values.push(pox_addr.to_db_string()); } + // if there are qualifying auto-unlocks, record them + if !reward_set.start_cycle_state.is_empty() { + let cycle_number = Burnchain::static_block_height_to_reward_cycle( + snapshot.block_height, + self.context.first_block_height, + self.context.pox_constants.reward_cycle_length.into(), + ) + .expect("FATAL: PoX reward cycle started before first block height"); + + keys.push(db_keys::pox_reward_cycle_unlocks(cycle_number)); + values.push(reward_set.start_cycle_state.serialize()); + } } else { // no anchor block; we're burning keys.push(db_keys::pox_reward_set_size().to_string()); diff --git a/src/chainstate/burn/operations/leader_block_commit.rs b/src/chainstate/burn/operations/leader_block_commit.rs index 162119069f..130d46a5ab 100644 --- a/src/chainstate/burn/operations/leader_block_commit.rs +++ b/src/chainstate/burn/operations/leader_block_commit.rs @@ -23,6 +23,7 @@ use crate::burnchains::BurnchainBlockHeader; use crate::burnchains::Txid; use crate::burnchains::{BurnchainRecipient, BurnchainSigner}; use crate::burnchains::{BurnchainTransaction, PublicKey}; +use crate::chainstate::burn::db::sortdb::SortitionHandle; use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleTx}; use crate::chainstate::burn::operations::Error as op_error; use crate::chainstate::burn::operations::{ diff --git a/src/chainstate/coordinator/mod.rs b/src/chainstate/coordinator/mod.rs index 73d51aecc7..b22b3a664d 100644 --- a/src/chainstate/coordinator/mod.rs +++ b/src/chainstate/coordinator/mod.rs @@ -64,6 +64,8 @@ use crate::chainstate::stacks::index::marf::MARFOpenOpts; pub use self::comm::CoordinatorCommunication; +use super::stacks::boot::RewardSet; + pub mod comm; #[cfg(test)] pub mod tests; @@ -72,7 +74,7 @@ pub mod tests; /// reward cycle's relationship to its PoX anchor #[derive(Debug, PartialEq)] pub enum PoxAnchorBlockStatus { - SelectedAndKnown(BlockHeaderHash, Vec), + SelectedAndKnown(BlockHeaderHash, RewardSet), SelectedAndUnknown(BlockHeaderHash), NotSelected, } @@ -97,7 +99,7 @@ impl RewardCycleInfo { SelectedAndKnown(_, _) | NotSelected => true, } } - pub fn known_selected_anchor_block(&self) -> Option<&Vec> { + pub fn known_selected_anchor_block(&self) -> Option<&RewardSet> { use self::PoxAnchorBlockStatus::*; match self.anchor_status { SelectedAndUnknown(_) => None, @@ -105,7 +107,7 @@ impl RewardCycleInfo { NotSelected => None, } } - pub fn known_selected_anchor_block_owned(self) -> Option> { + pub fn known_selected_anchor_block_owned(self) -> Option { use self::PoxAnchorBlockStatus::*; match self.anchor_status { SelectedAndUnknown(_) => None, @@ -210,7 +212,7 @@ pub trait RewardSetProvider { burnchain: &Burnchain, sortdb: &SortitionDB, block_id: &StacksBlockId, - ) -> Result, Error>; + ) -> Result; } pub struct OnChainRewardSetProvider(); @@ -223,7 +225,7 @@ impl RewardSetProvider for OnChainRewardSetProvider { burnchain: &Burnchain, sortdb: &SortitionDB, block_id: &StacksBlockId, - ) -> Result, Error> { + ) -> Result { let registered_addrs = chainstate.get_reward_addresses(burnchain, sortdb, current_burn_height, block_id)?; @@ -244,7 +246,7 @@ impl RewardSetProvider for OnChainRewardSetProvider { "participation" => participation, "liquid_ustx" => liquid_ustx, "registered_addrs" => registered_addrs.len()); - return Ok(vec![]); + return Ok(RewardSet::empty()); } else { info!("PoX reward cycle threshold computed"; "burn_height" => current_burn_height, diff --git a/src/chainstate/coordinator/tests.rs b/src/chainstate/coordinator/tests.rs index 8ba8df1d28..0cb4b63036 100644 --- a/src/chainstate/coordinator/tests.rs +++ b/src/chainstate/coordinator/tests.rs @@ -34,6 +34,7 @@ use crate::chainstate::burn::operations::*; use crate::chainstate::burn::*; use crate::chainstate::coordinator::{Error as CoordError, *}; use crate::chainstate::stacks::address::PoxAddress; +use crate::chainstate::stacks::boot::PoxStartCycleInfo; use crate::chainstate::stacks::db::{ accounts::MinerReward, ClarityTx, StacksChainState, StacksHeaderInfo, }; @@ -366,8 +367,13 @@ impl RewardSetProvider for StubbedRewardSetProvider { burnchain: &Burnchain, sortdb: &SortitionDB, block_id: &StacksBlockId, - ) -> Result, chainstate::coordinator::Error> { - Ok(self.0.clone()) + ) -> Result { + Ok(RewardSet { + rewarded_addresses: self.0.clone(), + start_cycle_state: PoxStartCycleInfo { + missed_reward_slots: vec![], + }, + }) } } diff --git a/src/chainstate/stacks/boot/contract_tests.rs b/src/chainstate/stacks/boot/contract_tests.rs index c1a370017b..76688db539 100644 --- a/src/chainstate/stacks/boot/contract_tests.rs +++ b/src/chainstate/stacks/boot/contract_tests.rs @@ -982,6 +982,7 @@ fn pox_2_lock_extend_units() { } else { &POX_ADDRS[1] }; + let expected_stacker = Value::from(&USER_KEYS[1]); assert_eq!( env.eval_read_only( &POX_2_CONTRACT_TESTNET, @@ -990,9 +991,10 @@ fn pox_2_lock_extend_units() { .unwrap() .0, execute(&format!( - "{{ pox-addr: {}, total-ustx: u{} }}", + "{{ pox-addr: {}, total-ustx: u{}, stacker: (some '{}) }}", expected_pox_addr, - 1_000_000 + 1_000_000, + &expected_stacker, )) ); } @@ -1441,7 +1443,7 @@ fn pox_2_delegate_extend_units() { .unwrap() .0.to_string(), "(err 21)".to_string(), "Delegate cannot stack-extend for User0 for 10 cycles", -); + ); assert_eq!( env.execute_transaction( @@ -1548,7 +1550,7 @@ fn pox_2_delegate_extend_units() { .unwrap() .0, execute(&format!( - "{{ pox-addr: {}, total-ustx: u{} }}", + "{{ pox-addr: {}, total-ustx: u{}, stacker: none }}", expected_pox_addr, MIN_THRESHOLD.deref(), )) diff --git a/src/chainstate/stacks/boot/mod.rs b/src/chainstate/stacks/boot/mod.rs index 1680835f9c..7a3d16736a 100644 --- a/src/chainstate/stacks/boot/mod.rs +++ b/src/chainstate/stacks/boot/mod.rs @@ -28,13 +28,21 @@ use crate::chainstate::stacks::db::StacksChainState; use crate::chainstate::stacks::index::marf::MarfConnection; use crate::chainstate::stacks::Error; use crate::clarity_vm::clarity::ClarityConnection; +use crate::clarity_vm::clarity::ClarityTransactionConnection; use crate::core::{POX_MAXIMAL_SCALING, POX_THRESHOLD_STEPS_USTX}; +use crate::util_lib::strings::VecDisplay; +use clarity::codec::StacksMessageCodec; +use clarity::types::chainstate::BlockHeaderHash; +use clarity::util::hash::to_hex; +use clarity::vm::clarity::TransactionConnection; use clarity::vm::contexts::ContractContext; use clarity::vm::costs::{ cost_functions::ClarityCostFunction, ClarityCostFunctionReference, CostStateSummary, }; use clarity::vm::database::ClarityDatabase; use clarity::vm::database::{NULL_BURN_STATE_DB, NULL_HEADER_DB}; +use clarity::vm::errors::InterpreterError; +use clarity::vm::events::StacksTransactionEvent; use clarity::vm::representations::ClarityName; use clarity::vm::representations::ContractName; use clarity::vm::types::{ @@ -51,6 +59,7 @@ use crate::types::chainstate::StacksAddress; use crate::types::chainstate::StacksBlockId; use crate::util_lib::boot; use crate::vm::{costs::LimitedCostTracker, SymbolicExpression}; +use clarity::vm::clarity::Error as ClarityError; use clarity::vm::ClarityVersion; const BOOT_CODE_POX_BODY: &'static str = std::include_str!("pox.clar"); @@ -124,7 +133,151 @@ pub fn make_contract_id(addr: &StacksAddress, name: &str) -> QualifiedContractId ) } +pub struct RawRewardSetEntry { + pub reward_address: PoxAddress, + pub amount_stacked: u128, + pub stacker: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct PoxStartCycleInfo { + /// This data contains the set of principals who missed a reward slot + /// in this reward cycle. + /// + /// The first element of the tuple is the principal whose microSTX + /// were locked, and the second element is the amount of microSTX + /// that were locked + pub missed_reward_slots: Vec<(PrincipalData, u128)>, +} + +#[derive(Debug, PartialEq)] +pub struct RewardSet { + pub rewarded_addresses: Vec, + pub start_cycle_state: PoxStartCycleInfo, +} + +const POX_CYCLE_START_HANDLED_VALUE: &'static str = "1"; + +impl PoxStartCycleInfo { + pub fn serialize(&self) -> String { + serde_json::to_string(self).expect("FATAL: failure to serialize internal struct") + } + + pub fn deserialize(from: &str) -> Option { + serde_json::from_str(from).ok() + } + + pub fn is_empty(&self) -> bool { + self.missed_reward_slots.is_empty() + } +} + +impl RewardSet { + /// Create an empty reward set where no one gets an early unlock + pub fn empty() -> RewardSet { + RewardSet { + rewarded_addresses: vec![], + start_cycle_state: PoxStartCycleInfo { + missed_reward_slots: vec![], + }, + } + } +} + impl StacksChainState { + /// Return the MARF key used to store whether or not a given PoX + /// cycle's "start" has been handled by the Stacks fork yet. This + /// is used in Stacks 2.1 to help process unlocks. + fn handled_pox_cycle_start_key(cycle_number: u64) -> String { + format!("chainstate_pox::handled_cycle_start::{}", cycle_number) + } + + /// Returns whether or not the `cycle_number` PoX cycle has been handled by the + /// Stacks fork in the opened `clarity_db`. + pub fn handled_pox_cycle_start(clarity_db: &mut ClarityDatabase, cycle_number: u64) -> bool { + let db_key = Self::handled_pox_cycle_start_key(cycle_number); + match clarity_db.get::(&db_key) { + Some(x) => x == POX_CYCLE_START_HANDLED_VALUE, + None => false, + } + } + + fn mark_pox_cycle_handled(db: &mut ClarityDatabase, cycle_number: u64) { + let db_key = Self::handled_pox_cycle_start_key(cycle_number); + db.put(&db_key, &POX_CYCLE_START_HANDLED_VALUE.to_string()); + } + + /// Do all the necessary Clarity operations at the start of a PoX reward cycle. + /// Currently, this just means applying any auto-unlocks to Stackers who qualified. + pub fn handle_pox_cycle_start( + clarity: &mut ClarityTransactionConnection, + cycle_number: u64, + cycle_info: Option, + ) -> Result, Error> { + clarity.with_clarity_db(|db| Ok(Self::mark_pox_cycle_handled(db, cycle_number)))?; + + debug!("Handling PoX reward cycle start"; "reward_cycle" => cycle_number, "cycle_active" => cycle_info.is_some()); + + let cycle_info = match cycle_info { + Some(x) => x, + None => return Ok(vec![]), + }; + + let sender_addr = PrincipalData::from(boot::boot_code_addr(clarity.is_mainnet())); + let pox_contract = boot::boot_code_id(POX_2_NAME, clarity.is_mainnet()); + + let mut total_events = vec![]; + for (principal, amount_locked) in cycle_info.missed_reward_slots.iter() { + // we have to do several things for each principal + // 1. lookup their Stacks account and accelerate their unlock + // 2. remove the user's entries from every `reward-cycle-pox-address-list` they were in + // (a) this can be done by moving the last entry to the now vacated spot, + // and, if necessary, updating the associated `stacking-state` entry's pointer + // (b) or, if they were the only entry in the list, then just deleting them from the list + // 3. correct the `reward-cycle-total-stacked` entry for every reward cycle they were in + // 4. delete the user's stacking-state entry. + clarity.with_clarity_db(|db| { + // lookup the Stacks account and alter their unlock height to next block + let mut balance = db.get_stx_balance_snapshot(&principal); + if balance.canonical_balance_repr().amount_locked() < *amount_locked { + panic!("Principal missed reward slots, but did not have as many locked tokens as expected. Actual: {}, Expected: {}", balance.canonical_balance_repr().amount_locked(), *amount_locked); + } + + balance.accelerate_unlock(); + balance.save(); + Ok(()) + }).expect("FATAL: failed to accelerate PoX unlock"); + + let (result, _, events, _) = clarity + .with_abort_callback( + |vm_env| { + vm_env.execute_in_env(sender_addr.clone(), None, None, |env| { + env.execute_contract_allow_private( + &pox_contract, + "handle-unlock", + &[ + SymbolicExpression::atom_value(principal.clone().into()), + SymbolicExpression::atom_value(Value::UInt(*amount_locked)), + SymbolicExpression::atom_value(Value::UInt( + cycle_number.into(), + )), + ], + false, + ) + }) + }, + |_, _| false, + ) + .expect("FATAL: failed to handle PoX unlock"); + + result.expect_result_ok(); + + total_events.extend(events.into_iter()); + } + + Ok(total_events) + } + fn eval_boot_code_read_only( &mut self, sortdb: &SortitionDB, @@ -249,19 +402,39 @@ impl StacksChainState { /// are repeated floor(stacked_amt / threshold) times. /// If an address appears in `addresses` multiple times, then the address's associated amounts /// are summed. - pub fn make_reward_set( - threshold: u128, - mut addresses: Vec<(PoxAddress, u128)>, - ) -> Vec { + pub fn make_reward_set(threshold: u128, mut addresses: Vec) -> RewardSet { let mut reward_set = vec![]; + let mut missed_slots = vec![]; // the way that we sum addresses relies on sorting. - addresses.sort_by_key(|k| k.0.bytes()); - while let Some((address, mut stacked_amt)) = addresses.pop() { - // peak at the next address in the set, and see if we need to sum - while addresses.last().map(|x| &x.0) == Some(&address) { - let (_, additional_amt) = addresses + addresses.sort_by_key(|k| k.reward_address.bytes()); + while let Some(RawRewardSetEntry { + reward_address: address, + amount_stacked: mut stacked_amt, + stacker, + }) = addresses.pop() + { + let mut contributed_stackers = vec![]; + if let Some(stacker) = stacker.as_ref() { + contributed_stackers.push((stacker.clone(), stacked_amt)); + } + // Here we check if we should combine any entries with the same + // reward address together in the reward set. + // The outer while loop pops the last element of the + // addresses vector, and here we peak at the last item in + // the vector (via last()). Because the items in the + // vector are sorted by address, we know that any entry + // with the same `reward_address` as `address` will be at the end of + // the list (and therefore found by this loop) + while addresses.last().map(|x| &x.reward_address) == Some(&address) { + let next_contrib = addresses .pop() .expect("BUG: first() returned some, but pop() is none."); + let additional_amt = next_contrib.amount_stacked; + + if let Some(stacker) = next_contrib.stacker { + contributed_stackers.push((stacker.clone(), additional_amt)); + } + stacked_amt = stacked_amt .checked_add(additional_amt) .expect("CORRUPTION: Stacker stacked > u128 max amount"); @@ -269,19 +442,52 @@ impl StacksChainState { let slots_taken = u32::try_from(stacked_amt / threshold) .expect("CORRUPTION: Stacker claimed > u32::max() reward slots"); info!( - "Slots taken by {} = {}, on stacked_amt = {}, threshold = {}", - &address.clone(), - slots_taken, - stacked_amt, - threshold + "Reward slots taken"; + "reward_address" => %address, + "slots_taken" => slots_taken, + "stacked_amt" => stacked_amt, + "pox_threshold" => threshold, ); for _i in 0..slots_taken { test_debug!("Add to PoX reward set: {:?}", &address); reward_set.push(address.clone()); } + // if stacker did not qualify for a slot *and* they have a stacker + // pointer set by the PoX contract, then add them to auto-unlock list + if slots_taken == 0 && !contributed_stackers.is_empty() { + info!( + "Stacker missed reward slot, added to unlock list"; + // "stackers" => %VecDisplay(&contributed_stackers), + "reward_address" => %address.clone().to_b58(), + "threshold" => threshold, + "stacked_amount" => stacked_amt + ); + contributed_stackers + .sort_by_cached_key(|(stacker, ..)| to_hex(&stacker.serialize_to_vec())); + while let Some((contributor, amt)) = contributed_stackers.pop() { + let mut total_amount = amt; + while contributed_stackers.last().map(|(stacker, ..)| stacker) + == Some(&contributor) + { + let (add_stacker, additional) = contributed_stackers + .pop() + .expect("BUG: last() returned some, but pop() is none."); + assert_eq!(&add_stacker, &contributor); + total_amount = total_amount + .checked_add(additional) + .expect("CORRUPTION: Stacked stacked > u128 max amount"); + } + missed_slots.push((contributor, total_amount)); + } + } } info!("Reward set calculated"; "slots_occuppied" => reward_set.len()); - reward_set + RewardSet { + rewarded_addresses: reward_set, + start_cycle_state: PoxStartCycleInfo { + missed_reward_slots: missed_slots, + }, + } } pub fn get_threshold_from_participation( @@ -304,12 +510,12 @@ impl StacksChainState { pub fn get_reward_threshold_and_participation( pox_settings: &PoxConstants, - addresses: &[(PoxAddress, u128)], + addresses: &[RawRewardSetEntry], liquid_ustx: u128, ) -> (u128, u128) { let participation = addresses .iter() - .fold(0, |agg, (_, stacked_amt)| agg + stacked_amt); + .fold(0, |agg, entry| agg + entry.amount_stacked); assert!( participation <= liquid_ustx, @@ -335,27 +541,91 @@ impl StacksChainState { (threshold, participation) } - /// Get the sequence of reward addresses, as well as the PoX-specified hash mode (which gets - /// lost in the conversion to StacksAddress) - /// Each address will have at least (get-stacking-minimum) tokens. - pub fn get_reward_addresses( + fn get_reward_addresses_pox_1( &mut self, - burnchain: &Burnchain, sortdb: &SortitionDB, - current_burn_height: u64, block_id: &StacksBlockId, - ) -> Result, Error> { - let reward_cycle = burnchain - .block_height_to_reward_cycle(current_burn_height) - .ok_or(Error::PoxNoRewardCycle)?; + reward_cycle: u64, + ) -> Result, Error> { + if !self.is_pox_active(sortdb, block_id, reward_cycle as u128, POX_1_NAME)? { + debug!( + "PoX was voted disabled in block {} (reward cycle {})", + block_id, reward_cycle + ); + return Ok(vec![]); + } - let reward_cycle_start_height = burnchain.reward_cycle_to_block_height(reward_cycle); + // how many in this cycle? + let num_addrs = self + .eval_boot_code_read_only( + sortdb, + block_id, + POX_1_NAME, + &format!("(get-reward-set-size u{})", reward_cycle), + )? + .expect_u128(); - let pox_contract_name = burnchain - .pox_constants - .active_pox_contract(reward_cycle_start_height); + debug!( + "At block {:?} (reward cycle {}): {} PoX reward addresses", + block_id, reward_cycle, num_addrs + ); - if !self.is_pox_active(sortdb, block_id, reward_cycle as u128, pox_contract_name)? { + let mut ret = vec![]; + for i in 0..num_addrs { + // value should be (optional (tuple (pox-addr (tuple (...))) (total-ustx uint))). + // Get the tuple. + let tuple_data = self + .eval_boot_code_read_only( + sortdb, + block_id, + POX_1_NAME, + &format!("(get-reward-set-pox-address u{} u{})", reward_cycle, i), + )? + .expect_optional() + .expect(&format!( + "FATAL: missing PoX address in slot {} out of {} in reward cycle {}", + i, num_addrs, reward_cycle + )) + .expect_tuple(); + + let pox_addr_tuple = tuple_data + .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_data + .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(); + + debug!( + "PoX reward address (for {} ustx): {}", + total_ustx, &reward_address, + ); + ret.push(RawRewardSetEntry { + reward_address, + amount_stacked: total_ustx, + stacker: None, + }) + } + + Ok(ret) + } + + fn get_reward_addresses_pox_2( + &mut self, + sortdb: &SortitionDB, + block_id: &StacksBlockId, + reward_cycle: u64, + ) -> Result, Error> { + if !self.is_pox_active(sortdb, block_id, reward_cycle as u128, POX_2_NAME)? { debug!( "PoX was voted disabled in block {} (reward cycle {})", block_id, reward_cycle @@ -363,14 +633,12 @@ impl StacksChainState { return Ok(vec![]); } - debug!("Using pox_contract = {}", pox_contract_name); - // how many in this cycle? let num_addrs = self .eval_boot_code_read_only( sortdb, block_id, - pox_contract_name, + POX_2_NAME, &format!("(get-reward-set-size u{})", reward_cycle), )? .expect_u128(); @@ -387,7 +655,7 @@ impl StacksChainState { .eval_boot_code_read_only( sortdb, block_id, - pox_contract_name, + POX_2_NAME, &format!("(get-reward-set-pox-address u{} u{})", reward_cycle, i), )? .expect_optional() @@ -402,9 +670,11 @@ impl StacksChainState { .expect(&format!("FATAL: no `pox-addr` in return value from (get-reward-set-pox-address u{} u{})", reward_cycle, i)) .to_owned(); - let pox_addr = PoxAddress::try_from_pox_tuple(self.mainnet, &pox_addr_tuple).expect( - &format!("FATAL: not a valid PoX address: {:?}", &pox_addr_tuple), - ); + 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") @@ -412,15 +682,61 @@ impl StacksChainState { .to_owned() .expect_u128(); + let stacker = 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() + .map(|value| value.expect_principal()); + debug!( - "PoX reward address (for {} ustx): {}", - total_ustx, &pox_addr + "Parsed PoX reward address"; + "stacked_ustx" => total_ustx, + "reward_address" => %reward_address, + "stacker" => ?stacker, ); - ret.push((pox_addr, total_ustx)); + ret.push(RawRewardSetEntry { + reward_address, + amount_stacked: total_ustx, + stacker, + }) } Ok(ret) } + + /// Get the sequence of reward addresses, as well as the PoX-specified hash mode (which gets + /// lost in the conversion to StacksAddress) + /// Each address will have at least (get-stacking-minimum) tokens. + pub fn get_reward_addresses( + &mut self, + burnchain: &Burnchain, + sortdb: &SortitionDB, + current_burn_height: u64, + block_id: &StacksBlockId, + ) -> Result, Error> { + let reward_cycle = burnchain + .block_height_to_reward_cycle(current_burn_height) + .ok_or(Error::PoxNoRewardCycle)?; + + let reward_cycle_start_height = burnchain.reward_cycle_to_block_height(reward_cycle); + + let pox_contract_name = burnchain + .pox_constants + .active_pox_contract(reward_cycle_start_height); + + match pox_contract_name { + x if x == POX_1_NAME => self.get_reward_addresses_pox_1(sortdb, block_id, reward_cycle), + x if x == POX_2_NAME => self.get_reward_addresses_pox_2(sortdb, block_id, reward_cycle), + unknown_contract => { + panic!("Blockchain implementation failure: PoX contract name '{}' is unknown. Chainstate is corrupted.", + unknown_contract); + } + } + } } #[cfg(test)] @@ -489,37 +805,44 @@ pub mod test { fn make_reward_set_units() { let threshold = 1_000; let addresses = vec![ - ( - PoxAddress::Standard( + RawRewardSetEntry { + reward_address: PoxAddress::Standard( StacksAddress::from_string("STVK1K405H6SK9NKJAP32GHYHDJ98MMNP8Y6Z9N0").unwrap(), Some(AddressHashMode::SerializeP2PKH), ), - 1500, - ), - ( - PoxAddress::Standard( + amount_stacked: 1500, + stacker: None, + }, + RawRewardSetEntry { + reward_address: PoxAddress::Standard( StacksAddress::from_string("ST76D2FMXZ7D2719PNE4N71KPSX84XCCNCMYC940").unwrap(), Some(AddressHashMode::SerializeP2PKH), ), - 500, - ), - ( - PoxAddress::Standard( + + amount_stacked: 500, + stacker: None, + }, + RawRewardSetEntry { + reward_address: PoxAddress::Standard( StacksAddress::from_string("STVK1K405H6SK9NKJAP32GHYHDJ98MMNP8Y6Z9N0").unwrap(), Some(AddressHashMode::SerializeP2PKH), ), - 1500, - ), - ( - PoxAddress::Standard( + amount_stacked: 1500, + stacker: None, + }, + RawRewardSetEntry { + reward_address: PoxAddress::Standard( StacksAddress::from_string("ST76D2FMXZ7D2719PNE4N71KPSX84XCCNCMYC940").unwrap(), Some(AddressHashMode::SerializeP2PKH), ), - 400, - ), + amount_stacked: 400, + stacker: None, + }, ]; assert_eq!( - StacksChainState::make_reward_set(threshold, addresses).len(), + StacksChainState::make_reward_set(threshold, addresses) + .rewarded_addresses + .len(), 3 ); } @@ -546,7 +869,11 @@ pub mod test { assert_eq!( StacksChainState::get_reward_threshold_and_participation( &test_pox_constants, - &[(rand_pox_addr(), liquid)], + &[RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: liquid, + stacker: None + }], liquid ) .0, @@ -568,7 +895,11 @@ pub mod test { assert_eq!( StacksChainState::get_reward_threshold_and_participation( &test_pox_constants, - &[(rand_pox_addr(), liquid / 4)], + &[RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: liquid / 4, + stacker: None + }], liquid ) .0, @@ -579,11 +910,16 @@ pub mod test { StacksChainState::get_reward_threshold_and_participation( &test_pox_constants, &[ - (rand_pox_addr(), liquid / 4), - ( - rand_pox_addr(), - 10_000_000 * (MICROSTACKS_PER_STACKS as u128) - ) + RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: liquid / 4, + stacker: None + }, + RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: 10_000_000 * (MICROSTACKS_PER_STACKS as u128), + stacker: None + }, ], liquid ) @@ -596,8 +932,16 @@ pub mod test { StacksChainState::get_reward_threshold_and_participation( &test_pox_constants, &[ - (rand_pox_addr(), liquid / 4), - (rand_pox_addr(), (MICROSTACKS_PER_STACKS as u128)) + RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: liquid / 4, + stacker: None + }, + RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: MICROSTACKS_PER_STACKS as u128, + stacker: None + }, ], liquid ) @@ -609,7 +953,11 @@ pub mod test { assert_eq!( StacksChainState::get_reward_threshold_and_participation( &test_pox_constants, - &[(rand_pox_addr(), liquid)], + &[RawRewardSetEntry { + reward_address: rand_pox_addr(), + amount_stacked: liquid, + stacker: None + }], liquid ) .0, @@ -1152,10 +1500,27 @@ pub mod test { block_id: &StacksBlockId, ) -> Result, Error> { let burn_block_height = get_par_burn_block_height(state, block_id); + get_reward_set_entries_at_block(state, burnchain, sortdb, block_id, burn_block_height).map( + |addrs| { + addrs + .into_iter() + .map(|x| (x.reward_address, x.amount_stacked)) + .collect() + }, + ) + } + + pub fn get_reward_set_entries_at_block( + state: &mut StacksChainState, + burnchain: &Burnchain, + sortdb: &SortitionDB, + block_id: &StacksBlockId, + burn_block_height: u64, + ) -> Result, Error> { state .get_reward_addresses(burnchain, sortdb, burn_block_height, block_id) .and_then(|mut addrs| { - addrs.sort_by_key(|k| k.0.bytes()); + addrs.sort_by_key(|k| k.reward_address.bytes()); Ok(addrs) }) } diff --git a/src/chainstate/stacks/boot/pox-2.clar b/src/chainstate/stacks/boot/pox-2.clar index c9a51706c8..4ac563ff09 100644 --- a/src/chainstate/stacks/boot/pox-2.clar +++ b/src/chainstate/stacks/boot/pox-2.clar @@ -1,6 +1,7 @@ ;; The .pox-2 contract ;; Error codes (define-constant ERR_STACKING_UNREACHABLE 255) +(define-constant ERR_STACKING_CORRUPTED_STATE 254) (define-constant ERR_STACKING_INSUFFICIENT_FUNDS 1) (define-constant ERR_STACKING_INVALID_LOCK_PERIOD 2) (define-constant ERR_STACKING_ALREADY_STACKED 3) @@ -73,7 +74,9 @@ ;; how long the uSTX are locked, in reward cycles. lock-period: uint, ;; reward cycle when rewards begin - first-reward-cycle: uint + first-reward-cycle: uint, + ;; indexes in each reward-set associated with this user + reward-set-indexes: (list 12 uint) } ) @@ -109,7 +112,8 @@ { reward-cycle: uint, index: uint } { pox-addr: { version: (buff 1), hashbytes: (buff 20) }, - total-ustx: uint + total-ustx: uint, + stacker: (optional principal) } ) @@ -234,20 +238,19 @@ ;; Add a single PoX address to a single reward cycle. ;; Used to build up a set of per-reward-cycle PoX addresses. ;; No checking will be done -- don't call if this PoX address is already registered in this reward cycle! +;; Returns the index into the reward cycle that the PoX address is stored to (define-private (append-reward-cycle-pox-addr (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) (reward-cycle uint) - (amount-ustx uint)) - (let ( - (sz (get-reward-set-size reward-cycle)) - ) - (map-set reward-cycle-pox-address-list - { reward-cycle: reward-cycle, index: sz } - { pox-addr: pox-addr, total-ustx: amount-ustx }) - (map-set reward-cycle-pox-address-list-len - { reward-cycle: reward-cycle } - { len: (+ u1 sz) }) - (+ u1 sz)) -) + (amount-ustx uint) + (stacker (optional principal))) + (let ((sz (get-reward-set-size reward-cycle))) + (map-set reward-cycle-pox-address-list + { reward-cycle: reward-cycle, index: sz } + { pox-addr: pox-addr, total-ustx: amount-ustx, stacker: stacker }) + (map-set reward-cycle-pox-address-list-len + { reward-cycle: reward-cycle } + { len: (+ u1 sz) }) + sz)) ;; How many uSTX are stacked? (define-read-only (get-total-ustx-stacked (reward-cycle uint)) @@ -261,6 +264,70 @@ (define-read-only (get-reward-set-pox-address (reward-cycle uint) (index uint)) (map-get? reward-cycle-pox-address-list { reward-cycle: reward-cycle, index: index })) +(define-private (set-uint-at (in-list (list 12 uint)) (index uint) (value uint)) + (unwrap-panic (as-max-len? + (concat (append (default-to (list) (slice in-list u0 index)) + value) + (default-to (list) (slice in-list (+ u1 index) (len in-list)))) + u12))) + +(define-private (fold-unlock-reward-cycle (set-index uint) + (data-res (response { cycle: uint, + first-unlocked-cycle: uint, + stacker: principal + } int))) + (let ((data (try! data-res)) + (cycle (get cycle data)) + (first-unlocked-cycle (get first-unlocked-cycle data))) + ;; if current-cycle hasn't reached first-unlocked-cycle, just continue to next iter + (asserts! (>= cycle first-unlocked-cycle) (ok (merge data { cycle: (+ u1 cycle) }))) + (let ((cycle-entry (unwrap-panic (map-get? reward-cycle-pox-address-list { reward-cycle: cycle, index: set-index }))) + (cycle-entry-u (get stacker cycle-entry)) + (cycle-entry-total-ustx (get total-ustx cycle-entry)) + (cycle-last-entry-ix (- (get len (unwrap-panic (map-get? reward-cycle-pox-address-list-len { reward-cycle: cycle }))) u1))) + (asserts! (is-eq cycle-entry-u (some (get stacker data))) (err ERR_STACKING_CORRUPTED_STATE)) + (if (not (is-eq cycle-last-entry-ix set-index)) + ;; do a "move" if the entry to remove isn't last + (let ((move-entry (unwrap-panic (map-get? reward-cycle-pox-address-list { reward-cycle: cycle, index: cycle-last-entry-ix })))) + (map-set reward-cycle-pox-address-list + { reward-cycle: cycle, index: set-index } + move-entry) + (match (get stacker move-entry) moved-stacker + ;; if the moved entry had an associated stacker, update its state + (let ((moved-state (unwrap-panic (map-get? stacking-state { stacker: moved-stacker }))) + ;; calculate the index into the reward-set-indexes that `cycle` is at + (moved-cycle-index (- cycle (get first-reward-cycle moved-state))) + (moved-reward-list (get reward-set-indexes moved-state)) + ;; reward-set-indexes[moved-cycle-index] = set-index via slice, append, concat. + (update-list (set-uint-at moved-reward-list moved-cycle-index set-index))) + (map-set stacking-state { stacker: moved-stacker } + (merge moved-state { reward-set-indexes: update-list }))) + ;; otherwise, we dont need to update stacking-state after move + true)) + ;; if not moving, just noop + true) + ;; in all cases, we now need to delete the last list entry + (map-delete reward-cycle-pox-address-list { reward-cycle: cycle, index: cycle-last-entry-ix }) + (map-set reward-cycle-pox-address-list-len { reward-cycle: cycle } { len: cycle-last-entry-ix }) + ;; finally, update `reward-cycle-total-stacked` + (map-set reward-cycle-total-stacked { reward-cycle: cycle } + { total-ustx: (- (get total-ustx (unwrap-panic (map-get? reward-cycle-total-stacked { reward-cycle: cycle }))) + cycle-entry-total-ustx) }) + (ok (merge data { cycle: (+ u1 cycle)} ))))) + +;; This method is called by the Stacks block processor directly in order to handle the contract state mutations +;; associated with an early unlock. This can only be invoked by the block processor: it is private, and no methods +;; from this contract invoke it. +(define-private (handle-unlock (user principal) (amount-locked uint) (cycle-to-unlock uint)) + (let ((user-stacking-state (unwrap-panic (map-get? stacking-state { stacker: user }))) + (first-cycle-locked (get first-reward-cycle user-stacking-state)) + (reward-set-indexes (get reward-set-indexes user-stacking-state))) + ;; iterate over each reward set the user is a member of, and remove them from the sets. only apply to reward sets after cycle-to-unlock. + (try! (fold fold-unlock-reward-cycle reward-set-indexes (ok { cycle: first-cycle-locked, first-unlocked-cycle: cycle-to-unlock, stacker: user }))) + ;; Now that we've cleaned up all the reward set entries for the user, delete the user's stacking-state + (map-delete stacking-state { stacker: user }) + (ok true))) + ;; Add a PoX address to the `cycle-index`-th reward cycle, if `cycle-index` is between 0 and the given num-cycles (exclusive). ;; Arguments are given as a tuple, so this function can be (folded ..)'ed onto a list of its arguments. ;; Used by add-pox-addr-to-reward-cycles. @@ -269,34 +336,42 @@ ;; the pox-addr was added to the given cycle. (define-private (add-pox-addr-to-ith-reward-cycle (cycle-index uint) (params (tuple (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) + (reward-set-indexes (list 12 uint)) (first-reward-cycle uint) (num-cycles uint) + (stacker (optional principal)) (amount-ustx uint) (i uint)))) (let ((reward-cycle (+ (get first-reward-cycle params) (get i params))) (num-cycles (get num-cycles params)) - (i (get i params))) + (i (get i params)) + (reward-set-index (if (< i num-cycles) + (let ((total-ustx (get-total-ustx-stacked reward-cycle)) + (reward-index + ;; record how many uSTX this pox-addr will stack for in the given reward cycle + (append-reward-cycle-pox-addr + (get pox-addr params) + reward-cycle + (get amount-ustx params) + (get stacker params) + ))) + ;; update running total + (map-set reward-cycle-total-stacked + { reward-cycle: reward-cycle } + { total-ustx: (+ (get amount-ustx params) total-ustx) }) + (some reward-index)) + none)) + (next-i (if (< i num-cycles) (+ i u1) i))) { pox-addr: (get pox-addr params), first-reward-cycle: (get first-reward-cycle params), num-cycles: num-cycles, amount-ustx: (get amount-ustx params), - i: (if (< i num-cycles) - (let ((total-ustx (get-total-ustx-stacked reward-cycle))) - ;; record how many uSTX this pox-addr will stack for in the given reward cycle - (append-reward-cycle-pox-addr - (get pox-addr params) - reward-cycle - (get amount-ustx params)) - - ;; update running total - (map-set reward-cycle-total-stacked - { reward-cycle: reward-cycle } - { total-ustx: (+ (get amount-ustx params) total-ustx) }) - - ;; updated _this_ reward cycle - (+ i u1)) - (+ i u0)) + stacker: (get stacker params), + reward-set-indexes: (match + reward-set-index new (unwrap-panic (as-max-len? (append (get reward-set-indexes params) new) u12)) + (get reward-set-indexes params)), + i: next-i })) ;; Add a PoX address to a given sequence of reward cycle lists. @@ -305,16 +380,18 @@ (define-private (add-pox-addr-to-reward-cycles (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) (first-reward-cycle uint) (num-cycles uint) - (amount-ustx uint)) - (let ((cycle-indexes (list u0 u1 u2 u3 u4 u5 u6 u7 u8 u9 u10 u11))) + (amount-ustx uint) + (stacker principal)) + (let ((cycle-indexes (list u0 u1 u2 u3 u4 u5 u6 u7 u8 u9 u10 u11)) + (results (fold add-pox-addr-to-ith-reward-cycle cycle-indexes + { pox-addr: pox-addr, first-reward-cycle: first-reward-cycle, num-cycles: num-cycles, + reward-set-indexes: (list), amount-ustx: amount-ustx, i: u0, stacker: (some stacker) })) + (reward-set-indexes (get reward-set-indexes results))) ;; For safety, add up the number of times (add-principal-to-ith-reward-cycle) returns 1. ;; It _should_ be equal to num-cycles. - (asserts! - (is-eq num-cycles - (get i (fold add-pox-addr-to-ith-reward-cycle cycle-indexes - { pox-addr: pox-addr, first-reward-cycle: first-reward-cycle, num-cycles: num-cycles, amount-ustx: amount-ustx, i: u0 }))) - (err ERR_STACKING_UNREACHABLE)) - (ok true))) + (asserts! (is-eq num-cycles (get i results)) (err ERR_STACKING_UNREACHABLE)) + (asserts! (is-eq num-cycles (len reward-set-indexes)) (err ERR_STACKING_UNREACHABLE)) + (ok reward-set-indexes))) (define-private (add-pox-partial-stacked-to-ith-cycle (cycle-index uint) @@ -382,7 +459,7 @@ (num-cycles uint)) (begin ;; minimum uSTX must be met - (asserts! (<= (print (get-stacking-minimum)) amount-ustx) + (asserts! (<= (get-stacking-minimum) amount-ustx) (err ERR_STACKING_THRESHOLD_NOT_MET)) (minimal-can-stack-stx pox-addr amount-ustx first-reward-cycle num-cycles))) @@ -479,19 +556,18 @@ (try! (can-stack-stx pox-addr amount-ustx first-reward-cycle lock-period)) ;; register the PoX address with the amount stacked - (try! (add-pox-addr-to-reward-cycles pox-addr first-reward-cycle lock-period amount-ustx)) - - ;; add stacker record - (map-set stacking-state - { stacker: tx-sender } - { amount-ustx: amount-ustx, - pox-addr: pox-addr, - first-reward-cycle: first-reward-cycle, - lock-period: lock-period }) - - ;; return the lock-up information, so the node can actually carry out the lock. - (ok { stacker: tx-sender, lock-amount: amount-ustx, unlock-burn-height: (reward-cycle-to-burn-height (+ first-reward-cycle lock-period)) })) -) + (let ((reward-set-indexes (try! (add-pox-addr-to-reward-cycles pox-addr first-reward-cycle lock-period amount-ustx tx-sender)))) + ;; add stacker record + (map-set stacking-state + { stacker: tx-sender } + { amount-ustx: amount-ustx, + pox-addr: pox-addr, + reward-set-indexes: reward-set-indexes, + first-reward-cycle: first-reward-cycle, + lock-period: lock-period }) + + ;; return the lock-up information, so the node can actually carry out the lock. + (ok { stacker: tx-sender, lock-amount: amount-ustx, unlock-burn-height: (reward-cycle-to-burn-height (+ first-reward-cycle lock-period)) })))) (define-public (revoke-delegate-stx) (begin @@ -557,6 +633,8 @@ { pox-addr: pox-addr, first-reward-cycle: reward-cycle, num-cycles: u1, + reward-set-indexes: (list), + stacker: none, amount-ustx: amount-ustx, i: u0 }) ;; don't update the stacking-state map, @@ -628,6 +706,7 @@ { amount-ustx: amount-ustx, pox-addr: pox-addr, first-reward-cycle: first-reward-cycle, + reward-set-indexes: (list), lock-period: lock-period }) ;; return the lock-up information, so the node can actually carry out the lock. @@ -728,18 +807,18 @@ ;; register the PoX address with the amount stacked ;; for the new cycles - (try! (add-pox-addr-to-reward-cycles pox-addr first-extend-cycle extend-count amount-ustx)) - - ;; update stacker record - (map-set stacking-state - { stacker: tx-sender } - { amount-ustx: amount-ustx, - pox-addr: pox-addr, - first-reward-cycle: first-extend-cycle, - lock-period: lock-period }) - - ;; return lock-up information - (ok { stacker: tx-sender, unlock-burn-height: new-unlock-ht })))) + (let ((reward-set-indexes (try! (add-pox-addr-to-reward-cycles pox-addr first-extend-cycle extend-count amount-ustx tx-sender)))) + ;; update stacker record + (map-set stacking-state + { stacker: tx-sender } + { amount-ustx: amount-ustx, + pox-addr: pox-addr, + reward-set-indexes: reward-set-indexes, + first-reward-cycle: cur-cycle, + lock-period: lock-period }) + + ;; return lock-up information + (ok { stacker: tx-sender, unlock-burn-height: new-unlock-ht }))))) ;; As a delegator, extend an active stacking lock, issuing a "partial commitment" for the ;; extended-to cycles. @@ -817,6 +896,7 @@ { stacker: stacker } { amount-ustx: amount-ustx, pox-addr: pox-addr, + reward-set-indexes: (list), first-reward-cycle: first-extend-cycle, lock-period: lock-period }) diff --git a/src/chainstate/stacks/boot/pox_2_tests.rs b/src/chainstate/stacks/boot/pox_2_tests.rs index 1b69ac5445..fcf9148291 100644 --- a/src/chainstate/stacks/boot/pox_2_tests.rs +++ b/src/chainstate/stacks/boot/pox_2_tests.rs @@ -12,9 +12,11 @@ use crate::chainstate::stacks::boot::{ use crate::chainstate::stacks::db::{ MinerPaymentSchedule, StacksHeaderInfo, MINER_REWARD_MATURITY, }; +use crate::chainstate::stacks::index::marf::MarfConnection; use crate::chainstate::stacks::index::MarfTrieId; use crate::chainstate::stacks::*; use crate::clarity_vm::database::marf::MarfedKV; +use crate::clarity_vm::database::HeadersDBConn; use crate::core::*; use crate::util_lib::db::{DBConn, FromRow}; use clarity::vm::contexts::OwnedEnvironment; @@ -51,7 +53,7 @@ use stacks_common::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, StacksAddress, StacksBlockId, VRFSeed, }; -use super::test::*; +use super::{test::*, RawRewardSetEntry}; use crate::clarity_vm::clarity::Error as ClarityError; use crate::chainstate::burn::operations::*; @@ -66,6 +68,88 @@ fn get_tip(sortdb: Option<&SortitionDB>) -> BlockSnapshot { SortitionDB::get_canonical_burn_chain_tip(&sortdb.unwrap().conn()).unwrap() } +/// Get the reward set entries if evaluated at the given StacksBlock +pub fn get_reward_set_entries_at( + peer: &mut TestPeer, + tip: &StacksBlockId, + at_burn_ht: u64, +) -> Vec { + let burnchain = peer.config.burnchain.clone(); + with_sortdb(peer, |ref mut c, ref sortdb| { + get_reward_set_entries_at_block(c, &burnchain, sortdb, tip, at_burn_ht).unwrap() + }) +} + +/// Get the STXBalance for `account` at the given chaintip +pub fn get_stx_account_at( + peer: &mut TestPeer, + tip: &StacksBlockId, + account: &PrincipalData, +) -> STXBalance { + with_clarity_db_ro(peer, tip, |db| db.get_account_stx_balance(account)) +} + +/// Get the STXBalance for `account` at the given chaintip +pub fn get_stacking_state_pox_2( + peer: &mut TestPeer, + tip: &StacksBlockId, + account: &PrincipalData, +) -> Option { + with_clarity_db_ro(peer, tip, |db| { + let lookup_tuple = Value::Tuple( + TupleData::from_data(vec![("stacker".into(), account.clone().into())]).unwrap(), + ); + db.fetch_entry_unknown_descriptor( + &boot_code_id(boot::POX_2_NAME, false), + "stacking-state", + &lookup_tuple, + ) + .unwrap() + .expect_optional() + }) +} + +/// Get the `cycle_number`'s total stacked amount at the given chaintip +pub fn get_reward_cycle_total(peer: &mut TestPeer, tip: &StacksBlockId, cycle_number: u64) -> u128 { + with_clarity_db_ro(peer, tip, |db| { + let total_stacked_key = TupleData::from_data(vec![( + "reward-cycle".into(), + Value::UInt(cycle_number.into()), + )]) + .unwrap() + .into(); + db.fetch_entry_unknown_descriptor( + &boot_code_id(boot::POX_2_NAME, false), + "reward-cycle-total-stacked", + &total_stacked_key, + ) + .map(|v| { + v.expect_optional() + .expect("Expected fetch_entry to return a value") + }) + .unwrap() + .expect_tuple() + .get_owned("total-ustx") + .expect("Malformed tuple returned by PoX contract") + .expect_u128() + }) +} + +/// Allows you to do something read-only with the ClarityDB at the given chaintip +pub fn with_clarity_db_ro(peer: &mut TestPeer, tip: &StacksBlockId, todo: F) -> R +where + F: FnOnce(&mut ClarityDatabase) -> R, +{ + with_sortdb(peer, |ref mut c, ref sortdb| { + let headers_db = HeadersDBConn(c.state_index.sqlite_conn()); + let burn_db = sortdb.index_conn(); + let mut read_only_clar = c + .clarity_state + .read_only_connection(tip, &headers_db, &burn_db); + read_only_clar.with_clarity_db_readonly(todo) + }) +} + /// In this test case, two Stackers, Alice and Bob stack and interact with the /// PoX v1 contract and PoX v2 contract across the epoch transition. /// @@ -505,6 +589,257 @@ fn test_simple_pox_lockup_transition_pox_2() { ); } +#[test] +fn test_simple_pox_2_auto_unlock_ab() { + test_simple_pox_2_auto_unlock(true) +} + +#[test] +fn test_simple_pox_2_auto_unlock_ba() { + test_simple_pox_2_auto_unlock(false) +} + +/// In this test case, two Stackers, Alice and Bob stack and interact with the +/// PoX v1 contract and PoX v2 contract across the epoch transition. +/// +/// Alice: stacks via PoX v1 for 4 cycles. The third of these cycles occurs after +/// the PoX v1 -> v2 transition, and so Alice gets "early unlocked". +/// After the early unlock, Alice re-stacks in PoX v2 +/// Alice tries to stack again via PoX v1, which is allowed by the contract, +/// but forbidden by the VM (because PoX has transitioned to v2) +/// Bob: stacks via PoX v2 for 6 cycles. He attempted to stack via PoX v1 as well, +/// but is forbidden because he has already placed an account lock via PoX v2. +/// +/// Note: this test is symmetric over the order of alice and bob's stacking calls. +/// when alice goes first, the auto-unlock code doesn't need to perform a "move" +/// when bob goes first, the auto-unlock code does need to perform a "move" +fn test_simple_pox_2_auto_unlock(alice_first: bool) { + // this is the number of blocks after the first sortition any V1 + // PoX locks will automatically unlock at. + let AUTO_UNLOCK_HEIGHT = 12; + let EXPECTED_FIRST_V2_CYCLE = 8; + // the sim environment produces 25 empty sortitions before + // tenures start being tracked. + let EMPTY_SORTITIONS = 25; + + let mut burnchain = Burnchain::default_unittest(0, &BurnchainHeaderHash::zero()); + burnchain.pox_constants.reward_cycle_length = 5; + burnchain.pox_constants.prepare_length = 2; + burnchain.pox_constants.anchor_threshold = 1; + burnchain.pox_constants.pox_participation_threshold_pct = 1; + burnchain.pox_constants.v1_unlock_height = AUTO_UNLOCK_HEIGHT + EMPTY_SORTITIONS; + + let first_v2_cycle = burnchain + .block_height_to_reward_cycle(burnchain.pox_constants.v1_unlock_height as u64) + .unwrap() + + 1; + + assert_eq!(first_v2_cycle, EXPECTED_FIRST_V2_CYCLE); + + eprintln!("First v2 cycle = {}", first_v2_cycle); + + let epochs = StacksEpoch::all(0, 0, EMPTY_SORTITIONS as u64 + 10); + + let observer = TestEventObserver::new(); + + let (mut peer, mut keys) = instantiate_pox_peer_with_epoch( + &burnchain, + &format!("test_simple_pox_2_auto_unlock_{}", alice_first), + 6002, + Some(epochs.clone()), + Some(&observer), + ); + + let num_blocks = 35; + + let alice = keys.pop().unwrap(); + let bob = keys.pop().unwrap(); + let charlie = keys.pop().unwrap(); + + let mut coinbase_nonce = 0; + + // produce blocks until the epoch switch + for _i in 0..10 { + peer.tenure_with_txs(&[], &mut coinbase_nonce); + } + + // in the next tenure, PoX 2 should now exist. + // Lets have Bob lock up for v2 + // this will lock for cycles 8, 9, 10, and 11 + // the first v2 cycle will be 8 + let tip = get_tip(peer.sortdb.as_ref()); + + let alice_lockup = make_pox_2_lockup( + &alice, + 0, + 1024 * POX_THRESHOLD_STEPS_USTX, + AddressHashMode::SerializeP2PKH, + key_to_stacks_addr(&alice).bytes, + 6, + tip.block_height, + ); + + let bob_lockup = make_pox_2_lockup( + &bob, + 0, + 1 * POX_THRESHOLD_STEPS_USTX, + AddressHashMode::SerializeP2PKH, + key_to_stacks_addr(&bob).bytes, + 6, + tip.block_height, + ); + + // our "tenure counter" is now at 10 + assert_eq!(tip.block_height, 10 + EMPTY_SORTITIONS as u64); + + let txs = if alice_first { + [alice_lockup, bob_lockup] + } else { + [bob_lockup, alice_lockup] + }; + let mut latest_block = peer.tenure_with_txs(&txs, &mut coinbase_nonce); + + // check that the "raw" reward set will contain entries for alice and bob + // at the cycle start + for cycle_number in EXPECTED_FIRST_V2_CYCLE..(EXPECTED_FIRST_V2_CYCLE + 6) { + let cycle_start = burnchain.reward_cycle_to_block_height(cycle_number); + let reward_set_entries = get_reward_set_entries_at(&mut peer, &latest_block, cycle_start); + assert_eq!(reward_set_entries.len(), 2); + assert_eq!( + reward_set_entries[0].reward_address.bytes(), + key_to_stacks_addr(&bob).bytes.0.to_vec() + ); + assert_eq!( + reward_set_entries[1].reward_address.bytes(), + key_to_stacks_addr(&alice).bytes.0.to_vec() + ); + } + + // we'll produce blocks until the next reward cycle gets through the "handled start" code + // this is one block after the reward cycle starts + let height_target = burnchain.reward_cycle_to_block_height(EXPECTED_FIRST_V2_CYCLE) + 1; + + // but first, check that bob has locked tokens at (height_target + 1) + let (bob_bal, _) = get_stx_account_at( + &mut peer, + &latest_block, + &key_to_stacks_addr(&bob).to_account_principal(), + ) + .canonical_repr_at_block(height_target + 1, burnchain.pox_constants.v1_unlock_height); + assert_eq!(bob_bal.amount_locked(), POX_THRESHOLD_STEPS_USTX); + + while get_tip(peer.sortdb.as_ref()).block_height < height_target { + latest_block = peer.tenure_with_txs(&[], &mut coinbase_nonce); + } + + // check that the "raw" reward sets for all cycles just contains entries for alice + // at the cycle start + for cycle_number in EXPECTED_FIRST_V2_CYCLE..(EXPECTED_FIRST_V2_CYCLE + 6) { + let cycle_start = burnchain.reward_cycle_to_block_height(cycle_number); + let reward_set_entries = get_reward_set_entries_at(&mut peer, &latest_block, cycle_start); + assert_eq!(reward_set_entries.len(), 1); + assert_eq!( + reward_set_entries[0].reward_address.bytes(), + key_to_stacks_addr(&alice).bytes.0.to_vec() + ); + } + + // now check that bob has no locked tokens at (height_target + 1) + let (bob_bal, _) = get_stx_account_at( + &mut peer, + &latest_block, + &key_to_stacks_addr(&bob).to_account_principal(), + ) + .canonical_repr_at_block(height_target + 1, burnchain.pox_constants.v1_unlock_height); + assert_eq!(bob_bal.amount_locked(), 0); + + // but bob's still locked at (height_target): the unlock is accelerated to the "next" burn block + let (bob_bal, _) = get_stx_account_at( + &mut peer, + &latest_block, + &key_to_stacks_addr(&bob).to_account_principal(), + ) + .canonical_repr_at_block(height_target + 1, burnchain.pox_constants.v1_unlock_height); + assert_eq!(bob_bal.amount_locked(), 0); + + // check that the total reward cycle amounts have decremented correctly + for cycle_number in EXPECTED_FIRST_V2_CYCLE..(EXPECTED_FIRST_V2_CYCLE + 6) { + assert_eq!( + get_reward_cycle_total(&mut peer, &latest_block, cycle_number), + 1024 * POX_THRESHOLD_STEPS_USTX + ); + } + + // check that bob's stacking-state is gone and alice's stacking-state is correct + assert!( + get_stacking_state_pox_2( + &mut peer, + &latest_block, + &key_to_stacks_addr(&bob).to_account_principal() + ) + .is_none(), + "Bob should not have a stacking-state entry" + ); + + let alice_state = get_stacking_state_pox_2( + &mut peer, + &latest_block, + &key_to_stacks_addr(&alice).to_account_principal(), + ) + .expect("Alice should have stacking-state entry") + .expect_tuple(); + let reward_indexes_str = format!("{}", alice_state.get("reward-set-indexes").unwrap()); + assert_eq!(reward_indexes_str, "(u0 u0 u0 u0 u0 u0)"); + + // now let's check some tx receipts + + let alice_address = key_to_stacks_addr(&alice); + let bob_address = key_to_stacks_addr(&bob); + let charlie_address = key_to_stacks_addr(&charlie); + let blocks = observer.get_blocks(); + + let mut alice_txs = HashMap::new(); + let mut bob_txs = HashMap::new(); + let mut charlie_txs = HashMap::new(); + + eprintln!("Alice addr: {}", alice_address); + eprintln!("Bob addr: {}", bob_address); + + for b in blocks.into_iter() { + for r in b.receipts.into_iter() { + if let TransactionOrigin::Stacks(ref t) = r.transaction { + let addr = t.auth.origin().address_testnet(); + eprintln!("TX addr: {}", addr); + if addr == alice_address { + alice_txs.insert(t.auth.get_origin_nonce(), r); + } else if addr == bob_address { + bob_txs.insert(t.auth.get_origin_nonce(), r); + } else if addr == charlie_address { + assert!( + r.execution_cost != ExecutionCost::zero(), + "Execution cost is not zero!" + ); + charlie_txs.insert(t.auth.get_origin_nonce(), r); + } + } + } + } + + assert_eq!(alice_txs.len(), 1); + assert_eq!(charlie_txs.len(), 0); + + assert_eq!(bob_txs.len(), 1); + + // TX0 -> Bob's initial lockup in PoX 2 + assert!( + match bob_txs.get(&0).unwrap().result { + Value::Response(ref r) => r.committed, + _ => false, + }, + "Bob tx0 should have committed okay" + ); +} + /// In this test case, two Stackers, Alice and Bob stack and interact with the /// PoX v1 contract and PoX v2 contract across the epoch transition. This test /// covers the two different ways a Stacker can validly extend via `stack-extend` -- diff --git a/src/chainstate/stacks/db/blocks.rs b/src/chainstate/stacks/db/blocks.rs index ae896a97cb..63f8ffd034 100644 --- a/src/chainstate/stacks/db/blocks.rs +++ b/src/chainstate/stacks/db/blocks.rs @@ -24,6 +24,7 @@ use std::io::prelude::*; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use clarity::types::chainstate::SortitionId; use rand::thread_rng; use rand::Rng; use rand::RngCore; @@ -46,6 +47,7 @@ use crate::chainstate::stacks::{ C32_ADDRESS_VERSION_TESTNET_MULTISIG, C32_ADDRESS_VERSION_TESTNET_SINGLESIG, }; use crate::clarity_vm::clarity::{ClarityBlockConnection, ClarityConnection, ClarityInstance}; +use crate::clarity_vm::database::SortitionDBRef; use crate::codec::MAX_MESSAGE_LEN; use crate::codec::{read_next, write_next}; use crate::core::mempool::MemPoolDB; @@ -5061,15 +5063,73 @@ impl StacksChainState { } } + /// Check if current PoX reward cycle (as of `burn_tip_height`) has handled any + /// Clarity VM work necessary at the start of the cycle (i.e., processing of accelerated unlocks + /// for failed stackers). + /// If it has not yet been handled, then perform that work now. + fn check_and_handle_reward_start( + burn_tip_height: u64, + burn_dbconn: &dyn BurnStateDB, + sortition_dbconn: &dyn SortitionDBRef, + clarity_tx: &mut ClarityTx, + chain_tip: &StacksHeaderInfo, + parent_sortition_id: &SortitionId, + ) -> Result, Error> { + let mut pox_reward_cycle = Burnchain::static_block_height_to_reward_cycle( + burn_tip_height, + burn_dbconn.get_burn_start_height().into(), + burn_dbconn.get_pox_reward_cycle_length().into(), + ).expect("FATAL: Unrecoverable chainstate corruption: Epoch 2.1 code evaluated before first burn block height"); + // Do not try to handle auto-unlocks on pox_reward_cycle 0 + // This cannot even occur in the mainchain, because 2.1 starts much + // after the 1st reward cycle, however, this could come up in mocknets or regtest. + if pox_reward_cycle > 1 { + if Burnchain::is_before_reward_cycle( + burn_dbconn.get_burn_start_height().into(), + burn_tip_height, + burn_dbconn.get_pox_reward_cycle_length().into(), + ) { + pox_reward_cycle -= 1; + } + let handled = clarity_tx.with_clarity_db_readonly(|clarity_db| { + Self::handled_pox_cycle_start(clarity_db, pox_reward_cycle) + }); + + if !handled { + let pox_start_cycle_info = sortition_dbconn.get_pox_start_cycle_info( + parent_sortition_id, + chain_tip.burn_header_height.into(), + pox_reward_cycle, + )?; + let events = clarity_tx.block.as_free_transaction(|clarity_tx| { + Self::handle_pox_cycle_start(clarity_tx, pox_reward_cycle, pox_start_cycle_info) + })?; + return Ok(events); + } + } + + Ok(vec![]) + } + /// Called in both follower and miner block assembly paths. + /// /// Returns clarity_tx, list of receipts, microblock execution cost, /// microblock fees, microblock burns, list of microblock tx receipts, /// miner rewards tuples, the stacks epoch id, and a boolean that /// represents whether the epoch transition has been applied. + /// + /// The `burn_dbconn`, `sortition_dbconn`, and `conn` arguments + /// all reference the same sortition database through different + /// interfaces. `burn_dbconn` and `sortition_dbconn` should + /// reference the same object. The reason to provide both is that + /// `SortitionDBRef` captures trait functions that Clarity does + /// not need, and Rust does not support trait upcasting (even + /// though it would theoretically be safe). pub fn setup_block<'a, 'b>( chainstate_tx: &'b mut ChainstateTx, clarity_instance: &'a mut ClarityInstance, burn_dbconn: &'b dyn BurnStateDB, + sortition_dbconn: &'b dyn SortitionDBRef, conn: &Connection, // connection to the sortition DB chain_tip: &StacksHeaderInfo, burn_tip: BurnchainHeaderHash, @@ -5080,8 +5140,10 @@ impl StacksChainState { mainnet: bool, miner_id_opt: Option, ) -> Result, Error> { - let parent_index_hash = - StacksBlockHeader::make_index_block_hash(&parent_consensus_hash, &parent_header_hash); + let parent_index_hash = StacksBlockId::new(&parent_consensus_hash, &parent_header_hash); + let parent_sortition_id = burn_dbconn + .get_sortition_id_from_consensus_hash(&parent_consensus_hash) + .expect("Failed to get parent SortitionID from ConsensusHash"); // find matured miner rewards, so we can grant them within the Clarity DB tx. let (latest_matured_miners, matured_miner_parent) = { @@ -5216,6 +5278,18 @@ impl StacksChainState { // epoch defined by this miner. clarity_tx.reset_cost(ExecutionCost::zero()); + debug!("Evaluating block with epoch = {}", evaluated_epoch); + if evaluated_epoch >= StacksEpochId::Epoch21 { + Self::check_and_handle_reward_start( + burn_tip_height.into(), + burn_dbconn, + sortition_dbconn, + &mut clarity_tx, + chain_tip, + &parent_sortition_id, + )?; + } + // is this stacks block the first of a new epoch? let (applied_epoch_transition, mut tx_receipts) = StacksChainState::process_epoch_transition(&mut clarity_tx, burn_tip_height)?; @@ -5434,6 +5508,7 @@ impl StacksChainState { chainstate_tx, clarity_instance, burn_dbconn, + burn_dbconn, &burn_dbconn.tx(), &parent_chain_tip, parent_burn_hash, diff --git a/src/chainstate/stacks/miner.rs b/src/chainstate/stacks/miner.rs index a10dc0ec71..ac6f7dff3c 100644 --- a/src/chainstate/stacks/miner.rs +++ b/src/chainstate/stacks/miner.rs @@ -118,6 +118,8 @@ pub struct MinerEpochInfo<'a> { pub chainstate_tx: ChainstateTx<'a>, pub clarity_instance: &'a mut ClarityInstance, pub burn_tip: BurnchainHeaderHash, + /// This is the expected burn tip height (i.e., the current burnchain tip + 1) + /// of the mined block pub burn_tip_height: u32, pub parent_microblocks: Vec, pub mainnet: bool, @@ -1881,6 +1883,7 @@ impl StacksBlockBuilder { &mut info.chainstate_tx, info.clarity_instance, burn_dbconn, + burn_dbconn, burn_dbconn.conn(), &self.chain_tip, info.burn_tip, diff --git a/src/clarity_vm/clarity.rs b/src/clarity_vm/clarity.rs index b53617bbf3..3e50d536aa 100644 --- a/src/clarity_vm/clarity.rs +++ b/src/clarity_vm/clarity.rs @@ -942,6 +942,32 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { } } + /// Execute `todo` as a transaction in this block. The execution + /// will use the "free" cost tracker. + /// This will unconditionally commit the edit log from the + /// transaction to the block, so any changes that should be + /// rolled back must be rolled back by `todo`. + pub fn as_free_transaction(&mut self, todo: F) -> R + where + F: FnOnce(&mut ClarityTransactionConnection) -> R, + { + // use the `using!` statement to ensure that the old cost_tracker is placed + // back in all branches after initialization + using!(self.cost_track, "cost tracker", |old_cost_tracker| { + // epoch initialization is *free* + self.cost_track.replace(LimitedCostTracker::new_free()); + + let mut tx = self.start_transaction_processing(); + let r = todo(&mut tx); + tx.commit(); + (old_cost_tracker, r) + }) + } + + /// Execute `todo` as a transaction in this block. + /// This will unconditionally commit the edit log from the + /// transaction to the block, so any changes that should be + /// rolled back must be rolled back by `todo`. pub fn as_transaction(&mut self, todo: F) -> R where F: FnOnce(&mut ClarityTransactionConnection) -> R, @@ -1149,6 +1175,10 @@ impl<'a, 'b> ClarityTransactionConnection<'a, 'b> { .and_then(|(value, ..)| Ok(value)) } + pub fn is_mainnet(&self) -> bool { + return self.mainnet; + } + /// Commit the changes from the edit log. /// panics if there is more than one open savepoint pub fn commit(mut self) { diff --git a/src/clarity_vm/database/mod.rs b/src/clarity_vm/database/mod.rs index 7064c03c53..933bac184f 100644 --- a/src/clarity_vm/database/mod.rs +++ b/src/clarity_vm/database/mod.rs @@ -1,9 +1,11 @@ +use clarity::vm::types::PrincipalData; use rusqlite::{Connection, OptionalExtension}; use crate::chainstate::burn::db::sortdb::{ - get_ancestor_sort_id, get_ancestor_sort_id_tx, SortitionDB, SortitionDBConn, + get_ancestor_sort_id, get_ancestor_sort_id_tx, SortitionDB, SortitionDBConn, SortitionHandle, SortitionHandleConn, SortitionHandleTx, }; +use crate::chainstate::stacks::boot::PoxStartCycleInfo; use crate::chainstate::stacks::db::accounts::MinerReward; use crate::chainstate::stacks::db::{MinerPaymentSchedule, StacksChainState, StacksHeaderInfo}; use crate::chainstate::stacks::index::MarfTrieId; @@ -19,6 +21,7 @@ use clarity::vm::errors::{InterpreterResult, RuntimeErrorType}; use crate::chainstate::stacks::db::ChainstateTx; use crate::chainstate::stacks::index::marf::{MarfConnection, MARF}; use crate::chainstate::stacks::index::{ClarityMarfTrieId, TrieMerkleProof}; +use crate::chainstate::stacks::Error as ChainstateError; use crate::types::chainstate::StacksBlockId; use crate::types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, SortitionId}; use crate::types::chainstate::{StacksAddress, VRFSeed}; @@ -224,6 +227,72 @@ fn get_matured_reward(conn: &DBConn, child_id_bhh: &StacksBlockId) -> Option Result, ChainstateError>; +} + +fn get_pox_start_cycle_info( + handle: &mut SortitionHandleConn, + parent_stacks_block_burn_ht: u64, + cycle_index: u64, +) -> Result, ChainstateError> { + let descended_from_last_pox_anchor = match handle.get_last_anchor_block_hash()? { + Some(pox_anchor) => handle.descended_from(parent_stacks_block_burn_ht, &pox_anchor)?, + None => return Ok(None), + }; + + if !descended_from_last_pox_anchor { + return Ok(None); + } + + let start_info = handle.get_reward_cycle_unlocks(cycle_index)?; + info!( + "get_pox_start_cycle_info"; + "start_info" => ?start_info, + ); + Ok(start_info) +} + +impl SortitionDBRef for SortitionHandleTx<'_> { + fn get_pox_start_cycle_info( + &self, + sortition_id: &SortitionId, + parent_stacks_block_burn_ht: u64, + cycle_index: u64, + ) -> Result, ChainstateError> { + let readonly_marf = self + .index() + .reopen_readonly() + .expect("BUG: failure trying to get a read-only interface into the sortition db."); + let mut context = self.context.clone(); + context.chain_tip = sortition_id.clone(); + let mut handle = SortitionHandleConn::new(&readonly_marf, context); + + get_pox_start_cycle_info(&mut handle, parent_stacks_block_burn_ht, cycle_index) + } +} + +impl SortitionDBRef for SortitionDBConn<'_> { + fn get_pox_start_cycle_info( + &self, + sortition_id: &SortitionId, + parent_stacks_block_burn_ht: u64, + cycle_index: u64, + ) -> Result, ChainstateError> { + let mut handle = self.as_handle(sortition_id); + get_pox_start_cycle_info(&mut handle, parent_stacks_block_burn_ht, cycle_index) + } +} + impl BurnStateDB for SortitionHandleTx<'_> { fn get_burn_block_height(&self, sortition_id: &SortitionId) -> Option { match SortitionDB::get_block_snapshot(self.tx(), sortition_id) { diff --git a/stacks-common/src/util/log.rs b/stacks-common/src/util/log.rs index 26de14e676..5f2095c7ea 100644 --- a/stacks-common/src/util/log.rs +++ b/stacks-common/src/util/log.rs @@ -65,7 +65,7 @@ fn print_msg_header(mut rd: &mut dyn RecordDecorator, record: &Record) -> io::Re write!(rd, " ")?; match thread::current().name() { None => write!(rd, "[{:?}]", thread::current().id())?, - Some(name) => write!(rd, "[{}]", name)?, + Some(name) => write!(rd, "[{:.15}]", name)?, } rd.start_whitespace()?;