Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7341-cost-voting-epoch-4-0-gate.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `.cost-voting` disablement gate beginning from epoch 4.0
29 changes: 20 additions & 9 deletions clarity/src/vm/costs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions clarity/src/vm/tests/epoch_gating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use stacks_common::types::StacksEpochId;
use stacks_common::types::{StacksEpochId, StacksEpochRangeTestExt as _};

use crate::vm::ClarityVersion;

Expand All @@ -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]);
Expand Down
94 changes: 76 additions & 18 deletions stacks-common/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Item = Self> {
(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<StacksEpochId> + 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<R> StacksEpochRangeTestExt for R where R: RangeBounds<StacksEpochId> {}

impl PartialOrd for StacksAddress {
fn partial_cmp(&self, other: &StacksAddress) -> Option<Ordering> {
Some(self.cmp(other))
Expand Down
107 changes: 107 additions & 0 deletions stacks-common/src/types/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,119 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

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() {
Expand Down
14 changes: 7 additions & 7 deletions stackslib/src/chainstate/tests/consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<StacksEpochId> {
let max = max_tested_epoch();
StacksEpochId::since(start)
(start..)
.iter()
.copied()
.filter(|epoch| *epoch <= max)
Expand Down
6 changes: 3 additions & 3 deletions stackslib/src/chainstate/tests/consensus_unit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

use clarity::types::StacksEpochId;
use clarity::types::{StacksEpochId, StacksEpochRangeTestExt as _};
use clarity::vm::{ClarityVersion, Value};

use crate::chainstate::tests::consensus::{
Expand All @@ -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,
);

Expand Down Expand Up @@ -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,
);

Expand Down
4 changes: 2 additions & 2 deletions stackslib/src/chainstate/tests/runtime_analysis_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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],
);
Expand Down
Loading
Loading