diff --git a/changelog.d/7341-cost-voting-epoch-4-0-gate.changed b/changelog.d/7341-cost-voting-epoch-4-0-gate.changed new file mode 100644 index 00000000000..abb315646b5 --- /dev/null +++ b/changelog.d/7341-cost-voting-epoch-4-0-gate.changed @@ -0,0 +1 @@ +Added `.cost-voting` disablement gate beginning from epoch 4.0 diff --git a/clarity/src/vm/costs/mod.rs b/clarity/src/vm/costs/mod.rs index 9efcab817cd..9e14915e88c 100644 --- a/clarity/src/vm/costs/mod.rs +++ b/clarity/src/vm/costs/mod.rs @@ -953,18 +953,29 @@ impl TrackerData { )) })?; + // TODO(cost-voting): Remove after epoch 4.0 activation (when cost-voting deactivates), and + // use `CostStateSummary::empty()` directly instead. + let state_summary = if epoch_id.supports_cost_voting_contract() { + // If cost-voting is active, load and apply the current cost function configuration from + // the chain tip, applying any changes that have been voted on but not yet applied. + load_cost_functions(self.mainnet, clarity_db, apply_updates).map_err(|e| { + let result = clarity_db + .roll_back() + .map_err(|e| CostErrors::Expect(e.to_string())); + match result { + Ok(_) => e, + Err(rollback_err) => rollback_err, + } + })? + } else { + // Cost-voting is retired at Epoch 4.0: every cost function uses boot defaults. + CostStateSummary::empty() + }; + let CostStateSummary { contract_call_circuits, mut cost_function_references, - } = load_cost_functions(self.mainnet, clarity_db, apply_updates).map_err(|e| { - let result = clarity_db - .roll_back() - .map_err(|e| CostErrors::Expect(e.to_string())); - match result { - Ok(_) => e, - Err(rollback_err) => rollback_err, - } - })?; + } = state_summary; self.contract_call_circuits = contract_call_circuits; diff --git a/clarity/src/vm/tests/epoch_gating.rs b/clarity/src/vm/tests/epoch_gating.rs index 902bdbb26a8..2faf3ae86db 100644 --- a/clarity/src/vm/tests/epoch_gating.rs +++ b/clarity/src/vm/tests/epoch_gating.rs @@ -12,7 +12,7 @@ // // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use stacks_common::types::StacksEpochId; +use stacks_common::types::{StacksEpochId, StacksEpochRangeTestExt as _}; use crate::vm::ClarityVersion; @@ -21,7 +21,7 @@ use crate::vm::ClarityVersion; #[test] fn test_default_for_epoch_is_monotonic() { // No Clarity in Epoch10. - let clarity_epochs = &StacksEpochId::since(StacksEpochId::Epoch20); + let clarity_epochs = (StacksEpochId::Epoch20..).as_slice(); for window in clarity_epochs.windows(2) { let earlier = ClarityVersion::default_for_epoch(window[0]); let later = ClarityVersion::default_for_epoch(window[1]); diff --git a/stacks-common/src/types/mod.rs b/stacks-common/src/types/mod.rs index 9ed0a014c5d..f86afc73e3f 100644 --- a/stacks-common/src/types/mod.rs +++ b/stacks-common/src/types/mod.rs @@ -17,6 +17,8 @@ use std::cmp::Ordering; use std::fmt; use std::io::{Read, Write}; +#[cfg(any(test, feature = "testing"))] +use std::ops::{Bound, RangeBounds}; use std::ops::{Deref, DerefMut, Index, IndexMut}; use std::str::FromStr; use std::sync::LazyLock; @@ -596,6 +598,12 @@ impl StacksEpochId { } } + /// Whether or not this epoch supports the cost-voting contract (SIP-006), which is + /// disabled from Epoch 4.0 (SIP-044). + pub fn supports_cost_voting_contract(&self) -> bool { + self < &StacksEpochId::Epoch40 + } + /// Returns true for epochs which use Nakamoto blocks. These blocks use a /// different header format than the previous Stacks blocks, which among /// other changes includes a Stacks-specific timestamp. @@ -814,34 +822,84 @@ impl StacksEpochId { StacksEpochId::Epoch40 => PEER_VERSION_EPOCH_4_0, } } +} - #[cfg(any(test, feature = "testing"))] - pub fn since(epoch: StacksEpochId) -> &'static [StacksEpochId] { - let idx = Self::ALL +/// Test-only helper functions for `StacksEpochId`. +/// +/// These functions rely on the [`StacksEpochId::ALL`] array of all defined epochs and are only +/// intended to be used in testing as they may return variants that are not yet active according to +/// [`StacksEpochId::RELEASE_LATEST_EPOCH`]. +#[cfg(any(test, feature = "testing"))] +impl StacksEpochId { + /// Gets the index of the provided `epoch` within the [`ALL`](StacksEpochId::ALL) array of + /// defined epochs. + fn index_of(epoch: Self) -> usize { + Self::ALL .iter() .position(|&e| e == epoch) - .expect("epoch not found in ALL"); + .expect("epoch not found in ALL") + } - &Self::ALL[idx..] + /// Returns all [`StacksEpochId`] variants after the provided `epoch` (exclusive). + /// + /// Provided as a helper function since this can't be expressed as a range (there's no + /// "excluded" start-bound syntax). + /// + /// Useful for iterating over all epochs _after_ a specific epoch, when the next epoch may not + /// yet be known or defined (e.g. in tests that want to assert an invariant for all future + /// [`StacksEpochId`] variants). + pub fn all_after(epoch: Self) -> impl Iterator { + (Bound::Excluded(epoch), Bound::Unbounded).iter().copied() } - /// Returns all [`StacksEpochId`] from `start` to `end`, both inclusive. - #[cfg(any(test, feature = "testing"))] - pub fn between(start: StacksEpochId, end: StacksEpochId) -> &'static [StacksEpochId] { - let start_idx = Self::ALL - .iter() - .position(|&e| e == start) - .expect("start epoch not found in ALL"); - let end_idx = Self::ALL - .iter() - .position(|&e| e == end) - .expect("end epoch not found in ALL"); - assert!(start_idx <= end_idx, "start epoch must be <= end epoch"); + /// Returns the first (lowest) [`StacksEpochId`] variant. + pub const fn first() -> StacksEpochId { + Self::ALL[0] + } + + /// Returns the last (highest) defined [`StacksEpochId`] variant. + pub const fn last() -> StacksEpochId { + Self::ALL[Self::ALL.len() - 1] + } +} + +/// Extension methods for iterating over standard Rust range bounds of [`StacksEpochId`]. +/// Note: When `Step` stabilizes, this can be refactored. +#[cfg(any(test, feature = "testing"))] +pub trait StacksEpochRangeTestExt: RangeBounds + Sized { + /// Iterates by reference over all [`StacksEpochId`] variants in this range. + /// + /// Forgiving: behaves like standard `Range` iterators in that `start >= end` results in an + /// empty iterator. + fn iter(&self) -> std::slice::Iter<'static, StacksEpochId> { + let start = match self.start_bound() { + Bound::Included(epoch) => StacksEpochId::index_of(*epoch), + Bound::Excluded(epoch) => StacksEpochId::index_of(*epoch) + 1, + Bound::Unbounded => 0, + }; + + let end = match self.end_bound() { + Bound::Included(epoch) => StacksEpochId::index_of(*epoch) + 1, + Bound::Excluded(epoch) => StacksEpochId::index_of(*epoch), + Bound::Unbounded => StacksEpochId::ALL.len(), + }; - &Self::ALL[start_idx..=end_idx] + // Yield an empty slice if end <= start, mirroring standard Rust behavior. + let end = end.max(start); + StacksEpochId::ALL[start..end].iter() + } + + /// Returns a slice of all [`StacksEpochId`] variants in this range. + fn as_slice(&self) -> &'static [StacksEpochId] { + self.iter().as_slice() } } +/// Implement [`StacksEpochRangeTestExt`] for [`StacksEpochId`] ranges (e.g. +/// `StacksEpochId::Epoch10..=StacksEpochId::Epoch23`). +#[cfg(any(test, feature = "testing"))] +impl StacksEpochRangeTestExt for R where R: RangeBounds {} + impl PartialOrd for StacksAddress { fn partial_cmp(&self, other: &StacksAddress) -> Option { Some(self.cmp(other)) diff --git a/stacks-common/src/types/tests.rs b/stacks-common/src/types/tests.rs index f5b8274f577..89bff743494 100644 --- a/stacks-common/src/types/tests.rs +++ b/stacks-common/src/types/tests.rs @@ -14,12 +14,119 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::ops::Bound; use std::str::FromStr; use super::{ set_test_coinbase_schedule, CoinbaseInterval, StacksEpochId, COINBASE_INTERVALS_MAINNET, COINBASE_INTERVALS_TESTNET, }; +use crate::types::StacksEpochRangeTestExt; + +#[test] +fn test_epoch40_cost_voting_contract_support_gate() { + // Up to (excluding) Epoch 4.0 = enabled. + for epoch in (..StacksEpochId::Epoch40).iter() { + assert!( + epoch.supports_cost_voting_contract(), + "cost-voting gate must remain active before Epoch 4.0 for {epoch}" + ); + } + + // Epoch 4.0 and onward = disabled. + for epoch in (StacksEpochId::Epoch40..).iter() { + assert!( + !epoch.supports_cost_voting_contract(), + "cost-voting gate must be inactive from Epoch 4.0 onward for {epoch}" + ); + } +} + +#[test] +fn test_epoch_range_ext_iter() { + // Alias to keep the below cleaner + fn epoch_index(epoch: StacksEpochId) -> usize { + StacksEpochId::index_of(epoch) + } + + // Full range is effectively equivalent to the ALL constant. + assert_eq!((..).as_slice(), StacksEpochId::ALL); + + // Start = inclusive, end = unbounded + assert_eq!( + (StacksEpochId::Epoch32..).as_slice(), + &StacksEpochId::ALL[epoch_index(StacksEpochId::Epoch32)..] + ); + + // Start = unbounded, end = exclusive + assert_eq!( + (..StacksEpochId::Epoch21).as_slice(), + &[ + StacksEpochId::Epoch10, + StacksEpochId::Epoch20, + StacksEpochId::Epoch2_05 + ] + ); + + // Start = unbounded, end = inclusive + assert_eq!( + (..=StacksEpochId::Epoch21).as_slice(), + &[ + StacksEpochId::Epoch10, + StacksEpochId::Epoch20, + StacksEpochId::Epoch2_05, + StacksEpochId::Epoch21 + ] + ); + + // Start = inclusive, end = exclusive + assert_eq!( + (StacksEpochId::Epoch20..StacksEpochId::Epoch22).as_slice(), + &[ + StacksEpochId::Epoch20, + StacksEpochId::Epoch2_05, + StacksEpochId::Epoch21 + ] + ); + + // Start = inclusive, end = inclusive + assert_eq!( + (StacksEpochId::Epoch20..=StacksEpochId::Epoch22).as_slice(), + &[ + StacksEpochId::Epoch20, + StacksEpochId::Epoch2_05, + StacksEpochId::Epoch21, + StacksEpochId::Epoch22 + ] + ); + + // Start = exclusive, end = unbounded (a'la `all_after`). + let expected = &StacksEpochId::ALL[epoch_index(StacksEpochId::Epoch32) + 1..]; + assert_eq!( + (Bound::Excluded(StacksEpochId::Epoch32), Bound::Unbounded).as_slice(), + expected + ); + let all_after: Vec<_> = StacksEpochId::all_after(StacksEpochId::Epoch32).collect(); + assert_eq!(&all_after, expected); + + // Single element range with exclusive end bound should yield empty. + assert_eq!( + (StacksEpochId::Epoch23..StacksEpochId::Epoch23).as_slice(), + &[] + ); + + // Single element range with inclusive end bound should yield that item. + assert_eq!( + (StacksEpochId::Epoch23..=StacksEpochId::Epoch23).as_slice(), + &[StacksEpochId::Epoch23] + ); + + // Reversed range should yield empty. + assert_eq!( + (StacksEpochId::Epoch22..StacksEpochId::Epoch20).as_slice(), + &[] + ); +} #[test] fn test_stacks_epoch_id_display_fromstr_tryfrom_roundtrip() { diff --git a/stackslib/src/chainstate/tests/consensus.rs b/stackslib/src/chainstate/tests/consensus.rs index 582171d1aa0..45cb8e5c812 100644 --- a/stackslib/src/chainstate/tests/consensus.rs +++ b/stackslib/src/chainstate/tests/consensus.rs @@ -19,7 +19,7 @@ use clarity::boot_util::boot_code_addr; use clarity::codec::StacksMessageCodec; use clarity::consts::{CHAIN_ID_TESTNET, STACKS_EPOCH_MAX}; use clarity::types::chainstate::{StacksAddress, StacksPrivateKey, StacksPublicKey, TrieHash}; -use clarity::types::{EpochList, StacksEpoch, StacksEpochId}; +use clarity::types::{EpochList, StacksEpoch, StacksEpochId, StacksEpochRangeTestExt as _}; use clarity::util::hash::{Hash160, MerkleTree, Sha512Trunc256Sum}; use clarity::util::secp256k1::MessageSignature; use clarity::vm::costs::ExecutionCost; @@ -72,14 +72,14 @@ pub fn max_tested_epoch() -> StacksEpochId { .expect("EPOCHS_TO_TEST must contain at least one epoch") } -/// Like [`StacksEpochId::since`], but clamped to [`max_tested_epoch`]. Tests use -/// this to build deploy/call epoch ranges so that excluded epochs are never -/// deployed in or called in. Deploying or calling in an excluded epoch would -/// otherwise keep that epoch's results (marf hashes, costs) in the snapshots and -/// reintroduce the churn that excluding it is meant to avoid. +/// Like a `(start..)` range, but clamped to [`max_tested_epoch`]. +/// +/// Tests use this to build deploy/call epoch ranges so that excluded epochs are never used, which +/// otherwise would keep that epoch's results (marf hashes, costs) in the snapshots and reintroduce +/// the churn that excluding it is meant to avoid. pub fn tested_epochs_since(start: StacksEpochId) -> Vec { let max = max_tested_epoch(); - StacksEpochId::since(start) + (start..) .iter() .copied() .filter(|epoch| *epoch <= max) diff --git a/stackslib/src/chainstate/tests/consensus_unit_tests.rs b/stackslib/src/chainstate/tests/consensus_unit_tests.rs index 4c9932e1337..218736f7551 100644 --- a/stackslib/src/chainstate/tests/consensus_unit_tests.rs +++ b/stackslib/src/chainstate/tests/consensus_unit_tests.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use clarity::types::StacksEpochId; +use clarity::types::{StacksEpochId, StacksEpochRangeTestExt as _}; use clarity::vm::{ClarityVersion, Value}; use crate::chainstate::tests::consensus::{ @@ -25,7 +25,7 @@ fn test_example_1_cdeploy() { let report = contract_deploy_consensus_unit_test!( contract_name: "map_empty", contract_code: "(map + (list) (list 10 20))", - deploy_epochs: StacksEpochId::since(StacksEpochId::Epoch20), + deploy_epochs: (StacksEpochId::Epoch20..).as_slice(), clarity_versions: ClarityVersion::ALL, ); @@ -57,7 +57,7 @@ fn test_example_2_ccall() { ", function_name: "trigger", function_args: &[], - deploy_epochs: StacksEpochId::since(StacksEpochId::Epoch20), + deploy_epochs: (StacksEpochId::Epoch20..).as_slice(), clarity_versions: ClarityVersion::ALL, ); diff --git a/stackslib/src/chainstate/tests/runtime_analysis_tests.rs b/stackslib/src/chainstate/tests/runtime_analysis_tests.rs index d6e66595a10..85eb9b6accf 100644 --- a/stackslib/src/chainstate/tests/runtime_analysis_tests.rs +++ b/stackslib/src/chainstate/tests/runtime_analysis_tests.rs @@ -17,7 +17,7 @@ use std::collections::HashMap; -use clarity::types::StacksEpochId; +use clarity::types::{StacksEpochId, StacksEpochRangeTestExt as _}; #[allow(unused_imports)] use clarity::vm::analysis::RuntimeCheckErrorKind; use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, MAX_TYPE_DEPTH}; @@ -613,7 +613,7 @@ fn runtime_check_error_kind_type_error_ccall() { (get-shares u999 .pool))", function_name: "trigger-error", function_args: &[], - deploy_epochs: StacksEpochId::between(StacksEpochId::Epoch20, StacksEpochId::Epoch33), + deploy_epochs: (StacksEpochId::Epoch20..=StacksEpochId::Epoch33).as_slice(), call_epochs: &[StacksEpochId::Epoch33], setup_contracts: &[contract_1, contract_2], ); diff --git a/stackslib/src/chainstate/tests/runtime_tests.rs b/stackslib/src/chainstate/tests/runtime_tests.rs index 7c4ef09aa86..6b77b00494a 100644 --- a/stackslib/src/chainstate/tests/runtime_tests.rs +++ b/stackslib/src/chainstate/tests/runtime_tests.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; use clarity::types::chainstate::{StacksPrivateKey, StacksPublicKey}; -use clarity::types::StacksEpochId; +use clarity::types::{StacksEpochId, StacksEpochRangeTestExt as _}; use clarity::vm::errors::RuntimeError; use clarity::vm::types::{PrincipalData, ResponseData}; use clarity::vm::{max_call_stack_depth_for_epoch, ClarityVersion, Value as ClarityValue}; @@ -682,7 +682,7 @@ fn unknown_block_header_hash_fork() { )", function_name: "trigger", function_args: &[], - deploy_epochs: StacksEpochId::between(StacksEpochId::Epoch20, StacksEpochId::Epoch33), + deploy_epochs: (StacksEpochId::Epoch20..=StacksEpochId::Epoch33).as_slice(), call_epochs: &[StacksEpochId::Epoch33], ); } @@ -708,7 +708,7 @@ fn bad_block_hash() { )", function_name: "trigger", function_args: &[], - deploy_epochs: StacksEpochId::between(StacksEpochId::Epoch20, StacksEpochId::Epoch33), + deploy_epochs: (StacksEpochId::Epoch20..=StacksEpochId::Epoch33).as_slice(), call_epochs: &[StacksEpochId::Epoch33], ); } diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index 5d3e44961b2..50f3451f3b0 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -1037,6 +1037,77 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { Ok(boot_code_account) } + /// Prepares a [`StacksTransaction`] with a [`SmartContract`](TransactionPayload::SmartContract) + /// payload for instantiating a boot contract, but does not execute it. + /// + /// Sets the transaction's auth to be the boot code account and the transaction version based on + /// this instance's `mainnet` field. + fn make_boot_code_smart_contract_tx( + &mut self, + contract_name: &str, + code_body: &str, + clarity_version: Option, + ) -> Result<(StacksAccount, StacksTransaction), ClarityError> { + let boot_code_account = self.get_boot_code_account()?; + let tx_version = if self.mainnet { + TransactionVersion::Mainnet + } else { + TransactionVersion::Testnet + }; + + let boot_code_auth = boot_code_tx_auth(boot_code_addr(self.mainnet)); + let payload = TransactionPayload::SmartContract( + TransactionSmartContract { + name: ContractName::try_from(contract_name.to_string()) + .expect("FATAL: invalid boot-code contract name"), + code_body: StacksString::from_str(code_body) + .expect("FATAL: invalid boot code body"), + }, + clarity_version, + ); + + Ok(( + boot_code_account, + StacksTransaction::new(tx_version, boot_code_auth, payload), + )) + } + + /// Instantiates a boot contract by: + /// + /// 1. Preparing a [`StacksTransaction`] with the appropriate version, payload and auth, + /// 2. Executing it using the boot account within a cost-free transaction, and + /// 3. Asserting the receipt for success. + /// + /// Panics if any of the above steps fail. + fn instantiate_boot_contract( + &mut self, + contract_name: &str, + code_body: &str, + clarity_version: Option, + ) -> Result { + let contract_id = boot_code_id(contract_name, self.mainnet); + + let (boot_code_account, contract_tx) = + self.make_boot_code_smart_contract_tx(contract_name, code_body, clarity_version)?; + + let receipt = self.as_free_transaction(|tx_conn| { + info!("Instantiate {} contract", &contract_id); + StacksChainState::process_transaction_payload( + tx_conn, + &contract_tx, + &boot_code_account, + None, + ) + .expect("FATAL: Failed to process boot contract initialization") + }); + + if receipt.result != Value::okay_true() || receipt.post_condition_aborted { + panic!("FATAL: Failure processing {contract_id} contract initialization: {receipt:#?}"); + } + + Ok(receipt) + } + pub fn initialize_epoch_2_05(&mut self) -> Result { // use the `using!` statement to ensure that the old cost_tracker is placed // back in all branches after initialization @@ -2002,6 +2073,27 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { }) } + /// Instantiates epoch 4.0's `costs-5` cost contract using the [`COSTS_5_NAME`] and + /// [`BOOT_CODE_COSTS_5`] constants. + /// + /// Returns an error if this instance's epoch is not already set to + /// [`Epoch40`](StacksEpochId::Epoch40). + /// + /// Used both by epoch 4.0 initialization and by tests which need to instantiate the costs-5 + /// contract without full epoch 4.0 initialization. + pub(super) fn instantiate_epoch_4_0_cost_contract( + &mut self, + ) -> Result { + if self.epoch != StacksEpochId::Epoch40 { + return Err(ClarityError::BadTransaction(format!( + "Epoch 4.0 cost contract initialization requires Epoch 4.0 rules; current epoch is {}", + self.epoch + ))); + } + + self.instantiate_boot_contract(COSTS_5_NAME, BOOT_CODE_COSTS_5, None) + } + pub fn initialize_epoch_4_0(&mut self) -> Result, ClarityError> { // use the `using!` statement to ensure that the old cost_tracker is placed // back in all branches after initialization @@ -2036,70 +2128,21 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { .expect("PANIC: PoX-5 first reward cycle begins *before* first burn block height") + 1; - let boot_code_account = self - .get_boot_code_account() - .expect("FATAL: did not get boot account"); - - let mainnet = self.mainnet; - let tx_version = if mainnet { - TransactionVersion::Mainnet - } else { - TransactionVersion::Testnet - }; - - let boot_code_address = boot_code_addr(mainnet); - let boot_code_auth = boot_code_tx_auth(boot_code_address); - /////////////////// .costs-5 //////////////////////// - let costs_5_contract_id = boot_code_id(COSTS_5_NAME, mainnet); - let payload = TransactionPayload::SmartContract( - TransactionSmartContract { - name: ContractName::try_from(COSTS_5_NAME) - .expect("FATAL: invalid boot-code contract name"), - code_body: StacksString::from_str(BOOT_CODE_COSTS_5) - .expect("FATAL: invalid boot code body"), - }, - None, - ); - - let costs_5_contract_tx = - StacksTransaction::new(tx_version, boot_code_auth.clone(), payload); - - let costs_5_initialization_receipt = self.as_transaction(|tx_conn| { - debug!("Instantiate {} contract", &costs_5_contract_id); - let receipt = StacksChainState::process_transaction_payload( - tx_conn, - &costs_5_contract_tx, - &boot_code_account, - None, - ) - .expect("FATAL: Failed to process .costs-5 contract initialization"); - receipt - }); - - if costs_5_initialization_receipt.result != Value::okay_true() - || costs_5_initialization_receipt.post_condition_aborted - { - panic!( - "FATAL: Failure processing .costs-5 contract initialization: {:#?}", - &costs_5_initialization_receipt - ); - } + let costs_5_initialization_receipt = self + .instantiate_epoch_4_0_cost_contract() + .expect("FATAL: Failed to initialize Epoch 4.0 cost contract"); /////////////////// .pox-5 //////////////////////// - let pox_5_contract_id = boot_code_id(POX_5_NAME, mainnet); - let pox_5_code = make_pox_5_body(mainnet); - let payload = TransactionPayload::SmartContract( - TransactionSmartContract { - name: ContractName::try_from(POX_5_NAME) - .expect("FATAL: invalid boot-code contract name"), - code_body: StacksString::from_str(&pox_5_code) - .expect("FATAL: invalid boot code body"), - }, - Some(ClarityVersion::Clarity6), - ); - - let pox_5_contract_tx = StacksTransaction::new(tx_version, boot_code_auth, payload); + let pox_5_contract_id = boot_code_id(POX_5_NAME, self.mainnet); + let pox_5_code = make_pox_5_body(self.mainnet); + let (boot_code_account, pox_5_contract_tx) = self + .make_boot_code_smart_contract_tx( + POX_5_NAME, + &pox_5_code, + Some(ClarityVersion::Clarity6), + ) + .expect("FATAL: Failed to construct .pox-5 contract initialization"); let pox_5_initialization_receipt = self.as_transaction(|tx_conn| { debug!("Instantiate {} contract", &pox_5_contract_id); diff --git a/stackslib/src/clarity_vm/tests/costs.rs b/stackslib/src/clarity_vm/tests/costs.rs index 7796007ac3f..7e2b782c0fc 100644 --- a/stackslib/src/clarity_vm/tests/costs.rs +++ b/stackslib/src/clarity_vm/tests/costs.rs @@ -22,6 +22,7 @@ use clarity::vm::costs::{ DefaultVersion, ExecutionCost, LimitedCostTracker, COSTS_1_NAME, COSTS_2_NAME, COSTS_3_NAME, COSTS_4_NAME, }; +use clarity::vm::database::clarity_db::ClarityDatabase; use clarity::vm::errors::VmExecutionError; use clarity::vm::events::StacksTransactionEvent; use clarity::vm::functions::NativeFunctions; @@ -215,10 +216,21 @@ fn execute_transaction( env.execute_transaction(issuer, None, contract_identifier.clone(), tx, args) } -fn with_owned_env(epoch: StacksEpochId, use_mainnet: bool, to_do: F) -> R -where - F: Fn(OwnedEnvironment) -> R, -{ +// Epochs which deploy boot contracts needed for costs tests. +const COST_TEST_SETUP_EPOCHS: [StacksEpochId; 4] = [ + StacksEpochId::Epoch2_05, + StacksEpochId::Epoch21, + StacksEpochId::Epoch33, + StacksEpochId::Epoch40, +]; + +fn next_test_block_id(block_id_byte: &mut u8) -> StacksBlockId { + let block_id = StacksBlockId([*block_id_byte; 32]); + *block_id_byte += 1; + block_id +} + +fn new_cost_test_clarity_instance(use_mainnet: bool) -> (ClarityInstance, StacksBlockId, u8) { let marf_kv = MarfedKV::temporary(); let chain_id = test_only_mainnet_to_chain_id(use_mainnet); let mut clarity_instance = ClarityInstance::new(use_mainnet, chain_id, marf_kv); @@ -233,52 +245,74 @@ where ) .commit_block(); - let mut tip = first_block.clone(); + (clarity_instance, first_block, 1) +} - if epoch >= StacksEpochId::Epoch2_05 { - let burn_state_db = generate_test_burn_state_db(StacksEpochId::Epoch2_05); - let next_block = StacksBlockId([1; 32]); - let mut clarity_conn = - clarity_instance.begin_block(&tip, &next_block, &TEST_HEADER_DB, &burn_state_db); - clarity_conn.initialize_epoch_2_05().unwrap(); - clarity_conn.commit_block(); - tip = next_block.clone(); +fn setup_cost_test_epoch( + clarity_instance: &mut ClarityInstance, + tip: &mut StacksBlockId, + block_id_byte: &mut u8, + setup_epoch: StacksEpochId, +) { + let burn_state_db = generate_test_burn_state_db(setup_epoch); + let next_block = next_test_block_id(block_id_byte); + let mut clarity_conn = + clarity_instance.begin_block(tip, &next_block, &TEST_HEADER_DB, &burn_state_db); + + match setup_epoch { + StacksEpochId::Epoch2_05 => { + clarity_conn.initialize_epoch_2_05().unwrap(); + } + StacksEpochId::Epoch21 => { + clarity_conn.initialize_epoch_2_1().unwrap(); + } + StacksEpochId::Epoch33 => { + clarity_conn.initialize_epoch_3_3().unwrap(); + } + StacksEpochId::Epoch40 => { + // `initialize_epoch_4_0()` also deploys PoX-5, which requires the chainstate + // test harness's sBTC stubs. Cost tests only need the costs-5 contract. + clarity_conn.set_epoch_for_testing(StacksEpochId::Epoch40); + clarity_conn.instantiate_epoch_4_0_cost_contract().unwrap(); + } + _ => unreachable!( + "COST_TEST_SETUP_EPOCHS only contains epochs which instantiate boot contracts needed for costs tests" + ), } - if epoch >= StacksEpochId::Epoch21 { - let burn_state_db = generate_test_burn_state_db(StacksEpochId::Epoch21); - let next_block = StacksBlockId([2; 32]); - let mut clarity_conn = - clarity_instance.begin_block(&tip, &next_block, &TEST_HEADER_DB, &burn_state_db); - clarity_conn.initialize_epoch_2_1().unwrap(); - clarity_conn.commit_block(); - tip = next_block.clone(); - } + clarity_conn.commit_block(); + *tip = next_block; +} - if epoch >= StacksEpochId::Epoch30 { - let burn_state_db = generate_test_burn_state_db(StacksEpochId::Epoch30); - let next_block = StacksBlockId([3; 32]); - let mut clarity_conn = - clarity_instance.begin_block(&tip, &next_block, &TEST_HEADER_DB, &burn_state_db); - clarity_conn.initialize_epoch_3_0().unwrap(); - clarity_conn.commit_block(); - tip = next_block.clone(); - } +fn setup_cost_test_epochs_through( + clarity_instance: &mut ClarityInstance, + tip: &mut StacksBlockId, + block_id_byte: &mut u8, + epoch: StacksEpochId, +) { + for setup_epoch in COST_TEST_SETUP_EPOCHS { + if epoch < setup_epoch { + break; + } - if epoch >= StacksEpochId::Epoch33 { - let burn_state_db = generate_test_burn_state_db(StacksEpochId::Epoch33); - let next_block = StacksBlockId([4; 32]); - let mut clarity_conn = - clarity_instance.begin_block(&tip, &next_block, &TEST_HEADER_DB, &burn_state_db); - clarity_conn.initialize_epoch_3_3().unwrap(); - clarity_conn.commit_block(); - tip = next_block.clone(); + setup_cost_test_epoch(clarity_instance, tip, block_id_byte, setup_epoch); } +} + +fn with_owned_env(epoch: StacksEpochId, use_mainnet: bool, to_do: F) -> R +where + F: Fn(OwnedEnvironment) -> R, +{ + let (mut clarity_instance, mut tip, mut block_id_byte) = + new_cost_test_clarity_instance(use_mainnet); + + setup_cost_test_epochs_through(&mut clarity_instance, &mut tip, &mut block_id_byte, epoch); let mut marf_kv = clarity_instance.destroy(); let burn_state_db = generate_test_burn_state_db(epoch); - let mut store = marf_kv.begin(&tip, &StacksBlockId([5; 32])); + let final_block = next_test_block_id(&mut block_id_byte); + let mut store = marf_kv.begin(&tip, &final_block); to_do(OwnedEnvironment::new_max_limit( store.as_clarity_db(&TEST_HEADER_DB, &burn_state_db), @@ -287,6 +321,228 @@ where )) } +fn cost_voting_contract(use_mainnet: bool) -> &'static QualifiedContractIdentifier { + if use_mainnet { + &COST_VOTING_MAINNET_CONTRACT + } else { + &COST_VOTING_TESTNET_CONTRACT + } +} + +struct CostProposal { + function_contract: PrincipalData, + function_name: &'static str, + cost_function_contract: PrincipalData, + cost_function_name: &'static str, +} + +fn cost_proposal( + function_contract: impl Into, + function_name: &'static str, + cost_function_contract: impl Into, + cost_function_name: &'static str, +) -> CostProposal { + CostProposal { + function_contract: function_contract.into(), + function_name, + cost_function_contract: cost_function_contract.into(), + cost_function_name, + } +} + +fn write_confirmed_cost_proposals( + db: &mut ClarityDatabase, + use_mainnet: bool, + proposal_start_id: usize, + confirmed_proposal_count: usize, + proposals: impl IntoIterator, +) { + let voting_contract_to_use = cost_voting_contract(use_mainnet); + db.set_variable_unknown_descriptor( + voting_contract_to_use, + "confirmed-proposal-count", + Value::UInt(confirmed_proposal_count as u128), + ) + .unwrap(); + + let epoch = db.get_clarity_epoch_version().unwrap(); + for (ix, proposal) in proposals.into_iter().enumerate() { + let value = format!( + "{{ function-contract: '{}, + function-name: \"{}\", + cost-function-contract: '{}, + cost-function-name: \"{}\", + confirmed-height: u1 }}", + proposal.function_contract, + proposal.function_name, + proposal.cost_function_contract, + proposal.cost_function_name + ); + db.set_entry_unknown_descriptor( + voting_contract_to_use, + "confirmed-proposals", + execute_on_network( + &format!("{{ confirmed-id: u{} }}", ix + proposal_start_id), + use_mainnet, + ), + execute_on_network(&value, use_mainnet), + &epoch, + ) + .unwrap(); + } +} + +fn cost_voting_test_cost_definer(use_mainnet: bool) -> QualifiedContractIdentifier { + let p1 = execute_on_network("'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR", use_mainnet); + let Value::Principal(PrincipalData::Standard(p1_principal)) = p1 else { + panic!("Expected a standard principal data"); + }; + QualifiedContractIdentifier::new(p1_principal, ContractName::from_literal("cost-definer")) +} + +fn deploy_cost_voting_test_cost_definer( + clarity_instance: &mut ClarityInstance, + tip: &mut StacksBlockId, + block_id_byte: &mut u8, + use_mainnet: bool, +) -> QualifiedContractIdentifier { + let cost_definer = cost_voting_test_cost_definer(use_mainnet); + let cost_definer_src = " + (define-read-only (cost-definition-le (size uint)) + { + runtime: u0, write_length: u0, write_count: u0, read_count: u0, read_length: u0 + }) + "; + let burn_state_db = generate_test_burn_state_db(StacksEpochId::Epoch20); + let next_block = next_test_block_id(block_id_byte); + let mut block_conn = + clarity_instance.begin_block(tip, &next_block, &TEST_HEADER_DB, &burn_state_db); + block_conn.as_transaction(|tx| { + let (ast, analysis) = tx + .analyze_smart_contract(&cost_definer, ClarityVersion::Clarity1, cost_definer_src) + .unwrap(); + tx.initialize_smart_contract( + &cost_definer, + ClarityVersion::Clarity1, + &ast, + cost_definer_src, + None, + |_, _| None, + None, + ) + .unwrap(); + tx.save_analysis(&cost_definer, &analysis).unwrap(); + }); + block_conn.commit_block(); + *tip = next_block; + + cost_definer +} + +fn write_voted_le_cost_state( + clarity_instance: &mut ClarityInstance, + tip: &mut StacksBlockId, + block_id_byte: &mut u8, + vote_state_epoch: StacksEpochId, + use_mainnet: bool, + cost_definer: &QualifiedContractIdentifier, +) { + let burn_state_db = generate_test_burn_state_db(vote_state_epoch); + let next_block = next_test_block_id(block_id_byte); + + let mut clarity_conn = + clarity_instance.begin_block(tip, &next_block, &TEST_HEADER_DB, &burn_state_db); + clarity_conn.as_transaction(|tx| { + tx.with_clarity_db(|db| { + db.set_clarity_epoch_version(vote_state_epoch)?; + write_confirmed_cost_proposals( + db, + use_mainnet, + 0, + 1, + [cost_proposal( + boot_code_id("costs", use_mainnet), + "cost_le", + cost_definer.clone(), + "cost-definition-le", + )], + ); + Ok(()) + }) + .unwrap(); + }); + clarity_conn.commit_block(); + *tip = next_block; +} + +fn tracker_with_voted_le_cost_state( + load_epoch: StacksEpochId, + vote_state_epoch: StacksEpochId, + use_mainnet: bool, +) -> (LimitedCostTracker, QualifiedContractIdentifier) { + let (mut clarity_instance, mut tip, mut block_id_byte) = + new_cost_test_clarity_instance(use_mainnet); + let cost_definer = deploy_cost_voting_test_cost_definer( + &mut clarity_instance, + &mut tip, + &mut block_id_byte, + use_mainnet, + ); + + let max_setup_epoch = std::cmp::max(load_epoch, vote_state_epoch); + let mut vote_state_written = false; + + for setup_epoch in COST_TEST_SETUP_EPOCHS { + if !vote_state_written && vote_state_epoch < setup_epoch { + write_voted_le_cost_state( + &mut clarity_instance, + &mut tip, + &mut block_id_byte, + vote_state_epoch, + use_mainnet, + &cost_definer, + ); + vote_state_written = true; + } + + if max_setup_epoch < setup_epoch { + break; + } + + setup_cost_test_epoch( + &mut clarity_instance, + &mut tip, + &mut block_id_byte, + setup_epoch, + ); + } + + if !vote_state_written { + write_voted_le_cost_state( + &mut clarity_instance, + &mut tip, + &mut block_id_byte, + vote_state_epoch, + use_mainnet, + &cost_definer, + ); + } + + let mut marf_kv = clarity_instance.destroy(); + + let burn_state_db = generate_test_burn_state_db(load_epoch); + let final_block = next_test_block_id(&mut block_id_byte); + let mut store = marf_kv.begin(&tip, &final_block); + let owned_env = OwnedEnvironment::new_max_limit( + store.as_clarity_db(&TEST_HEADER_DB, &burn_state_db), + load_epoch, + use_mainnet, + ); + let (_db, tracker) = owned_env.destruct().unwrap(); + + (tracker, cost_definer) +} + fn exec_cost(contract: &str, use_mainnet: bool, epoch: StacksEpochId) -> ExecutionCost { let p1 = execute("'SZ2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQ9H6DPR"); let Value::Principal(PrincipalData::Standard(p1_principal)) = p1.clone() else { @@ -1621,121 +1877,85 @@ fn test_cost_voting_integration(use_mainnet: bool, clarity_version: ClarityVersi let bad_cases = vec![ // non existent "replacement target" - ( - PrincipalData::from(QualifiedContractIdentifier::local("non-existent").unwrap()), + cost_proposal( + QualifiedContractIdentifier::local("non-existent").unwrap(), "non-existent-func", - PrincipalData::from(cost_definer.clone()), + cost_definer.clone(), "cost-definition", ), // replacement target isn't a contract principal - ( - p1_principal.clone().into(), + cost_proposal( + p1_principal.clone(), "non-existent-func", - cost_definer.clone().into(), + cost_definer.clone(), "cost-definition", ), // cost defining contract isn't a contract principal - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "intercepted-function", - p1_principal.into(), + p1_principal, "cost-definition", ), // replacement function doesn't exist - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "non-existent-func", - cost_definer.clone().into(), + cost_definer.clone(), "cost-definition", ), // replacement function isn't read-only - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "non-read-only", - cost_definer.clone().into(), + cost_definer.clone(), "cost-definition", ), // "boot cost" function doesn't exist - ( - boot_code_id("costs", false).into(), + cost_proposal( + boot_code_id("costs", false), "non-existent-func", - cost_definer.clone().into(), + cost_definer.clone(), "cost-definition", ), // cost defining contract doesn't exist - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "intercepted-function", - QualifiedContractIdentifier::local("non-existent") - .unwrap() - .into(), + QualifiedContractIdentifier::local("non-existent").unwrap(), "cost-definition", ), // cost defining function doesn't exist - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "intercepted-function", - cost_definer.clone().into(), + cost_definer.clone(), "cost-definition-2", ), // cost defining contract isn't arithmetic-only - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "intercepted-function", - bad_cost_definer.into(), + bad_cost_definer, "cost-definition", ), // cost defining contract has incorrect number of arguments - ( - intercepted.clone().into(), + cost_proposal( + intercepted.clone(), "intercepted-function", - bad_cost_args_definer.into(), + bad_cost_args_definer, "cost-definition", ), ]; let bad_proposals = bad_cases.len(); - let voting_contract_to_use: &QualifiedContractIdentifier = if use_mainnet { - &COST_VOTING_MAINNET_CONTRACT - } else { - &COST_VOTING_TESTNET_CONTRACT - }; - { let mut store = marf_kv.begin(&StacksBlockId([1; 32]), &StacksBlockId([2; 32])); let mut db = store.as_clarity_db(&TEST_HEADER_DB, burn_db); db.begin(); - - db.set_variable_unknown_descriptor( - voting_contract_to_use, - "confirmed-proposal-count", - Value::UInt(bad_proposals as u128), - ) - .unwrap(); - - for (ix, (intercepted_ct, intercepted_f, cost_ct, cost_f)) in - bad_cases.into_iter().enumerate() - { - let value = format!( - "{{ function-contract: '{}, - function-name: \"{}\", - cost-function-contract: '{}, - cost-function-name: \"{}\", - confirmed-height: u1 }}", - intercepted_ct, intercepted_f, cost_ct, cost_f - ); - let epoch = db.get_clarity_epoch_version().unwrap(); - db.set_entry_unknown_descriptor( - voting_contract_to_use, - "confirmed-proposals", - execute(&format!("{{ confirmed-id: u{} }}", ix)), - execute(&value), - &epoch, - ) - .unwrap(); - } + write_confirmed_cost_proposals(&mut db, use_mainnet, 0, bad_proposals, bad_cases); db.commit().unwrap(); store.test_commit(); } @@ -1778,19 +1998,19 @@ fn test_cost_voting_integration(use_mainnet: bool, clarity_version: ClarityVersi }; let good_cases = vec![ - ( + cost_proposal( intercepted.clone(), "intercepted-function", cost_definer.clone(), "cost-definition", ), - ( + cost_proposal( boot_code_id("costs", use_mainnet), "cost_le", cost_definer.clone(), "cost-definition-le", ), - ( + cost_proposal( intercepted.clone(), "intercepted-function2", cost_definer.clone(), @@ -1804,35 +2024,14 @@ fn test_cost_voting_integration(use_mainnet: bool, clarity_version: ClarityVersi let mut db = store.as_clarity_db(&TEST_HEADER_DB, burn_db); db.begin(); - let good_proposals = good_cases.len() as u128; - db.set_variable_unknown_descriptor( - voting_contract_to_use, - "confirmed-proposal-count", - Value::UInt(bad_proposals as u128 + good_proposals), - ) - .unwrap(); - - for (ix, (intercepted_ct, intercepted_f, cost_ct, cost_f)) in - good_cases.into_iter().enumerate() - { - let value = format!( - "{{ function-contract: '{}, - function-name: \"{}\", - cost-function-contract: '{}, - cost-function-name: \"{}\", - confirmed-height: u1 }}", - intercepted_ct, intercepted_f, cost_ct, cost_f - ); - let epoch = db.get_clarity_epoch_version().unwrap(); - db.set_entry_unknown_descriptor( - voting_contract_to_use, - "confirmed-proposals", - execute(&format!("{{ confirmed-id: u{} }}", ix + bad_proposals)), - execute(&value), - &epoch, - ) - .unwrap(); - } + let good_proposals = good_cases.len(); + write_confirmed_cost_proposals( + &mut db, + use_mainnet, + bad_proposals, + bad_proposals + good_proposals, + good_cases, + ); db.commit().unwrap(); store.test_commit(); @@ -1916,6 +2115,74 @@ fn test_cost_voting_integration_testnet() { test_cost_voting_integration(false, ClarityVersion::Clarity2); } +/// Before epoch 4.0, confirmed `.cost-voting` state can replace boot cost functions. +#[rstest::rstest] +#[case::mainnet(true)] +#[case::testnet(false)] +fn cost_voting_state_applies_before_epoch_40(#[case] use_mainnet: bool) { + let (tracker, cost_definer) = tracker_with_voted_le_cost_state( + StacksEpochId::Epoch34, + StacksEpochId::Epoch34, + use_mainnet, + ); + + let cost_function_references = tracker.cost_function_references(); + assert_eq!( + cost_function_references.len(), + ClarityCostFunction::ALL.len(), + "all cost functions must have a reference before cost-voting retires (mainnet={use_mainnet})" + ); + + let le_reference = cost_function_references + .get(&ClarityCostFunction::Le) + .expect("cost_le must have a reference"); + let ClarityCostFunctionEvaluator::Clarity(le_reference) = le_reference else { + panic!("voted cost_le should be evaluated in Clarity before Epoch 4.0"); + }; + assert_eq!(&le_reference.contract_id, &cost_definer); + assert_eq!(&le_reference.function_name, "cost-definition-le"); +} + +/// With `.cost-voting` disabled >= epoch 4.0, both existing and newly written voting state is +/// ignored and every cost function resolves to the boot cost contract. +#[rstest::rstest] +#[case::mainnet_pre_40_voting_state(true, StacksEpochId::Epoch34)] +#[case::testnet_pre_40_voting_state(false, StacksEpochId::Epoch34)] +#[case::mainnet_post_40_voting_state(true, StacksEpochId::Epoch40)] +#[case::testnet_post_40_voting_state(false, StacksEpochId::Epoch40)] +fn cost_voting_state_is_ignored_at_epoch_40( + #[case] use_mainnet: bool, + #[case] vote_state_epoch: StacksEpochId, +) { + let (tracker, _cost_definer) = + tracker_with_voted_le_cost_state(StacksEpochId::Epoch40, vote_state_epoch, use_mainnet); + + let cost_function_references = tracker.cost_function_references(); + assert_eq!( + cost_function_references.len(), + ClarityCostFunction::ALL.len(), + "all cost functions must have a reference after cost-voting retires (mainnet={use_mainnet}, vote_state_epoch={vote_state_epoch})" + ); + assert!( + cost_function_references.values().all(|evaluator| matches!( + evaluator, + ClarityCostFunctionEvaluator::Default(_, _, DefaultVersion::Costs5) + )), + "all cost functions must use the epoch 4.0 boot cost contract after cost-voting retires (mainnet={use_mainnet}, vote_state_epoch={vote_state_epoch})" + ); + + let le_reference = cost_function_references + .get(&ClarityCostFunction::Le) + .expect("cost_le must have a reference"); + assert!( + matches!( + le_reference, + ClarityCostFunctionEvaluator::Default(_, _, DefaultVersion::Costs5) + ), + "voted cost_le replacement must be ignored after cost-voting retires (mainnet={use_mainnet}, vote_state_epoch={vote_state_epoch})" + ); +} + #[test] fn test_cost_change() { let use_mainnet = false; diff --git a/stackslib/src/net/api/tests/postblock_v3.rs b/stackslib/src/net/api/tests/postblock_v3.rs index 34cce000517..a50883bdbc0 100644 --- a/stackslib/src/net/api/tests/postblock_v3.rs +++ b/stackslib/src/net/api/tests/postblock_v3.rs @@ -15,7 +15,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use clarity::util::hash::{MerkleTree, Sha512Trunc256Sum}; use stacks_common::types::chainstate::StacksPrivateKey; use stacks_common::types::StacksEpochId;