Repository: stacks-network/stacks-core (language: python)

Issue:
Feat/sip29 testnet config
<!--
  IMPORTANT
  Pull requests are ideal for making small changes to this project. However, they are NOT an appropriate venue to introducing non-trivial or breaking changes to the codebase.

  For introducing non-trivial or breaking changes to the codebase, please follow the SIP (Stacks Improvement Proposal) process documented here:
  https://github.com/stacksgov/sips/blob/main/sips/sip-000/sip-000-stacks-improvement-proposal-process.md.
-->

### Description

Allows configuration for epoch 3.1 (for sip-029)

### Applicable issues

- fixes #

### Additional info (benefits, drawbacks, caveats)

### Checklist

- [ ] Test coverage for new or modified code paths
- [ ] Changelog is updated
- [ ] Required documentation changes (e.g., `docs/rpc/openapi.yaml` and `rpc-endpoints.md` for v2 endpoints, `event-dispatcher.md` for new events)
- [ ] New clarity functions have corresponding PR in `clarity-benchmarking` repo
- [ ] New integration test(s) added to `bitcoin-tests.yml`



Discussion / hints:

Fix patch:
diff --git a/clarity/src/vm/analysis/mod.rs b/clarity/src/vm/analysis/mod.rs
index 6a8f64f1b2..d563dce6e8 100644
--- a/clarity/src/vm/analysis/mod.rs
+++ b/clarity/src/vm/analysis/mod.rs
@@ -148,7 +148,8 @@ pub fn run_analysis(
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => {
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => {
                 TypeChecker2_1::run_pass(&epoch, &mut contract_analysis, db, build_type_map)
             }
             StacksEpochId::Epoch10 => {
diff --git a/clarity/src/vm/analysis/type_checker/mod.rs b/clarity/src/vm/analysis/type_checker/mod.rs
index 800347d0f0..b4f6557c2e 100644
--- a/clarity/src/vm/analysis/type_checker/mod.rs
+++ b/clarity/src/vm/analysis/type_checker/mod.rs
@@ -52,7 +52,8 @@ impl FunctionType {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => self.check_args_2_1(accounting, args, clarity_version),
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => self.check_args_2_1(accounting, args, clarity_version),
             StacksEpochId::Epoch10 => {
                 return Err(CheckErrors::Expects("Epoch10 is not supported".into()).into())
             }
@@ -75,7 +76,8 @@ impl FunctionType {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => {
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => {
                 self.check_args_by_allowing_trait_cast_2_1(db, clarity_version, func_args)
             }
             StacksEpochId::Epoch10 => {
diff --git a/clarity/src/vm/costs/mod.rs b/clarity/src/vm/costs/mod.rs
index b3ee746fcf..430d707b02 100644
--- a/clarity/src/vm/costs/mod.rs
+++ b/clarity/src/vm/costs/mod.rs
@@ -775,7 +775,8 @@ impl LimitedCostTracker {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => COSTS_3_NAME.to_string(),
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => COSTS_3_NAME.to_string(),
         };
         Ok(result)
     }
diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs
index 833ed4baf8..6482493a29 100644
--- a/clarity/src/vm/functions/mod.rs
+++ b/clarity/src/vm/functions/mod.rs
@@ -63,6 +63,8 @@ macro_rules! switch_on_global_epoch {
                 StacksEpochId::Epoch25 => $Epoch205Version(args, env, context),
                 // Note: We reuse 2.05 for 3.0.
                 StacksEpochId::Epoch30 => $Epoch205Version(args, env, context),
+                // Note: We reuse 2.05 for 3.1.
+                StacksEpochId::Epoch31 => $Epoch205Version(args, env, context),
             }
         }
     };
diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs
index 280258e026..b3984c5251 100644
--- a/clarity/src/vm/types/signatures.rs
+++ b/clarity/src/vm/types/signatures.rs
@@ -587,7 +587,8 @@ impl TypeSignature {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => self.admits_type_v2_1(other),
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => self.admits_type_v2_1(other),
             StacksEpochId::Epoch10 => {
                 return Err(CheckErrors::Expects("epoch 1.0 not supported".into()))
             }
@@ -800,7 +801,8 @@ impl TypeSignature {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => self.canonicalize_v2_1(),
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => self.canonicalize_v2_1(),
         }
     }
 
@@ -1158,7 +1160,8 @@ impl TypeSignature {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => Self::least_supertype_v2_1(a, b),
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => Self::least_supertype_v2_1(a, b),
             StacksEpochId::Epoch10 => {
                 return Err(CheckErrors::Expects("epoch 1.0 not supported".into()))
             }
diff --git a/clarity/src/vm/version.rs b/clarity/src/vm/version.rs
index 4c437d52cc..7050d5dbd9 100644
--- a/clarity/src/vm/version.rs
+++ b/clarity/src/vm/version.rs
@@ -40,6 +40,7 @@ impl ClarityVersion {
             StacksEpochId::Epoch24 => ClarityVersion::Clarity2,
             StacksEpochId::Epoch25 => ClarityVersion::Clarity2,
             StacksEpochId::Epoch30 => ClarityVersion::Clarity3,
+            StacksEpochId::Epoch31 => ClarityVersion::Clarity3,
         }
     }
 }
diff --git a/stacks-common/src/libcommon.rs b/stacks-common/src/libcommon.rs
index 1a13aa02ed..04c3acc1ea 100644
--- a/stacks-common/src/libcommon.rs
+++ b/stacks-common/src/libcommon.rs
@@ -81,10 +81,11 @@ pub mod consts {
     pub const PEER_VERSION_EPOCH_2_4: u8 = 0x09;
     pub const PEER_VERSION_EPOCH_2_5: u8 = 0x0a;
     pub const PEER_VERSION_EPOCH_3_0: u8 = 0x0b;
+    pub const PEER_VERSION_EPOCH_3_1: u8 = 0x0c;
 
     /// this should be updated to the latest network epoch version supported by
     ///  this node. this will be checked by the `validate_epochs()` method.
-    pub const PEER_NETWORK_EPOCH: u32 = PEER_VERSION_EPOCH_3_0 as u32;
+    pub const PEER_NETWORK_EPOCH: u32 = PEER_VERSION_EPOCH_3_1 as u32;
 
     /// set the fourth byte of the peer version
     pub const PEER_VERSION_MAINNET: u32 = PEER_VERSION_MAINNET_MAJOR | PEER_NETWORK_EPOCH;
@@ -93,6 +94,9 @@ pub mod consts {
     /// network identifiers
     pub const NETWORK_ID_MAINNET: u32 = 0x17000000;
     pub const NETWORK_ID_TESTNET: u32 = 0xff000000;
+
+    /// number of uSTX per STX
+    pub const MICROSTACKS_PER_STACKS: u32 = 1_000_000;
 }
 
 /// This test asserts that the constant above doesn't change.
diff --git a/stacks-common/src/types/mod.rs b/stacks-common/src/types/mod.rs
index 8089c6c0a1..4a1f34cbc7 100644
--- a/stacks-common/src/types/mod.rs
+++ b/stacks-common/src/types/mod.rs
@@ -1,3 +1,20 @@
+// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation
+// Copyright (C) 2020-2024 Stacks Open Internet Foundation
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+use std::cell::LazyCell;
 use std::cmp::Ordering;
 use std::fmt;
 use std::ops::{Deref, DerefMut, Index, IndexMut};
@@ -11,6 +28,7 @@ use crate::address::{
     C32_ADDRESS_VERSION_MAINNET_MULTISIG, C32_ADDRESS_VERSION_MAINNET_SINGLESIG,
     C32_ADDRESS_VERSION_TESTNET_MULTISIG, C32_ADDRESS_VERSION_TESTNET_SINGLESIG,
 };
+use crate::consts::MICROSTACKS_PER_STACKS;
 use crate::deps_common::bitcoin::blockdata::transaction::TxOut;
 use crate::types::chainstate::{StacksAddress, StacksPublicKey};
 use crate::util::hash::Hash160;
@@ -19,6 +37,9 @@ use crate::util::secp256k1::{MessageSignature, Secp256k1PublicKey};
 pub mod chainstate;
 pub mod net;
 
+#[cfg(test)]
+pub mod tests;
+
 /// A container for public keys (compressed secp256k1 public keys)
 pub struct StacksPublicKeyBuffer(pub [u8; 33]);
 impl_array_newtype!(StacksPublicKeyBuffer, u8, 33);
@@ -81,6 +102,7 @@ pub enum StacksEpochId {
     Epoch24 = 0x02019,
     Epoch25 = 0x0201a,
     Epoch30 = 0x03000,
+    Epoch31 = 0x03001,
 }
 
 #[derive(Debug)]
@@ -89,9 +111,153 @@ pub enum MempoolCollectionBehavior {
     ByReceiveTime,
 }
 
+/// Struct describing an interval of time (measured in burnchain blocks) during which a coinbase is
+/// allotted.  Applies to SIP-029 code paths and later.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CoinbaseInterval {
+    /// amount of uSTX to award
+    pub coinbase: u128,
+    /// height of the chain after Stacks chain genesis at which this coinbase interval starts
+    pub effective_start_height: u64,
+}
+
+/// From SIP-029:
+///
+/// | Coinbase Interval  | Bitcoin Height | Offset Height       | Approx. Supply   | STX Reward | Annual Inflation |
+/// |--------------------|----------------|---------------------|------------------|------------|------------------|
+/// | Current            | -              | -                   | 1,552,452,847    | 1000       | -                |
+/// | 1st                |   945,000      |   278,950           | 1,627,352,847    | 500 (50%)  | 3.23%            |
+/// | 2nd                | 1,050,000      |   383,950           | 1,679,852,847    | 250 (50%)  | 1.57%            |
+/// | 3rd                | 1,260,000      |   593,950           | 1,732,352,847    | 125 (50%)  | 0.76%            |
+/// | 4th                | 1,470,000      |   803,950           | 1,758,602,847    | 62.5 (50%) | 0.37%            |
+/// | -                  | 2,197,560      | 1,531,510           | 1,804,075,347    | 62.5 (0%)  | 0.18%            |
+///
+/// The above is for mainnet, which has a burnchain year of 52596 blocks and starts at burnchain height 666050.
+/// The `Offset Height` column is simply the difference between `Bitcoin Height` and 666050.
+
+/// Mainnet coinbase intervals, as of SIP-029
+pub const COINBASE_INTERVALS_MAINNET: LazyCell<[CoinbaseInterval; 5]> = LazyCell::new(|| {
+    let emissions_schedule = [
+        CoinbaseInterval {
+            coinbase: 1_000 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 0,
+        },
+        CoinbaseInterval {
+            coinbase: 500 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 278_950,
+        },
+        CoinbaseInterval {
+            coinbase: 250 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 383_950,
+        },
+        CoinbaseInterval {
+            coinbase: 125 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 593_950,
+        },
+        CoinbaseInterval {
+            coinbase: (625 * u128::from(MICROSTACKS_PER_STACKS)) / 10,
+            effective_start_height: 803_950,
+        },
+    ];
+    assert!(CoinbaseInterval::check_order(&emissions_schedule));
+    emissions_schedule
+});
+
+/// Testnet coinbase intervals, as of SIP-029
+pub const COINBASE_INTERVALS_TESTNET: LazyCell<[CoinbaseInterval; 5]> = LazyCell::new(|| {
+    let emissions_schedule = [
+        CoinbaseInterval {
+            coinbase: 1_000 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 0,
+        },
+        CoinbaseInterval {
+            coinbase: 500 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 1000,
+        },
+        CoinbaseInterval {
+            coinbase: 250 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 2000,
+        },
+        CoinbaseInterval {
+            coinbase: 125 * u128::from(MICROSTACKS_PER_STACKS),
+            effective_start_height: 3000,
+        },
+        CoinbaseInterval {
+            coinbase: (625 * u128::from(MICROSTACKS_PER_STACKS)) / 10,
+            effective_start_height: 4000,
+        },
+    ];
+    assert!(CoinbaseInterval::check_order(&emissions_schedule));
+    emissions_schedule
+});
+
+/// Used for testing to substitute a coinbase schedule
+#[cfg(any(test, feature = "testing"))]
+pub static COINBASE_INTERVALS_TEST: std::sync::Mutex<Option<Vec<CoinbaseInterval>>> =
+    std::sync::Mutex::new(None);
+
+#[cfg(any(test, feature = "testing"))]
+pub fn set_test_coinbase_schedule(coinbase_schedule: Option<Vec<CoinbaseInterval>>) {
+    match COINBASE_INTERVALS_TEST.lock() {
+        Ok(mut schedule_guard) => {
+            *schedule_guard = coinbase_schedule;
+        }
+        Err(_e) => {
+            panic!("COINBASE_INTERVALS_TEST mutex poisoned");
+        }
+    }
+}
+
+impl CoinbaseInterval {
+    /// Look up the value of a coinbase at an effective height.
+    /// Precondition: `intervals` must be sorted in ascending order by `effective_start_height`
+    pub fn get_coinbase_at_effective_height(
+        intervals: &[CoinbaseInterval],
+        effective_height: u64,
+    ) -> u128 {
+        if intervals.is_empty() {
+            return 0;
+        }
+        if intervals.len() == 1 {
+            if intervals[0].effective_start_height <= effective_height {
+                return intervals[0].coinbase;
+            } else {
+                return 0;
+            }
+        }
+
+        for i in 0..(intervals.len() - 1) {
+            if intervals[i].effective_start_height <= effective_height
+                && effective_height < intervals[i + 1].effective_start_height
+            {
+                return intervals[i].coinbase;
+            }
+        }
+
+        // in last interval, which per the above checks is guaranteed to exist
+        intervals.last().unwrap_or_else(|| unreachable!()).coinbase
+    }
+
+    /// Verify that a list of intervals is sorted in ascending order by `effective_start_height`
+    pub fn check_order(intervals: &[CoinbaseInterval]) -> bool {
+        if intervals.len() < 2 {
+            return true;
+        }
+
+        let mut ht = intervals[0].effective_start_height;
+        for i in 1..intervals.len() {
+            if intervals[i].effective_start_height < ht {
+                return false;
+            }
+            ht = intervals[i].effective_start_height;
+        }
+        true
+    }
+}
+
 impl StacksEpochId {
     pub fn latest() -> StacksEpochId {
-        StacksEpochId::Epoch30
+        StacksEpochId::Epoch31
     }
 
     /// In this epoch, how should the mempool perform garbage collection?
@@ -105,7 +271,9 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => MempoolCollectionBehavior::ByStacksHeight,
-            StacksEpochId::Epoch30 => MempoolCollectionBehavior::ByReceiveTime,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => {
+                MempoolCollectionBehavior::ByReceiveTime
+            }
         }
     }
 
@@ -120,7 +288,7 @@ impl StacksEpochId {
             | StacksEpochId::Epoch22
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24 => false,
-            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 => true,
+            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => true,
         }
     }
 
@@ -134,7 +302,10 @@ impl StacksEpochId {
             | StacksEpochId::Epoch21
             | StacksEpochId::Epoch22
             | StacksEpochId::Epoch23 => false,
-            StacksEpochId::Epoch24 | StacksEpochId::Epoch25 | StacksEpochId::Epoch30 => true,
+            StacksEpochId::Epoch24
+            | StacksEpochId::Epoch25
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => true,
         }
     }
 
