diff --git a/.github/actions/docsgen/Dockerfile.docsgen b/.github/actions/docsgen/Dockerfile.docsgen index 5f073a65869..9bed9ff4621 100644 --- a/.github/actions/docsgen/Dockerfile.docsgen +++ b/.github/actions/docsgen/Dockerfile.docsgen @@ -4,13 +4,15 @@ WORKDIR /src COPY . . -RUN apt-get update && apt-get install -y git +RUN apt-get update && apt-get install -y git jq RUN cargo build RUN mkdir /out -RUN /src/target/debug/blockstack-core docgen > /out/clarity-reference.json +RUN /src/target/debug/blockstack-core docgen | jq . > /out/clarity-reference.json +RUN /src/target/debug/blockstack-core docgen_boot | jq . > /out/boot-contracts-reference.json FROM scratch AS export-stage COPY --from=build /out/clarity-reference.json / +COPY --from=build /out/boot-contracts-reference.json / diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index 05c2c6216c2..6f43ecb9c4f 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -43,18 +43,20 @@ jobs: id: push run: | cd docs.blockstack - git config user.email "robots@robots.actions" + git config user.email "kantai+robot@gmail.com" git config user.name "PR Robot" git fetch --unshallow git checkout -b $ROBOT_BRANCH cp ../docs-output/clarity-reference.json ./src/_data/clarity-reference.json - if $(git diff --quiet --exit-code); then - echo "No clarity-reference.json changes, stopping" + cp ../docs-output/boot-contracts-reference.json ./src/_data/boot-contracts-reference.json + git add src/_data/clarity-reference.json + git add src/_data/boot-contracts-reference.json + if $(git diff --staged --quiet --exit-code); then + echo "No reference.json changes, stopping" echo "::set-output name=open_pr::0" else git remote add robot https://github.com/$ROBOT_OWNER/$ROBOT_REPO - git add src/_data/clarity-reference.json - git commit -m "auto: update clarity-reference.json from stacks-blockchain@${GITHUB_SHA}" + git commit -m "auto: update Clarity references JSONs from stacks-blockchain@${GITHUB_SHA}" git push robot $ROBOT_BRANCH echo "::set-output name=open_pr::1" fi diff --git a/src/burnchains/mod.rs b/src/burnchains/mod.rs index 5f19fe6f81d..07daad27949 100644 --- a/src/burnchains/mod.rs +++ b/src/burnchains/mod.rs @@ -280,6 +280,9 @@ pub struct PoxConstants { /// fraction of liquid STX that must vote to reject PoX for /// it to revert to PoB in the next reward cycle pub pox_rejection_fraction: u64, + /// percentage of liquid STX that must participate for PoX + /// to occur + pub pox_participation_threshold_pct: u64, _shadow: PhantomData<()>, } @@ -289,6 +292,7 @@ impl PoxConstants { prepare_length: u32, anchor_threshold: u32, pox_rejection_fraction: u64, + pox_participation_threshold_pct: u64, ) -> PoxConstants { assert!(anchor_threshold > (prepare_length / 2)); @@ -297,20 +301,35 @@ impl PoxConstants { prepare_length, anchor_threshold, pox_rejection_fraction, + pox_participation_threshold_pct, _shadow: PhantomData, } } #[cfg(test)] pub fn test_default() -> PoxConstants { - PoxConstants::new(10, 5, 3, 25) + PoxConstants::new(10, 5, 3, 25, 5) + } + + pub fn reward_slots(&self) -> u32 { + self.reward_cycle_length + } + + /// is participating_ustx enough to engage in PoX in the next reward cycle? + pub fn enough_participation(&self, participating_ustx: u128, liquid_ustx: u128) -> bool { + participating_ustx + .checked_mul(100) + .expect("OVERFLOW: uSTX overflowed u128") + > liquid_ustx + .checked_mul(self.pox_participation_threshold_pct as u128) + .expect("OVERFLOW: uSTX overflowed u128") } pub fn mainnet_default() -> PoxConstants { - PoxConstants::new(1000, 240, 192, 25) + PoxConstants::new(1000, 240, 192, 25, 5) } pub fn testnet_default() -> PoxConstants { - PoxConstants::new(120, 30, 20, 3333333333333333) // total liquid supply is 40000000000000000 µSTX + PoxConstants::new(120, 30, 20, 3333333333333333, 5) // total liquid supply is 40000000000000000 µSTX } } diff --git a/src/chainstate/coordinator/mod.rs b/src/chainstate/coordinator/mod.rs index 85bfd81b426..ea358d09b9c 100644 --- a/src/chainstate/coordinator/mod.rs +++ b/src/chainstate/coordinator/mod.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use std::collections::VecDeque; -use std::convert::TryInto; +use std::convert::{TryFrom, TryInto}; use std::time::Duration; use burnchains::{ @@ -28,13 +28,18 @@ use chainstate::burn::{ BlockHeaderHash, BlockSnapshot, ConsensusHash, }; use chainstate::stacks::{ + boot::STACKS_BOOT_CODE_CONTRACT_ADDRESS, db::{ClarityTx, StacksChainState, StacksHeaderInfo}, events::StacksTransactionReceipt, Error as ChainstateError, StacksAddress, StacksBlock, StacksBlockHeader, StacksBlockId, }; use monitoring::increment_stx_blocks_processed_counter; use util::db::Error as DBError; -use vm::{costs::ExecutionCost, types::PrincipalData}; +use vm::{ + costs::ExecutionCost, + types::{PrincipalData, QualifiedContractIdentifier}, + Value, +}; pub mod comm; use chainstate::stacks::index::MarfTrieId; @@ -177,10 +182,35 @@ impl RewardSetProvider for OnChainRewardSetProvider { sortdb: &SortitionDB, block_id: &StacksBlockId, ) -> Result, Error> { - let res = + let registered_addrs = chainstate.get_reward_addresses(burnchain, sortdb, current_burn_height, block_id)?; - let addresses = res.iter().map(|a| a.0).collect::>(); - Ok(addresses) + + let liquid_ustx = StacksChainState::get_stacks_block_header_info_by_index_block_hash( + chainstate.headers_db(), + block_id, + )? + .expect("CORRUPTION: Failed to look up block header info for PoX anchor block") + .total_liquid_ustx; + + let (threshold, participation) = StacksChainState::get_reward_threshold_and_participation( + &burnchain.pox_constants, + ®istered_addrs, + liquid_ustx, + ); + + if !burnchain + .pox_constants + .enough_participation(participation, liquid_ustx) + { + info!("PoX reward cycle did not have enough participation. Defaulting to burn. participation={}, liquid_ustx={}, burn_height={}", + participation, liquid_ustx, current_burn_height); + return Ok(vec![]); + } + + Ok(StacksChainState::make_reward_set( + threshold, + registered_addrs, + )) } } @@ -212,7 +242,32 @@ impl<'a, T: BlockEventDispatcher> stacks_chain_id, chain_state_path, initial_balances, - boot_block_exec, + |clarity_tx| { + let burnchain = burnchain.clone(); + let contract = QualifiedContractIdentifier::parse(&format!( + "{}.pox", + STACKS_BOOT_CODE_CONTRACT_ADDRESS + )) + .expect("Failed to construct boot code contract address"); + let sender = PrincipalData::from(contract.clone()); + + clarity_tx.connection().as_transaction(|conn| { + conn.run_contract_call( + &sender, + &contract, + "set-burnchain-parameters", + &[ + Value::UInt(burnchain.first_block_height as u128), + Value::UInt(burnchain.pox_constants.prepare_length as u128), + Value::UInt(burnchain.pox_constants.reward_cycle_length as u128), + Value::UInt(burnchain.pox_constants.pox_rejection_fraction as u128), + ], + |_, _| false, + ) + .expect("Failed to set burnchain parameters in PoX contract"); + }); + boot_block_exec(clarity_tx) + }, block_limit, ) .unwrap(); diff --git a/src/chainstate/coordinator/tests.rs b/src/chainstate/coordinator/tests.rs index 5051e1dedfb..c8c74770e1b 100644 --- a/src/chainstate/coordinator/tests.rs +++ b/src/chainstate/coordinator/tests.rs @@ -268,7 +268,7 @@ fn make_reward_set_coordinator<'a>( pub fn get_burnchain(path: &str) -> Burnchain { let mut b = Burnchain::new(&format!("{}/burnchain/db/", path), "bitcoin", "regtest").unwrap(); - b.pox_constants = PoxConstants::new(5, 3, 3, 25); + b.pox_constants = PoxConstants::new(5, 3, 3, 25, 5); b } diff --git a/src/chainstate/stacks/boot/contract_tests.rs b/src/chainstate/stacks/boot/contract_tests.rs new file mode 100644 index 00000000000..26f24523df0 --- /dev/null +++ b/src/chainstate/stacks/boot/contract_tests.rs @@ -0,0 +1,847 @@ +use std::collections::{HashMap, VecDeque}; +use std::convert::TryFrom; + +use vm::contracts::Contract; +use vm::errors::{ + CheckErrors, Error, IncomparableError, InterpreterError, InterpreterResult as Result, + RuntimeErrorType, +}; +use vm::types::{ + OptionalData, PrincipalData, QualifiedContractIdentifier, StandardPrincipalData, + TupleTypeSignature, TypeSignature, Value, NONE, +}; + +use std::convert::TryInto; + +use burnchains::BurnchainHeaderHash; +use chainstate::burn::{BlockHeaderHash, ConsensusHash, VRFSeed}; +use chainstate::stacks::boot::STACKS_BOOT_CODE_CONTRACT_ADDRESS; +use chainstate::stacks::db::{MinerPaymentSchedule, StacksHeaderInfo}; +use chainstate::stacks::index::proofs::TrieMerkleProof; +use chainstate::stacks::index::MarfTrieId; +use chainstate::stacks::*; + +use util::db::{DBConn, FromRow}; +use util::hash::{Sha256Sum, Sha512Trunc256Sum}; +use vm::contexts::OwnedEnvironment; +use vm::costs::CostOverflowingMath; +use vm::database::*; +use vm::representations::SymbolicExpression; + +use util::hash::to_hex; +use vm::eval; +use vm::tests::{execute, is_committed, is_err_code, symbols_from_values}; + +use core::{ + FIRST_BURNCHAIN_BLOCK_HASH, FIRST_BURNCHAIN_BLOCK_HEIGHT, FIRST_BURNCHAIN_BLOCK_TIMESTAMP, + FIRST_BURNCHAIN_CONSENSUS_HASH, FIRST_STACKS_BLOCK_HASH, POX_REWARD_CYCLE_LENGTH, +}; + +const BOOT_CODE_POX_BODY: &'static str = std::include_str!("pox.clar"); +const BOOT_CODE_POX_TESTNET_CONSTS: &'static str = std::include_str!("pox-testnet.clar"); +const BOOT_CODE_POX_MAINNET_CONSTS: &'static str = std::include_str!("pox-mainnet.clar"); +const BOOT_CODE_LOCKUP: &'static str = std::include_str!("lockup.clar"); + +const USTX_PER_HOLDER: u128 = 1_000_000; + +lazy_static! { + static ref BOOT_CODE_POX_MAINNET: String = + format!("{}\n{}", BOOT_CODE_POX_MAINNET_CONSTS, BOOT_CODE_POX_BODY); + static ref BOOT_CODE_POX_TESTNET: String = + format!("{}\n{}", BOOT_CODE_POX_TESTNET_CONSTS, BOOT_CODE_POX_BODY); + static ref FIRST_INDEX_BLOCK_HASH: StacksBlockId = StacksBlockHeader::make_index_block_hash( + &FIRST_BURNCHAIN_CONSENSUS_HASH, + &FIRST_STACKS_BLOCK_HASH + ); + static ref POX_CONTRACT: QualifiedContractIdentifier = + QualifiedContractIdentifier::parse(&format!("{}.pox", STACKS_BOOT_CODE_CONTRACT_ADDRESS)) + .unwrap(); + static ref USER_KEYS: Vec = + (0..50).map(|_| StacksPrivateKey::new()).collect(); + static ref POX_ADDRS: Vec = (0..50u64) + .map(|ix| execute(&format!( + "{{ version: 0x00, hashbytes: 0x000000000000000000000000{} }}", + &to_hex(&ix.to_le_bytes()) + ))) + .collect(); + static ref LIQUID_SUPPLY: u128 = USTX_PER_HOLDER * (POX_ADDRS.len() as u128); + static ref MIN_THRESHOLD: u128 = *LIQUID_SUPPLY / 480; +} + +impl From<&StacksPrivateKey> for StandardPrincipalData { + fn from(o: &StacksPrivateKey) -> StandardPrincipalData { + let stacks_addr = StacksAddress::from_public_keys( + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + &AddressHashMode::SerializeP2PKH, + 1, + &vec![StacksPublicKey::from_private(o)], + ) + .unwrap(); + StandardPrincipalData::from(stacks_addr) + } +} + +impl From<&StacksPrivateKey> for Value { + fn from(o: &StacksPrivateKey) -> Value { + Value::from(StandardPrincipalData::from(o)) + } +} + +struct ClarityTestSim { + marf: MarfedKV, + height: u64, +} + +struct TestSimHeadersDB { + height: u64, +} + +impl ClarityTestSim { + pub fn new() -> ClarityTestSim { + let mut marf = MarfedKV::temporary(); + marf.begin( + &StacksBlockId::sentinel(), + &StacksBlockId(test_sim_height_to_hash(0)), + ); + { + marf.as_clarity_db(&NULL_HEADER_DB, &NULL_BURN_STATE_DB) + .initialize(); + + let mut owned_env = + OwnedEnvironment::new(marf.as_clarity_db(&NULL_HEADER_DB, &NULL_BURN_STATE_DB)); + + for user_key in USER_KEYS.iter() { + owned_env.stx_faucet( + &StandardPrincipalData::from(user_key).into(), + USTX_PER_HOLDER, + ); + } + } + marf.test_commit(); + + ClarityTestSim { marf, height: 0 } + } + + pub fn execute_next_block(&mut self, f: F) -> R + where + F: FnOnce(&mut OwnedEnvironment) -> R, + { + self.marf.begin( + &StacksBlockId(test_sim_height_to_hash(self.height)), + &StacksBlockId(test_sim_height_to_hash(self.height + 1)), + ); + + let r = { + let headers_db = TestSimHeadersDB { + height: self.height + 1, + }; + let mut owned_env = + OwnedEnvironment::new(self.marf.as_clarity_db(&headers_db, &NULL_BURN_STATE_DB)); + f(&mut owned_env) + }; + + self.marf.test_commit(); + self.height += 1; + + r + } +} + +fn test_sim_height_to_hash(burn_height: u64) -> [u8; 32] { + let mut out = [0; 32]; + out[0..8].copy_from_slice(&burn_height.to_le_bytes()); + out +} + +fn test_sim_hash_to_height(in_bytes: &[u8; 32]) -> Option { + if &in_bytes[8..] != &[0; 24] { + None + } else { + let mut bytes = [0; 8]; + bytes.copy_from_slice(&in_bytes[0..8]); + Some(u64::from_le_bytes(bytes)) + } +} + +impl HeadersDB for TestSimHeadersDB { + fn get_burn_header_hash_for_block( + &self, + id_bhh: &StacksBlockId, + ) -> Option { + if *id_bhh == *FIRST_INDEX_BLOCK_HASH { + Some(FIRST_BURNCHAIN_BLOCK_HASH) + } else { + self.get_burn_block_height_for_block(id_bhh)?; + Some(BurnchainHeaderHash(id_bhh.0.clone())) + } + } + + fn get_vrf_seed_for_block(&self, _bhh: &StacksBlockId) -> Option { + None + } + + fn get_stacks_block_header_hash_for_block( + &self, + id_bhh: &StacksBlockId, + ) -> Option { + if *id_bhh == *FIRST_INDEX_BLOCK_HASH { + Some(FIRST_STACKS_BLOCK_HASH) + } else { + self.get_burn_block_height_for_block(id_bhh)?; + Some(BlockHeaderHash(id_bhh.0.clone())) + } + } + + fn get_burn_block_time_for_block(&self, id_bhh: &StacksBlockId) -> Option { + if *id_bhh == *FIRST_INDEX_BLOCK_HASH { + Some(FIRST_BURNCHAIN_BLOCK_TIMESTAMP) + } else { + let burn_block_height = self.get_burn_block_height_for_block(id_bhh)? as u64; + Some( + FIRST_BURNCHAIN_BLOCK_TIMESTAMP + burn_block_height + - FIRST_BURNCHAIN_BLOCK_HEIGHT as u64, + ) + } + } + fn get_burn_block_height_for_block(&self, id_bhh: &StacksBlockId) -> Option { + if *id_bhh == *FIRST_INDEX_BLOCK_HASH { + Some(FIRST_BURNCHAIN_BLOCK_HEIGHT) + } else { + let input_height = test_sim_hash_to_height(&id_bhh.0)?; + if input_height > self.height { + eprintln!("{} > {}", input_height, self.height); + None + } else { + Some( + (FIRST_BURNCHAIN_BLOCK_HEIGHT as u64 + input_height) + .try_into() + .unwrap(), + ) + } + } + } + fn get_miner_address(&self, _id_bhh: &StacksBlockId) -> Option { + None + } + fn get_total_liquid_ustx(&self, _id_bhh: &StacksBlockId) -> u128 { + *LIQUID_SUPPLY + } +} + +#[test] +fn recency_tests() { + let mut sim = ClarityTestSim::new(); + let delegator = StacksPrivateKey::new(); + + sim.execute_next_block(|env| { + env.initialize_contract(POX_CONTRACT.clone(), &BOOT_CODE_POX_TESTNET) + .unwrap() + }); + sim.execute_next_block(|env| { + // try to issue a far future stacking tx + assert_eq!( + env.execute_transaction( + (&USER_KEYS[0]).into(), + POX_CONTRACT.clone(), + "stack-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + POX_ADDRS[0].clone(), + Value::UInt(3000), + Value::UInt(3), + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 24)".to_string() + ); + // let's delegate, and check if the delegate can issue a far future + // stacking tx + assert_eq!( + env.execute_transaction( + (&USER_KEYS[0]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(2 * USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[0]).into(), + Value::UInt(USTX_PER_HOLDER), + POX_ADDRS[1].clone(), + Value::UInt(3000), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 24)".to_string() + ); + }); +} + +#[test] +fn delegation_tests() { + let mut sim = ClarityTestSim::new(); + let delegator = StacksPrivateKey::new(); + + sim.execute_next_block(|env| { + env.initialize_contract(POX_CONTRACT.clone(), &BOOT_CODE_POX_TESTNET) + .unwrap() + }); + sim.execute_next_block(|env| { + assert_eq!( + env.execute_transaction( + (&USER_KEYS[0]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(2 * USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + + // already delegating... + assert_eq!( + env.execute_transaction( + (&USER_KEYS[0]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::error(Value::Int(20)).unwrap() + ); + + assert_eq!( + env.execute_transaction( + (&USER_KEYS[1]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::some(POX_ADDRS[0].clone()).unwrap() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + assert_eq!( + env.execute_transaction( + (&USER_KEYS[2]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + (&delegator).into(), + Value::some(Value::UInt(300)).unwrap(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + + assert_eq!( + env.execute_transaction( + (&USER_KEYS[3]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + + assert_eq!( + env.execute_transaction( + (&USER_KEYS[4]).into(), + POX_CONTRACT.clone(), + "delegate-stx", + &symbols_from_values(vec![ + Value::UInt(USTX_PER_HOLDER), + (&delegator).into(), + Value::none(), + Value::none() + ]) + ) + .unwrap() + .0, + Value::okay_true() + ); + }); + // let's do some delegated stacking! + sim.execute_next_block(|env| { + // try to stack more than [0]'s delegated amount! + let burn_height = env.eval_raw("burn-block-height").unwrap().0; + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[0]).into(), + Value::UInt(3 * USTX_PER_HOLDER), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 22)".to_string() + ); + + // try to stack more than [0] has! + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[0]).into(), + Value::UInt(2 * USTX_PER_HOLDER), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 1)".to_string() + ); + + // let's stack less than the threshold + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[0]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0, + execute(&format!( + "(ok {{ stacker: '{}, lock-amount: {}, unlock-burn-height: {} }})", + Value::from(&USER_KEYS[0]), + Value::UInt(*MIN_THRESHOLD - 1), + Value::UInt(360) + )) + ); + + assert_eq!( + env.eval_read_only( + &POX_CONTRACT, + &format!("(stx-get-balance '{})", &Value::from(&USER_KEYS[0])) + ) + .unwrap() + .0, + Value::UInt(USTX_PER_HOLDER - *MIN_THRESHOLD + 1) + ); + + // try to commit our partial stacking... + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "stack-aggregation-commit", + &symbols_from_values(vec![POX_ADDRS[1].clone(), Value::UInt(1)]) + ) + .unwrap() + .0 + .to_string(), + "(err 11)".to_string() + ); + // not enough! we need to stack more... + // but POX_ADDR[1] cannot be used for USER_KEYS[1]... + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[1]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 23)".to_string() + ); + + // And USER_KEYS[0] is already stacking... + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[0]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 3)".to_string() + ); + + // USER_KEYS[2] won't want to stack past the delegation expiration... + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[2]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 21)".to_string() + ); + + // but for just one block will be fine + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[2]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(1) + ]) + ) + .unwrap() + .0, + execute(&format!( + "(ok {{ stacker: '{}, lock-amount: {}, unlock-burn-height: {} }})", + Value::from(&USER_KEYS[2]), + Value::UInt(*MIN_THRESHOLD - 1), + Value::UInt(240) + )) + ); + + assert_eq!( + env.eval_read_only( + &POX_CONTRACT, + &format!("(stx-get-balance '{})", &Value::from(&USER_KEYS[2])) + ) + .unwrap() + .0, + Value::UInt(USTX_PER_HOLDER - *MIN_THRESHOLD + 1) + ); + + assert_eq!( + env.eval_read_only( + &POX_CONTRACT, + &format!("(stx-get-balance '{})", &Value::from(&USER_KEYS[0])) + ) + .unwrap() + .0, + Value::UInt(USTX_PER_HOLDER - *MIN_THRESHOLD + 1) + ); + + assert_eq!( + env.eval_read_only( + &POX_CONTRACT, + &format!("(stx-get-balance '{})", &Value::from(&USER_KEYS[1])) + ) + .unwrap() + .0, + Value::UInt(USTX_PER_HOLDER) + ); + + // try to commit our partial stacking again! + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "stack-aggregation-commit", + &symbols_from_values(vec![POX_ADDRS[1].clone(), Value::UInt(1)]) + ) + .unwrap() + .0 + .to_string(), + "(ok true)".to_string() + ); + + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-size u1)") + .unwrap() + .0 + .to_string(), + "u1" + ); + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-pox-address u1 u0)") + .unwrap() + .0, + execute(&format!( + "(some {{ pox-addr: {}, total-ustx: {} }})", + &POX_ADDRS[1], + &Value::UInt(2 * (*MIN_THRESHOLD - 1)) + )) + ); + + // can we double commit? I don't think so! + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "stack-aggregation-commit", + &symbols_from_values(vec![POX_ADDRS[1].clone(), Value::UInt(1)]) + ) + .unwrap() + .0 + .to_string(), + "(err 4)".to_string() + ); + + // okay, let's try some more delegation situations... + // 1. we already locked user[0] up for round 2, so let's add some more stacks for round 2 from + // user[3]. in the process, this will add more stacks for lockup in round 1, so lets commit + // that as well. + + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[3]).into(), + Value::UInt(*MIN_THRESHOLD), + POX_ADDRS[1].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0, + execute(&format!( + "(ok {{ stacker: '{}, lock-amount: {}, unlock-burn-height: {} }})", + Value::from(&USER_KEYS[3]), + Value::UInt(*MIN_THRESHOLD), + Value::UInt(360) + )) + ); + + assert_eq!( + env.eval_read_only( + &POX_CONTRACT, + &format!("(stx-get-balance '{})", &Value::from(&USER_KEYS[3])) + ) + .unwrap() + .0, + Value::UInt(USTX_PER_HOLDER - *MIN_THRESHOLD) + ); + + // let's commit to round 2 now. + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "stack-aggregation-commit", + &symbols_from_values(vec![POX_ADDRS[1].clone(), Value::UInt(2)]) + ) + .unwrap() + .0 + .to_string(), + "(ok true)".to_string() + ); + + // and we can commit to round 1 again as well! + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "stack-aggregation-commit", + &symbols_from_values(vec![POX_ADDRS[1].clone(), Value::UInt(1)]) + ) + .unwrap() + .0 + .to_string(), + "(ok true)".to_string() + ); + + // check reward sets for round 2 and round 1... + + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-size u2)") + .unwrap() + .0 + .to_string(), + "u1" + ); + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-pox-address u2 u0)") + .unwrap() + .0, + execute(&format!( + "(some {{ pox-addr: {}, total-ustx: {} }})", + &POX_ADDRS[1], + &Value::UInt(2 * (*MIN_THRESHOLD) - 1) + )) + ); + + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-size u1)") + .unwrap() + .0 + .to_string(), + "u2" + ); + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-pox-address u1 u0)") + .unwrap() + .0, + execute(&format!( + "(some {{ pox-addr: {}, total-ustx: {} }})", + &POX_ADDRS[1], + &Value::UInt(2 * (*MIN_THRESHOLD - 1)) + )) + ); + assert_eq!( + env.eval_read_only(&POX_CONTRACT, "(get-reward-set-pox-address u1 u1)") + .unwrap() + .0, + execute(&format!( + "(some {{ pox-addr: {}, total-ustx: {} }})", + &POX_ADDRS[1], + &Value::UInt(*MIN_THRESHOLD) + )) + ); + + // 2. lets make sure we can lock up for user[1] so long as it goes to pox[0]. + + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[1]).into(), + Value::UInt(*MIN_THRESHOLD), + POX_ADDRS[0].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0, + execute(&format!( + "(ok {{ stacker: '{}, lock-amount: {}, unlock-burn-height: {} }})", + Value::from(&USER_KEYS[1]), + Value::UInt(*MIN_THRESHOLD), + Value::UInt(360) + )) + ); + + // 3. lets try to lock up user[4], but do some revocation first. + assert_eq!( + env.execute_transaction( + (&USER_KEYS[4]).into(), + POX_CONTRACT.clone(), + "revoke-delegate-stx", + &[] + ) + .unwrap() + .0, + Value::okay_true() + ); + + // will run a second time, but return false + assert_eq!( + env.execute_transaction( + (&USER_KEYS[4]).into(), + POX_CONTRACT.clone(), + "revoke-delegate-stx", + &[] + ) + .unwrap() + .0 + .to_string(), + "(ok false)".to_string() + ); + + assert_eq!( + env.execute_transaction( + (&delegator).into(), + POX_CONTRACT.clone(), + "delegate-stack-stx", + &symbols_from_values(vec![ + (&USER_KEYS[4]).into(), + Value::UInt(*MIN_THRESHOLD - 1), + POX_ADDRS[0].clone(), + burn_height.clone(), + Value::UInt(2) + ]) + ) + .unwrap() + .0 + .to_string(), + "(err 9)".to_string() + ); + }); +} diff --git a/src/chainstate/stacks/boot/mod.rs b/src/chainstate/stacks/boot/mod.rs index 817e5ad26c7..da4873e2560 100644 --- a/src/chainstate/stacks/boot/mod.rs +++ b/src/chainstate/stacks/boot/mod.rs @@ -21,9 +21,10 @@ use chainstate::stacks::StacksBlockHeader; use address::AddressHashMode; use burnchains::bitcoin::address::BitcoinAddress; -use burnchains::Address; +use burnchains::{Address, PoxConstants}; use chainstate::burn::db::sortdb::SortitionDB; +use core::{POX_MAXIMAL_SCALING, POX_THRESHOLD_STEPS_USTX}; use vm::types::{ PrincipalData, QualifiedContractIdentifier, SequenceData, StandardPrincipalData, TupleData, @@ -39,6 +40,7 @@ use vm::representations::ContractName; use util::hash::Hash160; use std::boxed::Box; +use std::cmp; use std::convert::TryFrom; use std::convert::TryInto; @@ -180,6 +182,74 @@ impl StacksChainState { .map(|value| value.expect_bool()) } + /// Given a threshold and set of registered addresses, return a reward set where + /// every entry address has stacked more than the threshold, and addresses + /// 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<(StacksAddress, u128)>, + ) -> Vec { + let mut reward_set = vec![]; + // the way that we sum addresses relies on sorting. + addresses.sort_by_key(|k| k.0.bytes.0); + 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 + .pop() + .expect("BUG: first() returned some, but pop() is none."); + stacked_amt = stacked_amt + .checked_add(additional_amt) + .expect("CORRUPTION: Stacker stacked > u128 max amount"); + } + let slots_taken = u32::try_from(stacked_amt / threshold) + .expect("CORRUPTION: Stacker claimed > u32::max() reward slots"); + info!( + "Slots taken by {} = {}, on stacked_amt = {}", + &address, slots_taken, stacked_amt + ); + for _i in 0..slots_taken { + reward_set.push(address.clone()); + } + } + reward_set + } + + pub fn get_reward_threshold_and_participation( + pox_settings: &PoxConstants, + addresses: &[(StacksAddress, u128)], + liquid_ustx: u128, + ) -> (u128, u128) { + let participation = addresses + .iter() + .fold(0, |agg, (_, stacked_amt)| agg + stacked_amt); + + assert!( + participation <= liquid_ustx, + "CORRUPTION: More stacking participation than liquid STX" + ); + + // set the lower limit on reward scaling at 25% of liquid_ustx + // (i.e., liquid_ustx / POX_MAXIMAL_SCALING) + let scale_by = cmp::max(participation, liquid_ustx / POX_MAXIMAL_SCALING as u128); + + let reward_slots = pox_settings.reward_slots() as u128; + let threshold_precise = scale_by / reward_slots; + // compute the threshold as nearest 10k > threshold_precise + let ceil_amount = match threshold_precise % POX_THRESHOLD_STEPS_USTX { + 0 => 0, + remainder => POX_THRESHOLD_STEPS_USTX - remainder, + }; + let threshold = threshold_precise + ceil_amount; + info!( + "PoX participation threshold is {}, from {}", + threshold, threshold_precise + ); + (threshold, participation) + } + /// Each address will have at least (get-stacking-minimum) tokens. pub fn get_reward_addresses( &mut self, @@ -252,12 +322,13 @@ impl StacksChainState { ret.push((StacksAddress::new(version, hash), total_ustx)); } - ret.sort_by_key(|k| k.0.bytes.0); - Ok(ret) } } +#[cfg(test)] +mod contract_tests; + #[cfg(test)] pub mod test { use chainstate::burn::db::sortdb::*; @@ -279,6 +350,7 @@ pub mod test { use util::*; + use core::*; use vm::contracts::Contract; use vm::types::*; @@ -287,6 +359,123 @@ pub mod test { use util::hash::to_hex; + #[test] + fn make_reward_set_units() { + let threshold = 1_000; + let addresses = vec![ + ( + StacksAddress::from_string("STVK1K405H6SK9NKJAP32GHYHDJ98MMNP8Y6Z9N0").unwrap(), + 1500, + ), + ( + StacksAddress::from_string("ST76D2FMXZ7D2719PNE4N71KPSX84XCCNCMYC940").unwrap(), + 500, + ), + ( + StacksAddress::from_string("STVK1K405H6SK9NKJAP32GHYHDJ98MMNP8Y6Z9N0").unwrap(), + 1500, + ), + ( + StacksAddress::from_string("ST76D2FMXZ7D2719PNE4N71KPSX84XCCNCMYC940").unwrap(), + 400, + ), + ]; + assert_eq!( + StacksChainState::make_reward_set(threshold, addresses).len(), + 3 + ); + } + + #[test] + fn get_reward_threshold_units() { + let test_pox_constants = PoxConstants::new(1000, 1, 1, 1, 5); + // when the liquid amount = the threshold step, + // the threshold should always be the step size. + let liquid = POX_THRESHOLD_STEPS_USTX; + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[], + liquid + ) + .0, + POX_THRESHOLD_STEPS_USTX + ); + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[(rand_addr(), liquid)], + liquid + ) + .0, + POX_THRESHOLD_STEPS_USTX + ); + + let liquid = 200_000_000 * MICROSTACKS_PER_STACKS as u128; + // with zero participation, should scale to 25% of liquid + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[], + liquid + ) + .0, + 50_000 * MICROSTACKS_PER_STACKS as u128 + ); + // should be the same at 25% participation + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[(rand_addr(), liquid / 4)], + liquid + ) + .0, + 50_000 * MICROSTACKS_PER_STACKS as u128 + ); + // but not at 30% participation + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[ + (rand_addr(), liquid / 4), + (rand_addr(), 10_000_000 * (MICROSTACKS_PER_STACKS as u128)) + ], + liquid + ) + .0, + 60_000 * MICROSTACKS_PER_STACKS as u128 + ); + + // bump by just a little bit, should go to the next threshold step + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[ + (rand_addr(), liquid / 4), + (rand_addr(), (MICROSTACKS_PER_STACKS as u128)) + ], + liquid + ) + .0, + 60_000 * MICROSTACKS_PER_STACKS as u128 + ); + + // bump by just a little bit, should go to the next threshold step + assert_eq!( + StacksChainState::get_reward_threshold_and_participation( + &test_pox_constants, + &[(rand_addr(), liquid)], + liquid + ) + .0, + 200_000 * MICROSTACKS_PER_STACKS as u128 + ); + } + + fn rand_addr() -> StacksAddress { + key_to_stacks_addr(&StacksPrivateKey::new()) + } + fn key_to_stacks_addr(key: &StacksPrivateKey) -> StacksAddress { StacksAddress::from_public_keys( C32_ADDRESS_VERSION_TESTNET_SINGLESIG, @@ -506,38 +695,56 @@ pub mod test { addr_version: AddressHashMode, addr_bytes: Hash160, lock_period: u128, + burn_ht: u64, ) -> StacksTransaction { // (define-public (stack-stx (amount-ustx uint) // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) // (lock-period uint)) + make_pox_contract_call( + key, + nonce, + "stack-stx", + vec![ + Value::UInt(amount), + make_pox_addr(addr_version, addr_bytes), + Value::UInt(burn_ht as u128), + Value::UInt(lock_period), + ], + ) + } + fn make_tx( + key: &StacksPrivateKey, + nonce: u64, + fee_rate: u64, + payload: TransactionPayload, + ) -> StacksTransaction { let auth = TransactionAuth::from_p2pkh(key).unwrap(); let addr = auth.origin().address_testnet(); - let mut pox_lockup = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_contract_call( - boot_code_addr(), - "pox", - "stack-stx", - vec![ - Value::UInt(amount), - make_pox_addr(addr_version, addr_bytes), - Value::UInt(lock_period), - ], - ) - .unwrap(), - ); - pox_lockup.chain_id = 0x80000000; - pox_lockup.auth.set_origin_nonce(nonce); - pox_lockup.set_post_condition_mode(TransactionPostConditionMode::Allow); - pox_lockup.set_fee_rate(0); + let mut tx = StacksTransaction::new(TransactionVersion::Testnet, auth, payload); + tx.chain_id = 0x80000000; + tx.auth.set_origin_nonce(nonce); + tx.set_post_condition_mode(TransactionPostConditionMode::Allow); + tx.set_fee_rate(fee_rate); - let mut tx_signer = StacksTransactionSigner::new(&pox_lockup); + let mut tx_signer = StacksTransactionSigner::new(&tx); tx_signer.sign_origin(key).unwrap(); tx_signer.get_tx().unwrap() } + fn make_pox_contract_call( + key: &StacksPrivateKey, + nonce: u64, + function_name: &str, + args: Vec, + ) -> StacksTransaction { + let payload = + TransactionPayload::new_contract_call(boot_code_addr(), "pox", function_name, args) + .unwrap(); + + make_tx(key, nonce, 0, payload) + } + // make a stream of invalid pox-lockup transactions fn make_invalid_pox_lockups(key: &StacksPrivateKey, mut nonce: u64) -> Vec { let mut ret = vec![]; @@ -563,27 +770,12 @@ pub mod test { ); let generator = |amount, pox_addr, lock_period, nonce| { - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - let mut pox_lockup = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_contract_call( - boot_code_addr(), - "pox", - "stack-stx", - vec![Value::UInt(amount), pox_addr, Value::UInt(lock_period)], - ) - .unwrap(), - ); - pox_lockup.chain_id = 0x80000000; - pox_lockup.auth.set_origin_nonce(nonce); - pox_lockup.set_post_condition_mode(TransactionPostConditionMode::Allow); - pox_lockup.set_fee_rate(0); - - let mut tx_signer = StacksTransactionSigner::new(&pox_lockup); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + make_pox_contract_call( + key, + nonce, + "stack-stx", + vec![Value::UInt(amount), pox_addr, Value::UInt(lock_period)], + ) }; let bad_pox_addr_tx = generator(amount, bad_pox_addr_version, lock_period, nonce); @@ -626,21 +818,8 @@ pub mod test { name: &str, code: &str, ) -> StacksTransaction { - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - let mut bare_code = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_smart_contract(&name.to_string(), &code.to_string()).unwrap(), - ); - bare_code.chain_id = 0x80000000; - bare_code.auth.set_origin_nonce(nonce); - bare_code.set_post_condition_mode(TransactionPostConditionMode::Allow); - bare_code.set_fee_rate(fee_rate); - - let mut tx_signer = StacksTransactionSigner::new(&bare_code); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + let payload = TransactionPayload::new_smart_contract(name, code).unwrap(); + make_tx(key, nonce, fee_rate, payload) } fn make_token_transfer( @@ -650,23 +829,8 @@ pub mod test { dest: PrincipalData, amount: u64, ) -> StacksTransaction { - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - - let mut txn = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::TokenTransfer(dest, amount, TokenTransferMemo([0u8; 34])), - ); - - txn.chain_id = 0x80000000; - txn.auth.set_origin_nonce(nonce); - txn.set_post_condition_mode(TransactionPostConditionMode::Allow); - txn.set_fee_rate(fee_rate); - - let mut tx_signer = StacksTransactionSigner::new(&txn); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + let payload = TransactionPayload::TokenTransfer(dest, amount, TokenTransferMemo([0u8; 34])); + make_tx(key, nonce, fee_rate, payload) } fn make_pox_lockup_contract( @@ -685,7 +849,7 @@ pub mod test { ;; this contract stacks the stx given to it (as-contract - (contract-call? '{}.pox stack-stx amount-ustx pox-addr lock-period)) + (contract-call? '{}.pox stack-stx amount-ustx pox-addr burn-block-height lock-period)) )) ) @@ -716,31 +880,18 @@ pub mod test { addr_bytes: Hash160, lock_period: u128, ) -> StacksTransaction { - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - let mut pox_lockup = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_contract_call( - contract_addr.clone(), - name, - "do-contract-lockup", - vec![ - Value::UInt(amount), - make_pox_addr(addr_version, addr_bytes), - Value::UInt(lock_period), - ], - ) - .unwrap(), - ); - pox_lockup.chain_id = 0x80000000; - pox_lockup.auth.set_origin_nonce(nonce); - pox_lockup.set_post_condition_mode(TransactionPostConditionMode::Allow); - pox_lockup.set_fee_rate(0); - - let mut tx_signer = StacksTransactionSigner::new(&pox_lockup); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + let payload = TransactionPayload::new_contract_call( + contract_addr.clone(), + name, + "do-contract-lockup", + vec![ + Value::UInt(amount), + make_pox_addr(addr_version, addr_bytes), + Value::UInt(lock_period), + ], + ) + .unwrap(); + make_tx(key, nonce, 0, payload) } // call after make_pox_lockup_contract gets mined @@ -751,49 +902,19 @@ pub mod test { name: &str, amount: u128, ) -> StacksTransaction { - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - let mut pox_lockup = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_contract_call( - contract_addr.clone(), - name, - "withdraw-stx", - vec![Value::UInt(amount)], - ) - .unwrap(), - ); - pox_lockup.chain_id = 0x80000000; - pox_lockup.auth.set_origin_nonce(nonce); - pox_lockup.set_post_condition_mode(TransactionPostConditionMode::Allow); - pox_lockup.set_fee_rate(0); - - let mut tx_signer = StacksTransactionSigner::new(&pox_lockup); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + let payload = TransactionPayload::new_contract_call( + contract_addr.clone(), + name, + "withdraw-stx", + vec![Value::UInt(amount)], + ) + .unwrap(); + make_tx(key, nonce, 0, payload) } fn make_pox_reject(key: &StacksPrivateKey, nonce: u64) -> StacksTransaction { // (define-public (reject-pox)) - let auth = TransactionAuth::from_p2pkh(key).unwrap(); - let addr = auth.origin().address_testnet(); - - let mut tx = StacksTransaction::new( - TransactionVersion::Testnet, - auth, - TransactionPayload::new_contract_call(boot_code_addr(), "pox", "reject-pox", vec![]) - .unwrap(), - ); - - tx.chain_id = 0x80000000; - tx.auth.set_origin_nonce(nonce); - tx.set_post_condition_mode(TransactionPostConditionMode::Allow); - tx.set_fee_rate(0); - - let mut tx_signer = StacksTransactionSigner::new(&tx); - tx_signer.sign_origin(key).unwrap(); - tx_signer.get_tx().unwrap() + make_pox_contract_call(key, nonce, "reject-pox", vec![]) } fn get_reward_addresses_with_par_tip( @@ -803,7 +924,12 @@ pub mod test { block_id: &StacksBlockId, ) -> Result, Error> { let burn_block_height = get_par_burn_block_height(state, block_id); - state.get_reward_addresses(burnchain, sortdb, burn_block_height, block_id) + state + .get_reward_addresses(burnchain, sortdb, burn_block_height, block_id) + .and_then(|mut addrs| { + addrs.sort_by_key(|k| k.0.bytes.0); + Ok(addrs) + }) } fn get_parent_tip( @@ -928,35 +1054,43 @@ pub mod test { ]; if tenure_id == 1 { - let alice_lockup_1 = make_pox_lockup(&alice, 0, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1); + let alice_lockup_1 = make_pox_lockup(&alice, 0, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1, tip.block_height); block_txs.push(alice_lockup_1); } if tenure_id == 2 { let alice_test_tx = make_bare_contract(&alice, 1, 0, "nested-stacker", &format!( "(define-public (nested-stack-stx) - (contract-call? '{}.pox stack-stx u512000000 (tuple (version 0x00) (hashbytes 0xffffffffffffffffffffffffffffffffffffffff)) u1))", STACKS_BOOT_CODE_CONTRACT_ADDRESS)); + (contract-call? '{}.pox stack-stx u512000000 (tuple (version 0x00) (hashbytes 0xffffffffffffffffffffffffffffffffffffffff)) burn-block-height u1))", STACKS_BOOT_CODE_CONTRACT_ADDRESS)); block_txs.push(alice_test_tx); } if tenure_id == 8 { // alice locks 512_000_000 STX through her contract - let auth = TransactionAuth::from_p2pkh(&alice).unwrap(); - let addr = auth.origin().address_testnet(); - let mut contract_call = StacksTransaction::new(TransactionVersion::Testnet, auth, - TransactionPayload::new_contract_call(key_to_stacks_addr(&alice), - "nested-stacker", - "nested-stack-stx", - vec![]).unwrap()); - contract_call.chain_id = 0x80000000; - contract_call.auth.set_origin_nonce(2); - contract_call.set_post_condition_mode(TransactionPostConditionMode::Allow); - contract_call.set_fee_rate(0); - - let mut tx_signer = StacksTransactionSigner::new(&contract_call); - tx_signer.sign_origin(&alice).unwrap(); - let tx = tx_signer.get_tx().unwrap(); + let cc_payload = TransactionPayload::new_contract_call(key_to_stacks_addr(&alice), + "nested-stacker", + "nested-stack-stx", + vec![]).unwrap(); + let tx = make_tx(&alice, 2, 0, cc_payload.clone()); block_txs.push(tx); + + // the above tx _should_ error, because alice hasn't authorized that contract to stack + // try again with auth -> deauth -> auth + let alice_contract: Value = contract_id(&key_to_stacks_addr(&alice), "nested-stacker").into(); + + let alice_allowance = make_pox_contract_call(&alice, 3, "allow-contract-caller", vec![alice_contract.clone(), Value::none()]); + let alice_disallowance = make_pox_contract_call(&alice, 4, "disallow-contract-caller", vec![alice_contract.clone()]); + block_txs.push(alice_allowance); + block_txs.push(alice_disallowance); + + let tx = make_tx(&alice, 5, 0, cc_payload.clone()); + block_txs.push(tx); + + let alice_allowance = make_pox_contract_call(&alice, 6, "allow-contract-caller", vec![alice_contract.clone(), Value::none()]); + let tx = make_tx(&alice, 7, 0, cc_payload.clone()); // should be allowed! + block_txs.push(alice_allowance); + block_txs.push(tx); + } let block_builder = StacksBlockBuilder::make_block_builder(&parent_tip, vrf_proof, tip.total_burn, microblock_pubkeyhash).unwrap(); @@ -1139,6 +1273,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12, + tip.block_height, ); block_txs.push(alice_lockup); } @@ -1186,7 +1321,7 @@ pub mod test { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses let reward_addrs = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { @@ -1252,7 +1387,7 @@ pub mod test { // miner rewards increased liquid supply, so less than 25% is locked. // minimum participation decreases. assert!(total_liquid_ustx > 4 * 1024 * 1000000); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } else { // still at 25% or more locked assert!(total_liquid_ustx <= 4 * 1024 * 1000000); @@ -1379,12 +1514,11 @@ pub mod test { let alice_balance = get_balance(&mut peer, &key_to_stacks_addr(&alice).into()); assert_eq!(alice_balance, 1024 * 1000000); } - // stacking minimum should be floor(total-liquid-ustx / 20000) let min_ustx = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses let reward_addrs = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { @@ -1452,7 +1586,7 @@ pub mod test { // miner rewards increased liquid supply, so less than 25% is locked. // minimum participation decreases. assert!(total_liquid_ustx > 4 * 1024 * 1000000); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } else { // still at 25% or more locked assert!(total_liquid_ustx <= 4 * 1024 * 1000000); @@ -1590,6 +1724,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12, + tip.block_height, ); block_txs.push(alice_lockup); @@ -1601,6 +1736,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&bob).bytes, 12, + tip.block_height, ); block_txs.push(bob_lockup); } @@ -1644,12 +1780,11 @@ pub mod test { assert_eq!(bob_balance, 1024 * 1000000); } - // stacking minimum should be floor(total-liquid-ustx / 20000) let min_ustx = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses let reward_addrs = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { @@ -1723,7 +1858,7 @@ pub mod test { } // well over 25% locked, so this is always true - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // two reward addresses, and they're Alice's and Bob's. // They are present in sorted order @@ -1785,61 +1920,66 @@ pub mod test { if tenure_id == 1 { // Alice locks up exactly 12.5% of the liquid STX supply, twice. // Only the first one succeeds. - let alice_lockup_1 = make_pox_lockup(&alice, 0, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12); + let alice_lockup_1 = make_pox_lockup(&alice, 0, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12, tip.block_height); block_txs.push(alice_lockup_1); // will be rejected - let alice_lockup_2 = make_pox_lockup(&alice, 1, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12); + let alice_lockup_2 = make_pox_lockup(&alice, 1, 512 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12, tip.block_height); block_txs.push(alice_lockup_2); + + // let's make some allowances for contract-calls through smart contracts + // so that the tests in tenure_id == 3 don't just fail on permission checks + let alice_test = contract_id(&key_to_stacks_addr(&alice), "alice-test").into(); + let alice_allowance = make_pox_contract_call(&alice, 2, "allow-contract-caller", vec![alice_test, Value::none()]); + + let bob_test = contract_id(&key_to_stacks_addr(&bob), "bob-test").into(); + let bob_allowance = make_pox_contract_call(&bob, 0, "allow-contract-caller", vec![bob_test, Value::none()]); + + let charlie_test = contract_id(&key_to_stacks_addr(&charlie), "charlie-test").into(); + let charlie_allowance = make_pox_contract_call(&charlie, 0, "allow-contract-caller", vec![charlie_test, Value::none()]); + + block_txs.push(alice_allowance); + block_txs.push(bob_allowance); + block_txs.push(charlie_allowance); } if tenure_id == 2 { - // should fail -- Alice's PoX address is already in use, so Bob can't use it. - let bob_test_tx = make_bare_contract(&bob, 0, 0, "bob-test", &format!( - "(define-data-var bob-test-run bool false) - (let ( - (res - (contract-call? '{}.pox stack-stx u256000000 (tuple (version 0x00) (hashbytes 0xae1593226f85e49a7eaff5b633ff687695438cc9)) u12)) - ) - (begin - (asserts! (is-eq (err 12) res) - (err res)) - - (var-set bob-test-run true) - )) + // should pass -- there's no problem with Bob adding more stacking power to Alice's PoX address + let bob_test_tx = make_bare_contract(&bob, 1, 0, "bob-test", &format!( + "(define-data-var test-run bool false) + (define-data-var test-result int -1) + (let ((result + (contract-call? '{}.pox stack-stx u256000000 (tuple (version 0x00) (hashbytes 0xae1593226f85e49a7eaff5b633ff687695438cc9)) burn-block-height u12))) + (var-set test-result + (match result ok_value -1 err_value err_value)) + (var-set test-run true)) ", STACKS_BOOT_CODE_CONTRACT_ADDRESS)); block_txs.push(bob_test_tx); // should fail -- Alice has already stacked. - let alice_test_tx = make_bare_contract(&alice, 2, 0, "alice-test", &format!( - "(define-data-var alice-test-run bool false) - (let ( - (res - (contract-call? '{}.pox stack-stx u512000000 (tuple (version 0x00) (hashbytes 0xffffffffffffffffffffffffffffffffffffffff)) u12)) - ) - (begin - (asserts! (is-eq (err 3) res) - (err res)) - - (var-set alice-test-run true) - )) + // expect err 3 + let alice_test_tx = make_bare_contract(&alice, 3, 0, "alice-test", &format!( + "(define-data-var test-run bool false) + (define-data-var test-result int -1) + (let ((result + (contract-call? '{}.pox stack-stx u512000000 (tuple (version 0x00) (hashbytes 0xffffffffffffffffffffffffffffffffffffffff)) burn-block-height u12))) + (var-set test-result + (match result ok_value -1 err_value err_value)) + (var-set test-run true)) ", STACKS_BOOT_CODE_CONTRACT_ADDRESS)); block_txs.push(alice_test_tx); // should fail -- Charlie doesn't have enough uSTX - let charlie_test_tx = make_bare_contract(&charlie, 0, 0, "charlie-test", &format!( - "(define-data-var charlie-test-run bool false) - (let ( - (res - (contract-call? '{}.pox stack-stx u1024000000000 (tuple (version 0x00) (hashbytes 0xfefefefefefefefefefefefefefefefefefefefe)) u12)) - ) - (begin - (asserts! (is-eq (err 1) res) - (err res)) - - (var-set charlie-test-run true) - )) + // expect err 1 + let charlie_test_tx = make_bare_contract(&charlie, 1, 0, "charlie-test", &format!( + "(define-data-var test-run bool false) + (define-data-var test-result int -1) + (let ((result + (contract-call? '{}.pox stack-stx u1024000000000 (tuple (version 0x00) (hashbytes 0xfefefefefefefefefefefefefefefefefefefefe)) burn-block-height u12))) + (var-set test-result + (match result ok_value -1 err_value err_value)) + (var-set test-run true)) ", STACKS_BOOT_CODE_CONTRACT_ADDRESS)); block_txs.push(charlie_test_tx); @@ -1906,19 +2046,42 @@ pub mod test { &mut peer, &key_to_stacks_addr(&alice), "alice-test", - "(var-get alice-test-run)", + "(var-get test-run)", + ); + let bob_test_result = eval_contract_at_tip( + &mut peer, + &key_to_stacks_addr(&bob), + "bob-test", + "(var-get test-run)", + ); + let charlie_test_result = eval_contract_at_tip( + &mut peer, + &key_to_stacks_addr(&charlie), + "charlie-test", + "(var-get test-run)", + ); + + assert!(alice_test_result.expect_bool()); + assert!(bob_test_result.expect_bool()); + assert!(charlie_test_result.expect_bool()); + + let alice_test_result = eval_contract_at_tip( + &mut peer, + &key_to_stacks_addr(&alice), + "alice-test", + "(var-get test-result)", ); let bob_test_result = eval_contract_at_tip( &mut peer, &key_to_stacks_addr(&bob), "bob-test", - "(var-get bob-test-run)", + "(var-get test-result)", ); let charlie_test_result = eval_contract_at_tip( &mut peer, &key_to_stacks_addr(&charlie), "charlie-test", - "(var-get charlie-test-run)", + "(var-get test-result)", ); eprintln!( @@ -1926,9 +2089,9 @@ pub mod test { &alice_test_result, &bob_test_result, &charlie_test_result ); - assert!(alice_test_result.expect_bool()); - assert!(bob_test_result.expect_bool()); - assert!(charlie_test_result.expect_bool()); + assert_eq!(bob_test_result, Value::Int(-1)); + assert_eq!(alice_test_result, Value::Int(3)); + assert_eq!(charlie_test_result, Value::Int(1)); } } } @@ -1979,6 +2142,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1, + tip.block_height, ); block_txs.push(alice_lockup); } @@ -2018,12 +2182,11 @@ pub mod test { assert_eq!(alice_balance, 1024 * 1000000); } - // stacking minimum should be floor(total-liquid-ustx / 20000) let min_ustx = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses let reward_addrs = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { @@ -2087,7 +2250,7 @@ pub mod test { // miner rewards increased liquid supply, so less than 25% is locked. // minimum participation decreases. assert!(total_liquid_ustx > 4 * 1024 * 1000000); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } if cur_reward_cycle == alice_reward_cycle { @@ -2138,7 +2301,7 @@ pub mod test { assert_eq!(reward_addrs.len(), 0); // min STX is reset - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // Unlock is lazy let alice_account = @@ -2214,6 +2377,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1, + tip.block_height, ); block_txs.push(alice_lockup); @@ -2241,6 +2405,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1, + tip.block_height, ); block_txs.push(alice_lockup); @@ -2319,12 +2484,11 @@ pub mod test { assert_eq!(charlie_balance, 0); } - // stacking minimum should be floor(total-liquid-ustx / 20000) let min_ustx = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses assert_eq!(reward_addrs.len(), 0); @@ -2348,13 +2512,13 @@ pub mod test { assert_eq!(charlie_balance, 1024 * 1000000); } else if tenure_id == 11 { // should have just re-locked - // stacking minimum should be floor(total-liquid-ustx / 20000), since we haven't + // stacking minimum should be minimum, since we haven't // locked up 25% of the tokens yet let min_ustx = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { chainstate.get_stacking_minimum(sortdb, &tip_index_block) }) .unwrap(); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses assert_eq!(reward_addrs.len(), 0); @@ -2377,13 +2541,13 @@ pub mod test { // miner rewards increased liquid supply, so less than 25% is locked. // minimum participation decreases. assert!(total_liquid_ustx > 4 * 1024 * 1000000); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } else if tenure_id >= 1 && cur_reward_cycle < first_reward_cycle { // still at 25% or more locked assert!(total_liquid_ustx <= 4 * 1024 * 1000000); } else if tenure_id < 1 { // nothing locked yet - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } if first_reward_cycle > 0 && second_reward_cycle == 0 { @@ -2475,7 +2639,7 @@ pub mod test { assert_eq!(reward_addrs.len(), 0); // min STX is reset - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // Unlock is lazy let alice_account = get_account(&mut peer, &key_to_stacks_addr(&alice).into()); @@ -2596,7 +2760,7 @@ pub mod test { assert_eq!(reward_addrs.len(), 0); // min STX is reset - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // Unlock is lazy let alice_account = get_account(&mut peer, &key_to_stacks_addr(&alice).into()); @@ -2684,6 +2848,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 1, + tip.block_height, ); block_txs.push(alice_lockup); @@ -2694,6 +2859,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&bob).bytes, 1, + tip.block_height, ); block_txs.push(bob_lockup); @@ -2704,6 +2870,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&charlie).bytes, 1, + tip.block_height, ); block_txs.push(charlie_lockup); @@ -2714,6 +2881,7 @@ pub mod test { AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&danielle).bytes, 1, + tip.block_height, ); block_txs.push(danielle_lockup); @@ -2922,8 +3090,7 @@ pub mod test { assert_eq!(balance, expected_balance); } } - // stacking minimum should be floor(total-liquid-ustx / 20000) - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses let reward_addrs = with_sortdb(&mut peer, |ref mut chainstate, ref sortdb| { @@ -3039,7 +3206,7 @@ pub mod test { assert_eq!(reward_addrs.len(), 0); // min STX is reset - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } } @@ -3111,25 +3278,33 @@ pub mod test { if tenure_id == 1 { // Alice locks up exactly 25% of the liquid STX supply, so this should succeed. - let alice_lockup = make_pox_lockup(&alice, 0, 1024 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12); + let alice_lockup = make_pox_lockup(&alice, 0, 1024 * 1000000, AddressHashMode::SerializeP2PKH, key_to_stacks_addr(&alice).bytes, 12, tip.block_height); block_txs.push(alice_lockup); // Bob rejects with exactly 25% of the liquid STX supply (shouldn't affect // anything). let bob_reject = make_pox_reject(&bob, 0); block_txs.push(bob_reject); - } - else if tenure_id == 2 { + } else if tenure_id == 2 { // Charlie rejects + // this _should_ be included in the block let charlie_reject = make_pox_reject(&charlie, 0); block_txs.push(charlie_reject); + // allowance for the contract-caller + // this _should_ be included in the block + let charlie_contract: Value = contract_id(&key_to_stacks_addr(&charlie), "charlie-try-stack").into(); + let charlie_allowance = make_pox_contract_call(&charlie, 1, "allow-contract-caller", + vec![charlie_contract, Value::none()]); + block_txs.push(charlie_allowance); + // Charlie tries to stack, but it should fail. // Specifically, (stack-stx) should fail with (err 17). // If it's the case, then this tx will NOT be mined. - let charlie_stack = make_bare_contract(&charlie, 1, 0, "charlie-try-stack", + // Note: this behavior is a bug in the miner and block processor: see issue #? + let charlie_stack = make_bare_contract(&charlie, 2, 0, "charlie-try-stack", &format!( - "(asserts! (not (is-eq (contract-call? '{}.pox stack-stx u1 {{ version: 0x01, hashbytes: 0x1111111111111111111111111111111111111111 }} u1) (err 17))) (err 1))", + "(asserts! (not (is-eq (print (contract-call? '{}.pox stack-stx u1 {{ version: 0x01, hashbytes: 0x1111111111111111111111111111111111111111 }} burn-block-height u1)) (err 17))) (err 1))", boot_code_addr())); block_txs.push(charlie_stack); @@ -3140,7 +3315,7 @@ pub mod test { // If it's the case, then this tx will NOT be mined let alice_reject = make_bare_contract(&alice, 1, 0, "alice-try-reject", &format!( - "(asserts! (not (is-eq (contract-call? '{}.pox reject-pox) (err 3))) (err 1))", + "(asserts! (not (is-eq (print (contract-call? '{}.pox reject-pox)) (err 3))) (err 1))", boot_code_addr())); block_txs.push(alice_reject); @@ -3148,9 +3323,9 @@ pub mod test { // Charlie tries to reject again, but it should fail. // Specifically, (reject-pox) should fail with (err 17). // If it's the case, then this tx will NOT be mined. - let charlie_reject = make_bare_contract(&charlie, 1, 0, "charlie-try-reject", + let charlie_reject = make_bare_contract(&charlie, 3, 0, "charlie-try-reject", &format!( - "(asserts! (not (is-eq (contract-call? '{}.pox reject-pox) (err 17))) (err 1))", + "(asserts! (not (is-eq (print (contract-call? '{}.pox reject-pox)) (err 17))) (err 1))", boot_code_addr())); block_txs.push(charlie_reject); @@ -3160,7 +3335,8 @@ pub mod test { let (anchored_block, _size, _cost) = StacksBlockBuilder::make_anchored_block_from_txs(block_builder, chainstate, &sortdb.index_conn(), block_txs).unwrap(); if tenure_id == 2 { - assert_eq!(anchored_block.txs.len(), 2); + // block should be coinbase tx + 2 allowed txs + assert_eq!(anchored_block.txs.len(), 3); } (anchored_block, vec![]) @@ -3213,7 +3389,7 @@ pub mod test { assert_eq!(alice_account.stx_balance.unlock_height, 0); } - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); // no reward addresses assert_eq!(reward_addrs.len(), 0); @@ -3273,7 +3449,7 @@ pub mod test { // miner rewards increased liquid supply, so less than 25% is locked. // minimum participation decreases. assert!(total_liquid_ustx > 4 * 1024 * 1000000); - assert_eq!(min_ustx, total_liquid_ustx / 20000); + assert_eq!(min_ustx, total_liquid_ustx / 480); } else { // still at 25% or more locked assert!(total_liquid_ustx <= 4 * 1024 * 1000000); diff --git a/src/chainstate/stacks/boot/pox.clar b/src/chainstate/stacks/boot/pox.clar index c3ad688e280..28db2338289 100644 --- a/src/chainstate/stacks/boot/pox.clar +++ b/src/chainstate/stacks/boot/pox.clar @@ -14,6 +14,11 @@ (define-constant ERR_STACKING_ALREADY_REJECTED 17) (define-constant ERR_STACKING_INVALID_AMOUNT 18) (define-constant ERR_NOT_ALLOWED 19) +(define-constant ERR_STACKING_ALREADY_DELEGATED 20) +(define-constant ERR_DELEGATION_EXPIRES_DURING_LOCK 21) +(define-constant ERR_DELEGATION_TOO_MUCH_LOCKED 22) +(define-constant ERR_DELEGATION_POX_ADDR_REQUIRED 23) +(define-constant ERR_INVALID_START_BURN_HEIGHT 24) ;; PoX disabling threshold (a percent) (define-constant POX_REJECTION_FRACTION u25) @@ -30,7 +35,7 @@ ;; This function can only be called once, when it boots up (define-public (set-burnchain-parameters (first-burn-height uint) (prepare-cycle-length uint) (reward-cycle-length uint) (rejection-fraction uint)) (begin - (asserts! (and is-in-regtest (not (var-get configured))) (err ERR_NOT_ALLOWED)) + (asserts! (not (var-get configured)) (err ERR_NOT_ALLOWED)) (var-set first-burnchain-block-height first-burn-height) (var-set pox-prepare-cycle-length prepare-cycle-length) (var-set pox-reward-cycle-length reward-cycle-length) @@ -64,6 +69,22 @@ ) ) +;; Delegation relationships +(define-map delegation-state + ((stacker principal)) + ((amount-ustx uint) ;; how many uSTX delegated? + (delegated-to principal) ;; who are we delegating? + (until-burn-ht (optional uint)) ;; how long does the delegation last? + ;; does the delegate _need_ to use a specific + ;; pox recipient address? + (pox-addr (optional { version: (buff 1), + hashbytes: (buff 20) })))) + +;; allowed contract-callers +(define-map allowance-contract-callers + ((sender principal) (contract-caller principal)) + ((until-burn-ht (optional uint)))) + ;; How many uSTX are stacked in a given reward cycle. ;; Updated when a new PoX address is registered, or when more STX are granted ;; to it. @@ -87,16 +108,15 @@ ((len uint)) ) -;; When is a PoX address active? -;; Used to check of a PoX address is registered or not in a given -;; reward cycle. -(define-map pox-addr-reward-cycles - ((pox-addr (tuple (version (buff 1)) (hashbytes (buff 20))))) - ( - (first-reward-cycle uint) - (num-cycles uint) - ) -) +;; how much has been locked up for this address before +;; committing? +;; this map allows stackers to stack amounts < minimum +;; by paying the cost of aggregation during the commit +(define-map partial-stacked-by-cycle + ((pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) + (reward-cycle uint) + (sender principal)) + ((stacked-amount uint))) ;; Amount of uSTX that reject PoX, by reward cycle (define-map stacking-rejection @@ -153,8 +173,30 @@ ) ;; no state at all none - ) -) + )) + +(define-private (check-caller-allowed) + (or (is-eq tx-sender contract-caller) + (let ((caller-allowed + ;; if not in the caller map, return false + (unwrap! (map-get? allowance-contract-callers + { sender: tx-sender, contract-caller: contract-caller }) + false))) + ;; is the caller allowance expired? + (if (< burn-block-height (unwrap! (get until-burn-ht caller-allowed) true)) + false + true)))) + +(define-private (get-check-delegation (stacker principal)) + (let ((delegation-info (try! (map-get? delegation-state { stacker: stacker })))) + ;; did the existing delegation expire? + (if (match (get until-burn-ht delegation-info) + until-burn-ht (> burn-block-height until-burn-ht) + false) + ;; it expired, return none + none + ;; delegation is active + (some delegation-info)))) ;; Get the size of the reward set for a reward cycle. ;; Note that this does _not_ return duplicate PoX addresses. @@ -167,26 +209,6 @@ u0 (get len (map-get? reward-cycle-pox-address-list-len { reward-cycle: reward-cycle })))) -;; Is a PoX address registered anywhere in a given range of reward cycles? -;; Checkes the integer range [reward-cycle-start, reward-cycle-start + num-cycles) -;; Returns true if it's registered in at least one reward cycle in the given range. -;; Returns false if it's not registered in any reward cycle in the given range. -(define-private (is-pox-addr-registered (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) - (reward-cycle-start uint) - (num-cycles uint)) - (let ( - (pox-addr-range-opt (map-get? pox-addr-reward-cycles { pox-addr: pox-addr })) - ) - (match pox-addr-range-opt - ;; some range - pox-addr-range - (and (>= (+ reward-cycle-start num-cycles) (get first-reward-cycle pox-addr-range)) - (< reward-cycle-start (+ (get first-reward-cycle pox-addr-range) (get num-cycles pox-addr-range)))) - ;; none - false - )) -) - ;; How many rejection votes have we been accumulating for the next block (define-private (next-cycle-rejection-votes) (default-to @@ -236,13 +258,14 @@ (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))) { pox-addr: (get pox-addr params), first-reward-cycle: (get first-reward-cycle params), - num-cycles: (get num-cycles params), + num-cycles: num-cycles, amount-ustx: (get amount-ustx params), - i: (if (< i (get num-cycles 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 @@ -263,36 +286,63 @@ ;; Add a PoX address to a given sequence of reward cycle lists. ;; A PoX address can be added to at most 12 consecutive cycles. ;; No checking is done. -;; Returns the number of reward cycles added (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)) - ) + (let ((cycle-indexes (list u0 u1 u2 u3 u4 u5 u6 u7 u8 u9 u10 u11))) ;; 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)) - - ;; mark address in use over this range - (map-set pox-addr-reward-cycles - { pox-addr: pox-addr } - { first-reward-cycle: first-reward-cycle, num-cycles: num-cycles } - ) - (ok true) - ) -) + (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))) + +(define-private (add-pox-partial-stacked-to-ith-cycle + (cycle-index uint) + (params { pox-addr: { version: (buff 1), hashbytes: (buff 20) }, + reward-cycle: uint, + num-cycles: uint, + amount-ustx: uint })) + (let ((pox-addr (get pox-addr params)) + (num-cycles (get num-cycles params)) + (reward-cycle (get reward-cycle params)) + (amount-ustx (get amount-ustx params))) + (let ((current-amount + (default-to u0 + (get stacked-amount + (map-get? partial-stacked-by-cycle { sender: tx-sender, pox-addr: pox-addr, reward-cycle: reward-cycle }))))) + (if (>= cycle-index num-cycles) + ;; do not add to cycles >= cycle-index + false + ;; otherwise, add to the partial-stacked-by-cycle + (map-set partial-stacked-by-cycle + { sender: tx-sender, pox-addr: pox-addr, reward-cycle: reward-cycle } + { stacked-amount: (+ amount-ustx current-amount) })) + ;; produce the next params tuple + { pox-addr: pox-addr, + reward-cycle: (+ u1 reward-cycle), + num-cycles: num-cycles, + amount-ustx: amount-ustx }))) + +;; Add a PoX address to a given sequence of partial reward cycle lists. +;; A PoX address can be added to at most 12 consecutive cycles. +;; No checking is done. +(define-private (add-pox-partial-stacked (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))) + (fold add-pox-partial-stacked-to-ith-cycle cycle-indexes + { pox-addr: pox-addr, reward-cycle: first-reward-cycle, num-cycles: num-cycles, amount-ustx: amount-ustx }) + true)) ;; What is the minimum number of uSTX to be stacked in the given reward cycle? ;; Used internally by the Stacks node, and visible publicly. (define-read-only (get-stacking-minimum) - (/ stx-liquid-supply u20000) -) + (/ stx-liquid-supply STACKING_THRESHOLD_25)) ;; Is the address mode valid for a PoX burn address? (define-private (check-pox-addr-version (version (buff 1))) @@ -314,37 +364,58 @@ (amount-ustx uint) (first-reward-cycle uint) (num-cycles uint)) - (let ((is-registered (is-pox-addr-registered pox-addr first-reward-cycle num-cycles))) - ;; amount must be valid - (asserts! (> amount-ustx u0) - (err ERR_STACKING_INVALID_AMOUNT)) - - ;; tx-sender principal must not have rejected in this upcoming reward cycle - (asserts! (is-none (get-pox-rejection tx-sender first-reward-cycle)) - (err ERR_STACKING_ALREADY_REJECTED)) - - ;; can't be registered yet - (asserts! (not is-registered) - (err ERR_STACKING_POX_ADDRESS_IN_USE)) + (begin + ;; minimum uSTX must be met + (asserts! (<= (print (get-stacking-minimum)) amount-ustx) + (err ERR_STACKING_THRESHOLD_NOT_MET)) - ;; the Stacker must have sufficient unlocked funds - (asserts! (>= (stx-get-balance tx-sender) amount-ustx) - (err ERR_STACKING_INSUFFICIENT_FUNDS)) - - ;; minimum uSTX must be met - (asserts! (<= (get-stacking-minimum) amount-ustx) - (err ERR_STACKING_THRESHOLD_NOT_MET)) - - ;; lock period must be in acceptable range. - (asserts! (check-pox-lock-period num-cycles) - (err ERR_STACKING_INVALID_LOCK_PERIOD)) + (minimal-can-stack-stx pox-addr amount-ustx first-reward-cycle num-cycles))) - ;; address version must be valid - (asserts! (check-pox-addr-version (get version pox-addr)) - (err ERR_STACKING_INVALID_POX_ADDRESS)) - - (ok true)) -) +;; Evaluate if a participant can stack an amount of STX for a given period. +;; This method is designed as a read-only method so that it can be used as +;; a set of guard conditions and also as a read-only RPC call that can be +;; performed beforehand. +(define-read-only (minimal-can-stack-stx + (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) + (amount-ustx uint) + (first-reward-cycle uint) + (num-cycles uint)) + (begin + ;; amount must be valid + (asserts! (> amount-ustx u0) + (err ERR_STACKING_INVALID_AMOUNT)) + + ;; sender principal must not have rejected in this upcoming reward cycle + (asserts! (is-none (get-pox-rejection tx-sender first-reward-cycle)) + (err ERR_STACKING_ALREADY_REJECTED)) + + ;; lock period must be in acceptable range. + (asserts! (check-pox-lock-period num-cycles) + (err ERR_STACKING_INVALID_LOCK_PERIOD)) + + ;; address version must be valid + (asserts! (check-pox-addr-version (get version pox-addr)) + (err ERR_STACKING_INVALID_POX_ADDRESS)) + (ok true))) + +;; Revoke contract-caller authorization to call stacking methods +(define-public (disallow-contract-caller (caller principal)) + (begin + (asserts! (is-eq tx-sender contract-caller) + (err ERR_STACKING_PERMISSION_DENIED)) + (ok (map-delete allowance-contract-callers { sender: tx-sender, contract-caller: caller })))) + +;; Give a contract-caller authorization to call stacking methods +;; normally, stacking methods may only be invoked by _direct_ transactions +;; (i.e., the tx-sender issues a direct contract-call to the stacking methods) +;; by issuing an allowance, the tx-sender may call through the allowed contract +(define-public (allow-contract-caller (caller principal) (until-burn-ht (optional uint))) + (begin + (asserts! (is-eq tx-sender contract-caller) + (err ERR_STACKING_PERMISSION_DENIED)) + (ok (map-set allowance-contract-callers + { sender: tx-sender, contract-caller: caller } + { until-burn-ht: until-burn-ht })))) ;; Lock up some uSTX for stacking! Note that the given amount here is in micro-STX (uSTX). ;; The STX will be locked for the given number of reward cycles (lock-period). @@ -355,18 +426,39 @@ ;; at the time this method is called. ;; * You may need to increase the amount of uSTX locked up later, since the minimum uSTX threshold ;; may increase between reward cycles. +;; * The Stacker will receive rewards in the reward cycle following `start-burn-ht`. +;; Importantly, `start-burn-ht` may not be further into the future than the next reward cycle, +;; and in most cases should be set to the current burn block height. ;; ;; The tokens will unlock and be returned to the Stacker (tx-sender) automatically. (define-public (stack-stx (amount-ustx uint) (pox-addr (tuple (version (buff 1)) (hashbytes (buff 20)))) + (start-burn-ht uint) (lock-period uint)) ;; this stacker's first reward cycle is the _next_ reward cycle - (let ((first-reward-cycle (+ u1 (current-pox-reward-cycle)))) + (let ((first-reward-cycle (+ u1 (current-pox-reward-cycle))) + (specified-reward-cycle (+ u1 (burn-height-to-reward-cycle start-burn-ht)))) + ;; the start-burn-ht must result in the next reward cycle, do not allow stackers + ;; to "post-date" their `stack-stx` transaction + (asserts! (is-eq first-reward-cycle specified-reward-cycle) + (err ERR_INVALID_START_BURN_HEIGHT)) + + ;; must be called directly by the tx-sender or by an allowed contract-caller + (asserts! (check-caller-allowed) + (err ERR_STACKING_PERMISSION_DENIED)) ;; tx-sender principal must not be stacking (asserts! (is-none (get-stacker-info tx-sender)) (err ERR_STACKING_ALREADY_STACKED)) + ;; tx-sender must not be delegating + (asserts! (is-none (get-check-delegation tx-sender)) + (err ERR_STACKING_ALREADY_DELEGATED)) + + ;; the Stacker must have sufficient unlocked funds + (asserts! (>= (stx-get-balance tx-sender) amount-ustx) + (err ERR_STACKING_INSUFFICIENT_FUNDS)) + ;; ensure that stacking can be performed (try! (can-stack-stx pox-addr amount-ustx first-reward-cycle lock-period)) @@ -376,8 +468,7 @@ ;; add stacker record (map-set stacking-state { stacker: tx-sender } - { - amount-ustx: amount-ustx, + { amount-ustx: amount-ustx, pox-addr: pox-addr, first-reward-cycle: first-reward-cycle, lock-period: lock-period }) @@ -386,6 +477,149 @@ (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 + ;; must be called directly by the tx-sender or by an allowed contract-caller + (asserts! (check-caller-allowed) + (err ERR_STACKING_PERMISSION_DENIED)) + (ok (map-delete delegation-state { stacker: tx-sender })))) + +;; Delegate to `delegate-to` the ability to stack from a given address. +;; This method _does not_ lock the funds, rather, it allows the delegate +;; to issue the stacking lock. +;; The caller specifies: +;; * amount-ustx: the total amount of ustx the delegate may be allowed to lock +;; * until-burn-ht: an optional burn height at which this delegation expiration +;; * pox-addr: an optional address to which any rewards *must* be sent +(define-public (delegate-stx (amount-ustx uint) + (delegate-to principal) + (until-burn-ht (optional uint)) + (pox-addr (optional { version: (buff 1), + hashbytes: (buff 20) }))) + (begin + ;; must be called directly by the tx-sender or by an allowed contract-caller + (asserts! (check-caller-allowed) + (err ERR_STACKING_PERMISSION_DENIED)) + + ;; tx-sender principal must not be stacking + (asserts! (is-none (get-stacker-info tx-sender)) + (err ERR_STACKING_ALREADY_STACKED)) + + ;; tx-sender must not be delegating + (asserts! (is-none (get-check-delegation tx-sender)) + (err ERR_STACKING_ALREADY_DELEGATED)) + + ;; add delegation record + (map-set delegation-state + { stacker: tx-sender } + { amount-ustx: amount-ustx, + delegated-to: delegate-to, + until-burn-ht: until-burn-ht, + pox-addr: pox-addr }) + + (ok true))) + +;; Commit partially stacked STX. +;; This allows a stacker/delegate to lock fewer STX than the minimal threshold in multiple transactions, +;; so long as: 1. The pox-addr is the same. +;; 2. This "commit" transaction is called _before_ the PoX anchor block. +;; This ensures that each entry in the reward set returned to the stacks-node is greater than the threshold, +;; but does not require it be all locked up within a single transaction +(define-public (stack-aggregation-commit (pox-addr { version: (buff 1), hashbytes: (buff 20) }) + (reward-cycle uint)) + (let ((partial-stacked + ;; fetch the partial commitments + (unwrap! (map-get? partial-stacked-by-cycle { pox-addr: pox-addr, sender: tx-sender, reward-cycle: reward-cycle }) + (err ERR_STACKING_NO_SUCH_PRINCIPAL)))) + ;; must be called directly by the tx-sender or by an allowed contract-caller + (asserts! (check-caller-allowed) + (err ERR_STACKING_PERMISSION_DENIED)) + (let ((amount-ustx (get stacked-amount partial-stacked))) + (try! (can-stack-stx pox-addr amount-ustx reward-cycle u1)) + ;; add the pox addr to the reward cycle + (add-pox-addr-to-ith-reward-cycle + u0 + { pox-addr: pox-addr, + first-reward-cycle: reward-cycle, + num-cycles: u1, + amount-ustx: amount-ustx, + i: u0 }) + ;; don't update the stacking-state map, + ;; because it _already has_ this stacker's state + ;; don't lock the STX, because the STX is already locked + ;; + ;; clear the partial-stacked state + (map-delete partial-stacked-by-cycle { pox-addr: pox-addr, sender: tx-sender, reward-cycle: reward-cycle }) + (ok true)))) + +;; As a delegate, stack the given principal's STX using partial-stacked-by-cycle +;; Once the delegate has stacked > minimum, the delegate should call stack-aggregation-commit +(define-public (delegate-stack-stx (stacker principal) + (amount-ustx uint) + (pox-addr { version: (buff 1), hashbytes: (buff 20) }) + (start-burn-ht uint) + (lock-period uint)) + ;; this stacker's first reward cycle is the _next_ reward cycle + (let ((first-reward-cycle (+ u1 (current-pox-reward-cycle))) + (specified-reward-cycle (+ u1 (burn-height-to-reward-cycle start-burn-ht))) + (unlock-burn-height (reward-cycle-to-burn-height (+ (current-pox-reward-cycle) u1 lock-period)))) + ;; the start-burn-ht must result in the next reward cycle, do not allow stackers + ;; to "post-date" their `stack-stx` transaction + (asserts! (is-eq first-reward-cycle specified-reward-cycle) + (err ERR_INVALID_START_BURN_HEIGHT)) + + ;; must be called directly by the tx-sender or by an allowed contract-caller + (asserts! (check-caller-allowed) + (err ERR_STACKING_PERMISSION_DENIED)) + + ;; stacker must have delegated to the caller + (let ((delegation-info (unwrap! (get-check-delegation stacker) (err ERR_STACKING_PERMISSION_DENIED)))) + ;; must have delegated to tx-sender + (asserts! (is-eq (get delegated-to delegation-info) tx-sender) + (err ERR_STACKING_PERMISSION_DENIED)) + ;; must have delegated enough stx + (asserts! (>= (get amount-ustx delegation-info) amount-ustx) + (err ERR_DELEGATION_TOO_MUCH_LOCKED)) + ;; if pox-addr is set, must be equal to pox-addr + (asserts! (match (get pox-addr delegation-info) + specified-pox-addr (is-eq pox-addr specified-pox-addr) + true) + (err ERR_DELEGATION_POX_ADDR_REQUIRED)) + ;; delegation must not expire before lock period + (asserts! (match (get until-burn-ht delegation-info) + until-burn-ht (>= until-burn-ht + unlock-burn-height) + true) + (err ERR_DELEGATION_EXPIRES_DURING_LOCK))) + + ;; stacker principal must not be stacking + (asserts! (is-none (get-stacker-info stacker)) + (err ERR_STACKING_ALREADY_STACKED)) + + ;; the Stacker must have sufficient unlocked funds + (asserts! (>= (stx-get-balance stacker) amount-ustx) + (err ERR_STACKING_INSUFFICIENT_FUNDS)) + + ;; ensure that stacking can be performed + (try! (minimal-can-stack-stx pox-addr amount-ustx first-reward-cycle lock-period)) + + ;; register the PoX address with the amount stacked via partial stacking + ;; before it can be included in the reward set, this must be committed! + (add-pox-partial-stacked pox-addr first-reward-cycle lock-period amount-ustx) + + ;; add stacker record + (map-set stacking-state + { stacker: stacker } + { 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: stacker, + lock-amount: amount-ustx, + unlock-burn-height: unlock-burn-height }))) + ;; Reject Stacking for this reward cycle. ;; tx-sender votes all its uSTX for rejection. ;; Note that unlike PoX, rejecting PoX does not lock the tx-sender's @@ -431,4 +665,4 @@ current-rejection-votes: (next-cycle-rejection-votes), total-liquid-supply-ustx: stx-liquid-supply, }) -) \ No newline at end of file +) diff --git a/src/chainstate/stacks/db/headers.rs b/src/chainstate/stacks/db/headers.rs index f299673c448..a3920f6d66d 100644 --- a/src/chainstate/stacks/db/headers.rs +++ b/src/chainstate/stacks/db/headers.rs @@ -33,7 +33,8 @@ use vm::costs::ExecutionCost; use util::db::Error as db_error; use util::db::{ - query_count, query_row, query_row_columns, query_rows, DBConn, FromColumn, FromRow, + query_count, query_row, query_row_columns, query_row_panic, query_rows, DBConn, FromColumn, + FromRow, }; use core::FIRST_BURNCHAIN_CONSENSUS_HASH; @@ -275,14 +276,10 @@ impl StacksChainState { index_block_hash: &StacksBlockId, ) -> Result, Error> { let sql = "SELECT * FROM block_headers WHERE index_block_hash = ?1".to_string(); - let mut rows = query_rows::(conn, &sql, &[&index_block_hash]) - .map_err(Error::DBError)?; - let cnt = rows.len(); - if cnt > 1 { - unreachable!("FATAL: multiple rows for the same block hash") // should be unreachable, since index_block_hash is unique - } - - Ok(rows.pop()) + query_row_panic(conn, &sql, &[&index_block_hash], || { + "FATAL: multiple rows for the same block hash".to_string() + }) + .map_err(Error::DBError) } /// Get an ancestor block header diff --git a/src/chainstate/stacks/transaction.rs b/src/chainstate/stacks/transaction.rs index af8e572abc6..1cb0bdb3a7a 100644 --- a/src/chainstate/stacks/transaction.rs +++ b/src/chainstate/stacks/transaction.rs @@ -232,10 +232,10 @@ impl TransactionPayload { })) } - pub fn new_smart_contract(name: &String, contract: &String) -> Option { + pub fn new_smart_contract(name: &str, contract: &str) -> Option { match ( - ContractName::try_from((*name).clone()), - StacksString::from_string(contract), + ContractName::try_from(name.to_string()), + StacksString::from_str(contract), ) { (Ok(s_name), Some(s_body)) => Some(TransactionPayload::SmartContract( TransactionSmartContract { diff --git a/src/clarity.rs b/src/clarity.rs index 2d8bab2e82c..bef687a43d3 100644 --- a/src/clarity.rs +++ b/src/clarity.rs @@ -556,10 +556,19 @@ pub fn invoke_command(invoked_by: &str, args: &[String]) { let contract_id = QualifiedContractIdentifier::transient(); - let content: String = friendly_expect( - fs::read_to_string(&args[1]), - &format!("Error reading file: {}", args[1]), - ); + let content: String = if &args[1] == "-" { + let mut buffer = String::new(); + friendly_expect( + io::stdin().read_to_string(&mut buffer), + "Error reading from stdin.", + ); + buffer + } else { + friendly_expect( + fs::read_to_string(&args[1]), + &format!("Error reading file: {}", args[1]), + ) + }; let mut ast = friendly_expect(parse(&contract_id, &content), "Failed to parse program"); diff --git a/src/core/mod.rs b/src/core/mod.rs index 707fbae3e1b..b0f2905e65f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -58,8 +58,18 @@ pub const BURNCHAIN_BOOT_CONSENSUS_HASH: ConsensusHash = ConsensusHash([0xff; 20 pub const CHAINSTATE_VERSION: &'static str = "23.0.0.0"; +pub const MICROSTACKS_PER_STACKS: u32 = 1_000_000; + pub const POX_PREPARE_WINDOW_LENGTH: u32 = 240; pub const POX_REWARD_CYCLE_LENGTH: u32 = 1000; +/// The maximum amount that PoX rewards can be scaled by. +/// That is, if participation is very low, rewards are: +/// POX_MAXIMAL_SCALING x (rewards with 100% participation) +/// Set a 4x, this implies the lower bound of participation for scaling +/// is 25% +pub const POX_MAXIMAL_SCALING: u128 = 4; +/// This is the amount that PoX threshold adjustments are stepped by. +pub const POX_THRESHOLD_STEPS_USTX: u128 = 10_000 * (MICROSTACKS_PER_STACKS as u128); /// Synchronize burn transactions from the Bitcoin blockchain pub fn sync_burnchain_bitcoin( diff --git a/src/main.rs b/src/main.rs index 803d1840ecd..5fb078f60a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -343,6 +343,14 @@ fn main() { return; } + if argv[1] == "docgen_boot" { + println!( + "{}", + vm::docs::contracts::make_json_boot_contracts_reference() + ); + return; + } + if argv[1] == "local" { clarity::invoke_command(&format!("{} {}", argv[0], argv[1]), &argv[2..]); return; diff --git a/src/net/inv.rs b/src/net/inv.rs index 46ccaf7db8a..85a0f4e82d5 100644 --- a/src/net/inv.rs +++ b/src/net/inv.rs @@ -2564,7 +2564,7 @@ mod test { #[test] fn test_inv_merge_pox_inv() { let mut burnchain = Burnchain::new("unused", "bitcoin", "regtest").unwrap(); - burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25); + burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25, 5); let mut peer_inv = PeerBlocksInv::new(vec![0x01], vec![0x01], vec![0x01], 1, 1, 0); for i in 0..32 { @@ -2582,7 +2582,7 @@ mod test { #[test] fn test_inv_truncate_pox_inv() { let mut burnchain = Burnchain::new("unused", "bitcoin", "regtest").unwrap(); - burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25); + burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25, 5); let mut peer_inv = PeerBlocksInv::new(vec![0x01], vec![0x01], vec![0x01], 1, 1, 0); for i in 0..5 { diff --git a/src/net/mod.rs b/src/net/mod.rs index 2c5d04da569..abda8df964b 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -2012,7 +2012,7 @@ pub mod test { ) .unwrap(), ); - burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25); + burnchain.pox_constants = PoxConstants::new(5, 3, 3, 25, 5); let spending_account = TestMinerFactory::new().next_miner( &burnchain, diff --git a/src/net/neighbors.rs b/src/net/neighbors.rs index be98aafad03..87488ee70fa 100644 --- a/src/net/neighbors.rs +++ b/src/net/neighbors.rs @@ -4088,6 +4088,7 @@ mod test { } #[test] + #[ignore] fn test_step_walk_2_neighbors_rekey() { with_timeout(600, || { let mut peer_1_config = TestPeerConfig::from_port(32600); diff --git a/src/vm/analysis/errors.rs b/src/vm/analysis/errors.rs index 1fafeb1c2a4..9bc2d1ac83e 100644 --- a/src/vm/analysis/errors.rs +++ b/src/vm/analysis/errors.rs @@ -200,7 +200,7 @@ impl CheckError { self.expressions.replace(vec![expr.clone()]); } - pub fn set_expressions(&mut self, exprs: Vec) { + pub fn set_expressions(&mut self, exprs: &[SymbolicExpression]) { self.diagnostic.spans = exprs.iter().map(|e| e.span.clone()).collect(); self.expressions.replace(exprs.clone().to_vec()); } diff --git a/src/vm/analysis/read_only_checker/mod.rs b/src/vm/analysis/read_only_checker/mod.rs index b44bc4fead6..4b7048cc1e0 100644 --- a/src/vm/analysis/read_only_checker/mod.rs +++ b/src/vm/analysis/read_only_checker/mod.rs @@ -319,7 +319,10 @@ impl<'a, 'b> ReadOnlyChecker<'a, 'b> { .match_atom() .ok_or(CheckErrors::NonFunctionApplication)?; - if let Some(result) = self.try_native_function_check(function_name, args) { + if let Some(mut result) = self.try_native_function_check(function_name, args) { + if let Err(ref mut check_err) = result { + check_err.set_expressions(expression); + } result } else { let is_function_read_only = self diff --git a/src/vm/docs/contracts.rs b/src/vm/docs/contracts.rs new file mode 100644 index 00000000000..8ee081b66fc --- /dev/null +++ b/src/vm/docs/contracts.rs @@ -0,0 +1,238 @@ +use chainstate::stacks::boot::STACKS_BOOT_CODE_MAINNET; +use vm::analysis::{mem_type_check, ContractAnalysis}; +use vm::docs::{get_input_type_string, get_output_type_string, get_signature}; +use vm::types::{FunctionType, Value}; + +use vm::execute; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::iter::FromIterator; + +#[derive(Serialize)] +struct ContractRef { + public_functions: Vec, + read_only_functions: Vec, + error_codes: Vec, +} + +#[derive(Serialize)] +struct FunctionRef { + name: String, + input_type: String, + output_type: String, + signature: String, + description: String, +} + +#[derive(Serialize)] +struct ErrorCode { + name: String, + #[serde(rename = "type")] + value_type: String, + value: String, +} + +struct ContractSupportDocs { + descriptions: HashMap<&'static str, &'static str>, + skip_func_display: HashSet<&'static str>, +} + +fn make_contract_support_docs() -> HashMap<&'static str, ContractSupportDocs> { + let pox_descriptions = vec![ + ("disallow-contract-caller", "Revokes authorization from a contract to invoke stacking methods through contract-calls"), + ("allow-contract-caller", "Give a contract-caller authorization to call stacking methods. Normally, stacking methods may +only be invoked by _direct_ transactions (i.e., the `tx-sender` issues a direct `contract-call` to the stacking methods). +By issuing an allowance, the tx-sender may call through the allowed contract."), + ("stack-stx", "Lock up some uSTX for stacking! Note that the given amount here is in micro-STX (uSTX). +The STX will be locked for the given number of reward cycles (lock-period). +This is the self-service interface. tx-sender will be the Stacker. + +* The given stacker cannot currently be stacking. +* You will need the minimum uSTX threshold. This isn't determined until the reward cycle begins, but this + method still requires stacking over the _absolute minimum_ amount, which can be obtained by calling `get-stacking-minimum`. + +The tokens will unlock and be returned to the Stacker (tx-sender) automatically."), + ("revoke-delegate-stx", "Revoke a Stacking delegate relationship. A particular Stacker may only have one delegate, +so this method does not take any parameters, and just revokes the Stacker's current delegate (if one exists)."), + ("delegate-stx", "Delegate to `delegate-to` the ability to stack from a given address. +This method _does not_ lock the funds, rather, it allows the delegate to issue the stacking lock. + +The caller specifies: + * amount-ustx: the total amount of ustx the delegate may be allowed to lock + * until-burn-ht: an optional burn height at which this delegation expiration + * pox-addr: an optional address to which any rewards *must* be sent"), + ("delegate-stack-stx", "As a delegate, stack the given principal's STX using `partial-stacked-by-cycle`. +Once the delegate has stacked > minimum, the delegate should call `stack-aggregation-commit`."), + ("stack-aggregation-commit", "Commit partially stacked STX. + +This allows a stacker/delegate to lock fewer STX than the minimal threshold in multiple transactions, +so long as: + 1. The pox-addr is the same. + 2. This \"commit\" transaction is called _before_ the PoX anchor block. +This ensures that each entry in the reward set returned to the stacks-node is greater than the threshold, + but does not require it be all locked up within a single transaction"), + ("reject-pox", "Reject Stacking for this reward cycle. +`tx-sender` votes all its uSTX for rejection. +Note that unlike Stacking, rejecting PoX does not lock the tx-sender's tokens: PoX rejection acts like a coin vote."), + ("can-stack-stx", "Evaluate if a participant can stack an amount of STX for a given period."), + ("get-stacking-minimum", "Returns the absolute minimum amount that could be validly Stacked (the threshold to Stack in +a given reward cycle may be higher than this"), + ("get-pox-rejection", "Returns the amount of uSTX that a given principal used to reject a PoX cycle."), + ("is-pox-active", "Returns whether or not PoX has been rejected at a given PoX cycle."), + ("get-stacker-info", "Returns the _current_ stacking information for `stacker. If the information +is expired, or if there's never been such a stacker, then returns none."), + ("get-total-ustx-stacked", "Returns the amount of currently participating uSTX in the given cycle."), + ("get-pox-info", "Returns information about PoX status.") + ]; + + let pox_skip_display = vec![ + "set-burnchain-parameters", + "minimal-can-stack-stx", + "get-reward-set-size", + "get-reward-set-pox-address", + ]; + + HashMap::from_iter(vec![( + "pox", + ContractSupportDocs { + descriptions: HashMap::from_iter(pox_descriptions.into_iter()), + skip_func_display: HashSet::from_iter(pox_skip_display.into_iter()), + }, + )]) +} + +fn make_func_ref(func_name: &str, func_type: &FunctionType, description: &str) -> FunctionRef { + let input_type = get_input_type_string(func_type); + let output_type = get_output_type_string(func_type); + let signature = get_signature(func_name, func_type) + .expect("BUG: failed to build signature for boot contract"); + FunctionRef { + input_type, + output_type, + signature, + name: func_name.to_string(), + description: description.to_string(), + } +} + +fn get_constant_value(var_name: &str, contract_content: &str) -> Value { + let to_eval = format!("{}\n{}", contract_content, var_name); + execute(&to_eval) + .expect("BUG: failed to evaluate contract for constant value") + .expect("BUG: failed to return constant value") +} + +fn produce_docs() -> BTreeMap { + let mut docs = BTreeMap::new(); + let support_docs = make_contract_support_docs(); + + for (contract_name, content) in STACKS_BOOT_CODE_MAINNET.iter() { + let (_, contract_analysis) = + mem_type_check(content).expect("BUG: failed to type check boot contract"); + + if let Some(contract_support) = support_docs.get(*contract_name) { + let ContractAnalysis { + public_function_types, + read_only_function_types, + variable_types, + .. + } = contract_analysis; + let public_functions: Vec<_> = public_function_types + .iter() + .filter(|(func_name, _)| { + !contract_support + .skip_func_display + .contains(func_name.as_str()) + }) + .map(|(func_name, func_type)| { + let description = contract_support + .descriptions + .get(func_name.as_str()) + .expect(&format!("BUG: no description for {}", func_name.as_str())); + make_func_ref(func_name, func_type, description) + }) + .collect(); + + let read_only_functions: Vec<_> = read_only_function_types + .iter() + .filter(|(func_name, _)| { + !contract_support + .skip_func_display + .contains(func_name.as_str()) + }) + .map(|(func_name, func_type)| { + let description = contract_support + .descriptions + .get(func_name.as_str()) + .expect(&format!("BUG: no description for {}", func_name.as_str())); + make_func_ref(func_name, func_type, description) + }) + .collect(); + + let ecode_names = variable_types + .iter() + .filter_map(|(var_name, _)| { + if var_name.starts_with("ERR_") { + Some(format!("{}: {}", var_name.as_str(), var_name.as_str())) + } else { + None + } + }) + .collect::>() + .join(", "); + let ecode_to_eval = format!("{}\n {{ {} }}", content, ecode_names); + let ecode_result = execute(&ecode_to_eval) + .expect("BUG: failed to evaluate contract for constant value") + .expect("BUG: failed to return constant value") + .expect_tuple(); + + let error_codes = variable_types + .iter() + .filter_map(|(var_name, type_signature)| { + if var_name.starts_with("ERR_") { + let value = ecode_result + .get(var_name) + .expect("BUG: failed to fetch tuple entry from ecode output") + .to_string(); + Some(ErrorCode { + name: var_name.to_string(), + value, + value_type: type_signature.to_string(), + }) + } else { + None + } + }) + .collect(); + + docs.insert( + contract_name.to_string(), + ContractRef { + public_functions, + read_only_functions, + error_codes, + }, + ); + } + } + + docs +} + +pub fn make_json_boot_contracts_reference() -> String { + let api_out = produce_docs(); + format!( + "{}", + serde_json::to_string(&api_out).expect("Failed to serialize documentation") + ) +} + +#[cfg(test)] +mod tests { + use vm::docs::contracts::make_json_boot_contracts_reference; + + #[test] + fn test_make_boot_contracts_reference() { + make_json_boot_contracts_reference(); + } +} diff --git a/src/vm/docs/mod.rs b/src/vm/docs/mod.rs index 16b0e3b1ab3..3c8fdc70c11 100644 --- a/src/vm/docs/mod.rs +++ b/src/vm/docs/mod.rs @@ -18,9 +18,11 @@ use vm::analysis::type_checker::natives::SimpleNativeFunction; use vm::analysis::type_checker::TypedNativeFunction; use vm::functions::define::DefineFunctions; use vm::functions::NativeFunctions; -use vm::types::{FixedFunction, FunctionType}; +use vm::types::{FixedFunction, FunctionType, Value}; use vm::variables::NativeVariables; +pub mod contracts; + #[derive(Serialize)] struct ReferenceAPIs { functions: Vec, @@ -308,6 +310,55 @@ const LESS_API: SimpleFunctionAPI = SimpleFunctionAPI { ", }; +pub fn get_input_type_string(function_type: &FunctionType) -> String { + match function_type { + FunctionType::Variadic(ref in_type, _) => format!("{}, ...", in_type), + FunctionType::Fixed(FixedFunction { ref args, .. }) => { + let in_types: Vec = args.iter().map(|x| format!("{}", x.signature)).collect(); + in_types.join(", ") + } + FunctionType::UnionArgs(ref in_types, _) => { + let in_types: Vec = in_types.iter().map(|x| format!("{}", x)).collect(); + in_types.join(" | ") + } + FunctionType::ArithmeticVariadic => "int, ... | uint, ...".to_string(), + FunctionType::ArithmeticUnary => "int | uint".to_string(), + FunctionType::ArithmeticBinary | FunctionType::ArithmeticComparison => { + "int, int | uint, uint".to_string() + } + } +} + +pub fn get_output_type_string(function_type: &FunctionType) -> String { + match function_type { + FunctionType::Variadic(_, ref out_type) => format!("{}", out_type), + FunctionType::Fixed(FixedFunction { ref returns, .. }) => format!("{}", returns), + FunctionType::UnionArgs(_, ref out_type) => format!("{}", out_type), + FunctionType::ArithmeticVariadic + | FunctionType::ArithmeticUnary + | FunctionType::ArithmeticBinary => "int | uint".to_string(), + FunctionType::ArithmeticComparison => "bool".to_string(), + } +} + +pub fn get_signature(function_name: &str, function_type: &FunctionType) -> Option { + if let FunctionType::Fixed(FixedFunction { ref args, .. }) = function_type { + let in_names: Vec = args + .iter() + .map(|x| format!("{}", x.name.as_str())) + .collect(); + let arg_examples = in_names.join(" "); + Some(format!( + "({}{}{})", + function_name, + if arg_examples.len() == 0 { "" } else { " " }, + arg_examples + )) + } else { + None + } +} + fn make_for_simple_native( api: &SimpleFunctionAPI, function: &NativeFunctions, @@ -317,32 +368,8 @@ fn make_for_simple_native( if let TypedNativeFunction::Simple(SimpleNativeFunction(function_type)) = TypedNativeFunction::type_native_function(&function) { - let input_type = match function_type { - FunctionType::Variadic(ref in_type, _) => format!("{}, ...", in_type), - FunctionType::Fixed(FixedFunction { ref args, .. }) => { - let in_types: Vec = - args.iter().map(|x| format!("{}", x.signature)).collect(); - in_types.join(", ") - } - FunctionType::UnionArgs(ref in_types, _) => { - let in_types: Vec = in_types.iter().map(|x| format!("{}", x)).collect(); - in_types.join(" | ") - } - FunctionType::ArithmeticVariadic => "int, ... | uint, ...".to_string(), - FunctionType::ArithmeticUnary => "int | uint".to_string(), - FunctionType::ArithmeticBinary | FunctionType::ArithmeticComparison => { - "int, int | uint, uint".to_string() - } - }; - let output_type = match function_type { - FunctionType::Variadic(_, ref out_type) => format!("{}", out_type), - FunctionType::Fixed(FixedFunction { ref returns, .. }) => format!("{}", returns), - FunctionType::UnionArgs(_, ref out_type) => format!("{}", out_type), - FunctionType::ArithmeticVariadic - | FunctionType::ArithmeticUnary - | FunctionType::ArithmeticBinary => "int | uint".to_string(), - FunctionType::ArithmeticComparison => "bool".to_string(), - }; + let input_type = get_input_type_string(&function_type); + let output_type = get_output_type_string(&function_type); (input_type, output_type) } else { panic!( diff --git a/src/vm/functions/special.rs b/src/vm/functions/special.rs index 1285c0071ae..db6d838f832 100644 --- a/src/vm/functions/special.rs +++ b/src/vm/functions/special.rs @@ -64,11 +64,11 @@ fn parse_pox_stacking_result( /// Handle special cases when calling into the PoX API contract fn handle_pox_api_contract_call( global_context: &mut GlobalContext, - sender_opt: Option<&PrincipalData>, + _sender_opt: Option<&PrincipalData>, function_name: &str, value: &Value, ) -> Result<()> { - if function_name == "stack-stx" { + if function_name == "stack-stx" || function_name == "delegate-stack-stx" { debug!( "Handle special-case contract-call to {:?} {} (which returned {:?})", boot_code_id("pox"), @@ -76,26 +76,13 @@ fn handle_pox_api_contract_call( value ); - // sender is required - let sender = match sender_opt { - None => { - return Err(RuntimeErrorType::NoSenderInContext.into()); - } - Some(sender) => (*sender).clone(), - }; - match parse_pox_stacking_result(value) { Ok((stacker, locked_amount, unlock_height)) => { - assert_eq!( - stacker, sender, - "BUG: tx-sender is not contract-call origin!" - ); - // if this fails, then there's a bug in the contract (since it already does // the necessary checks) match StacksChainState::pox_lock( &mut global_context.database, - &sender, + &stacker, locked_amount, unlock_height as u64, ) { @@ -112,7 +99,7 @@ fn handle_pox_api_contract_call( Err(e) => { panic!( "FATAL: failed to lock {} from {} until {}: '{:?}'", - locked_amount, sender, unlock_height, &e + locked_amount, stacker, unlock_height, &e ); } } diff --git a/src/vm/tests/mod.rs b/src/vm/tests/mod.rs index 9edf72ff8e5..8bc712efe82 100644 --- a/src/vm/tests/mod.rs +++ b/src/vm/tests/mod.rs @@ -109,7 +109,7 @@ pub fn symbols_from_values(mut vec: Vec) -> Vec { .collect() } -fn is_committed(v: &Value) -> bool { +pub fn is_committed(v: &Value) -> bool { eprintln!("is_committed?: {}", v); match v { @@ -118,7 +118,7 @@ fn is_committed(v: &Value) -> bool { } } -fn is_err_code(v: &Value, e: u128) -> bool { +pub fn is_err_code(v: &Value, e: u128) -> bool { eprintln!("is_err_code?: {}", v); match v { Value::Response(ref data) => !data.committed && *data.data == Value::UInt(e), diff --git a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index d704af243d5..1c64200b68a 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -53,12 +53,21 @@ pub struct BitcoinRegtestController { burnchain_db: Option, chain_tip: Option, use_coordinator: Option, + burnchain_config: Option, } const DUST_UTXO_LIMIT: u64 = 5500; impl BitcoinRegtestController { pub fn new(config: Config, coordinator_channel: Option) -> Self { + BitcoinRegtestController::with_burnchain(config, coordinator_channel, None) + } + + pub fn with_burnchain( + config: Config, + coordinator_channel: Option, + burnchain_config: Option, + ) -> Self { std::fs::create_dir_all(&config.node.get_burnchain_path()) .expect("Unable to create workdir"); @@ -93,11 +102,12 @@ impl BitcoinRegtestController { Self { use_coordinator: coordinator_channel, - config: config, + config, indexer_config, db: None, burnchain_db: None, chain_tip: None, + burnchain_config, } } @@ -122,22 +132,28 @@ impl BitcoinRegtestController { Self { use_coordinator: None, - config: config, + config, indexer_config, db: None, burnchain_db: None, chain_tip: None, + burnchain_config: None, } } fn setup_burnchain(&self) -> (Burnchain, BitcoinNetworkType) { let (network_name, network_type) = self.config.burnchain.get_bitcoin_network(); - let working_dir = self.config.get_burn_db_path(); - match Burnchain::new(&working_dir, &self.config.burnchain.chain, &network_name) { - Ok(burnchain) => (burnchain, network_type), - Err(e) => { - error!("Failed to instantiate burnchain: {}", e); - panic!() + match &self.burnchain_config { + Some(burnchain) => (burnchain.clone(), network_type), + None => { + let working_dir = self.config.get_burn_db_path(); + match Burnchain::new(&working_dir, &self.config.burnchain.chain, &network_name) { + Ok(burnchain) => (burnchain, network_type), + Err(e) => { + error!("Failed to instantiate burnchain: {}", e); + panic!() + } + } } } } @@ -312,6 +328,90 @@ impl BitcoinRegtestController { Ok((burnchain_tip, burnchain_height)) } + #[cfg(test)] + pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { + // Configure UTXO filter + let pkh = Hash160::from_data(&public_key.to_bytes()) + .to_bytes() + .to_vec(); + let (_, network_id) = self.config.burnchain.get_bitcoin_network(); + let address = + BitcoinAddress::from_bytes(network_id, BitcoinAddressType::PublicKeyHash, &pkh) + .expect("Public key incorrect"); + let filter_addresses = vec![address.to_b58()]; + let _result = BitcoinRPCRequest::import_public_key(&self.config, &public_key); + + sleep_ms(1000); + + let min_conf = 0; + let max_conf = 9999999; + let minimum_amount = ParsedUTXO::sat_to_serialized_btc(1); + + let payload = BitcoinRPCRequest { + method: "listunspent".to_string(), + params: vec![ + min_conf.into(), + max_conf.into(), + filter_addresses.clone().into(), + true.into(), + json!({ "minimumAmount": minimum_amount }), + ], + id: "stacks".to_string(), + jsonrpc: "2.0".to_string(), + }; + + let mut res = BitcoinRPCRequest::send(&self.config, payload).unwrap(); + let mut result_vec = vec![]; + + if let Some(ref mut object) = res.as_object_mut() { + match object.get_mut("result") { + Some(serde_json::Value::Array(entries)) => { + while let Some(entry) = entries.pop() { + let parsed_utxo: ParsedUTXO = match serde_json::from_value(entry) { + Ok(utxo) => utxo, + Err(err) => { + warn!("Failed parsing UTXO: {}", err); + continue; + } + }; + let amount = match parsed_utxo.get_sat_amount() { + Some(amount) => amount, + None => continue, + }; + + if amount < 1 { + continue; + } + + let script_pub_key = match parsed_utxo.get_script_pub_key() { + Some(script_pub_key) => script_pub_key, + None => { + continue; + } + }; + + let txid = match parsed_utxo.get_txid() { + Some(amount) => amount, + None => continue, + }; + + result_vec.push(UTXO { + txid, + vout: parsed_utxo.vout, + script_pub_key, + amount, + }); + } + } + _ => { + warn!("Failed to get UTXOs"); + } + } + } + + result_vec + } + pub fn get_utxos( &self, public_key: &Secp256k1PublicKey, @@ -382,7 +482,7 @@ impl BitcoinRegtestController { let total_unspent: u64 = utxos.iter().map(|o| o.amount).sum(); if total_unspent < amount_required { - debug!( + warn!( "Total unspent {} < {} for {:?}", total_unspent, amount_required, diff --git a/testnet/stacks-node/src/config.rs b/testnet/stacks-node/src/config.rs index f997b63b2de..57abb707374 100644 --- a/testnet/stacks-node/src/config.rs +++ b/testnet/stacks-node/src/config.rs @@ -355,6 +355,9 @@ impl Config { .wait_time_for_microblocks .unwrap_or(default_node_config.wait_time_for_microblocks), prometheus_bind: node.prometheus_bind, + pox_sync_sample_secs: node + .pox_sync_sample_secs + .unwrap_or(default_node_config.pox_sync_sample_secs), }; node_config.set_bootstrap_node(node.bootstrap_node); node_config @@ -412,6 +415,9 @@ impl Config { .burnchain_op_tx_fee .unwrap_or(default_burnchain_config.burnchain_op_tx_fee), process_exit_at_block_height: burnchain.process_exit_at_block_height, + poll_time_secs: burnchain + .poll_time_secs + .unwrap_or(default_burnchain_config.poll_time_secs), } } None => default_burnchain_config, @@ -719,6 +725,7 @@ pub struct BurnchainConfig { pub local_mining_public_key: Option, pub burnchain_op_tx_fee: u64, pub process_exit_at_block_height: Option, + pub poll_time_secs: u64, } impl BurnchainConfig { @@ -741,6 +748,7 @@ impl BurnchainConfig { local_mining_public_key: None, burnchain_op_tx_fee: MINIMUM_DUST_FEE, process_exit_at_block_height: None, + poll_time_secs: 30, // TODO: this is a testnet specific value. } } @@ -791,6 +799,7 @@ pub struct BurnchainConfigFile { pub local_mining_public_key: Option, pub burnchain_op_tx_fee: Option, pub process_exit_at_block_height: Option, + pub poll_time_secs: Option, } #[derive(Clone, Debug, Default)] @@ -808,6 +817,7 @@ pub struct NodeConfig { pub mine_microblocks: bool, pub wait_time_for_microblocks: u64, pub prometheus_bind: Option, + pub pox_sync_sample_secs: u64, } impl NodeConfig { @@ -841,6 +851,7 @@ impl NodeConfig { mine_microblocks: false, wait_time_for_microblocks: 15000, prometheus_bind: None, + pox_sync_sample_secs: 30, } } @@ -939,6 +950,7 @@ pub struct NodeConfigFile { pub mine_microblocks: Option, pub wait_time_for_microblocks: Option, pub prometheus_bind: Option, + pub pox_sync_sample_secs: Option, } #[derive(Clone, Deserialize, Default)] diff --git a/testnet/stacks-node/src/main.rs b/testnet/stacks-node/src/main.rs index 62d04ceef62..a9cab5da31d 100644 --- a/testnet/stacks-node/src/main.rs +++ b/testnet/stacks-node/src/main.rs @@ -139,7 +139,7 @@ fn main() { || conf.burnchain.mode == "xenon" { let mut run_loop = neon::RunLoop::new(conf); - run_loop.start(num_round); + run_loop.start(num_round, None); } else { println!("Burnchain mode '{}' not supported", conf.burnchain.mode); } diff --git a/testnet/stacks-node/src/neon_node.rs b/testnet/stacks-node/src/neon_node.rs index 39691f9b39c..b6151eb9442 100644 --- a/testnet/stacks-node/src/neon_node.rs +++ b/testnet/stacks-node/src/neon_node.rs @@ -85,6 +85,7 @@ pub struct NeonGenesisNode { pub config: Config, keychain: Keychain, event_dispatcher: EventDispatcher, + burnchain: Burnchain, } #[cfg(test)] @@ -596,19 +597,13 @@ impl InitializedNeonNode { miner: bool, blocks_processed: BlocksProcessedCounter, coord_comms: CoordinatorChannels, + burnchain: Burnchain, ) -> InitializedNeonNode { // we can call _open_ here rather than _connect_, since connect is first called in // make_genesis_block let sortdb = SortitionDB::open(&config.get_burn_db_file_path(), false) .expect("Error while instantiating sortition db"); - let burnchain = Burnchain::new( - &config.get_burn_db_path(), - &config.burnchain.chain, - "regtest", - ) - .expect("Error while instantiating burnchain"); - let view = { let ic = sortdb.index_conn(); let sortition_tip = SortitionDB::get_canonical_burn_chain_tip(&ic) @@ -1036,6 +1031,7 @@ impl InitializedNeonNode { /// Process a state coming from the burnchain, by extracting the validated KeyRegisterOp /// and inspecting if a sortition was won. + /// `ibd`: boolean indicating whether or not we are in the initial block download pub fn process_burnchain_state( &mut self, sortdb: &SortitionDB, @@ -1117,7 +1113,12 @@ impl InitializedNeonNode { impl NeonGenesisNode { /// Instantiate and initialize a new node, given a config - pub fn new(config: Config, mut event_dispatcher: EventDispatcher, boot_block_exec: F) -> Self + pub fn new( + config: Config, + mut event_dispatcher: EventDispatcher, + burnchain: Burnchain, + boot_block_exec: F, + ) -> Self where F: FnOnce(&mut ClarityTx) -> (), { @@ -1151,6 +1152,7 @@ impl NeonGenesisNode { keychain, config, event_dispatcher, + burnchain, } } @@ -1172,6 +1174,7 @@ impl NeonGenesisNode { true, blocks_processed, coord_comms, + self.burnchain, ) } @@ -1193,6 +1196,7 @@ impl NeonGenesisNode { false, blocks_processed, coord_comms, + self.burnchain, ) } } diff --git a/testnet/stacks-node/src/run_loop/neon.rs b/testnet/stacks-node/src/run_loop/neon.rs index b471beeab28..01d137b0cc2 100644 --- a/testnet/stacks-node/src/run_loop/neon.rs +++ b/testnet/stacks-node/src/run_loop/neon.rs @@ -83,15 +83,18 @@ impl RunLoop { /// It will start the burnchain (separate thread), set-up a channel in /// charge of coordinating the new blocks coming from the burnchain and /// the nodes, taking turns on tenures. - pub fn start(&mut self, _expected_num_rounds: u64) { + pub fn start(&mut self, _expected_num_rounds: u64, burnchain_opt: Option) { let (coordinator_receivers, coordinator_senders) = self .coordinator_channels .take() .expect("Run loop already started, can only start once after initialization."); // Initialize and start the burnchain. - let mut burnchain = - BitcoinRegtestController::new(self.config.clone(), Some(coordinator_senders.clone())); + let mut burnchain = BitcoinRegtestController::with_burnchain( + self.config.clone(), + Some(coordinator_senders.clone()), + burnchain_opt, + ); let pox_constants = burnchain.get_pox_constants(); let is_miner = if self.config.node.miner { @@ -136,7 +139,6 @@ impl RunLoop { .iter() .map(|e| (e.address.clone(), e.amount)) .collect(); - let burnchain_poll_time = 30; // TODO: this is testnet-specific // setup dispatcher let mut event_dispatcher = EventDispatcher::new(); @@ -145,17 +147,7 @@ impl RunLoop { } let mut coordinator_dispatcher = event_dispatcher.clone(); - let burnchain_config = match Burnchain::new( - &self.config.get_burn_db_path(), - &self.config.burnchain.chain, - "regtest", - ) { - Ok(burnchain) => burnchain, - Err(e) => { - error!("Failed to instantiate burnchain: {}", e); - panic!() - } - }; + let burnchain_config = burnchain.get_burnchain(); let chainstate_path = self.config.get_chainstate_path(); let coordinator_burnchain_config = burnchain_config.clone(); @@ -178,7 +170,12 @@ impl RunLoop { let mut block_height = burnchain_tip.block_snapshot.block_height; // setup genesis - let node = NeonGenesisNode::new(self.config.clone(), event_dispatcher, |_| {}); + let node = NeonGenesisNode::new( + self.config.clone(), + event_dispatcher, + burnchain_config.clone(), + |_| {}, + ); let mut node = if is_miner { node.into_initialized_leader_node( burnchain_tip.clone(), @@ -212,8 +209,8 @@ impl RunLoop { mainnet, chainid, chainstate_path, - burnchain_poll_time, - self.config.connection_options.timeout, + self.config.burnchain.poll_time_secs, + self.config.node.pox_sync_sample_secs, ) .unwrap(); let mut burnchain_height = 1; diff --git a/testnet/stacks-node/src/syncctl.rs b/testnet/stacks-node/src/syncctl.rs index b46093b355b..e0becb83297 100644 --- a/testnet/stacks-node/src/syncctl.rs +++ b/testnet/stacks-node/src/syncctl.rs @@ -43,6 +43,8 @@ pub struct PoxSyncWatchdog { chainstate: StacksChainState, } +const PER_SAMPLE_WAIT_MS: u64 = 1000; + impl PoxSyncWatchdog { pub fn new( mainnet: bool, @@ -350,7 +352,7 @@ impl PoxSyncWatchdog { &self.max_samples ); } - sleep_ms(1000); + sleep_ms(PER_SAMPLE_WAIT_MS); continue; } @@ -359,7 +361,7 @@ impl PoxSyncWatchdog { { // still waiting for that first block in this reward cycle debug!("PoX watchdog: Still warming up: waiting until {}s for first Stacks block download (estimated download time: {}s)...", expected_first_block_deadline, self.estimated_block_download_time); - sleep_ms(1000); + sleep_ms(PER_SAMPLE_WAIT_MS); continue; } @@ -406,7 +408,7 @@ impl PoxSyncWatchdog { { debug!("PoX watchdog: Still processing blocks; waiting until at least min({},{})s before burnchain synchronization (estimated block-processing time: {}s)", get_epoch_time_secs() + 1, expected_last_block_deadline, self.estimated_block_process_time); - sleep_ms(1000); + sleep_ms(PER_SAMPLE_WAIT_MS); continue; } @@ -418,7 +420,7 @@ impl PoxSyncWatchdog { flat_attachable, flat_processed, &attachable_deviants, &processed_deviants); if !flat_attachable || !flat_processed { - sleep_ms(1000); + sleep_ms(PER_SAMPLE_WAIT_MS); continue; } } else { @@ -429,7 +431,7 @@ impl PoxSyncWatchdog { debug!("PoX watchdog: In steady-state; waiting until at least {} before burnchain synchronization", self.steady_state_resync_ts); steady_state = true; } - sleep_ms(1000); + sleep_ms(PER_SAMPLE_WAIT_MS); continue; } } diff --git a/testnet/stacks-node/src/tests/neon_integrations.rs b/testnet/stacks-node/src/tests/neon_integrations.rs index 87a72fc2330..fab30cf8a81 100644 --- a/testnet/stacks-node/src/tests/neon_integrations.rs +++ b/testnet/stacks-node/src/tests/neon_integrations.rs @@ -2,12 +2,13 @@ use super::{ make_contract_call, make_contract_publish, make_contract_publish_microblock_only, make_microblock, make_stacks_transfer_mblock_only, to_addr, ADDR_4, SK_1, }; -use stacks::burnchains::{Address, PublicKey}; +use stacks::burnchains::{Address, PoxConstants, PublicKey}; use stacks::chainstate::burn::ConsensusHash; use stacks::chainstate::stacks::{ db::StacksChainState, StacksAddress, StacksBlock, StacksBlockHeader, StacksPrivateKey, StacksPublicKey, StacksTransaction, }; +use stacks::core; use stacks::net::StacksMessageCodec; use stacks::util::secp256k1::Secp256k1PublicKey; use stacks::vm::costs::ExecutionCost; @@ -44,6 +45,9 @@ fn neon_integration_test_conf() -> (Config, StacksAddress) { Some(keychain.generate_op_signer().get_public_key().to_hex()); conf.burnchain.commit_anchor_block_within = 0; + conf.burnchain.poll_time_secs = 1; + conf.node.pox_sync_sample_secs = 1; + let miner_account = keychain.origin_address().unwrap(); (conf, miner_account) @@ -212,7 +216,7 @@ fn bitcoind_integration_test() { let channel = run_loop.get_coordinator_channel().unwrap(); - thread::spawn(move || run_loop.start(0)); + thread::spawn(move || run_loop.start(0, None)); // give the run loop some time to start up! wait_for_runloop(&blocks_processed); @@ -290,7 +294,7 @@ fn microblock_integration_test() { let channel = run_loop.get_coordinator_channel().unwrap(); - thread::spawn(move || run_loop.start(0)); + thread::spawn(move || run_loop.start(0, None)); // give the run loop some time to start up! wait_for_runloop(&blocks_processed); @@ -577,7 +581,7 @@ fn size_check_integration_test() { let client = reqwest::blocking::Client::new(); let channel = run_loop.get_coordinator_channel().unwrap(); - thread::spawn(move || run_loop.start(0)); + thread::spawn(move || run_loop.start(0, None)); // give the run loop some time to start up! wait_for_runloop(&blocks_processed); @@ -701,6 +705,12 @@ fn pox_integration_test() { let spender_sk = StacksPrivateKey::new(); let spender_addr: PrincipalData = to_addr(&spender_sk).into(); + let spender_2_sk = StacksPrivateKey::new(); + let spender_2_addr: PrincipalData = to_addr(&spender_2_sk).into(); + + let spender_3_sk = StacksPrivateKey::new(); + let spender_3_addr: PrincipalData = to_addr(&spender_3_sk).into(); + let pox_pubkey = Secp256k1PublicKey::from_hex( "02f006a09b59979e2cb8449f58076152af6b124aa29b948a3714b8d5f15aa94ede", ) @@ -711,14 +721,33 @@ fn pox_integration_test() { .to_vec(), ); + let pox_2_pubkey = Secp256k1PublicKey::from_private(&StacksPrivateKey::new()); + let pox_2_pubkey_hash = bytes_to_hex( + &Hash160::from_data(&pox_2_pubkey.to_bytes()) + .to_bytes() + .to_vec(), + ); + let (mut conf, miner_account) = neon_integration_test_conf(); - let total_bal = 10_000_000_000; - let stacked_bal = 1_000_000_000; + let first_bal = 6_000_000_000 * (core::MICROSTACKS_PER_STACKS as u64); + let second_bal = 2_000_000_000 * (core::MICROSTACKS_PER_STACKS as u64); + let third_bal = 2_000_000_000 * (core::MICROSTACKS_PER_STACKS as u64); + let stacked_bal = 1_000_000_000 * (core::MICROSTACKS_PER_STACKS as u128); conf.initial_balances.push(InitialBalance { address: spender_addr.clone(), - amount: total_bal, + amount: first_bal, + }); + + conf.initial_balances.push(InitialBalance { + address: spender_2_addr.clone(), + amount: second_bal, + }); + + conf.initial_balances.push(InitialBalance { + address: spender_3_addr.clone(), + amount: third_bal, }); let mut btcd_controller = BitcoinCoreController::new(conf.clone()); @@ -730,16 +759,20 @@ fn pox_integration_test() { let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None); let http_origin = format!("http://{}", &conf.node.rpc_bind); + let mut burnchain_config = btc_regtest_controller.get_burnchain(); + let mut pox_constants = PoxConstants::new(10, 5, 4, 5, 15); + burnchain_config.pox_constants = pox_constants; + btc_regtest_controller.bootstrap_chain(201); eprintln!("Chain bootstrapped..."); - let mut run_loop = neon::RunLoop::new(conf); + let mut run_loop = neon::RunLoop::new(conf.clone()); let blocks_processed = run_loop.get_blocks_processed_arc(); let client = reqwest::blocking::Client::new(); let channel = run_loop.get_coordinator_channel().unwrap(); - thread::spawn(move || run_loop.start(0)); + thread::spawn(move || run_loop.start(0, Some(burnchain_config))); // give the run loop some time to start up! wait_for_runloop(&blocks_processed); @@ -753,6 +786,8 @@ fn pox_integration_test() { // second block will be the first mined Stacks block next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + let sort_height = channel.get_sortitions_processed(); + // let's query the miner's account nonce: eprintln!("Miner account: {}", miner_account); @@ -779,14 +814,14 @@ fn pox_integration_test() { .unwrap(); assert_eq!( u128::from_str_radix(&res.balance[2..], 16).unwrap(), - total_bal as u128 + first_bal as u128 ); assert_eq!(res.nonce, 0); let tx = make_contract_call( &spender_sk, 0, - 243, + 260, &StacksAddress::from_string("ST000000000000000000002AMW42H").unwrap(), "pox", "stack-stx", @@ -798,6 +833,7 @@ fn pox_integration_test() { )) .unwrap() .unwrap(), + Value::UInt(sort_height as u128), Value::UInt(3), ], ); @@ -825,45 +861,156 @@ fn pox_integration_test() { panic!(""); } - // now let's mine a couple blocks, and then check the sender's nonce. - // at the end of mining three blocks, there should be _one_ transaction from the microblock - // only set that got mined (since the block before this one was empty, a microblock can - // be added), - // and _two_ transactions from the two anchor blocks that got mined (and processed) - // - // this one wakes up our node, so that it'll mine a microblock _and_ an anchor block. - next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); - // this one will contain the sortition from above anchor block, - // which *should* have also confirmed the microblock. - next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + let mut sort_height = channel.get_sortitions_processed(); + eprintln!("Sort height: {}", sort_height); - next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + // now let's mine until the next reward cycle starts ... + while sort_height < 222 { + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + sort_height = channel.get_sortitions_processed(); + eprintln!("Sort height: {}", sort_height); + } - // let's figure out how many micro-only and anchor-only txs got accepted - // by examining our account nonces: - let path = format!("{}/v2/accounts/{}?proof=0", &http_origin, spender_addr); + // let's stack with spender 2 and spender 3... + + // now let's have sender_2 and sender_3 stack to pox addr 2 in + // two different txs, and make sure that they sum together in the reward set. + + let tx = make_contract_call( + &spender_2_sk, + 0, + 260, + &StacksAddress::from_string("ST000000000000000000002AMW42H").unwrap(), + "pox", + "stack-stx", + &[ + Value::UInt(stacked_bal / 2), + execute(&format!( + "{{ hashbytes: 0x{}, version: 0x00 }}", + pox_2_pubkey_hash + )) + .unwrap() + .unwrap(), + Value::UInt(sort_height as u128), + Value::UInt(3), + ], + ); + + // okay, let's push that stacking transaction! + let path = format!("{}/v2/transactions", &http_origin); let res = client - .get(&path) + .post(&path) + .header("Content-Type", "application/octet-stream") + .body(tx.clone()) + .send() + .unwrap(); + eprintln!("{:#?}", res); + if res.status().is_success() { + let res: String = res.json().unwrap(); + assert_eq!( + res, + StacksTransaction::consensus_deserialize(&mut &tx[..]) + .unwrap() + .txid() + .to_string() + ); + } else { + eprintln!("{}", res.text().unwrap()); + panic!(""); + } + + let tx = make_contract_call( + &spender_3_sk, + 0, + 260, + &StacksAddress::from_string("ST000000000000000000002AMW42H").unwrap(), + "pox", + "stack-stx", + &[ + Value::UInt(stacked_bal / 2), + execute(&format!( + "{{ hashbytes: 0x{}, version: 0x00 }}", + pox_2_pubkey_hash + )) + .unwrap() + .unwrap(), + Value::UInt(sort_height as u128), + Value::UInt(3), + ], + ); + + // okay, let's push that stacking transaction! + let path = format!("{}/v2/transactions", &http_origin); + let res = client + .post(&path) + .header("Content-Type", "application/octet-stream") + .body(tx.clone()) .send() - .unwrap() - .json::() .unwrap(); - if res.nonce != 1 { - assert_eq!(res.nonce, 1, "Spender address nonce should be 1"); + eprintln!("{:#?}", res); + if res.status().is_success() { + let res: String = res.json().unwrap(); + assert_eq!( + res, + StacksTransaction::consensus_deserialize(&mut &tx[..]) + .unwrap() + .txid() + .to_string() + ); + } else { + eprintln!("{}", res.text().unwrap()); + panic!(""); } - // now let's mine until the next reward cycle starts ... - for _i in 0..35 { + // mine until the end of the current reward cycle. + sort_height = channel.get_sortitions_processed(); + while sort_height < 229 { next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + sort_height = channel.get_sortitions_processed(); + eprintln!("Sort height: {}", sort_height); } - // we should have received a Bitcoin commitment - let utxos = btc_regtest_controller - .get_utxos(&pox_pubkey, 1) - .expect("Should have been able to retrieve UTXOs for PoX recipient"); + // we should have received _no_ Bitcoin commitments, because the pox participation threshold + // was not met! + let utxos = btc_regtest_controller.get_all_utxos(&pox_pubkey); + eprintln!("Got UTXOs: {}", utxos.len()); + assert_eq!( + utxos.len(), + 0, + "Should have received no outputs during PoX reward cycle" + ); + + // mine until the end of the next reward cycle, + // the participation threshold now should be met. + while sort_height < 239 { + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + sort_height = channel.get_sortitions_processed(); + eprintln!("Sort height: {}", sort_height); + } + + // we should have received _three_ Bitcoin commitments, because our commitment was 3 * threshold + let utxos = btc_regtest_controller.get_all_utxos(&pox_pubkey); eprintln!("Got UTXOs: {}", utxos.len()); - assert!(utxos.len() > 0, "Should have received an output during PoX"); + assert_eq!( + utxos.len(), + 3, + "Should have received three outputs during PoX reward cycle" + ); + + // we should have received _three_ Bitcoin commitments to pox_2_pubkey, because our commitment was 3 * threshold + // note: that if the reward set "summing" isn't implemented, this recipient would only have received _2_ slots, + // because each `stack-stx` call only received enough to get 1 slot individually. + let utxos = btc_regtest_controller.get_all_utxos(&pox_2_pubkey); + + eprintln!("Got UTXOs: {}", utxos.len()); + assert_eq!( + utxos.len(), + 3, + "Should have received three outputs during PoX reward cycle" + ); + + // okay, the threshold for participation should be channel.stop_chains_coordinator(); }