@@ -150,7 +321,7 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => false,
-            StacksEpochId::Epoch30 => true,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => true,
         }
     }
 
@@ -166,7 +337,7 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => false,
-            StacksEpochId::Epoch30 => true,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => true,
         }
     }
 
@@ -181,7 +352,7 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => false,
-            StacksEpochId::Epoch30 => true,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => true,
         }
     }
 
@@ -212,7 +383,7 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => 0,
-            StacksEpochId::Epoch30 => MINING_COMMITMENT_FREQUENCY_NAKAMOTO,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => MINING_COMMITMENT_FREQUENCY_NAKAMOTO,
         }
     }
 
@@ -248,7 +419,132 @@ impl StacksEpochId {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25 => false,
-            StacksEpochId::Epoch30 => cur_reward_cycle > first_epoch30_reward_cycle,
+            StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => {
+                cur_reward_cycle > first_epoch30_reward_cycle
+            }
+        }
+    }
+
+    /// What is the coinbase (in uSTX) to award for the given burnchain height?
+    /// Applies prior to SIP-029
+    fn coinbase_reward_pre_sip029(
+        &self,
+        first_burnchain_height: u64,
+        current_burnchain_height: u64,
+    ) -> u128 {
+        /*
+        From https://forum.stacks.org/t/pox-consensus-and-stx-future-supply
+
+        """
+
+        1000 STX for years 0-4
+        500 STX for years 4-8
+        250 STX for years 8-12
+        125 STX in perpetuity
+
+
+        From the Token Whitepaper:
+
+        We expect that once native mining goes live, approximately 4383 blocks will be pro-
+        cessed per month, or approximately 52,596 blocks will be processed per year.
+
+        """
+        */
+        // this is saturating subtraction for the initial reward calculation
+        //   where we are computing the coinbase reward for blocks that occur *before*
+        //   the `first_burn_block_height`
+        let effective_ht = current_burnchain_height.saturating_sub(first_burnchain_height);
+        let blocks_per_year = 52596;
+        let stx_reward = if effective_ht < blocks_per_year * 4 {
+            1000
+        } else if effective_ht < blocks_per_year * 8 {
+            500
+        } else if effective_ht < blocks_per_year * 12 {
+            250
+        } else {
+            125
+        };
+
+        stx_reward * (u128::from(MICROSTACKS_PER_STACKS))
+    }
+
+    /// Get the coinbase intervals to use.
+    /// Can be overriden by tests
+    #[cfg(any(test, feature = "testing"))]
+    pub(crate) fn get_coinbase_intervals(mainnet: bool) -> Vec<CoinbaseInterval> {
+        match COINBASE_INTERVALS_TEST.lock() {
+            Ok(schedule_opt) => {
+                if let Some(schedule) = (*schedule_opt).as_ref() {
+                    info!("Use overridden coinbase schedule {:?}", &schedule);
+                    return schedule.clone();
+                }
+            }
+            Err(_e) => {
+                panic!("COINBASE_INTERVALS_TEST mutex poisoned");
+            }
+        }
+
+        if mainnet {
+            COINBASE_INTERVALS_MAINNET.to_vec()
+        } else {
+            COINBASE_INTERVALS_TESTNET.to_vec()
+        }
+    }
+
+    #[cfg(not(any(test, feature = "testing")))]
+    pub(crate) fn get_coinbase_intervals(mainnet: bool) -> Vec<CoinbaseInterval> {
+        if mainnet {
+            COINBASE_INTERVALS_MAINNET.to_vec()
+        } else {
+            COINBASE_INTERVALS_TESTNET.to_vec()
+        }
+    }
+
+    /// what are the offsets after chain-start when coinbase reductions occur?
+    /// Applies at and after SIP-029.
+    /// Uses coinbase intervals defined by COINBASE_INTERVALS_MAINNET, unless overridden by a unit
+    /// or integration test.
+    fn coinbase_reward_sip029(
+        &self,
+        mainnet: bool,
+        first_burnchain_height: u64,
+        current_burnchain_height: u64,
+    ) -> u128 {
+        let effective_ht = current_burnchain_height.saturating_sub(first_burnchain_height);
+        let coinbase_intervals = Self::get_coinbase_intervals(mainnet);
+        CoinbaseInterval::get_coinbase_at_effective_height(&coinbase_intervals, effective_ht)
+    }
+
+    /// What is the coinbase to award?
+    pub fn coinbase_reward(
+        &self,
+        mainnet: bool,
+        first_burnchain_height: u64,
+        current_burnchain_height: u64,
+    ) -> u128 {
+        match self {
+            StacksEpochId::Epoch10 => {
+                // Stacks is not active
+                0
+            }
+            StacksEpochId::Epoch20
+            | StacksEpochId::Epoch2_05
+            | StacksEpochId::Epoch21
+            | StacksEpochId::Epoch22
+            | StacksEpochId::Epoch23
+            | StacksEpochId::Epoch24
+            | StacksEpochId::Epoch25
+            | StacksEpochId::Epoch30 => {
+                self.coinbase_reward_pre_sip029(first_burnchain_height, current_burnchain_height)
+            }
+            StacksEpochId::Epoch31 => {
+                let cb = self.coinbase_reward_sip029(
+                    mainnet,
+                    first_burnchain_height,
+                    current_burnchain_height,
+                );
+                cb
+            }
         }
     }
 }
@@ -265,6 +561,7 @@ impl std::fmt::Display for StacksEpochId {
             StacksEpochId::Epoch24 => write!(f, "2.4"),
             StacksEpochId::Epoch25 => write!(f, "2.5"),
             StacksEpochId::Epoch30 => write!(f, "3.0"),
+            StacksEpochId::Epoch31 => write!(f, "3.1"),
         }
     }
 }
@@ -283,6 +580,7 @@ impl TryFrom<u32> for StacksEpochId {
             x if x == StacksEpochId::Epoch24 as u32 => Ok(StacksEpochId::Epoch24),
             x if x == StacksEpochId::Epoch25 as u32 => Ok(StacksEpochId::Epoch25),
             x if x == StacksEpochId::Epoch30 as u32 => Ok(StacksEpochId::Epoch30),
+            x if x == StacksEpochId::Epoch31 as u32 => Ok(StacksEpochId::Epoch31),
             _ => Err("Invalid epoch"),
         }
     }
diff --git a/stackslib/src/burnchains/burnchain.rs b/stackslib/src/burnchains/burnchain.rs
index 84a45eb278..b688097d70 100644
--- a/stackslib/src/burnchains/burnchain.rs
+++ b/stackslib/src/burnchains/burnchain.rs
@@ -1083,7 +1083,9 @@ impl Burnchain {
     }
 
     /// Hand off the block to the ChainsCoordinator _and_ process the sortition
-    ///   *only* to be used by legacy stacks node interfaces, like the Helium node
+    ///   *only* to be used by legacy stacks node interfaces, like the Helium node.
+    ///
+    /// It does not work on mainnet.
     fn process_block_and_sortition_deprecated<B: BurnchainHeaderReader>(
         db: &mut SortitionDB,
         burnchain_db: &mut BurnchainDB,
@@ -1120,6 +1122,7 @@ impl Burnchain {
         // method is deprecated and only used in defunct helium nodes
 
         db.evaluate_sortition(
+            false,
             &header,
             blockstack_txs,
             burnchain,
diff --git a/stackslib/src/chainstate/burn/db/processing.rs b/stackslib/src/chainstate/burn/db/processing.rs
index 4aa0d64929..0aacd2816a 100644
--- a/stackslib/src/chainstate/burn/db/processing.rs
+++ b/stackslib/src/chainstate/burn/db/processing.rs
@@ -112,6 +112,7 @@ impl<'a> SortitionHandleTx<'a> {
     /// * return the snapshot (and sortition results)
     fn process_checked_block_ops(
         &mut self,
+        mainnet: bool,
         burnchain: &Burnchain,
         parent_snapshot: &BlockSnapshot,
         block_header: &BurnchainBlockHeader,
@@ -141,6 +142,7 @@ impl<'a> SortitionHandleTx<'a> {
 
         // do the cryptographic sortition and pick the next winning block.
         let mut snapshot = BlockSnapshot::make_snapshot(
+            mainnet,
             self,
             burnchain,
             &next_sortition_id,
@@ -158,6 +160,11 @@ impl<'a> SortitionHandleTx<'a> {
             BurnchainError::DBError(e)
         })?;
 
+        let snapshot_epoch = SortitionDB::get_stacks_epoch(self, snapshot.block_height)?
+            .unwrap_or_else(|| {
+                panic!("FATAL: no epoch defined for snapshot");
+            });
+
         // was this snapshot the first with mining?
         //  compute the initial block rewards.
         let initialize_bonus = if snapshot.sortition && parent_snapshot.total_burn == 0 {
@@ -166,6 +173,8 @@ impl<'a> SortitionHandleTx<'a> {
             let mut total_reward = 0;
             for burn_block_height in burnchain.initial_reward_start_block..snapshot.block_height {
                 total_reward += StacksChainState::get_coinbase_reward(
+                    snapshot_epoch.epoch_id,
+                    mainnet,
                     burn_block_height,
                     self.context.first_block_height,
                 );
@@ -227,6 +236,7 @@ impl<'a> SortitionHandleTx<'a> {
     /// Returns the BlockSnapshot created from this block.
     pub fn process_block_ops(
         &mut self,
+        mainnet: bool,
         burnchain: &Burnchain,
         parent_snapshot: &BlockSnapshot,
         block_header: &BurnchainBlockHeader,
@@ -279,6 +289,7 @@ impl<'a> SortitionHandleTx<'a> {
         // process them
         let res = self
             .process_checked_block_ops(
+                mainnet,
                 burnchain,
                 parent_snapshot,
                 block_header,
@@ -305,6 +316,7 @@ impl<'a> SortitionHandleTx<'a> {
     /// list of blockstack transactions.
     pub fn process_block_txs(
         &mut self,
+        mainnet: bool,
         parent_snapshot: &BlockSnapshot,
         this_block_header: &BurnchainBlockHeader,
         burnchain: &Burnchain,
@@ -324,6 +336,7 @@ impl<'a> SortitionHandleTx<'a> {
         );
 
         let new_snapshot = self.process_block_ops(
+            mainnet,
             burnchain,
             &parent_snapshot,
             &this_block_header,
@@ -431,6 +444,7 @@ mod tests {
 
             let processed = ic
                 .process_block_ops(
+                    false,
                     &burnchain,
                     &snapshot,
                     &next_block_header,
diff --git a/stackslib/src/chainstate/burn/db/sortdb.rs b/stackslib/src/chainstate/burn/db/sortdb.rs
index 5cc06f54b1..e399121e07 100644
--- a/stackslib/src/chainstate/burn/db/sortdb.rs
+++ b/stackslib/src/chainstate/burn/db/sortdb.rs
@@ -3163,6 +3163,7 @@ impl SortitionDB {
             StacksEpochId::Epoch24 => version_u32 >= 3,
             StacksEpochId::Epoch25 => version_u32 >= 3,
             StacksEpochId::Epoch30 => version_u32 >= 3,
+            StacksEpochId::Epoch31 => version_u32 >= 3,
         }
     }
 
@@ -4288,6 +4289,7 @@ impl SortitionDB {
     ///                   commits its results. This is used to post the calculated reward set to an event observer.
     pub fn evaluate_sortition<F: FnOnce(Option<RewardSetInfo>) -> ()>(
         &mut self,
+        mainnet: bool,
         burn_header: &BurnchainBlockHeader,
         ops: Vec<BlockstackOperationType>,
         burnchain: &Burnchain,
@@ -4365,6 +4367,7 @@ impl SortitionDB {
         };
 
         let new_snapshot = sortition_db_handle.process_block_txs(
+            mainnet,
             &parent_snapshot,
             burn_header,
             burnchain,
diff --git a/stackslib/src/chainstate/burn/operations/leader_block_commit.rs b/stackslib/src/chainstate/burn/operations/leader_block_commit.rs
index f19faecd61..a752131668 100644
--- a/stackslib/src/chainstate/burn/operations/leader_block_commit.rs
+++ b/stackslib/src/chainstate/burn/operations/leader_block_commit.rs
@@ -42,7 +42,7 @@ use crate::chainstate::stacks::{StacksPrivateKey, StacksPublicKey};
 use crate::core::{
     StacksEpoch, StacksEpochId, STACKS_EPOCH_2_05_MARKER, STACKS_EPOCH_2_1_MARKER,
     STACKS_EPOCH_2_2_MARKER, STACKS_EPOCH_2_3_MARKER, STACKS_EPOCH_2_4_MARKER,
-    STACKS_EPOCH_2_5_MARKER, STACKS_EPOCH_3_0_MARKER,
+    STACKS_EPOCH_2_5_MARKER, STACKS_EPOCH_3_0_MARKER, STACKS_EPOCH_3_1_MARKER,
 };
 use crate::net::Error as net_error;
 
@@ -881,6 +881,7 @@ impl LeaderBlockCommitOp {
             StacksEpochId::Epoch24 => self.check_epoch_commit_marker(STACKS_EPOCH_2_4_MARKER),
             StacksEpochId::Epoch25 => self.check_epoch_commit_marker(STACKS_EPOCH_2_5_MARKER),
             StacksEpochId::Epoch30 => self.check_epoch_commit_marker(STACKS_EPOCH_3_0_MARKER),
+            StacksEpochId::Epoch31 => self.check_epoch_commit_marker(STACKS_EPOCH_3_1_MARKER),
         }
     }
 
@@ -900,7 +901,8 @@ impl LeaderBlockCommitOp {
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => {
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => {
                 // correct behavior -- uses *sortition height* to find the intended sortition ID
                 let sortition_height = self
                     .block_height
diff --git a/stackslib/src/chainstate/burn/sortition.rs b/stackslib/src/chainstate/burn/sortition.rs
index 63a7b7feb3..ff71b0cf10 100644
--- a/stackslib/src/chainstate/burn/sortition.rs
+++ b/stackslib/src/chainstate/burn/sortition.rs
@@ -498,6 +498,7 @@ impl BlockSnapshot {
     ///
     /// Call this *after* you store all of the block's transactions to the burn db.
     pub fn make_snapshot(
+        mainnet: bool,
         sort_tx: &mut SortitionHandleTx,
         burnchain: &Burnchain,
         my_sortition_id: &SortitionId,
@@ -518,6 +519,7 @@ impl BlockSnapshot {
             .epoch_id;
 
         Self::make_snapshot_in_epoch(
+            mainnet,
             sort_tx,
             burnchain,
             my_sortition_id,
@@ -531,6 +533,7 @@ impl BlockSnapshot {
     }
 
     pub fn make_snapshot_in_epoch(
+        mainnet: bool,
         sort_tx: &mut SortitionHandleTx,
         burnchain: &Burnchain,
         my_sortition_id: &SortitionId,
@@ -561,6 +564,8 @@ impl BlockSnapshot {
             initial_mining_bonus_ustx
         } else {
             let missed_coinbase = StacksChainState::get_coinbase_reward(
+                epoch_id,
+                mainnet,
                 parent_snapshot.block_height,
                 first_block_height,
             );
@@ -788,6 +793,7 @@ mod test {
         burnchain_state_transition: &BurnchainStateTransition,
     ) -> Result<BlockSnapshot, db_error> {
         BlockSnapshot::make_snapshot(
+            false,
             sort_tx,
             burnchain,
             my_sortition_id,
diff --git a/stackslib/src/chainstate/coordinator/mod.rs b/stackslib/src/chainstate/coordinator/mod.rs
index 5b7c7e89b6..139a666098 100644
--- a/stackslib/src/chainstate/coordinator/mod.rs
+++ b/stackslib/src/chainstate/coordinator/mod.rs
@@ -419,8 +419,8 @@ impl<'a, T: BlockEventDispatcher> OnChainRewardSetProvider<'a, T> {
                     return Ok(RewardSet::empty());
                 }
             }
-            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 => {
-                // Epoch 2.5 and 3.0 compute reward sets, but *only* if PoX-4 is active
+            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => {
+                // Epoch 2.5, 3.0, and 3.1 compute reward sets, but *only* if PoX-4 is active
                 if burnchain
                     .pox_constants
                     .active_pox_contract(current_burn_height)
@@ -2674,6 +2674,7 @@ impl<
                     let (next_snapshot, _) = self
                         .sortition_db
                         .evaluate_sortition(
+                            self.chain_state_db.mainnet,
                             &header,
                             ops,
                             &self.burnchain,
diff --git a/stackslib/src/chainstate/nakamoto/coordinator/mod.rs b/stackslib/src/chainstate/nakamoto/coordinator/mod.rs
index fc7c8ba504..1bb5e44192 100644
--- a/stackslib/src/chainstate/nakamoto/coordinator/mod.rs
+++ b/stackslib/src/chainstate/nakamoto/coordinator/mod.rs
@@ -1164,6 +1164,7 @@ impl<
             let (next_snapshot, _) = self
                 .sortition_db
                 .evaluate_sortition(
+                    self.chain_state_db.mainnet,
                     &header,
                     ops,
                     &self.burnchain,
diff --git a/stackslib/src/chainstate/nakamoto/mod.rs b/stackslib/src/chainstate/nakamoto/mod.rs
index dbaf226015..d874186988 100644
--- a/stackslib/src/chainstate/nakamoto/mod.rs
+++ b/stackslib/src/chainstate/nakamoto/mod.rs
@@ -131,7 +131,7 @@ pub use self::staging_blocks::{
     NakamotoStagingBlocksConn, NakamotoStagingBlocksConnRef, NakamotoStagingBlocksTx,
 };
 
-pub const NAKAMOTO_BLOCK_VERSION: u8 = 0;
+pub const NAKAMOTO_BLOCK_VERSION: u8 = 1;
 
 define_named_enum!(HeaderTypeNames {
     Nakamoto("nakamoto"),
diff --git a/stackslib/src/chainstate/nakamoto/tenure.rs b/stackslib/src/chainstate/nakamoto/tenure.rs
index 9852733311..b72bbdda14 100644
--- a/stackslib/src/chainstate/nakamoto/tenure.rs
+++ b/stackslib/src/chainstate/nakamoto/tenure.rs
@@ -971,6 +971,8 @@ impl NakamotoChainState {
         .accumulated_coinbase_ustx;
 
         let coinbase_at_block = StacksChainState::get_coinbase_reward(
+            evaluated_epoch,
+            chainstate_tx.config.mainnet,
             chain_tip_burn_header_height,
             burn_dbconn.context.first_block_height,
         );
diff --git a/stackslib/src/chainstate/stacks/db/blocks.rs b/stackslib/src/chainstate/stacks/db/blocks.rs
index 04f772da02..791f2064dc 100644
--- a/stackslib/src/chainstate/stacks/db/blocks.rs
+++ b/stackslib/src/chainstate/stacks/db/blocks.rs
@@ -3612,41 +3612,13 @@ impl StacksChainState {
     }
 
     /// Get the coinbase at this burn block height, in microSTX
-    pub fn get_coinbase_reward(burn_block_height: u64, first_burn_block_height: u64) -> u128 {
-        /*
-        From https://forum.stacks.org/t/pox-consensus-and-stx-future-supply
-
-        """
-
-        1000 STX for years 0-4
-        500 STX for years 4-8
-        250 STX for years 8-12
-        125 STX in perpetuity
-
-
-        From the Token Whitepaper:
-
-        We expect that once native mining goes live, approximately 4383 blocks will be pro-
-        cessed per month, or approximately 52,596 blocks will be processed per year.
-
-        """
-        */
-        // this is saturating subtraction for the initial reward calculation
-        //   where we are computing the coinbase reward for blocks that occur *before*
-        //   the `first_burn_block_height`
-        let effective_ht = burn_block_height.saturating_sub(first_burn_block_height);
-        let blocks_per_year = 52596;
-        let stx_reward = if effective_ht < blocks_per_year * 4 {
-            1000
-        } else if effective_ht < blocks_per_year * 8 {
-            500
-        } else if effective_ht < blocks_per_year * 12 {
-            250
-        } else {
-            125
-        };
-
-        stx_reward * (u128::from(MICROSTACKS_PER_STACKS))
+    pub fn get_coinbase_reward(
+        epoch: StacksEpochId,
+        mainnet: bool,
+        burn_block_height: u64,
+        first_burn_block_height: u64,
+    ) -> u128 {
+        epoch.coinbase_reward(mainnet, first_burn_block_height, burn_block_height)
     }
 
     /// Create the block reward.
@@ -4132,7 +4104,12 @@ impl StacksChainState {
                         current_epoch = StacksEpochId::Epoch30;
                     }
                     StacksEpochId::Epoch30 => {
-                        panic!("No defined transition from Epoch30 forward")
+                        // no special initialization is needed, since only the coinbase emission
+                        // schedule is changing.
+                        current_epoch = StacksEpochId::Epoch31;
+                    }
+                    StacksEpochId::Epoch31 => {
+                        panic!("No defined transition from Epoch31 forward")
                     }
                 }
             }
@@ -4942,8 +4919,7 @@ impl StacksChainState {
                     )?;
                 Ok((stack_ops, transfer_ops, delegate_ops, vec![]))
             }
-            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 => {
-                // TODO: sbtc ops in epoch 3.0
+            StacksEpochId::Epoch25 | StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => {
                 StacksChainState::get_stacking_and_transfer_and_delegate_burn_ops_v210(
                     chainstate_tx,
                     parent_index_hash,
@@ -5033,7 +5009,7 @@ impl StacksChainState {
                     pox_reward_cycle,
                     pox_start_cycle_info,
                 ),
-                StacksEpochId::Epoch25 | StacksEpochId::Epoch30 => {
+                StacksEpochId::Epoch25 | StacksEpochId::Epoch30 | StacksEpochId::Epoch31 => {
                     Self::handle_pox_cycle_start_pox_4(
                         clarity_tx,
                         pox_reward_cycle,
@@ -5758,6 +5734,8 @@ impl StacksChainState {
             .accumulated_coinbase_ustx;
 
             let coinbase_at_block = StacksChainState::get_coinbase_reward(
+                evaluated_epoch,
+                mainnet,
                 u64::from(chain_tip_burn_header_height),
                 burn_dbconn.context.first_block_height,
             );
diff --git a/stackslib/src/chainstate/stacks/db/mod.rs b/stackslib/src/chainstate/stacks/db/mod.rs
index 6b6f523f88..7fed3e9a46 100644
--- a/stackslib/src/chainstate/stacks/db/mod.rs
+++ b/stackslib/src/chainstate/stacks/db/mod.rs
@@ -307,6 +307,7 @@ impl DBConfig {
             StacksEpochId::Epoch24 => version_u32 >= 3 && version_u32 <= 8,
             StacksEpochId::Epoch25 => version_u32 >= 3 && version_u32 <= 8,
             StacksEpochId::Epoch30 => version_u32 >= 3 && version_u32 <= 8,
+            StacksEpochId::Epoch31 => version_u32 >= 3 && version_u32 <= 8,
         }
     }
 }
diff --git a/stackslib/src/chainstate/stacks/db/transactions.rs b/stackslib/src/chainstate/stacks/db/transactions.rs
index e9de9139a2..aef9627d15 100644
--- a/stackslib/src/chainstate/stacks/db/transactions.rs
+++ b/stackslib/src/chainstate/stacks/db/transactions.rs
@@ -8725,6 +8725,7 @@ pub mod test {
                     StacksEpochId::Epoch24 => self.get_stacks_epoch(5),
                     StacksEpochId::Epoch25 => self.get_stacks_epoch(6),
                     StacksEpochId::Epoch30 => self.get_stacks_epoch(7),
+                    StacksEpochId::Epoch31 => self.get_stacks_epoch(8),
                 }
             }
             fn get_pox_payout_addrs(
diff --git a/stackslib/src/core/mod.rs b/stackslib/src/core/mod.rs
index bb850a784c..ba4dbf14d2 100644
--- a/stackslib/src/core/mod.rs
+++ b/stackslib/src/core/mod.rs
@@ -18,6 +18,7 @@ use std::collections::HashSet;
 
 use clarity::vm::costs::ExecutionCost;
 use lazy_static::lazy_static;
+pub use stacks_common::consts::MICROSTACKS_PER_STACKS;
 use stacks_common::types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockId};
 pub use stacks_common::types::StacksEpochId;
 use stacks_common::types::{EpochList as GenericEpochList, StacksEpoch as GenericStacksEpoch};
@@ -46,7 +47,7 @@ pub use stacks_common::consts::{
     NETWORK_ID_TESTNET, PEER_NETWORK_EPOCH, PEER_VERSION_EPOCH_1_0, PEER_VERSION_EPOCH_2_0,
     PEER_VERSION_EPOCH_2_05, PEER_VERSION_EPOCH_2_1, PEER_VERSION_EPOCH_2_2,
     PEER_VERSION_EPOCH_2_3, PEER_VERSION_EPOCH_2_4, PEER_VERSION_EPOCH_2_5, PEER_VERSION_EPOCH_3_0,
-    PEER_VERSION_MAINNET, PEER_VERSION_MAINNET_MAJOR, PEER_VERSION_TESTNET,
+    PEER_VERSION_EPOCH_3_1, PEER_VERSION_MAINNET, PEER_VERSION_MAINNET_MAJOR, PEER_VERSION_TESTNET,
     PEER_VERSION_TESTNET_MAJOR, STACKS_EPOCH_MAX,
 };
 
@@ -99,7 +100,11 @@ pub const BITCOIN_MAINNET_STACKS_24_BURN_HEIGHT: u64 = 791_551;
 pub const BITCOIN_MAINNET_STACKS_25_BURN_HEIGHT: u64 = 840_360;
 /// This is Epoch-3.0, activation height proposed in SIP-021
 pub const BITCOIN_MAINNET_STACKS_30_BURN_HEIGHT: u64 = 867_867;
+/// This is Epoch-3.1, activation height proposed in SIP-029
+pub const BITCOIN_MAINNET_STACKS_31_BURN_HEIGHT: u64 = 875_000;
 
+/// Bitcoin mainline testnet3 activation heights.
+/// TODO: No longer used since testnet3 is dead, so remove.
 pub const BITCOIN_TESTNET_FIRST_BLOCK_HEIGHT: u64 = 2000000;
 pub const BITCOIN_TESTNET_FIRST_BLOCK_TIMESTAMP: u32 = 1622691840;
 pub const BITCOIN_TESTNET_FIRST_BLOCK_HASH: &str =
@@ -111,6 +116,7 @@ pub const BITCOIN_TESTNET_STACKS_23_BURN_HEIGHT: u64 = 2_431_633;
 pub const BITCOIN_TESTNET_STACKS_24_BURN_HEIGHT: u64 = 2_432_545;
 pub const BITCOIN_TESTNET_STACKS_25_BURN_HEIGHT: u64 = 2_583_893;
 pub const BITCOIN_TESTNET_STACKS_30_BURN_HEIGHT: u64 = 30_000_000;
+pub const BITCOIN_TESTNET_STACKS_31_BURN_HEIGHT: u64 = 30_000_001;
 
 /// This constant sets the approximate testnet bitcoin height at which 2.5 Xenon
 ///  was reorged back to 2.5 instantiation. This is only used to calculate the
@@ -133,8 +139,6 @@ lazy_static! {
 pub const BOOT_BLOCK_HASH: BlockHeaderHash = BlockHeaderHash([0xff; 32]);
 pub const BURNCHAIN_BOOT_CONSENSUS_HASH: ConsensusHash = ConsensusHash([0xff; 20]);
 
-pub const MICROSTACKS_PER_STACKS: u32 = 1_000_000;
-
 pub const POX_SUNSET_START: u64 = 100_000;
 pub const POX_SUNSET_END: u64 = POX_SUNSET_START + 400_000;
 
@@ -298,10 +302,17 @@ lazy_static! {
         StacksEpoch {
             epoch_id: StacksEpochId::Epoch30,
             start_height: BITCOIN_MAINNET_STACKS_30_BURN_HEIGHT,
-            end_height: STACKS_EPOCH_MAX,
+            end_height: BITCOIN_MAINNET_STACKS_31_BURN_HEIGHT,
             block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
             network_epoch: PEER_VERSION_EPOCH_3_0
         },
+        StacksEpoch {
+            epoch_id: StacksEpochId::Epoch31,
+            start_height: BITCOIN_MAINNET_STACKS_31_BURN_HEIGHT,
+            end_height: STACKS_EPOCH_MAX,
+            block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
+            network_epoch: PEER_VERSION_EPOCH_3_1
+        },
     ]);
 }
 
@@ -366,10 +377,17 @@ lazy_static! {
         StacksEpoch {
             epoch_id: StacksEpochId::Epoch30,
             start_height: BITCOIN_TESTNET_STACKS_30_BURN_HEIGHT,
-            end_height: STACKS_EPOCH_MAX,
+            end_height: BITCOIN_TESTNET_STACKS_31_BURN_HEIGHT,
             block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
             network_epoch: PEER_VERSION_EPOCH_3_0
         },
+        StacksEpoch {
+            epoch_id: StacksEpochId::Epoch31,
+            start_height: BITCOIN_TESTNET_STACKS_31_BURN_HEIGHT,
+            end_height: STACKS_EPOCH_MAX,
+            block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
+            network_epoch: PEER_VERSION_EPOCH_3_1
+        },
     ]);
 }
 
@@ -434,10 +452,17 @@ lazy_static! {
         StacksEpoch {
             epoch_id: StacksEpochId::Epoch30,
             start_height: 7001,
-            end_height: STACKS_EPOCH_MAX,
+            end_height: 8001,
             block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
             network_epoch: PEER_VERSION_EPOCH_3_0
         },
+        StacksEpoch {
+            epoch_id: StacksEpochId::Epoch31,
+            start_height: 8001,
+            end_height: STACKS_EPOCH_MAX,
+            block_limit: BLOCK_LIMIT_MAINNET_21.clone(),
+            network_epoch: PEER_VERSION_EPOCH_3_1
+        },
     ]);
 }
 
@@ -469,6 +494,10 @@ pub static STACKS_EPOCH_2_5_MARKER: u8 = 0x0a;
 /// *or greater*.
 pub static STACKS_EPOCH_3_0_MARKER: u8 = 0x0b;
 
+/// Stacks 3.1 epoch marker.  All block-commits in 3.1 must have a memo bitfield with this value
+/// *or greater*.
+pub static STACKS_EPOCH_3_1_MARKER: u8 = 0x0c;
+
 #[test]
 fn test_ord_for_stacks_epoch() {
     let epochs = &*STACKS_EPOCHS_MAINNET;
@@ -648,6 +677,42 @@ fn test_ord_for_stacks_epoch() {
         epochs[StacksEpochId::Epoch30].cmp(&epochs[StacksEpochId::Epoch25]),
         Ordering::Greater
     );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch10]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch20]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch2_05]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch21]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch22]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch23]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch24]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch25]),
+        Ordering::Greater
+    );
+    assert_eq!(
+        epochs[StacksEpochId::Epoch31].cmp(&epochs[StacksEpochId::Epoch30]),
+        Ordering::Greater
+    );
 }
 
 #[test]
@@ -711,6 +776,8 @@ pub trait StacksEpochExtension {
     #[cfg(test)]
     fn unit_test_3_0(epoch_2_0_block_height: u64) -> EpochList;
     #[cfg(test)]
+    fn unit_test_3_1(epoch_2_0_block_height: u64) -> EpochList;
+    #[cfg(test)]
     fn unit_test_2_1_only(epoch_2_0_block_height: u64) -> EpochList;
     #[cfg(test)]
     fn unit_test_3_0_only(first_burnchain_height: u64) -> EpochList;
@@ -1350,6 +1417,135 @@ impl StacksEpochExtension for StacksEpoch {
         ])
     }
 
+    #[cfg(test)]
+    fn unit_test_3_1(first_burnchain_height: u64) -> EpochList {
+        info!(
+            "StacksEpoch unit_test_3_1 first_burn_height = {}",
+            first_burnchain_height
+        );
+
+        EpochList::new(&[
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch10,
+                start_height: 0,
+                end_height: first_burnchain_height,
+                block_limit: ExecutionCost::max_value(),
+                network_epoch: PEER_VERSION_EPOCH_1_0,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch20,
+                start_height: first_burnchain_height,
+                end_height: first_burnchain_height + 4,
+                block_limit: ExecutionCost::max_value(),
+                network_epoch: PEER_VERSION_EPOCH_2_0,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch2_05,
+                start_height: first_burnchain_height + 4,
+                end_height: first_burnchain_height + 8,
+                block_limit: ExecutionCost {
+                    write_length: 205205,
+                    write_count: 205205,
+                    read_length: 205205,
+                    read_count: 205205,
+                    runtime: 205205,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_05,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch21,
+                start_height: first_burnchain_height + 8,
+                end_height: first_burnchain_height + 12,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_1,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch22,
+                start_height: first_burnchain_height + 12,
+                end_height: first_burnchain_height + 16,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_2,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch23,
+                start_height: first_burnchain_height + 16,
+                end_height: first_burnchain_height + 20,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_3,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch24,
+                start_height: first_burnchain_height + 20,
+                end_height: first_burnchain_height + 24,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_4,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch25,
+                start_height: first_burnchain_height + 24,
+                end_height: first_burnchain_height + 28,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_2_5,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch30,
+                start_height: first_burnchain_height + 28,
+                end_height: first_burnchain_height + 32,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_3_0,
+            },
+            StacksEpoch {
+                epoch_id: StacksEpochId::Epoch31,
+                start_height: first_burnchain_height + 32,
+                end_height: STACKS_EPOCH_MAX,
+                block_limit: ExecutionCost {
+                    write_length: 210210,
+                    write_count: 210210,
+                    read_length: 210210,
+                    read_count: 210210,
+                    runtime: 210210,
+                },
+                network_epoch: PEER_VERSION_EPOCH_3_1,
+            },
+        ])
+    }
+
     #[cfg(test)]
     fn unit_test_2_1_only(first_burnchain_height: u64) -> EpochList {
         info!(
@@ -1488,6 +1684,7 @@ impl StacksEpochExtension for StacksEpoch {
             StacksEpochId::Epoch24 => StacksEpoch::unit_test_2_4(first_burnchain_height),
             StacksEpochId::Epoch25 => StacksEpoch::unit_test_2_5(first_burnchain_height),
             StacksEpochId::Epoch30 => StacksEpoch::unit_test_3_0(first_burnchain_height),
+            StacksEpochId::Epoch31 => StacksEpoch::unit_test_3_1(first_burnchain_height),
         }
     }
 
@@ -1542,8 +1739,8 @@ impl StacksEpochExtension for StacksEpoch {
             .iter()
             .max()
             .expect("FATAL: expect at least one epoch");
-        if max_epoch.epoch_id == StacksEpochId::Epoch30 {
-            assert!(PEER_NETWORK_EPOCH >= u32::from(PEER_VERSION_EPOCH_2_5));
+        if max_epoch.epoch_id == StacksEpochId::Epoch31 {
+            assert!(PEER_NETWORK_EPOCH >= u32::from(PEER_VERSION_EPOCH_3_0));
         } else {
             assert!(
                 max_epoch.network_epoch as u32 <= PEER_NETWORK_EPOCH,
diff --git a/stackslib/src/cost_estimates/pessimistic.rs b/stackslib/src/cost_estimates/pessimistic.rs
index bb1cf48f38..cdb3ceb7da 100644
--- a/stackslib/src/cost_estimates/pessimistic.rs
+++ b/stackslib/src/cost_estimates/pessimistic.rs
@@ -230,6 +230,8 @@ impl PessimisticEstimator {
                     StacksEpochId::Epoch25 => ":2.1",
                     // reuse cost estimates in Epoch30
                     StacksEpochId::Epoch30 => ":2.1",
+                    // reuse cost estimates in Epoch31
+                    StacksEpochId::Epoch31 => ":2.1",
                 };
                 format!(
                     "cc{}:{}:{}.{}",
diff --git a/stackslib/src/main.rs b/stackslib/src/main.rs
index 16fbd7c2d2..7f324c52c8 100644
--- a/stackslib/src/main.rs
+++ b/stackslib/src/main.rs
@@ -1597,6 +1597,7 @@ simulating a miner.
                     SortitionDB::get_canonical_burn_chain_tip(new_sortition_db.conn()).unwrap();
                 new_sortition_db
                     .evaluate_sortition(
+                        false,
                         &burn_block_header,
                         blockstack_txs,
                         &burnchain,
@@ -2094,6 +2095,7 @@ fn analyze_sortition_mev(argv: Vec<String>) {
         debug!("Re-evaluate sortition at height {}", height);
         let (next_sn, state_transition) = sortdb
             .evaluate_sortition(
+                true,
                 &burn_block.header,
                 burn_block.ops.clone(),
                 &burnchain,
@@ -2109,6 +2111,7 @@ fn analyze_sortition_mev(argv: Vec<String>) {
         let mut sort_tx = sortdb.tx_begin_at_tip();
         let tip_pox_id = sort_tx.get_pox_id().unwrap();
         let next_sn_nakamoto = BlockSnapshot::make_snapshot_in_epoch(
+            true,
             &mut sort_tx,
             &burnchain,
             &ancestor_sn.sortition_id,
diff --git a/stackslib/src/net/api/postblock_proposal.rs b/stackslib/src/net/api/postblock_proposal.rs
index 2d93c4a789..27a930d634 100644
--- a/stackslib/src/net/api/postblock_proposal.rs
+++ b/stackslib/src/net/api/postblock_proposal.rs
@@ -38,7 +38,7 @@ use crate::burnchains::affirmation::AffirmationMap;
 use crate::burnchains::Txid;
 use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandleConn};
 use crate::chainstate::nakamoto::miner::NakamotoBlockBuilder;
-use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState};
+use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState, NAKAMOTO_BLOCK_VERSION};
 use crate::chainstate::stacks::db::blocks::MINIMUM_TX_FEE_RATE_PER_BYTE;
 use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState};
 use crate::chainstate::stacks::miner::{BlockBuilder, BlockLimitFunction, TransactionResult};
@@ -374,6 +374,15 @@ impl NakamotoBlockProposal {
             });
         }
 
+        // Check block version. If it's less than the compiled-in version, just emit a warning
+        // because there's a new version of the node / signer binary available that really ought to
+        // be used (hint, hint)
+        if self.block.header.version != NAKAMOTO_BLOCK_VERSION {
+            warn!("Proposed block has unexpected version. Upgrade your node and/or signer ASAP.";
+                  "block.header.version" => %self.block.header.version,
+                  "expected" => %NAKAMOTO_BLOCK_VERSION);
+        }
+
         // open sortition view to the current burn view.
         // If the block has a TenureChange with an Extend cause, then the burn view is whatever is
         // indicated in the TenureChange.
diff --git a/stackslib/src/net/chat.rs b/stackslib/src/net/chat.rs
index 7d45b39769..1d8e5d10d2 100644
--- a/stackslib/src/net/chat.rs
+++ b/stackslib/src/net/chat.rs
@@ -6375,6 +6375,8 @@ mod test {
         }
     }
 
+    // TODO: test for has_acceptable_epoch()
+
     #[test]
     fn convo_process_relayers() {
         let conn_opts = ConnectionOptions::default();


Test patch:
diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml
index 04e74f94e8..41e14b4458 100644
--- a/.github/workflows/bitcoin-tests.yml
+++ b/.github/workflows/bitcoin-tests.yml
@@ -142,6 +142,7 @@ jobs:
           - tests::nakamoto_integrations::v3_signer_api_endpoint
           - tests::nakamoto_integrations::test_shadow_recovery
           - tests::nakamoto_integrations::signer_chainstate
+          - tests::nakamoto_integrations::sip029_coinbase_change
           - tests::nakamoto_integrations::clarity_cost_spend_down
           - tests::nakamoto_integrations::v3_blockbyheight_api_endpoint
           # TODO: enable these once v1 signer is supported by a new nakamoto epoch
diff --git a/clarity/src/vm/test_util/mod.rs b/clarity/src/vm/test_util/mod.rs
index f2b6d4dd09..295909859f 100644
--- a/clarity/src/vm/test_util/mod.rs
+++ b/clarity/src/vm/test_util/mod.rs
@@ -52,7 +52,8 @@ pub fn generate_test_burn_state_db(epoch_id: StacksEpochId) -> UnitTestBurnState
         | StacksEpochId::Epoch23
         | StacksEpochId::Epoch24
         | StacksEpochId::Epoch25
-        | StacksEpochId::Epoch30 => UnitTestBurnStateDB {
+        | StacksEpochId::Epoch30
+        | StacksEpochId::Epoch31 => UnitTestBurnStateDB {
             epoch_id,
             ast_rules: ASTRules::PrecheckSize,
         },
diff --git a/clarity/src/vm/tests/mod.rs b/clarity/src/vm/tests/mod.rs
index 2c6f23ef42..5fa58b507b 100644
--- a/clarity/src/vm/tests/mod.rs
+++ b/clarity/src/vm/tests/mod.rs
@@ -122,6 +122,7 @@ epochs_template! {
     Epoch24,
     Epoch25,
     Epoch30,
+    Epoch31,
 }
 
 clarity_template! {
@@ -140,6 +141,9 @@ clarity_template! {
     (Epoch30, Clarity1),
     (Epoch30, Clarity2),
     (Epoch30, Clarity3),
+    (Epoch31, Clarity1),
+    (Epoch31, Clarity2),
+    (Epoch31, Clarity3),
 }
 
 #[cfg(test)]
diff --git a/stacks-common/src/types/tests.rs b/stacks-common/src/types/tests.rs
new file mode 100644
index 0000000000..20676999e7
--- /dev/null
+++ b/stacks-common/src/types/tests.rs
@@ -0,0 +1,352 @@
+// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation
+// Copyright (C) 2020-2024 Stacks Open Internet Foundation
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+use super::{
+    set_test_coinbase_schedule, CoinbaseInterval, StacksEpochId, COINBASE_INTERVALS_MAINNET,
+    COINBASE_INTERVALS_TESTNET,
+};
+
+#[test]
+fn test_mainnet_coinbase_emissions() {
+    assert_eq!(COINBASE_INTERVALS_MAINNET.len(), 5);
+    assert_eq!(COINBASE_INTERVALS_MAINNET[0].coinbase, 1_000_000_000);
+    assert_eq!(COINBASE_INTERVALS_MAINNET[1].coinbase, 500_000_000);
+    assert_eq!(COINBASE_INTERVALS_MAINNET[2].coinbase, 250_000_000);
+    assert_eq!(COINBASE_INTERVALS_MAINNET[3].coinbase, 125_000_000);
+    assert_eq!(COINBASE_INTERVALS_MAINNET[4].coinbase, 62_500_000);
+
+    // heights from SIP-029
+    assert_eq!(
+        COINBASE_INTERVALS_MAINNET[0].effective_start_height,
+        666_050 - 666_050
+    );
+    assert_eq!(
+        COINBASE_INTERVALS_MAINNET[1].effective_start_height,
+        945_000 - 666_050
+    );
+    assert_eq!(
+        COINBASE_INTERVALS_MAINNET[2].effective_start_height,
+        1_050_000 - 666_050
+    );
+    assert_eq!(
+        COINBASE_INTERVALS_MAINNET[3].effective_start_height,
+        1_260_000 - 666_050
+    );
+    assert_eq!(
+        COINBASE_INTERVALS_MAINNET[4].effective_start_height,
+        1_470_000 - 666_050
+    );
+}
+
+#[test]
+fn test_get_coinbase_at_effective_height() {
+    assert!(CoinbaseInterval::check_order(&*COINBASE_INTERVALS_MAINNET));
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            666050 - 666050
+        ),
+        1_000_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            666051 - 666050
+        ),
+        1_000_000_000
+    );
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            944_999 - 666050
+        ),
+        1_000_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            945_000 - 666050
+        ),
+        500_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            945_001 - 666050
+        ),
+        500_000_000
+    );
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_049_999 - 666050
+        ),
+        500_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_050_000 - 666050
+        ),
+        250_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_050_001 - 666050
+        ),
+        250_000_000
+    );
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_259_999 - 666050
+        ),
+        250_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_260_000 - 666050
+        ),
+        125_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_260_001 - 666050
+        ),
+        125_000_000
+    );
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_469_999 - 666050
+        ),
+        125_000_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_470_000 - 666050
+        ),
+        62_500_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            1_470_001 - 666050
+        ),
+        62_500_000
+    );
+
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            2_197_559 - 666050
+        ),
+        62_500_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            2_197_560 - 666050
+        ),
+        62_500_000
+    );
+    assert_eq!(
+        CoinbaseInterval::get_coinbase_at_effective_height(
+            &*COINBASE_INTERVALS_MAINNET,
+            2_197_561 - 666050
+        ),
+        62_500_000
+    );
+}
+
+#[test]
+fn test_epoch_coinbase_reward() {
+    // new coinbase schedule
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 666050),
+        1_000_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 666051),
+        1_000_000_000
+    );
+
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 944_999),
+        1_000_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 945_000),
+        500_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 945_001),
+        500_000_000
+    );
+
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_049_999),
+        500_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_050_000),
+        250_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_050_001),
+        250_000_000
+    );
+
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_259_999),
+        250_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_260_000),
+        125_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_260_001),
+        125_000_000
+    );
+
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_469_999),
+        125_000_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_470_000),
+        62_500_000
+    );
+    assert_eq!(
+        StacksEpochId::Epoch31.coinbase_reward(true, 666050, 1_470_001),
+        62_500_000
+    );
+
+    // old coinbase schedule
+    for epoch in [
+        StacksEpochId::Epoch20,
+        StacksEpochId::Epoch2_05,
+        StacksEpochId::Epoch21,
+        StacksEpochId::Epoch22,
+        StacksEpochId::Epoch23,
+        StacksEpochId::Epoch24,
+        StacksEpochId::Epoch25,
+    ]
+    .iter()
+    {
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 4 - 1),
+            1_000_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 4),
+            500_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 4 + 1),
+            500_000_000
+        );
+
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 8 - 1),
+            500_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 8),
+            250_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 8 + 1),
+            250_000_000
+        );
+
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 12 - 1),
+            250_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 12),
+            125_000_000
+        );
+        assert_eq!(
+            epoch.coinbase_reward(true, 666050, 666050 + 52596 * 12 + 1),
+            125_000_000
+        );
+    }
+}
+
+/// Verifies that the test facility for setting a coinbase schedule in a unit or integration test
+/// actually works.
+#[test]
+fn test_set_coinbase_intervals() {
+    let new_sched = vec![
+        CoinbaseInterval {
+            coinbase: 1,
+            effective_start_height: 0,
+        },
+        CoinbaseInterval {
+            coinbase: 2,
+            effective_start_height: 1,
+        },
+        CoinbaseInterval {
+            coinbase: 3,
+            effective_start_height: 2,
+        },
+        CoinbaseInterval {
+            coinbase: 4,
+            effective_start_height: 3,
+        },
+        CoinbaseInterval {
+            coinbase: 5,
+            effective_start_height: 4,
+        },
+    ];
+
+    assert_eq!(
+        StacksEpochId::get_coinbase_intervals(true),
+        *COINBASE_INTERVALS_MAINNET
+    );
+    assert_eq!(
+        StacksEpochId::get_coinbase_intervals(false),
+        *COINBASE_INTERVALS_TESTNET
+    );
+
+    set_test_coinbase_schedule(Some(new_sched.clone()));
+
+    assert_eq!(StacksEpochId::get_coinbase_intervals(true), new_sched);
+    assert_eq!(StacksEpochId::get_coinbase_intervals(false), new_sched);
+
+    set_test_coinbase_schedule(None);
+
+    assert_eq!(
+        StacksEpochId::get_coinbase_intervals(true),
+        *COINBASE_INTERVALS_MAINNET
+    );
+    assert_eq!(
+        StacksEpochId::get_coinbase_intervals(false),
+        *COINBASE_INTERVALS_TESTNET
+    );
+}
diff --git a/stackslib/src/burnchains/tests/burnchain.rs b/stackslib/src/burnchains/tests/burnchain.rs
index 1dd8d283ef..8d72d4efa9 100644
--- a/stackslib/src/burnchains/tests/burnchain.rs
+++ b/stackslib/src/burnchains/tests/burnchain.rs
@@ -477,6 +477,7 @@ fn test_process_block_ops() {
 
         let (sn121, _) = tx
             .process_block_ops(
+                false,
                 &burnchain,
                 &initial_snapshot,
                 &header,
@@ -499,6 +500,7 @@ fn test_process_block_ops() {
 
         let (sn122, _) = tx
             .process_block_ops(
+                false,
                 &burnchain,
                 &block_121_snapshot,
                 &header,
@@ -520,6 +522,7 @@ fn test_process_block_ops() {
         let mut tx = SortitionHandleTx::begin(&mut db, &block_122_snapshot.sortition_id).unwrap();
         let (sn123, _) = tx
             .process_block_ops(
+                false,
                 &burnchain,
                 &block_122_snapshot,
                 &header,
@@ -631,6 +634,7 @@ fn test_process_block_ops() {
                 SortitionHandleTx::begin(&mut db, &block_123_snapshot.sortition_id).unwrap();
             let (sn124, _) = tx
                 .process_block_ops(
+                    false,
                     &burnchain,
                     &block_123_snapshot,
                     &header,
@@ -872,6 +876,7 @@ fn test_burn_snapshot_sequence() {
             let mut tx = SortitionHandleTx::begin(&mut db, &prev_snapshot.sortition_id).unwrap();
             let (sn, _) = tx
                 .process_block_ops(
+                    false,
                     &burnchain,
                     &prev_snapshot,
                     &header,
diff --git a/stackslib/src/burnchains/tests/mod.rs b/stackslib/src/burnchains/tests/mod.rs
index 887b56861b..c8543b1142 100644
--- a/stackslib/src/burnchains/tests/mod.rs
+++ b/stackslib/src/burnchains/tests/mod.rs
@@ -627,6 +627,7 @@ impl TestBurnchainBlock {
 
         let new_snapshot = sortition_db_handle
             .process_block_txs(
+                false,
                 &parent_snapshot,
                 &header,
                 burnchain,
diff --git a/stackslib/src/clarity_vm/tests/large_contract.rs b/stackslib/src/clarity_vm/tests/large_contract.rs
index 8db6b3043a..e7d8faff0c 100644
--- a/stackslib/src/clarity_vm/tests/large_contract.rs
+++ b/stackslib/src/clarity_vm/tests/large_contract.rs
@@ -168,7 +168,8 @@ fn test_simple_token_system(#[case] version: ClarityVersion, #[case] epoch: Stac
             | StacksEpochId::Epoch23
             | StacksEpochId::Epoch24
             | StacksEpochId::Epoch25
-            | StacksEpochId::Epoch30 => {
+            | StacksEpochId::Epoch30
+            | StacksEpochId::Epoch31 => {
                 let (ast, _analysis) = tx
                     .analyze_smart_contract(
                         &boot_code_id("costs-3", false),
diff --git a/testnet/stacks-node/src/burnchains/mocknet_controller.rs b/testnet/stacks-node/src/burnchains/mocknet_controller.rs
index 15adebef95..9a2811d1b5 100644
--- a/testnet/stacks-node/src/burnchains/mocknet_controller.rs
+++ b/testnet/stacks-node/src/burnchains/mocknet_controller.rs
@@ -274,6 +274,7 @@ impl BurnchainController for MocknetController {
                             .unwrap();
                     let new_chain_tip = burn_tx
                         .process_block_ops(
+                            false,
                             &self.burnchain,
                             &chain_tip.block_snapshot,
                             &next_block_header,
diff --git a/testnet/stacks-node/src/config.rs b/testnet/stacks-node/src/config.rs
index 2a9816fea4..f3773ff611 100644
--- a/testnet/stacks-node/src/config.rs
+++ b/testnet/stacks-node/src/config.rs
@@ -660,6 +660,8 @@ impl Config {
                 Ok(StacksEpochId::Epoch25)
             } else if epoch_name == EPOCH_CONFIG_3_0_0 {
                 Ok(StacksEpochId::Epoch30)
+            } else if epoch_name == EPOCH_CONFIG_3_1_0 {
+                Ok(StacksEpochId::Epoch31)
             } else {
                 Err(format!("Unknown epoch name specified: {epoch_name}"))
             }?;
@@ -686,6 +688,7 @@ impl Config {
             StacksEpochId::Epoch24,
             StacksEpochId::Epoch25,
             StacksEpochId::Epoch30,
+            StacksEpochId::Epoch31,
         ];
         for (expected_epoch, configured_epoch) in expected_list
             .iter()
@@ -1297,6 +1300,7 @@ pub const EPOCH_CONFIG_2_3_0: &str = "2.3";
 pub const EPOCH_CONFIG_2_4_0: &str = "2.4";
 pub const EPOCH_CONFIG_2_5_0: &str = "2.5";
 pub const EPOCH_CONFIG_3_0_0: &str = "3.0";
+pub const EPOCH_CONFIG_3_1_0: &str = "3.1";
 
 #[derive(Clone, Deserialize, Default, Debug)]
 pub struct AffirmationOverride {
diff --git a/testnet/stacks-node/src/nakamoto_node/relayer.rs b/testnet/stacks-node/src/nakamoto_node/relayer.rs
index b346cdc346..8cc1293acd 100644
--- a/testnet/stacks-node/src/nakamoto_node/relayer.rs
+++ b/testnet/stacks-node/src/nakamoto_node/relayer.rs
@@ -38,7 +38,7 @@ use stacks::chainstate::stacks::miner::{
     get_mining_spend_amount, signal_mining_blocked, signal_mining_ready,
 };
 use stacks::core::mempool::MemPoolDB;
-use stacks::core::STACKS_EPOCH_3_0_MARKER;
+use stacks::core::STACKS_EPOCH_3_1_MARKER;
 use stacks::monitoring::increment_stx_blocks_mined_counter;
 use stacks::net::db::LocalPeer;
 use stacks::net::p2p::NetworkHandle;
@@ -738,7 +738,7 @@ impl RelayerThread {
             key_block_ptr: u32::try_from(key.block_height)
                 .expect("FATAL: burn block height exceeded u32"),
             key_vtxindex: u16::try_from(key.op_vtxindex).expect("FATAL: vtxindex exceeded u16"),
-            memo: vec![STACKS_EPOCH_3_0_MARKER],
+            memo: vec![STACKS_EPOCH_3_1_MARKER],
             new_seed: VRFSeed::from_proof(&tip_vrf_proof),
             parent_block_ptr: u32::try_from(commit_parent_block_burn_height)
                 .expect("FATAL: burn block height exceeded u32"),
diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
index ef6199d331..25d22801c0 100644
--- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs
+++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
@@ -29,6 +29,7 @@ use http_types::headers::AUTHORIZATION;
 use lazy_static::lazy_static;
 use libsigner::v0::messages::SignerMessage as SignerMessageV0;
 use libsigner::{SignerSession, StackerDBSession};
+use rusqlite::OptionalExtension;
 use stacks::burnchains::{MagicBytes, Txid};
 use stacks::chainstate::burn::db::sortdb::SortitionDB;
 use stacks::chainstate::burn::operations::{
@@ -61,7 +62,7 @@ use stacks::core::{
     EpochList, StacksEpoch, StacksEpochId, BLOCK_LIMIT_MAINNET_10, HELIUM_BLOCK_LIMIT_20,
     PEER_VERSION_EPOCH_1_0, PEER_VERSION_EPOCH_2_0, PEER_VERSION_EPOCH_2_05,
     PEER_VERSION_EPOCH_2_1, PEER_VERSION_EPOCH_2_2, PEER_VERSION_EPOCH_2_3, PEER_VERSION_EPOCH_2_4,
-    PEER_VERSION_EPOCH_2_5, PEER_VERSION_EPOCH_3_0, PEER_VERSION_TESTNET,
+    PEER_VERSION_EPOCH_2_5, PEER_VERSION_EPOCH_3_0, PEER_VERSION_EPOCH_3_1, PEER_VERSION_TESTNET,
 };
 use stacks::libstackerdb::SlotMetadata;
 use stacks::net::api::callreadonly::CallReadOnlyRequestBody;
@@ -71,7 +72,7 @@ use stacks::net::api::getstackers::GetStackersResponse;
 use stacks::net::api::postblock_proposal::{
     BlockValidateReject, BlockValidateResponse, NakamotoBlockProposal, ValidateRejectCode,
 };
-use stacks::types::chainstate::StacksBlockId;
+use stacks::types::chainstate::{ConsensusHash, StacksBlockId};
 use stacks::util::hash::hex_bytes;
 use stacks::util_lib::boot::boot_code_id;
 use stacks::util_lib::signed_structured_data::pox4::{
@@ -85,7 +86,7 @@ use stacks_common::types::chainstate::{
     BlockHeaderHash, BurnchainHeaderHash, StacksAddress, StacksPrivateKey, StacksPublicKey,
     TrieHash,
 };
-use stacks_common::types::StacksPublicKeyBuffer;
+use stacks_common::types::{set_test_coinbase_schedule, CoinbaseInterval, StacksPublicKeyBuffer};
 use stacks_common::util::hash::{to_hex, Hash160, Sha512Trunc256Sum};
 use stacks_common::util::secp256k1::{MessageSignature, Secp256k1PrivateKey, Secp256k1PublicKey};
 use stacks_common::util::{get_epoch_time_secs, sleep_ms};
@@ -117,7 +118,7 @@ pub static POX_4_DEFAULT_STACKER_BALANCE: u64 = 100_000_000_000_000;
 pub static POX_4_DEFAULT_STACKER_STX_AMT: u128 = 99_000_000_000_000;
 
 lazy_static! {
-    pub static ref NAKAMOTO_INTEGRATION_EPOCHS: [StacksEpoch; 9] = [
+    pub static ref NAKAMOTO_INTEGRATION_EPOCHS: [StacksEpoch; 10] = [
         StacksEpoch {
             epoch_id: StacksEpochId::Epoch10,
             start_height: 0,
@@ -177,10 +178,17 @@ lazy_static! {
         StacksEpoch {
             epoch_id: StacksEpochId::Epoch30,
             start_height: 231,
-            end_height: STACKS_EPOCH_MAX,
+            end_height: 241,
             block_limit: HELIUM_BLOCK_LIMIT_20.clone(),
             network_epoch: PEER_VERSION_EPOCH_3_0
         },
+        StacksEpoch {
+            epoch_id: StacksEpochId::Epoch31,
+            start_height: 241,
+            end_height: STACKS_EPOCH_MAX,
+            block_limit: HELIUM_BLOCK_LIMIT_20.clone(),
+            network_epoch: PEER_VERSION_EPOCH_3_1
+        },
     ];
 }
 
@@ -9812,9 +9820,226 @@ fn test_shadow_recovery() {
 
 #[test]
 #[ignore]
+/// Integration test for SIP-029
+fn sip029_coinbase_change() {
+    if env::var("BITCOIND_TEST") != Ok("1".into()) {
+        return;
+    }
+    let new_sched = vec![
+        CoinbaseInterval {
+            coinbase: 1_000_000_000,
+            effective_start_height: 0,
+        },
+        // NOTE: epoch 3.1 goes into effect at 241
+        CoinbaseInterval {
+            coinbase: 500_000_000,
+            effective_start_height: 245,
+        },
+        CoinbaseInterval {
+            coinbase: 125_000_000,
+            effective_start_height: 255,
+        },
+        CoinbaseInterval {
+            coinbase: 62_500_000,
+            effective_start_height: 265,
+        },
+    ];
+
+    set_test_coinbase_schedule(Some(new_sched.clone()));
+
+    let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None);
+    naka_conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
+    naka_conf.node.pox_sync_sample_secs = 180;
+    naka_conf.burnchain.max_rbf = 10_000_000;
+
+    let sender_sk = Secp256k1PrivateKey::new();
+    let sender_signer_sk = Secp256k1PrivateKey::new();
+    let sender_signer_addr = tests::to_addr(&sender_signer_sk);
+    let mut signers = TestSigners::new(vec![sender_signer_sk]);
+    let tenure_count = 5;
+    let inter_blocks_per_tenure = 9;
+    // setup sender + recipient for some test stx transfers
+    // these are necessary for the interim blocks to get mined at all
+    let sender_addr = tests::to_addr(&sender_sk);
+    let send_amt = 100;
+    let send_fee = 180;
+    naka_conf.add_initial_balance(
+        PrincipalData::from(sender_addr).to_string(),
+        (send_amt + send_fee) * tenure_count * inter_blocks_per_tenure,
+    );
+    naka_conf.add_initial_balance(PrincipalData::from(sender_signer_addr).to_string(), 100000);
+    let stacker_sk = setup_stacker(&mut naka_conf);
+
+    test_observer::spawn();
+    test_observer::register_any(&mut naka_conf);
+
+    let mut btcd_controller = BitcoinCoreController::new(naka_conf.clone());
+    btcd_controller
+        .start_bitcoind()
+        .expect("Failed starting bitcoind");
+    let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None);
+    btc_regtest_controller.bootstrap_chain(201);
+
+    let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap();
+    let run_loop_stopper = run_loop.get_termination_switch();
+    let Counters {
+        blocks_processed,
+        naka_submitted_commits: commits_submitted,
+        naka_proposed_blocks: proposals_submitted,
+        ..
+    } = run_loop.counters();
+
+    let coord_channel = run_loop.coordinator_channels();
+
+    let run_loop_thread = thread::Builder::new()
+        .name("run_loop".into())
+        .spawn(move || run_loop.start(None, 0))
+        .unwrap();
+    wait_for_runloop(&blocks_processed);
+    boot_to_epoch_3(
+        &naka_conf,
+        &blocks_processed,
+        &[stacker_sk],
+        &[sender_signer_sk],
+        &mut Some(&mut signers),
+        &mut btc_regtest_controller,
+    );
+
+    info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner");
+
+    let burnchain = naka_conf.get_burnchain();
+    let sortdb = burnchain.open_sortition_db(true).unwrap();
+    let (chainstate, _) = StacksChainState::open(
+        naka_conf.is_mainnet(),
+        naka_conf.burnchain.chain_id,
+        &naka_conf.get_chainstate_path_str(),
+        None,
+    )
+    .unwrap();
+
+    info!("Nakamoto miner started...");
+    blind_signer(&naka_conf, &signers, proposals_submitted);
+
+    wait_for_first_naka_block_commit(60, &commits_submitted);
+
+    // mine until burnchain height 270
+    loop {
+        let commits_before = commits_submitted.load(Ordering::SeqCst);
+        next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel)
+            .unwrap();
+        wait_for(20, || {
+            Ok(commits_submitted.load(Ordering::SeqCst) > commits_before)
+        })
+        .unwrap();
+
+        let node_info = get_chain_info_opt(&naka_conf).unwrap();
+        if node_info.burn_block_height >= 270 {
+            break;
+        }
+    }
+
+    info!("Nakamoto miner has advanced to burn height 270");
+
+    // inspect `payments` table to see that coinbase was applied
+    let all_snapshots = sortdb.get_all_snapshots().unwrap();
+
+    // whether or not the last snapshot had a sortition
+    let mut prev_sortition = false;
+
+    // whether or not we witnessed the requisite coinbases
+    let mut witnessed_1000 = false;
+    let mut witnessed_500 = false;
+    let mut witnessed_125 = false;
+    let mut witnessed_62_5 = false;
+
+    // initial mining bonus
+    let initial_mining_bonus = 20400000;
+
+    for sn in all_snapshots {
+        if !sn.sortition {
+            prev_sortition = false;
+            continue;
+        }
+        if sn.consensus_hash == ConsensusHash([0x00; 20]) {
+            continue;
+        }
+        let coinbase = {
+            let sql = "SELECT coinbase FROM payments WHERE consensus_hash = ?1";
+            let args = rusqlite::params![&sn.consensus_hash];
+            let Some(coinbase) = chainstate
+                .db()
+                .query_row(sql, args, |r| {
+                    let coinbase_txt: String = r.get_unwrap(0);
+                    let coinbase: u64 = coinbase_txt.parse().unwrap();
+                    Ok(coinbase)
+                })
+                .optional()
+                .unwrap()
+            else {
+                info!("No coinbase for {} {}", sn.block_height, &sn.consensus_hash);
+                continue;
+            };
+
+            coinbase
+        };
+
+        info!(
+            "Coinbase at {} {}: {}",
+            sn.block_height, &sn.consensus_hash, coinbase
+        );
+        // use >= for coinbases since a missed sortition can lead to coinbase accumulation
+        if sn.block_height < 245 {
+            if prev_sortition {
+                assert_eq!(coinbase, 1_000_000_000 + initial_mining_bonus);
+                witnessed_1000 = true;
+            } else {
+                assert!(coinbase >= 1_000_000_000 + initial_mining_bonus);
+            }
+        } else if sn.block_height < 255 {
+            if prev_sortition {
+                assert_eq!(coinbase, 500_000_000 + initial_mining_bonus);
+                witnessed_500 = true;
+            } else {
+                assert!(coinbase >= 500_000_000 + initial_mining_bonus);
+            }
+        } else if sn.block_height < 265 {
+            if prev_sortition {
+                assert_eq!(coinbase, 125_000_000 + initial_mining_bonus);
+                witnessed_125 = true;
+            } else {
+                assert!(coinbase >= 125_000_000 + initial_mining_bonus);
+            }
+        } else {
+            if prev_sortition {
+                assert_eq!(coinbase, 62_500_000 + initial_mining_bonus);
+                witnessed_62_5 = true;
+            } else {
+                assert!(coinbase >= 62_500_000 + initial_mining_bonus);
+            }
+        }
+
+        prev_sortition = true;
+    }
+
+    assert!(witnessed_1000);
+    assert!(witnessed_500);
+    assert!(witnessed_125);
+    assert!(witnessed_62_5);
+
+    coord_channel
+        .lock()
+        .expect("Mutex poisoned")
+        .stop_chains_coordinator();
+    run_loop_stopper.store(false, Ordering::SeqCst);
+
+    run_loop_thread.join().unwrap();
+}
+
 /// This test is testing that the clarity cost spend down works as expected,
 /// spreading clarity contract calls across the tenure instead of all in the first block.
 /// It also ensures that the clarity cost resets at the start of each tenure.
+#[test]
+#[ignore]
 fn clarity_cost_spend_down() {
     if env::var("BITCOIND_TEST") != Ok("1".into()) {
         return;
diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs
index cd3488bd07..9c9fee200f 100644
--- a/testnet/stacks-node/src/tests/signer/v0.rs
+++ b/testnet/stacks-node/src/tests/signer/v0.rs
@@ -1270,8 +1270,18 @@ fn bitcoind_forking_test() {
     let sender_addr = tests::to_addr(&sender_sk);
     let send_amt = 100;
     let send_fee = 180;
-    let mut signer_test: SignerTest<SpawnedSigner> =
-        SignerTest::new(num_signers, vec![(sender_addr, send_amt + send_fee)]);
+    let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
+        num_signers,
+        vec![(sender_addr, send_amt + send_fee)],
+        |_| {},
+        |node_config| {
+            let epochs = node_config.burnchain.epochs.as_mut().unwrap();
+            epochs[StacksEpochId::Epoch30].end_height = 3_015;
+            epochs[StacksEpochId::Epoch31].start_height = 3_015;
+        },
+        None,
+        None,
+    );
     let conf = signer_test.running_nodes.conf.clone();
     let http_origin = format!("http://{}", &conf.node.rpc_bind);
     let miner_address = Keychain::default(conf.node.seed.clone())
@@ -3072,6 +3082,8 @@ fn mock_sign_epoch_25() {
             let epochs = node_config.burnchain.epochs.as_mut().unwrap();
             epochs[StacksEpochId::Epoch25].end_height = 251;
             epochs[StacksEpochId::Epoch30].start_height = 251;
+            epochs[StacksEpochId::Epoch30].end_height = 265;
+            epochs[StacksEpochId::Epoch31].start_height = 265;
         },
         None,
         None,
@@ -3170,7 +3182,7 @@ fn mock_sign_epoch_25() {
             );
         }
         assert!(
-            main_poll_time.elapsed() <= Duration::from_secs(45),
+            main_poll_time.elapsed() <= Duration::from_secs(145),
             "Timed out waiting to advance epoch 3.0 boundary"
         );
     }
@@ -3232,6 +3244,8 @@ fn multiple_miners_mock_sign_epoch_25() {
             let epochs = config.burnchain.epochs.as_mut().unwrap();
             epochs[StacksEpochId::Epoch25].end_height = 251;
             epochs[StacksEpochId::Epoch30].start_height = 251;
+            epochs[StacksEpochId::Epoch30].end_height = 265;
+            epochs[StacksEpochId::Epoch31].start_height = 265;
             config.events_observers.retain(|listener| {
                 let Ok(addr) = std::net::SocketAddr::from_str(&listener.endpoint) else {
                     warn!(