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

Issue:
feat: better exposing of signer version
We previously had a `VERSION_STRING` that you could use with `stacks-signer --version`. This PR does a few things:

- Use the `version_string` helper to improve code re-use (it was the same exact code anyways)
- When starting the signer, print the version
- Add version to `check-config` CLI command
- Add version to the `/info` endpoint on the metrics server


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ccb9b5cac..4025a66c3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,7 +17,9 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
     - `get-tenure-info?` added
     - `get-block-info?` removed
 - Added `/v3/signer/{signer_pubkey}/{reward_cycle}` endpoint
+- Added `tenure_height` to `/v2/info` endpoint
 - Added optional `timeout_ms` to `events_observer` configuration
+- Added support for re-sending events to event observers across restarts
 
 ## [2.5.0.0.7]
 
diff --git a/Cargo.lock b/Cargo.lock
index b06e285ec4..5d724448dc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -212,6 +212,16 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "async-attributes"
 version = "1.1.2"
@@ -560,9 +570,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
 
 [[package]]
 name = "cc"
-version = "1.1.28"
+version = "1.1.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e80e3b6a3ab07840e1cae9b0666a63970dc28e8ed5ffbcdacbfc760c281bfc1"
+checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945"
 dependencies = [
  "jobserver",
  "libc",
@@ -623,9 +633,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.19"
+version = "4.5.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615"
+checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -633,9 +643,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.19"
+version = "4.5.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b"
+checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
 dependencies = [
  "anstream",
  "anstyle",
@@ -664,10 +674,10 @@ checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
 [[package]]
 name = "clar2wasm"
 version = "0.1.0"
-source = "git+https://github.com/stacks-network/clarity-wasm.git?branch=main#136f60a6e88ae65d6467b25b00431cf54e567d4f"
+source = "git+https://github.com/stacks-network/clarity-wasm.git?branch=main#d559910820712ccf29af2bf0a6516e0f6f1a5a94"
 dependencies = [
  "chrono",
- "clap 4.5.19",
+ "clap 4.5.20",
  "clarity",
  "lazy_static",
  "regex",
@@ -683,7 +693,7 @@ dependencies = [
 name = "clarity"
 version = "2.3.0"
 dependencies = [
- "assert-json-diff",
+ "assert-json-diff 1.1.0",
  "getrandom 0.2.15",
  "hashbrown 0.14.5",
  "integer-sqrt",
@@ -710,6 +720,16 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
 
+[[package]]
+name = "colored"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8"
+dependencies = [
+ "lazy_static",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "2.5.0"
@@ -1596,6 +1616,25 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "h2"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.1.0",
+ "indexmap 2.6.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
 [[package]]
 name = "half"
 version = "1.8.3"
@@ -1783,6 +1822,29 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http 1.1.0",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "http-types"
 version = "2.12.0"
@@ -1819,17 +1881,17 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
 [[package]]
 name = "hyper"
-version = "0.14.30"
+version = "0.14.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
+checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85"
 dependencies = [
  "bytes",
  "futures-channel",
  "futures-core",
  "futures-util",
- "h2",
+ "h2 0.3.26",
  "http 0.2.12",
- "http-body",
+ "http-body 0.4.6",
  "httparse",
  "httpdate",
  "itoa",
@@ -1841,6 +1903,26 @@ dependencies = [
  "want",
 ]
 
+[[package]]
+name = "hyper"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2 0.4.6",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
 [[package]]
 name = "hyper-rustls"
 version = "0.24.2"
@@ -1849,12 +1931,27 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
 dependencies = [
  "futures-util",
  "http 0.2.12",
- "hyper",
+ "hyper 0.14.31",
  "rustls",
  "tokio",
  "tokio-rustls",
 ]
 
+[[package]]
+name = "hyper-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "hyper 1.5.0",
+ "pin-project-lite",
+ "tokio",
+]
+
 [[package]]
 name = "iana-time-zone"
 version = "0.1.61"
@@ -2028,9 +2125,9 @@ dependencies = [
 
 [[package]]
 name = "js-sys"
-version = "0.3.70"
+version = "0.3.72"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
+checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9"
 dependencies = [
  "wasm-bindgen",
 ]
@@ -2077,9 +2174,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
 
 [[package]]
 name = "libc"
-version = "0.2.159"
+version = "0.2.161"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
+checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
 
 [[package]]
 name = "libflate"
@@ -2361,6 +2458,30 @@ dependencies = [
  "ws2_32-sys",
 ]
 
+[[package]]
+name = "mockito"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09b34bd91b9e5c5b06338d392463e1318d683cf82ec3d3af4014609be6e2108d"
+dependencies = [
+ "assert-json-diff 2.0.2",
+ "bytes",
+ "colored",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.5.0",
+ "hyper-util",
+ "log",
+ "rand 0.8.5",
+ "regex",
+ "serde_json",
+ "serde_urlencoded",
+ "similar",
+ "tokio",
+]
+
 [[package]]
 name = "multer"
 version = "2.1.0"
@@ -2522,9 +2643,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
 
 [[package]]
 name = "pest"
-version = "2.7.13"
+version = "2.7.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9"
+checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442"
 dependencies = [
  "memchr",
  "thiserror",
@@ -2732,9 +2853,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.87"
+version = "1.0.88"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a"
+checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9"
 dependencies = [
  "unicode-ident",
 ]
@@ -2963,10 +3084,10 @@ dependencies = [
  "encoding_rs",
  "futures-core",
  "futures-util",
- "h2",
+ "h2 0.3.26",
  "http 0.2.12",
- "http-body",
- "hyper",
+ "http-body 0.4.6",
+ "hyper 0.14.31",
  "hyper-rustls",
  "ipnet",
  "js-sys",
@@ -3223,9 +3344,9 @@ dependencies = [
 
 [[package]]
 name = "rustversion"
-version = "1.0.17"
+version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248"
 
 [[package]]
 name = "ryu"
@@ -3244,9 +3365,9 @@ dependencies = [
 
 [[package]]
 name = "scc"
-version = "2.2.0"
+version = "2.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "836f1e0f4963ef5288b539b643b35e043e76a32d0f4e47e67febf69576527f50"
+checksum = "f2c1f7fc6deb21665a9060dfc7d271be784669295a31babdcd4dd2c79ae8cbfb"
 dependencies = [
  "sdd",
 ]
@@ -3275,9 +3396,9 @@ dependencies = [
 
 [[package]]
 name = "sdd"
-version = "3.0.3"
+version = "3.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60a7b59a5d9b0099720b417b6325d91a52cbf5b3dcb5041d864be53eefa58abc"
+checksum = "49c1eeaf4b6a87c7479688c6d52b9f1153cedd3c489300564f932b065c6eab95"
 
 [[package]]
 name = "secp256k1"
@@ -3369,9 +3490,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.128"
+version = "1.0.129"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
+checksum = "6dbcf9b78a125ee667ae19388837dd12294b858d101fdd393cb9d5501ef09eb2"
 dependencies = [
  "itoa",
  "memchr",
@@ -3531,6 +3652,12 @@ dependencies = [
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "similar"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e"
+
 [[package]]
 name = "siphasher"
 version = "0.3.11"
@@ -3660,7 +3787,7 @@ dependencies = [
 name = "stacks-common"
 version = "0.0.2"
 dependencies = [
- "assert-json-diff",
+ "assert-json-diff 1.1.0",
  "chrono",
  "curve25519-dalek 2.0.0",
  "ed25519-dalek",
@@ -3705,6 +3832,7 @@ dependencies = [
  "lazy_static",
  "libc",
  "libsigner",
+ "mockito",
  "mutants",
  "pico-args",
  "rand 0.8.5",
@@ -3721,6 +3849,7 @@ dependencies = [
  "stacks-signer",
  "stackslib",
  "stx-genesis",
+ "tempfile",
  "tikv-jemallocator",
  "tiny_http",
  "tokio",
@@ -3736,7 +3865,7 @@ name = "stacks-signer"
 version = "0.0.1"
 dependencies = [
  "backoff",
- "clap 4.5.19",
+ "clap 4.5.20",
  "clarity",
  "hashbrown 0.14.5",
  "lazy_static",
@@ -3772,7 +3901,7 @@ dependencies = [
 name = "stackslib"
 version = "0.0.1"
 dependencies = [
- "assert-json-diff",
+ "assert-json-diff 1.1.0",
  "chrono",
  "clar2wasm",
  "clarity",
@@ -3957,6 +4086,19 @@ version = "0.12.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
 
+[[package]]
+name = "tempfile"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b"
+dependencies = [
+ "cfg-if 1.0.0",
+ "fastrand 2.1.1",
+ "once_cell",
+ "rustix 0.38.37",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "term"
 version = "0.7.0"
@@ -4143,6 +4285,7 @@ dependencies = [
  "bytes",
  "libc",
  "mio 1.0.2",
+ "parking_lot",
  "pin-project-lite",
  "socket2 0.5.7",
  "windows-sys 0.52.0",
@@ -4393,9 +4536,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
 [[package]]
 name = "uuid"
-version = "1.10.0"
+version = "1.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
+checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
 
 [[package]]
 name = "valuable"
@@ -4485,7 +4628,7 @@ dependencies = [
  "futures-util",
  "headers",
  "http 0.2.12",
- "hyper",
+ "hyper 0.14.31",
  "log",
  "mime",
  "mime_guess",
@@ -4517,9 +4660,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
 
 [[package]]
 name = "wasm-bindgen"
-version = "0.2.93"
+version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
+checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
 dependencies = [
  "cfg-if 1.0.0",
  "once_cell",
@@ -4528,9 +4671,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-backend"
-version = "0.2.93"
+version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
+checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
 dependencies = [
  "bumpalo",
  "log",
@@ -4543,9 +4686,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-futures"
-version = "0.4.43"
+version = "0.4.45"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed"
+checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b"
 dependencies = [
  "cfg-if 1.0.0",
  "js-sys",
@@ -4555,9 +4698,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro"
-version = "0.2.93"
+version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
+checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
 dependencies = [
  "quote",
  "wasm-bindgen-macro-support",
@@ -4565,9 +4708,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro-support"
-version = "0.2.93"
+version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
+checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -4578,9 +4721,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-shared"
-version = "0.2.93"
+version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
+checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
 
 [[package]]
 name = "wasm-encoder"
@@ -4602,11 +4745,12 @@ dependencies = [
 
 [[package]]
 name = "wasm-encoder"
-version = "0.218.0"
+version = "0.219.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22b896fa8ceb71091ace9bcb81e853f54043183a1c9667cf93422c40252ffa0a"
+checksum = "29cbbd772edcb8e7d524a82ee8cef8dd046fc14033796a754c3ad246d019fa54"
 dependencies = [
  "leb128",
+ "wasmparser 0.219.1",
 ]
 
 [[package]]
@@ -4625,6 +4769,16 @@ dependencies = [
  "semver 1.0.23",
 ]
 
+[[package]]
+name = "wasmparser"
+version = "0.219.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c771866898879073c53b565a6c7b49953795159836714ac56a5befb581227c5"
+dependencies = [
+ "bitflags 2.6.0",
+ "indexmap 2.6.0",
+]
+
 [[package]]
 name = "wasmtime"
 version = "15.0.1"
@@ -4911,31 +5065,31 @@ checksum = "9b6060bc082cc32d9a45587c7640e29e3c7b89ada82677ac25d87850aaccb368"
 
 [[package]]
 name = "wast"
-version = "218.0.0"
+version = "219.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a53cd1f0fa505df97557e36a58bddb8296e2fcdcd089529545ebfdb18a1b9d7"
+checksum = "4f79a9d9df79986a68689a6b40bcc8d5d40d807487b235bebc2ac69a242b54a1"
 dependencies = [
  "bumpalo",
  "leb128",
  "memchr",
  "unicode-width",
- "wasm-encoder 0.218.0",
+ "wasm-encoder 0.219.1",
 ]
 
 [[package]]
 name = "wat"
-version = "1.218.0"
+version = "1.219.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f87f8e14e776762e07927c27c2054d2cf678aab9aae2d431a79b3e31e4dd391"
+checksum = "8bc3cf014fb336883a411cd662f987abf6a1d2a27f2f0008616a0070bbf6bd0d"
 dependencies = [
  "wast",
 ]
 
 [[package]]
 name = "web-sys"
-version = "0.3.70"
+version = "0.3.72"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0"
+checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
diff --git a/clarity/src/vm/database/clarity_db.rs b/clarity/src/vm/database/clarity_db.rs
index 3367425724..87b6aef5a8 100644
--- a/clarity/src/vm/database/clarity_db.rs
+++ b/clarity/src/vm/database/clarity_db.rs
@@ -127,6 +127,11 @@ pub trait HeadersDB {
         id_bhh: &StacksBlockId,
         epoch: &StacksEpochId,
     ) -> Option<u128>;
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32>;
 }
 
 pub trait BurnStateDB {
@@ -285,6 +290,13 @@ impl HeadersDB for NullHeadersDB {
     ) -> Option<u128> {
         None
     }
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        _tip: &StacksBlockId,
+        _tenure_height: u32,
+    ) -> Option<u32> {
+        None
+    }
 }
 
 #[allow(clippy::panic)]
@@ -915,6 +927,39 @@ impl<'a> ClarityDatabase<'a> {
         }
     }
 
+    /// Return the block height for a given tenure height
+    ///   if the block information is queryable for the tenure height.
+    /// The block information for a given tenure height is queryable iff:
+    ///  * `tenure_height` falls in 2.x, and `tenure_height` < `current_height`
+    ///  * `tenure_height` falls in 3.x, and the first block of the tenure
+    ///    at `tenure_height` has a stacks block height less than `current_height`
+    ///
+    /// If the block information isn't queryable, return `Ok(None)`
+    pub fn get_block_height_for_tenure_height(
+        &mut self,
+        tenure_height: u32,
+    ) -> Result<Option<u32>> {
+        let current_tenure_height = self.get_tenure_height()?;
+        if current_tenure_height < tenure_height {
+            return Ok(None);
+        }
+        let current_height = self.get_current_block_height();
+        if current_height <= tenure_height {
+            return Ok(None);
+        }
+        // check if we're querying a 2.x block
+        let id_bhh = self.get_index_block_header_hash(tenure_height)?;
+        let epoch = self.get_stacks_epoch_for_block(&id_bhh)?;
+        if !epoch.uses_nakamoto_blocks() {
+            return Ok(Some(tenure_height));
+        }
+        // query from the parent
+        let query_tip = self.get_index_block_header_hash(current_height.saturating_sub(1))?;
+        Ok(self
+            .headers_db
+            .get_stacks_height_for_tenure_height(&query_tip, tenure_height.into()))
+    }
+
     /// Get the last-known burnchain block height.
     /// Note that this is _not_ the burnchain height in which this block was mined!
     /// This is the burnchain block height of the parent of the Stacks block at the current Stacks
diff --git a/clarity/src/vm/docs/mod.rs b/clarity/src/vm/docs/mod.rs
index 30d4d35ea4..d355045f54 100644
--- a/clarity/src/vm/docs/mod.rs
+++ b/clarity/src/vm/docs/mod.rs
@@ -2862,6 +2862,14 @@ mod test {
         ) -> Option<u128> {
             Some(12000)
         }
+
+        fn get_stacks_height_for_tenure_height(
+            &self,
+            _tip: &StacksBlockId,
+            tenure_height: u32,
+        ) -> Option<u32> {
+            Some(tenure_height)
+        }
     }
 
     struct DocBurnStateDB {}
diff --git a/clarity/src/vm/functions/database.rs b/clarity/src/vm/functions/database.rs
index f048a59536..ff14507ead 100644
--- a/clarity/src/vm/functions/database.rs
+++ b/clarity/src/vm/functions/database.rs
@@ -16,6 +16,7 @@
 
 use std::cmp;
 
+use stacks_common::consts::CHAIN_ID_TESTNET;
 use stacks_common::types::chainstate::StacksBlockId;
 use stacks_common::types::StacksEpochId;
 
@@ -769,6 +770,29 @@ pub fn special_get_block_info(
         _ => return Ok(Value::none()),
     };
 
+    // interpret height as a tenure height IFF
+    // * clarity version is less than Clarity3
+    // * the evaluated epoch is geq 3.0
+    // * we are not on (classic) primary testnet
+    let interpret_height_as_tenure_height = env.contract_context.get_clarity_version()
+        < &ClarityVersion::Clarity3
+        && env.global_context.epoch_id >= StacksEpochId::Epoch30
+        && env.global_context.chain_id != CHAIN_ID_TESTNET;
+
+    let height_value = if !interpret_height_as_tenure_height {
+        height_value
+    } else {
+        // interpretting height_value as a tenure height
+        let height_opt = env
+            .global_context
+            .database
+            .get_block_height_for_tenure_height(height_value)?;
+        match height_opt {
+            Some(x) => x,
+            None => return Ok(Value::none()),
+        }
+    };
+
     let current_block_height = env.global_context.database.get_current_block_height();
     if height_value >= current_block_height {
         return Ok(Value::none());
diff --git a/docs/rpc/api/core-node/get-info.example.json b/docs/rpc/api/core-node/get-info.example.json
index afc42e6f68..77ece128c3 100644
--- a/docs/rpc/api/core-node/get-info.example.json
+++ b/docs/rpc/api/core-node/get-info.example.json
@@ -11,6 +11,7 @@
   "stacks_tip": "b1807a2d3f7f8c7922f7c1d60d7c34145ade05d789640dc7dc9ec1021e07bb54",
   "stacks_tip_consensus_hash": "17f76e597bab45646956f38dd39573085d72cbc0",
   "unanchored_tip": "0000000000000000000000000000000000000000000000000000000000000000",
+  "tenure_height": 523,
   "exit_at_block_height": null,
   "is_fully_synced": false
 }
diff --git a/docs/rpc/api/core-node/get-info.schema.json b/docs/rpc/api/core-node/get-info.schema.json
index 16b560ed5e..e997a2d19c 100644
--- a/docs/rpc/api/core-node/get-info.schema.json
+++ b/docs/rpc/api/core-node/get-info.schema.json
@@ -17,6 +17,7 @@
     "stacks_tip",
     "stacks_tip_consensus_hash",
     "unanchored_tip",
+    "tenure_height",
     "exit_at_block_height",
     "is_fully_synced"
   ],
@@ -69,6 +70,10 @@
       "type": "string",
       "description": "the latest microblock hash if any microblocks were processed. if no microblock has been processed for the current block, a 000.., hex array is returned"
     },
+    "tenure_height": {
+      "type": "integer",
+      "description": "the latest Stacks tenure height"
+    },
     "exit_at_block_height": {
       "type": "integer",
       "description": "the block height at which the testnet network will be reset. not applicable for mainnet"
diff --git a/stacks-signer/src/cli.rs b/stacks-signer/src/cli.rs
index 3b74635cbc..97829b6977 100644
--- a/stacks-signer/src/cli.rs
+++ b/stacks-signer/src/cli.rs
@@ -29,7 +29,6 @@ use clarity::util::hash::Sha256Sum;
 use clarity::util::secp256k1::MessageSignature;
 use clarity::vm::types::{QualifiedContractIdentifier, TupleData};
 use clarity::vm::Value;
-use lazy_static::lazy_static;
 use serde::{Deserialize, Serialize};
 use stacks_common::address::{
     b58, AddressHashMode, C32_ADDRESS_VERSION_MAINNET_MULTISIG,
@@ -39,31 +38,9 @@ use stacks_common::address::{
 use stacks_common::define_u8_enum;
 use stacks_common::types::chainstate::StacksPrivateKey;
 
-extern crate alloc;
+use crate::VERSION_STRING;
 
-const GIT_BRANCH: Option<&'static str> = option_env!("GIT_BRANCH");
-const GIT_COMMIT: Option<&'static str> = option_env!("GIT_COMMIT");
-#[cfg(debug_assertions)]
-const BUILD_TYPE: &str = "debug";
-#[cfg(not(debug_assertions))]
-const BUILD_TYPE: &str = "release";
-
-lazy_static! {
-    static ref VERSION_STRING: String = {
-        let pkg_version = option_env!("STACKS_NODE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
-        let git_branch = GIT_BRANCH.unwrap_or("");
-        let git_commit = GIT_COMMIT.unwrap_or("");
-        format!(
-            "{} ({}:{}, {} build, {} [{}])",
-            pkg_version,
-            git_branch,
-            git_commit,
-            BUILD_TYPE,
-            std::env::consts::OS,
-            std::env::consts::ARCH
-        )
-    };
-}
+extern crate alloc;
 
 #[derive(Parser, Debug)]
 #[command(author, version, about)]
diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs
index 3fe9f09354..9885182d98 100644
--- a/stacks-signer/src/client/mod.rs
+++ b/stacks-signer/src/client/mod.rs
@@ -322,6 +322,7 @@ pub(crate) mod tests {
             stacks_tip_consensus_hash: generate_random_consensus_hash(),
             unanchored_tip: None,
             unanchored_seq: Some(0),
+            tenure_height: thread_rng().next_u64(),
             exit_at_block_height: None,
             is_fully_synced: false,
             genesis_chainstate_hash: Sha256Sum::zero(),
diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs
index 375ed1a171..3392906682 100644
--- a/stacks-signer/src/config.rs
+++ b/stacks-signer/src/config.rs
@@ -156,7 +156,7 @@ pub struct GlobalConfig {
     /// How much time to wait for a miner to propose a block following a sortition
     pub block_proposal_timeout: Duration,
     /// An optional custom Chain ID
-    chain_id: Option<u32>,
+    pub chain_id: Option<u32>,
 }
 
 /// Internal struct for loading up the config file
diff --git a/stacks-signer/src/lib.rs b/stacks-signer/src/lib.rs
index 20c2bc2ca8..3555435eaa 100644
--- a/stacks-signer/src/lib.rs
+++ b/stacks-signer/src/lib.rs
@@ -48,8 +48,10 @@ mod tests;
 use std::fmt::{Debug, Display};
 use std::sync::mpsc::{channel, Receiver, Sender};
 
+use blockstack_lib::version_string;
 use chainstate::SortitionsView;
 use config::GlobalConfig;
+use lazy_static::lazy_static;
 use libsigner::{SignerEvent, SignerEventReceiver, SignerEventTrait};
 use runloop::SignerResult;
 use slog::{slog_info, slog_warn};
@@ -59,6 +61,14 @@ use crate::client::StacksClient;
 use crate::config::SignerConfig;
 use crate::runloop::RunLoop;
 
+lazy_static! {
+    /// The version string for the signer
+    pub static ref VERSION_STRING: String = {
+        let pkg_version = option_env!("STACKS_NODE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
+        version_string("stacks-signer", pkg_version)
+    };
+}
+
 /// A trait which provides a common `Signer` interface for `v0` and `v1`
 pub trait Signer<T: SignerEventTrait>: Debug + Display {
     /// Create a new `Signer` instance
@@ -113,6 +123,7 @@ impl<S: Signer<T> + Send + 'static, T: SignerEventTrait + 'static> SpawnedSigner
     /// Create a new spawned signer
     pub fn new(config: GlobalConfig) -> Self {
         let endpoint = config.endpoint;
+        info!("Stacks signer version {:?}", VERSION_STRING.as_str());
         info!("Starting signer with config: {:?}", config);
         warn!(
             "Reminder: The signer is primarily designed for use with a local or subnet network stacks node. \
diff --git a/stacks-signer/src/main.rs b/stacks-signer/src/main.rs
index bb680aae0b..56f322b185 100644
--- a/stacks-signer/src/main.rs
+++ b/stacks-signer/src/main.rs
@@ -47,6 +47,7 @@ use stacks_signer::config::GlobalConfig;
 use stacks_signer::monitor_signers::SignerMonitor;
 use stacks_signer::utils::stackerdb_session;
 use stacks_signer::v0::SpawnedSigner;
+use stacks_signer::VERSION_STRING;
 use tracing_subscriber::prelude::*;
 use tracing_subscriber::{fmt, EnvFilter};
 
@@ -157,7 +158,11 @@ fn handle_generate_stacking_signature(
 
 fn handle_check_config(args: RunSignerArgs) {
     let config = GlobalConfig::try_from(&args.config).unwrap();
-    println!("Config: {}", config);
+    println!(
+        "Signer version: {}\nConfig: \n{}",
+        VERSION_STRING.to_string(),
+        config
+    );
 }
 
 fn handle_generate_vote(args: GenerateVoteArgs, do_print: bool) -> MessageSignature {
diff --git a/stacks-signer/src/monitoring/server.rs b/stacks-signer/src/monitoring/server.rs
index ffde008c9f..f5e3cceef1 100644
--- a/stacks-signer/src/monitoring/server.rs
+++ b/stacks-signer/src/monitoring/server.rs
@@ -28,6 +28,7 @@ use crate::client::{ClientError, StacksClient};
 use crate::config::{GlobalConfig, Network};
 use crate::monitoring::prometheus::gather_metrics_string;
 use crate::monitoring::{update_signer_nonce, update_stacks_tip_height};
+use crate::VERSION_STRING;
 
 #[derive(thiserror::Error, Debug)]
 /// Monitoring server errors
@@ -215,6 +216,7 @@ impl MonitoringServer {
             "signerPublicKey": to_hex(&self.public_key.to_bytes_compressed()),
             "network": self.network.to_string(),
             "stxAddress": self.stacks_client.get_signer_address().to_string(),
+            "version": VERSION_STRING.to_string(),
         }))
         .expect("Failed to serialize JSON")
     }
diff --git a/stackslib/src/blockstack_cli.rs b/stackslib/src/blockstack_cli.rs
index 6fb9f45ed6..dbecb0393d 100644
--- a/stackslib/src/blockstack_cli.rs
+++ b/stackslib/src/blockstack_cli.rs
@@ -73,7 +73,9 @@ For usage information on those methods, call `blockstack-cli [method] -h`
 
 `blockstack-cli` accepts flag options as well:
 
-   --testnet       instruct the transaction generator to use a testnet version byte instead of MAINNET (default)
+   --testnet[=chain-id]
+                     instruct the transaction generator to use a testnet version byte instead of MAINNET (default)
+                     optionally, you can specify a custom chain ID to use for the transaction
 
 ";
 
@@ -185,6 +187,7 @@ enum CliError {
     ClarityGeneralError(ClarityError),
     Message(String),
     Usage,
+    InvalidChainId(std::num::ParseIntError),
 }
 
 impl std::error::Error for CliError {
@@ -204,6 +207,7 @@ impl std::fmt::Display for CliError {
             CliError::ClarityGeneralError(e) => write!(f, "Clarity error: {}", e),
             CliError::Message(e) => write!(f, "{}", e),
             CliError::Usage => write!(f, "{}", USAGE),
+            CliError::InvalidChainId(e) => write!(f, "Invalid chain ID: {}", e),
         }
     }
 }
@@ -848,18 +852,26 @@ fn main() {
 }
 
 fn main_handler(mut argv: Vec<String>) -> Result<String, CliError> {
-    let tx_version = if let Some(ix) = argv.iter().position(|x| x == "--testnet") {
-        argv.remove(ix);
-        TransactionVersion::Testnet
-    } else {
-        TransactionVersion::Mainnet
-    };
+    let mut tx_version = TransactionVersion::Mainnet;
+    let mut chain_id = CHAIN_ID_MAINNET;
+
+    // Look for the `--testnet` flag
+    if let Some(ix) = argv.iter().position(|x| x.starts_with("--testnet")) {
+        let flag = argv.remove(ix);
+
+        // Check if `--testnet=<chain_id>` is used
+        if let Some(custom_chain_id) = flag.split('=').nth(1) {
+            // Attempt to parse the custom chain ID from hex
+            chain_id = u32::from_str_radix(custom_chain_id.trim_start_matches("0x"), 16)
+                .map_err(|err| CliError::InvalidChainId(err))?;
+        } else {
+            // Use the default testnet chain ID
+            chain_id = CHAIN_ID_TESTNET;
+        }
 
-    let chain_id = if tx_version == TransactionVersion::Testnet {
-        CHAIN_ID_TESTNET
-    } else {
-        CHAIN_ID_MAINNET
-    };
+        // Set the transaction version to Testnet
+        tx_version = TransactionVersion::Testnet;
+    }
 
     if let Some((method, args)) = argv.split_first() {
         match method.as_str() {
@@ -1220,4 +1232,43 @@ mod test {
         let result = main_handler(to_string_vec(&header_args)).unwrap();
         eprintln!("result:\n{}", result);
     }
+
+    #[test]
+    fn custom_chain_id() {
+        // Standard chain id
+        let tt_args = [
+            "--testnet",
+            "token-transfer",
+            "043ff5004e3d695060fa48ac94c96049b8c14ef441c50a184a6a3875d2a000f3",
+            "1",
+            "0",
+            "ST1A14RBKJ289E3DP89QAZE2RRHDPWP5RHMYFRCHV",
+            "10",
+        ];
+
+        let result = main_handler(to_string_vec(&tt_args));
+        assert!(result.is_ok());
+
+        let result = result.unwrap();
+        let tx = decode_transaction(&[result], TransactionVersion::Testnet).unwrap();
+        assert!(tx.contains("chain_id\":2147483648"));
+
+        // Custom chain id
+        let tt_args = [
+            "--testnet=0x12345678",
+            "token-transfer",
+            "043ff5004e3d695060fa48ac94c96049b8c14ef441c50a184a6a3875d2a000f3",
+            "1",
+            "0",
+            "ST1A14RBKJ289E3DP89QAZE2RRHDPWP5RHMYFRCHV",
+            "10",
+        ];
+
+        let result = main_handler(to_string_vec(&tt_args));
+        assert!(result.is_ok());
+
+        let result = result.unwrap();
+        let tx = decode_transaction(&[result], TransactionVersion::Testnet).unwrap();
+        assert!(tx.contains("chain_id\":305419896"));
+    }
 }
diff --git a/stackslib/src/chainstate/nakamoto/keys.rs b/stackslib/src/chainstate/nakamoto/keys.rs
index 2944c70aff..bf33c4448e 100644
--- a/stackslib/src/chainstate/nakamoto/keys.rs
+++ b/stackslib/src/chainstate/nakamoto/keys.rs
@@ -23,7 +23,7 @@ pub fn ongoing_tenure_id() -> &'static str {
     "nakamoto::tenures::ongoing_tenure_id"
 }
 
-/// MARF key to map the coinbase height of a tenure to its consensus hash
+/// MARF key to map the coinbase height of a tenure to its first block ID
 pub fn ongoing_tenure_coinbase_height(coinbase_height: u64) -> String {
     format!(
         "nakamoto::tenures::ongoing_tenure_coinbase_height::{}",
diff --git a/stackslib/src/chainstate/nakamoto/mod.rs b/stackslib/src/chainstate/nakamoto/mod.rs
index b0267a6ea9..975f65f97a 100644
--- a/stackslib/src/chainstate/nakamoto/mod.rs
+++ b/stackslib/src/chainstate/nakamoto/mod.rs
@@ -3886,7 +3886,7 @@ impl NakamotoChainState {
 
     /// Append a Nakamoto Stacks block to the Stacks chain state.
     /// NOTE: This does _not_ set the block as processed!  The caller must do this.
-    fn append_block<'a>(
+    pub(crate) fn append_block<'a>(
         chainstate_tx: &mut ChainstateTx,
         clarity_instance: &'a mut ClarityInstance,
         burn_dbconn: &mut SortitionHandleConn,
diff --git a/stackslib/src/chainstate/stacks/boot/mod.rs b/stackslib/src/chainstate/stacks/boot/mod.rs
index 8562449dd3..fb36021152 100644
--- a/stackslib/src/chainstate/stacks/boot/mod.rs
+++ b/stackslib/src/chainstate/stacks/boot/mod.rs
@@ -1998,6 +1998,44 @@ pub mod test {
         make_tx(key, nonce, 0, payload)
     }
 
+    pub fn make_pox_4_lockup_chain_id(
+        key: &StacksPrivateKey,
+        nonce: u64,
+        amount: u128,
+        addr: &PoxAddress,
+        lock_period: u128,
+        signer_key: &StacksPublicKey,
+        burn_ht: u64,
+        signature_opt: Option<Vec<u8>>,
+        max_amount: u128,
+        auth_id: u128,
+        chain_id: u32,
+    ) -> StacksTransaction {
+        let addr_tuple = Value::Tuple(addr.as_clarity_tuple().unwrap());
+        let signature = match signature_opt {
+            Some(sig) => Value::some(Value::buff_from(sig).unwrap()).unwrap(),
+            None => Value::none(),
+        };
+        let payload = TransactionPayload::new_contract_call(
+            boot_code_test_addr(),
+            "pox-4",
+            "stack-stx",
+            vec![
+                Value::UInt(amount),
+                addr_tuple,
+                Value::UInt(burn_ht as u128),
+                Value::UInt(lock_period),
+                signature,
+                Value::buff_from(signer_key.to_bytes_compressed()).unwrap(),
+                Value::UInt(max_amount),
+                Value::UInt(auth_id),
+            ],
+        )
+        .unwrap();
+
+        make_tx_chain_id(key, nonce, 0, payload, chain_id)
+    }
+
     pub fn make_pox_2_or_3_lockup(
         key: &StacksPrivateKey,
         nonce: u64,
@@ -2450,11 +2488,21 @@ pub mod test {
         nonce: u64,
         tx_fee: u64,
         payload: TransactionPayload,
+    ) -> StacksTransaction {
+        make_tx_chain_id(key, nonce, tx_fee, payload, CHAIN_ID_TESTNET)
+    }
+
+    fn make_tx_chain_id(
+        key: &StacksPrivateKey,
+        nonce: u64,
+        tx_fee: u64,
+        payload: TransactionPayload,
+        chain_id: u32,
     ) -> StacksTransaction {
         let auth = TransactionAuth::from_p2pkh(key).unwrap();
         let addr = auth.origin().address_testnet();
         let mut tx = StacksTransaction::new(TransactionVersion::Testnet, auth, payload);
-        tx.chain_id = 0x80000000;
+        tx.chain_id = chain_id;
         tx.auth.set_origin_nonce(nonce);
         tx.set_post_condition_mode(TransactionPostConditionMode::Allow);
         tx.set_tx_fee(tx_fee);
diff --git a/stackslib/src/clarity_cli.rs b/stackslib/src/clarity_cli.rs
index 21cf55dea6..f23be191ff 100644
--- a/stackslib/src/clarity_cli.rs
+++ b/stackslib/src/clarity_cli.rs
@@ -766,6 +766,14 @@ impl HeadersDB for CLIHeadersDB {
         // if the block is defined at all, then return a constant
         get_cli_block_height(&self.conn(), id_bhh).map(|_| 3000)
     }
+
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        _tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        Some(tenure_height)
+    }
 }
 
 fn get_eval_input(invoked_by: &str, args: &[String]) -> EvalInput {
diff --git a/stackslib/src/clarity_vm/database/mod.rs b/stackslib/src/clarity_vm/database/mod.rs
index 81f0bac43c..44eeaa2e07 100644
--- a/stackslib/src/clarity_vm/database/mod.rs
+++ b/stackslib/src/clarity_vm/database/mod.rs
@@ -47,6 +47,14 @@ pub trait GetTenureStartId {
         tip: &StacksBlockId,
         tenure_id_consensus_hash: &ConsensusHash,
     ) -> Result<Option<TenureBlockId>, DBError>;
+    /// Return the StacksBlockId of the tenure start block for the
+    ///  tenure with coinbase height `coinbase_height` in the fork
+    ///  referenced by `tip`.
+    fn get_tenure_block_id_at_cb_height(
+        &self,
+        tip: &StacksBlockId,
+        coinbase_height: u64,
+    ) -> Result<Option<StacksBlockId>, DBError>;
     fn conn(&self) -> &Connection;
 }
 
@@ -66,6 +74,21 @@ impl GetTenureStartId for StacksDBConn<'_> {
             .map(|block_id| TenureBlockId::from(block_id)))
     }
 
+    fn get_tenure_block_id_at_cb_height(
+        &self,
+        tip: &StacksBlockId,
+        coinbase_height: u64,
+    ) -> Result<Option<StacksBlockId>, DBError> {
+        let opt_out = self
+            .get_indexed(
+                tip,
+                &nakamoto_keys::ongoing_tenure_coinbase_height(coinbase_height),
+            )?
+            .map(|hex_inp| nakamoto_keys::parse_block_id(&hex_inp))
+            .flatten();
+        Ok(opt_out)
+    }
+
     fn conn(&self) -> &Connection {
         self.sqlite()
     }
@@ -87,6 +110,21 @@ impl GetTenureStartId for StacksDBTx<'_> {
             .map(|block_id| TenureBlockId::from(block_id)))
     }
 
+    fn get_tenure_block_id_at_cb_height(
+        &self,
+        tip: &StacksBlockId,
+        coinbase_height: u64,
+    ) -> Result<Option<StacksBlockId>, DBError> {
+        let opt_out = self
+            .get_indexed_ref(
+                tip,
+                &nakamoto_keys::ongoing_tenure_coinbase_height(coinbase_height),
+            )?
+            .map(|hex_inp| nakamoto_keys::parse_block_id(&hex_inp))
+            .flatten();
+        Ok(opt_out)
+    }
+
     fn conn(&self) -> &Connection {
         self.sqlite()
     }
@@ -105,6 +143,15 @@ impl GetTenureStartId for MARF<StacksBlockId> {
     fn conn(&self) -> &Connection {
         self.sqlite_conn()
     }
+
+    fn get_tenure_block_id_at_cb_height(
+        &self,
+        tip: &StacksBlockId,
+        coinbase_height: u64,
+    ) -> Result<Option<StacksBlockId>, DBError> {
+        let dbconn = StacksDBConn::new(self, ());
+        dbconn.get_tenure_block_id_at_cb_height(tip, coinbase_height)
+    }
 }
 
 pub struct HeadersDBConn<'a>(pub StacksDBConn<'a>);
@@ -188,6 +235,22 @@ impl<'a> HeadersDB for HeadersDBConn<'a> {
         })
     }
 
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        let tenure_block_id =
+            GetTenureStartId::get_tenure_block_id_at_cb_height(&self.0, tip, tenure_height.into())
+                .expect("FATAL: bad DB data for tenure height lookups")?;
+        get_stacks_header_column(self.0.conn(), &tenure_block_id, "block_height", |r| {
+            u64::from_row(r)
+                .expect("FATAL: malformed block_height")
+                .try_into()
+                .expect("FATAL: blockchain too long")
+        })
+    }
+
     fn get_vrf_seed_for_block(
         &self,
         id_bhh: &StacksBlockId,
@@ -417,6 +480,25 @@ impl<'a> HeadersDB for ChainstateTx<'a> {
         let tenure_id_bhh = get_first_block_in_tenure(self.deref(), id_bhh, Some(epoch));
         get_matured_reward(self.deref(), &tenure_id_bhh, epoch).map(|x| x.total().into())
     }
+
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        let tenure_block_id = GetTenureStartId::get_tenure_block_id_at_cb_height(
+            self.deref(),
+            tip,
+            tenure_height.into(),
+        )
+        .expect("FATAL: bad DB data for tenure height lookups")?;
+        get_stacks_header_column(self.deref(), &tenure_block_id, "block_height", |r| {
+            u64::from_row(r)
+                .expect("FATAL: malformed block_height")
+                .try_into()
+                .expect("FATAL: blockchain too long")
+        })
+    }
 }
 
 impl HeadersDB for MARF<StacksBlockId> {
@@ -572,6 +654,22 @@ impl HeadersDB for MARF<StacksBlockId> {
         let tenure_id_bhh = get_first_block_in_tenure(self, id_bhh, Some(epoch));
         get_matured_reward(self, &tenure_id_bhh, epoch).map(|x| x.total().into())
     }
+
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        let tenure_block_id =
+            GetTenureStartId::get_tenure_block_id_at_cb_height(self, tip, tenure_height.into())
+                .expect("FATAL: bad DB data for tenure height lookups")?;
+        get_stacks_header_column(self.sqlite_conn(), &tenure_block_id, "block_height", |r| {
+            u64::from_row(r)
+                .expect("FATAL: malformed block_height")
+                .try_into()
+                .expect("FATAL: blockchain too long")
+        })
+    }
 }
 
 /// Select a specific column from the headers table, specifying whether to use
diff --git a/stackslib/src/cli.rs b/stackslib/src/cli.rs
index 493ab18de5..9ff6e55644 100644
--- a/stackslib/src/cli.rs
+++ b/stackslib/src/cli.rs
@@ -22,6 +22,7 @@ use std::time::Instant;
 use std::{env, fs, io, process, thread};
 
 use clarity::types::chainstate::SortitionId;
+use db::blocks::DummyEventDispatcher;
 use db::ChainstateTx;
 use regex::Regex;
 use rusqlite::{Connection, OpenFlags};
@@ -30,12 +31,16 @@ use stacks_common::types::sqlite::NO_PARAMS;
 
 use crate::burnchains::db::BurnchainDB;
 use crate::burnchains::PoxConstants;
-use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandle, SortitionHandleContext};
+use crate::chainstate::burn::db::sortdb::{
+    get_ancestor_sort_id, SortitionDB, SortitionHandle, SortitionHandleContext,
+};
 use crate::chainstate::burn::{BlockSnapshot, ConsensusHash};
+use crate::chainstate::coordinator::OnChainRewardSetProvider;
+use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState};
 use crate::chainstate::stacks::db::blocks::StagingBlock;
 use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState, StacksHeaderInfo};
 use crate::chainstate::stacks::miner::*;
-use crate::chainstate::stacks::*;
+use crate::chainstate::stacks::{Error as ChainstateError, *};
 use crate::clarity_vm::clarity::ClarityInstance;
 use crate::core::*;
 use crate::util_lib::db::IndexDBTx;
@@ -519,3 +524,279 @@ fn replay_block(
         }
     };
 }
+
+fn replay_block_nakamoto(
+    sort_db: &mut SortitionDB,
+    stacks_chain_state: &mut StacksChainState,
+    mut chainstate_tx: ChainstateTx,
+    clarity_instance: &mut ClarityInstance,
+    block: &NakamotoBlock,
+    block_size: u64,
+) -> Result<(), ChainstateError> {
+    // find corresponding snapshot
+    let next_ready_block_snapshot =
+        SortitionDB::get_block_snapshot_consensus(sort_db.conn(), &block.header.consensus_hash)?
+            .unwrap_or_else(|| {
+                panic!(
+                    "CORRUPTION: staging Nakamoto block {}/{} does not correspond to a burn block",
+                    &block.header.consensus_hash,
+                    &block.header.block_hash()
+                )
+            });
+
+    debug!("Process staging Nakamoto block";
+           "consensus_hash" => %block.header.consensus_hash,
+           "stacks_block_hash" => %block.header.block_hash(),
+           "stacks_block_id" => %block.header.block_id(),
+           "burn_block_hash" => %next_ready_block_snapshot.burn_header_hash
+    );
+
+    let elected_height = sort_db
+        .get_consensus_hash_height(&block.header.consensus_hash)?
+        .ok_or_else(|| ChainstateError::NoSuchBlockError)?;
+    let elected_in_cycle = sort_db
+        .pox_constants
+        .block_height_to_reward_cycle(sort_db.first_block_height, elected_height)
+        .ok_or_else(|| {
+            ChainstateError::InvalidStacksBlock(
+                "Elected in block height before first_block_height".into(),
+            )
+        })?;
+    let active_reward_set = OnChainRewardSetProvider::<DummyEventDispatcher>(None)
+        .read_reward_set_nakamoto_of_cycle(
+            elected_in_cycle,
+            stacks_chain_state,
+            sort_db,
+            &block.header.parent_block_id,
+            true,
+        )
+        .map_err(|e| {
+            warn!(
+                "Cannot process Nakamoto block: could not load reward set that elected the block";
+                "err" => ?e,
+                "consensus_hash" => %block.header.consensus_hash,
+                "stacks_block_hash" => %block.header.block_hash(),
+                "stacks_block_id" => %block.header.block_id(),
+                "parent_block_id" => %block.header.parent_block_id,
+            );
+            ChainstateError::NoSuchBlockError
+        })?;
+    let (mut chainstate_tx, clarity_instance) = stacks_chain_state.chainstate_tx_begin()?;
+
+    // find parent header
+    let Some(parent_header_info) =
+        NakamotoChainState::get_block_header(&chainstate_tx.tx, &block.header.parent_block_id)?
+    else {
+        // no parent; cannot process yet
+        info!("Cannot process Nakamoto block: missing parent header";
+               "consensus_hash" => %block.header.consensus_hash,
+               "stacks_block_hash" => %block.header.block_hash(),
+               "stacks_block_id" => %block.header.block_id(),
+               "parent_block_id" => %block.header.parent_block_id
+        );
+        return Ok(());
+    };
+
+    // sanity check -- must attach to parent
+    let parent_block_id = StacksBlockId::new(
+        &parent_header_info.consensus_hash,
+        &parent_header_info.anchored_header.block_hash(),
+    );
+    if parent_block_id != block.header.parent_block_id {
+        drop(chainstate_tx);
+
+        let msg = "Discontinuous Nakamoto Stacks block";
+        warn!("{}", &msg;
+              "child parent_block_id" => %block.header.parent_block_id,
+              "expected parent_block_id" => %parent_block_id,
+              "consensus_hash" => %block.header.consensus_hash,
+              "stacks_block_hash" => %block.header.block_hash(),
+              "stacks_block_id" => %block.header.block_id()
+        );
+        return Err(ChainstateError::InvalidStacksBlock(msg.into()));
+    }
+
+    // set the sortition handle's pointer to the block's burnchain view.
+    //   this is either:
+    //    (1)  set by the tenure change tx if one exists
+    //    (2)  the same as parent block id
+
+    let burnchain_view = if let Some(tenure_change) = block.get_tenure_tx_payload() {
+        if let Some(ref parent_burn_view) = parent_header_info.burn_view {
+            // check that the tenure_change's burn view descends from the parent
+            let parent_burn_view_sn = SortitionDB::get_block_snapshot_consensus(
+                sort_db.conn(),
+                parent_burn_view,
+            )?
+            .ok_or_else(|| {
+                warn!(
+                    "Cannot process Nakamoto block: could not find parent block's burnchain view";
+                    "consensus_hash" => %block.header.consensus_hash,
+                    "stacks_block_hash" => %block.header.block_hash(),
+                    "stacks_block_id" => %block.header.block_id(),
+                    "parent_block_id" => %block.header.parent_block_id
+                );
+                ChainstateError::InvalidStacksBlock(
+                    "Failed to load burn view of parent block ID".into(),
+                )
+            })?;
+            let handle = sort_db.index_handle_at_ch(&tenure_change.burn_view_consensus_hash)?;
+            let connected_sort_id = get_ancestor_sort_id(
+                &handle,
+                parent_burn_view_sn.block_height,
+                &handle.context.chain_tip,
+            )?
+            .ok_or_else(|| {
+                warn!(
+                    "Cannot process Nakamoto block: could not find parent block's burnchain view";
+                    "consensus_hash" => %block.header.consensus_hash,
+                    "stacks_block_hash" => %block.header.block_hash(),
+                    "stacks_block_id" => %block.header.block_id(),
+                    "parent_block_id" => %block.header.parent_block_id
+                );
+                ChainstateError::InvalidStacksBlock(
+                    "Failed to load burn view of parent block ID".into(),
+                )
+            })?;
+            if connected_sort_id != parent_burn_view_sn.sortition_id {
+                warn!(
+                    "Cannot process Nakamoto block: parent block's burnchain view does not connect to own burn view";
+                    "consensus_hash" => %block.header.consensus_hash,
+                    "stacks_block_hash" => %block.header.block_hash(),
+                    "stacks_block_id" => %block.header.block_id(),
+                    "parent_block_id" => %block.header.parent_block_id
+                );
+                return Err(ChainstateError::InvalidStacksBlock(
+                    "Does not connect to burn view of parent block ID".into(),
+                ));
+            }
+        }
+        tenure_change.burn_view_consensus_hash
+    } else {
+        parent_header_info.burn_view.clone().ok_or_else(|| {
+                warn!(
+                    "Cannot process Nakamoto block: parent block does not have a burnchain view and current block has no tenure tx";
+                    "consensus_hash" => %block.header.consensus_hash,
+                    "stacks_block_hash" => %block.header.block_hash(),
+                    "stacks_block_id" => %block.header.block_id(),
+                    "parent_block_id" => %block.header.parent_block_id
+                );
+                ChainstateError::InvalidStacksBlock("Failed to load burn view of parent block ID".into())
+            })?
+    };
+    let Some(burnchain_view_sn) =
+        SortitionDB::get_block_snapshot_consensus(sort_db.conn(), &burnchain_view)?
+    else {
+        // This should be checked already during block acceptance and parent block processing
+        //   - The check for expected burns returns `NoSuchBlockError` if the burnchain view
+        //      could not be found for a block with a tenure tx.
+        // We error here anyways, but the check during block acceptance makes sure that the staging
+        //  db doesn't get into a situation where it continuously tries to retry such a block (because
+        //  such a block shouldn't land in the staging db).
+        warn!(
+            "Cannot process Nakamoto block: failed to find Sortition ID associated with burnchain view";
+            "consensus_hash" => %block.header.consensus_hash,
+            "stacks_block_hash" => %block.header.block_hash(),
+            "stacks_block_id" => %block.header.block_id(),
+            "burn_view_consensus_hash" => %burnchain_view,
+        );
+        return Ok(());
+    };
+
+    // find commit and sortition burns if this is a tenure-start block
+    let Ok(new_tenure) = block.is_wellformed_tenure_start_block() else {
+        return Err(ChainstateError::InvalidStacksBlock(
+            "Invalid Nakamoto block: invalid tenure change tx(s)".into(),
+        ));
+    };
+
+    let (commit_burn, sortition_burn) = if new_tenure {
+        // find block-commit to get commit-burn
+        let block_commit = SortitionDB::get_block_commit(
+            sort_db.conn(),
+            &next_ready_block_snapshot.winning_block_txid,
+            &next_ready_block_snapshot.sortition_id,
+        )?
+        .expect("FATAL: no block-commit for tenure-start block");
+
+        let sort_burn =
+            SortitionDB::get_block_burn_amount(sort_db.conn(), &next_ready_block_snapshot)?;
+        (block_commit.burn_fee, sort_burn)
+    } else {
+        (0, 0)
+    };
+
+    // attach the block to the chain state and calculate the next chain tip.
+    let pox_constants = sort_db.pox_constants.clone();
+
+    // NOTE: because block status is updated in a separate transaction, we need `chainstate_tx`
+    // and `clarity_instance` to go out of scope before we can issue the it (since we need a
+    // mutable reference to `stacks_chain_state` to start it).  This means ensuring that, in the
+    // `Ok(..)` case, the `clarity_commit` gets dropped beforehand.  In order to do this, we first
+    // run `::append_block()` here, and capture both the Ok(..) and Err(..) results as
+    // Option<..>'s.  Then, if we errored, we can explicitly drop the `Ok(..)` option (even
+    // though it will always be None), which gets the borrow-checker to believe that it's safe
+    // to access `stacks_chain_state` again.  In the `Ok(..)` case, it's instead sufficient so
+    // simply commit the block before beginning the second transaction to mark it processed.
+
+    let mut burn_view_handle = sort_db.index_handle(&burnchain_view_sn.sortition_id);
+    let (ok_opt, err_opt) = match NakamotoChainState::append_block(
+        &mut chainstate_tx,
+        clarity_instance,
+        &mut burn_view_handle,
+        &burnchain_view,
+        &pox_constants,
+        &parent_header_info,
+        &next_ready_block_snapshot.burn_header_hash,
+        next_ready_block_snapshot
+            .block_height
+            .try_into()
+            .expect("Failed to downcast u64 to u32"),
+        next_ready_block_snapshot.burn_header_timestamp,
+        &block,
+        block_size,
+        commit_burn,
+        sortition_burn,
+        &active_reward_set,
+    ) {
+        Ok(next_chain_tip_info) => (Some(next_chain_tip_info), None),
+        Err(e) => (None, Some(e)),
+    };
+
+    if let Some(e) = err_opt {
+        // force rollback
+        drop(ok_opt);
+        drop(chainstate_tx);
+
+        warn!(
+            "Failed to append {}/{}: {:?}",
+            &block.header.consensus_hash,
+            &block.header.block_hash(),
+            &e;
+            "stacks_block_id" => %block.header.block_id()
+        );
+
+        // as a separate transaction, mark this block as processed and orphaned.
+        // This is done separately so that the staging blocks DB, which receives writes
+        // from the network to store blocks, will be available for writes while a block is
+        // being processed. Therefore, it's *very important* that block-processing happens
+        // within the same, single thread.  Also, it's *very important* that this update
+        // succeeds, since *we have already processed* the block.
+        return Err(e);
+    };
+
+    let (receipt, clarity_commit, reward_set_data) = ok_opt.expect("FATAL: unreachable");
+
+    assert_eq!(
+        receipt.header.anchored_header.block_hash(),
+        block.header.block_hash()
+    );
+    assert_eq!(receipt.header.consensus_hash, block.header.consensus_hash);
+
+    info!(
+        "Advanced to new tip! {}/{}",
+        &receipt.header.consensus_hash,
+        &receipt.header.anchored_header.block_hash()
+    );
+    Ok(())
+}
diff --git a/stackslib/src/net/api/getinfo.rs b/stackslib/src/net/api/getinfo.rs
index 237205f63a..d95b94803a 100644
--- a/stackslib/src/net/api/getinfo.rs
+++ b/stackslib/src/net/api/getinfo.rs
@@ -27,11 +27,12 @@ use stacks_common::util::hash::{Hash160, Sha256Sum};
 use crate::burnchains::affirmation::AffirmationMap;
 use crate::burnchains::Txid;
 use crate::chainstate::burn::db::sortdb::SortitionDB;
+use crate::chainstate::nakamoto::NakamotoChainState;
 use crate::chainstate::stacks::db::StacksChainState;
 use crate::core::mempool::MemPoolDB;
 use crate::net::http::{
     parse_json, Error, HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse,
-    HttpResponseContents, HttpResponsePayload, HttpResponsePreamble,
+    HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError,
 };
 use crate::net::httpcore::{
     HttpPreambleExtensions, RPCRequestHandler, StacksHttpRequest, StacksHttpResponse,
@@ -81,6 +82,7 @@ pub struct RPCPeerInfoData {
     pub genesis_chainstate_hash: Sha256Sum,
     pub unanchored_tip: Option<StacksBlockId>,
     pub unanchored_seq: Option<u16>,
+    pub tenure_height: u64,
     pub exit_at_block_height: Option<u64>,
     pub is_fully_synced: bool,
     #[serde(default)]
@@ -106,6 +108,7 @@ impl RPCPeerInfoData {
         chainstate: &StacksChainState,
         exit_at_block_height: Option<u64>,
         genesis_chainstate_hash: &Sha256Sum,
+        coinbase_height: u64,
         ibd: bool,
     ) -> RPCPeerInfoData {
         let server_version = version_string(
@@ -148,7 +151,7 @@ impl RPCPeerInfoData {
             stacks_tip_consensus_hash: network.stacks_tip.consensus_hash.clone(),
             unanchored_tip: unconfirmed_tip,
             unanchored_seq: unconfirmed_seq,
-            exit_at_block_height: exit_at_block_height,
+            exit_at_block_height,
             is_fully_synced,
             genesis_chainstate_hash: genesis_chainstate_hash.clone(),
             node_public_key: Some(public_key_buf),
@@ -169,6 +172,7 @@ impl RPCPeerInfoData {
                     .map(|cid| format!("{}", cid))
                     .collect(),
             ),
+            tenure_height: coinbase_height,
         }
     }
 }
@@ -217,16 +221,28 @@ impl RPCRequestHandler for RPCPeerInfoRequestHandler {
         node: &mut StacksNodeState,
     ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> {
         let ibd = node.ibd;
-        let rpc_peer_info =
+
+        let rpc_peer_info: Result<RPCPeerInfoData, StacksHttpResponse> =
             node.with_node_state(|network, _sortdb, chainstate, _mempool, rpc_args| {
-                RPCPeerInfoData::from_network(
+                let coinbase_height = network.stacks_tip.coinbase_height;
+
+                Ok(RPCPeerInfoData::from_network(
                     network,
                     chainstate,
                     rpc_args.exit_at_block_height.clone(),
                     &rpc_args.genesis_chainstate_hash,
+                    coinbase_height,
                     ibd,
-                )
+                ))
             });
+
+        let rpc_peer_info = match rpc_peer_info {
+            Ok(rpc_peer_info) => rpc_peer_info,
+            Err(response) => {
+                return response.try_into_contents().map_err(NetError::from);
+            }
+        };
+
         let mut preamble = HttpResponsePreamble::ok_json(&preamble);
         preamble.set_canonical_stacks_tip_height(Some(node.canonical_stacks_tip_height()));
         let body = HttpResponseContents::try_from_json(&rpc_peer_info)?;
diff --git a/stackslib/src/net/api/postmempoolquery.rs b/stackslib/src/net/api/postmempoolquery.rs
index 2155863220..25da52a66d 100644
--- a/stackslib/src/net/api/postmempoolquery.rs
+++ b/stackslib/src/net/api/postmempoolquery.rs
@@ -116,9 +116,9 @@ impl StacksMemPoolStream {
 
         Self {
             tx_query,
-            last_randomized_txid: last_randomized_txid,
+            last_randomized_txid,
             num_txs: 0,
-            max_txs: max_txs,
+            max_txs,
             coinbase_height,
             corked: false,
             finished: false,
@@ -275,14 +275,8 @@ impl RPCRequestHandler for RPCMempoolQueryRequestHandler {
             .ok_or(NetError::SendError("`mempool_query` not set".into()))?;
         let page_id = self.page_id.take();
 
-        let stream_res = node.with_node_state(|network, sortdb, chainstate, mempool, _rpc_args| {
-            let header = self.get_stacks_chain_tip(&preamble, sortdb, chainstate)
-                .map_err(|e| StacksHttpResponse::new_error(&preamble, &HttpServerError::new(format!("Failed to load chain tip: {:?}", &e))))?;
-
-            let coinbase_height = NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &header.index_block_hash())
-                .map_err(|e| StacksHttpResponse::new_error(&preamble, &HttpServerError::new(format!("Failed to load coinbase height: {:?}", &e))))?
-                .unwrap_or(0);
-
+        let stream_res = node.with_node_state(|network, _sortdb, _chainstate, mempool, _rpc_args| {
+            let coinbase_height = network.stacks_tip.coinbase_height;
             let max_txs = network.connection_opts.mempool_max_tx_query;
             debug!(
                 "Begin mempool query";
diff --git a/stackslib/src/net/mod.rs b/stackslib/src/net/mod.rs
index a33d1bc466..f0c5003740 100644
--- a/stackslib/src/net/mod.rs
+++ b/stackslib/src/net/mod.rs
@@ -2379,6 +2379,7 @@ pub mod test {
         ) -> TestPeer<'a> {
             let test_path = TestPeer::make_test_path(&config);
             let mut miner_factory = TestMinerFactory::new();
+            miner_factory.chain_id = config.network_id;
             let mut miner =
                 miner_factory.next_miner(&config.burnchain, 1, 1, AddressHashMode::SerializeP2PKH);
             // manually set fees
diff --git a/stackslib/src/net/p2p.rs b/stackslib/src/net/p2p.rs
index eb224f3e80..054fefaf1d 100644
--- a/stackslib/src/net/p2p.rs
+++ b/stackslib/src/net/p2p.rs
@@ -105,7 +105,7 @@ struct NetworkHandleServer {
 
 impl NetworkHandle {
     pub fn new(chan_in: SyncSender<NetworkRequest>) -> NetworkHandle {
-        NetworkHandle { chan_in: chan_in }
+        NetworkHandle { chan_in }
     }
 
     /// Send out a command to the p2p thread.  Do not bother waiting for the response.
@@ -175,7 +175,7 @@ impl NetworkHandle {
 
 impl NetworkHandleServer {
     pub fn new(chan_in: Receiver<NetworkRequest>) -> NetworkHandleServer {
-        NetworkHandleServer { chan_in: chan_in }
+        NetworkHandleServer { chan_in }
     }
 
     pub fn pair(bufsz: usize) -> (NetworkHandleServer, NetworkHandle) {
@@ -246,6 +246,7 @@ pub struct StacksTipInfo {
     pub consensus_hash: ConsensusHash,
     pub block_hash: BlockHeaderHash,
     pub height: u64,
+    pub coinbase_height: u64,
     pub is_nakamoto: bool,
 }
 
@@ -255,6 +256,7 @@ impl StacksTipInfo {
             consensus_hash: ConsensusHash([0u8; 20]),
             block_hash: BlockHeaderHash([0u8; 32]),
             height: 0,
+            coinbase_height: 0,
             is_nakamoto: false,
         }
     }
@@ -481,11 +483,11 @@ impl PeerNetwork {
         }
 
         let mut network = PeerNetwork {
-            peer_version: peer_version,
-            epochs: epochs,
+            peer_version,
+            epochs,
 
-            local_peer: local_peer,
-            chain_view: chain_view,
+            local_peer,
+            chain_view,
             chain_view_stable_consensus_hash: ConsensusHash([0u8; 20]),
             ast_rules: ASTRules::Typical,
             heaviest_affirmation_map: AffirmationMap::empty(),
@@ -504,8 +506,8 @@ impl PeerNetwork {
             tenure_start_block_id: StacksBlockId([0x00; 32]),
             current_reward_sets: BTreeMap::new(),
 
-            peerdb: peerdb,
-            atlasdb: atlasdb,
+            peerdb,
+            atlasdb,
 
             peers: PeerMap::new(),
             sockets: HashMap::new(),
@@ -521,8 +523,8 @@ impl PeerNetwork {
             p2p_network_handle: 0,
             http_network_handle: 0,
 
-            burnchain: burnchain,
-            connection_opts: connection_opts,
+            burnchain,
+            connection_opts,
 
             work_state: PeerNetworkWorkState::GetPublicIP,
             nakamoto_work_state: PeerNetworkWorkState::GetPublicIP,
@@ -553,8 +555,8 @@ impl PeerNetwork {
             attachments_downloader: None,
 
             stacker_db_syncs: Some(stacker_db_sync_map),
-            stacker_db_configs: stacker_db_configs,
-            stackerdbs: stackerdbs,
+            stacker_db_configs,
+            stackerdbs,
 
             prune_outbound_counts: HashMap::new(),
             prune_inbound_counts: HashMap::new(),
@@ -3491,7 +3493,7 @@ impl PeerNetwork {
                     }
                     let microblocks_data = MicroblocksData {
                         index_anchor_block: anchor_block_id.clone(),
-                        microblocks: microblocks,
+                        microblocks,
                     };
 
                     debug!(
@@ -4039,7 +4041,7 @@ impl PeerNetwork {
                             peer_version: nk.peer_version,
                             network_id: nk.network_id,
                             ts: get_epoch_time_secs(),
-                            pubkey: pubkey,
+                            pubkey,
                         },
                     );
 
@@ -4218,14 +4220,39 @@ impl PeerNetwork {
 
         let parent_tenure_start_header = NakamotoChainState::get_tenure_start_block_header(&mut chainstate.index_conn(), stacks_tip_block_id, &parent_header.consensus_hash)?
             .ok_or_else(|| {
-                debug!("{:?}: get_parent_stacks_tip: No tenure-start block for parent tenure {} off of child {} (parnet {})", self.get_local_peer(), &parent_header.consensus_hash, stacks_tip_block_id, &parent_block_id);
+                debug!("{:?}: get_parent_stacks_tip: No tenure-start block for parent tenure {} off of child {} (parent {})", self.get_local_peer(), &parent_header.consensus_hash, stacks_tip_block_id, &parent_block_id);
                 net_error::DBError(db_error::NotFoundError)
             })?;
 
+        let parent_stacks_tip_block_hash = parent_tenure_start_header.anchored_header.block_hash();
+        let parent_stacks_tip_block_id = StacksBlockId::new(
+            &parent_tenure_start_header.consensus_hash,
+            &parent_stacks_tip_block_hash,
+        );
+        let parent_coinbase_height = NakamotoChainState::get_coinbase_height(
+            &mut chainstate.index_conn(),
+            &parent_stacks_tip_block_id,
+        )?;
+
+        let coinbase_height = match parent_coinbase_height {
+            Some(cbh) => cbh,
+            None => {
+                if parent_tenure_start_header.is_epoch_2_block() {
+                    // The coinbase height is the same as the stacks block height as
+                    // every block contains a coinbase in epoch 2.x
+                    parent_tenure_start_header.stacks_block_height
+                } else {
+                    debug!("{:?}: get_parent_stacks_tip: No coinbase height found for nakamoto block {parent_stacks_tip_block_id}", self.get_local_peer());
+                    return Err(net_error::DBError(db_error::NotFoundError));
+                }
+            }
+        };
+
         let parent_stacks_tip = StacksTipInfo {
             consensus_hash: parent_tenure_start_header.consensus_hash,
-            block_hash: parent_tenure_start_header.anchored_header.block_hash(),
+            block_hash: parent_stacks_tip_block_hash,
             height: parent_tenure_start_header.anchored_header.height(),
+            coinbase_height,
             is_nakamoto: parent_tenure_start_header
                 .anchored_header
                 .as_stacks_nakamoto()
@@ -4377,6 +4404,25 @@ impl PeerNetwork {
             self.stacks_tip.is_nakamoto
         };
 
+        let stacks_tip_cbh = NakamotoChainState::get_coinbase_height(
+            &mut chainstate.index_conn(),
+            &new_stacks_tip_block_id,
+        )?;
+
+        let coinbase_height = match stacks_tip_cbh {
+            Some(cbh) => cbh,
+            None => {
+                if !stacks_tip_is_nakamoto {
+                    // The coinbase height is the same as the stacks block height as
+                    // every block contains a coinbase in epoch 2.x
+                    stacks_tip_height
+                } else {
+                    debug!("{:?}: No coinbase height found for nakamoto block {new_stacks_tip_block_id}", self.get_local_peer());
+                    return Err(net_error::DBError(db_error::NotFoundError));
+                }
+            }
+        };
+
         let need_stackerdb_refresh = canonical_sn.canonical_stacks_tip_consensus_hash
             != self.burnchain_tip.canonical_stacks_tip_consensus_hash
             || burnchain_tip_changed
@@ -4415,6 +4461,7 @@ impl PeerNetwork {
                         consensus_hash: FIRST_BURNCHAIN_CONSENSUS_HASH.clone(),
                         block_hash: FIRST_STACKS_BLOCK_HASH.clone(),
                         height: 0,
+                        coinbase_height: 0,
                         is_nakamoto: false,
                     }
                 }
@@ -4610,6 +4657,7 @@ impl PeerNetwork {
                 consensus_hash: stacks_tip_ch,
                 block_hash: stacks_tip_bhh,
                 height: stacks_tip_height,
+                coinbase_height,
                 is_nakamoto: stacks_tip_is_nakamoto,
             };
             self.parent_stacks_tip = parent_stacks_tip;
@@ -5184,7 +5232,7 @@ mod test {
                     0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x7f,
                     0x00, 0x00, 0x01,
                 ]),
-                port: port,
+                port,
             },
             public_key: Secp256k1PublicKey::from_hex(
                 "02fa66b66f8971a8cd4d20ffded09674e030f0f33883f337f34b95ad4935bac0e3",


Test patch:
diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml
index 4115118eaf..23eed46f1e 100644
--- a/.github/workflows/bitcoin-tests.yml
+++ b/.github/workflows/bitcoin-tests.yml
@@ -118,6 +118,8 @@ jobs:
           - tests::signer::v0::mine_2_nakamoto_reward_cycles
           - tests::signer::v0::signer_set_rollover
           - tests::signer::v0::signing_in_0th_tenure_of_reward_cycle
+          - tests::signer::v0::continue_after_tenure_extend
+          - tests::signer::v0::multiple_miners_with_custom_chain_id
           - tests::nakamoto_integrations::burn_ops_integration_test
           - tests::nakamoto_integrations::check_block_heights
           - tests::nakamoto_integrations::clarity_burn_state
diff --git a/clarity/src/vm/test_util/mod.rs b/clarity/src/vm/test_util/mod.rs
index 2df79766a2..f2b6d4dd09 100644
--- a/clarity/src/vm/test_util/mod.rs
+++ b/clarity/src/vm/test_util/mod.rs
@@ -229,6 +229,14 @@ impl HeadersDB for UnitTestHeaderDB {
         // if the block is defined at all, then return a constant
         self.get_burn_block_height_for_block(id_bhh).map(|_| 3000)
     }
+
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        _tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        Some(tenure_height)
+    }
 }
 
 impl BurnStateDB for UnitTestBurnStateDB {
diff --git a/clarity/src/vm/tests/contracts.rs b/clarity/src/vm/tests/contracts.rs
index 3c4dc14b2e..9cb5aea4b1 100644
--- a/clarity/src/vm/tests/contracts.rs
+++ b/clarity/src/vm/tests/contracts.rs
@@ -139,7 +139,7 @@ fn test_get_block_info_eval(
             .unwrap();
 
         let mut env = owned_env.get_exec_environment(None, None, &mut placeholder_context);
-
+        eprintln!("{}", contracts[i]);
         let eval_result = env.eval_read_only(&contract_identifier, "(test-func)");
         match expected[i] {
             // any (some UINT) is okay for checking get-block-info? time
diff --git a/stackslib/src/burnchains/tests/mod.rs b/stackslib/src/burnchains/tests/mod.rs
index 31e29c0b26..e7fa51a89c 100644
--- a/stackslib/src/burnchains/tests/mod.rs
+++ b/stackslib/src/burnchains/tests/mod.rs
@@ -123,11 +123,13 @@ pub struct TestMiner {
     pub nonce: u64,
     pub spent_at_nonce: HashMap<u64, u128>, // how much uSTX this miner paid in a given tx's nonce
     pub test_with_tx_fees: bool, // set to true to make certain helper methods attach a pre-defined tx fee
+    pub chain_id: u32,
 }
 
 pub struct TestMinerFactory {
     pub key_seed: [u8; 32],
     pub next_miner_id: usize,
+    pub chain_id: u32,
 }
 
 impl TestMiner {
@@ -136,6 +138,7 @@ impl TestMiner {
         privks: &Vec<StacksPrivateKey>,
         num_sigs: u16,
         hash_mode: &AddressHashMode,
+        chain_id: u32,
     ) -> TestMiner {
         TestMiner {
             burnchain: burnchain.clone(),
@@ -150,6 +153,7 @@ impl TestMiner {
             nonce: 0,
             spent_at_nonce: HashMap::new(),
             test_with_tx_fees: true,
+            chain_id,
         }
     }
 
@@ -314,15 +318,7 @@ impl TestMinerFactory {
         TestMinerFactory {
             key_seed: [0u8; 32],
             next_miner_id: 1,
-        }
-    }
-
-    pub fn from_u16(seed: u16) -> TestMinerFactory {
-        let mut bytes = [0u8; 32];
-        (&mut bytes[0..2]).copy_from_slice(&seed.to_be_bytes());
-        TestMinerFactory {
-            key_seed: bytes,
-            next_miner_id: seed as usize,
+            chain_id: CHAIN_ID_TESTNET,
         }
     }
 
@@ -346,7 +342,7 @@ impl TestMinerFactory {
         }
 
         test_debug!("New miner: {:?} {}:{:?}", &hash_mode, num_sigs, &keys);
-        let mut m = TestMiner::new(burnchain, &keys, num_sigs, &hash_mode);
+        let mut m = TestMiner::new(burnchain, &keys, num_sigs, &hash_mode, self.chain_id);
         m.id = self.next_miner_id;
         self.next_miner_id += 1;
         m
diff --git a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs
index 0a59c1a67b..23bf3313e9 100644
--- a/stackslib/src/chainstate/nakamoto/coordinator/tests.rs
+++ b/stackslib/src/chainstate/nakamoto/coordinator/tests.rs
@@ -17,9 +17,12 @@
 use std::collections::{HashMap, HashSet};
 use std::sync::Mutex;
 
+use clarity::consts::CHAIN_ID_TESTNET;
 use clarity::vm::clarity::ClarityConnection;
-use clarity::vm::types::PrincipalData;
-use clarity::vm::Value;
+use clarity::vm::costs::ExecutionCost;
+use clarity::vm::database::clarity_db::NullBurnStateDB;
+use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier};
+use clarity::vm::{ClarityVersion, Value};
 use rand::prelude::SliceRandom;
 use rand::{thread_rng, Rng, RngCore};
 use stacks_common::address::{AddressHashMode, C32_ADDRESS_VERSION_TESTNET_SINGLESIG};
@@ -35,6 +38,7 @@ use stacks_common::util::hash::Hash160;
 use stacks_common::util::secp256k1::Secp256k1PrivateKey;
 use stacks_common::util::vrf::VRFProof;
 
+use crate::burnchains::tests::TestMiner;
 use crate::burnchains::{PoxConstants, Txid};
 use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandle};
 use crate::chainstate::burn::operations::{
@@ -64,7 +68,7 @@ use crate::chainstate::stacks::events::TransactionOrigin;
 use crate::chainstate::stacks::{
     CoinbasePayload, Error as ChainstateError, StacksTransaction, StacksTransactionSigner,
     TenureChangeCause, TokenTransferMemo, TransactionAnchorMode, TransactionAuth,
-    TransactionPayload, TransactionVersion,
+    TransactionPayload, TransactionSmartContract, TransactionVersion,
 };
 use crate::clarity::vm::types::StacksAddressExtensions;
 use crate::core::StacksEpochExtension;
@@ -76,6 +80,7 @@ use crate::stacks_common::codec::StacksMessageCodec;
 use crate::util_lib::boot::boot_code_id;
 use crate::util_lib::db::{query_rows, u64_to_sql};
 use crate::util_lib::signed_structured_data::pox4::Pox4SignatureTopic;
+use crate::util_lib::strings::StacksString;
 
 impl<'a> NakamotoStagingBlocksConnRef<'a> {
     pub fn get_blocks_at_height(&self, height: u64) -> Vec<NakamotoBlock> {
@@ -285,7 +290,7 @@ pub fn make_token_transfer(
             TokenTransferMemo([0x00; 34]),
         ),
     );
-    stx_transfer.chain_id = 0x80000000;
+    stx_transfer.chain_id = chainstate.chain_id;
     stx_transfer.anchor_mode = TransactionAnchorMode::OnChainOnly;
     stx_transfer.set_tx_fee(fee);
     stx_transfer.auth.set_origin_nonce(nonce);
@@ -297,6 +302,37 @@ pub fn make_token_transfer(
     stx_transfer_signed
 }
 
+/// Make contract publish
+pub fn make_contract(
+    chainstate: &mut StacksChainState,
+    name: &str,
+    code: &str,
+    private_key: &StacksPrivateKey,
+    version: ClarityVersion,
+    nonce: u64,
+    fee: u64,
+) -> StacksTransaction {
+    let mut stx_tx = StacksTransaction::new(
+        TransactionVersion::Testnet,
+        TransactionAuth::from_p2pkh(private_key).unwrap(),
+        TransactionPayload::SmartContract(
+            TransactionSmartContract {
+                name: name.into(),
+                code_body: StacksString::from_str(code).unwrap(),
+            },
+            Some(version),
+        ),
+    );
+    stx_tx.chain_id = chainstate.chain_id;
+    stx_tx.anchor_mode = TransactionAnchorMode::OnChainOnly;
+    stx_tx.set_tx_fee(fee);
+    stx_tx.auth.set_origin_nonce(nonce);
+
+    let mut tx_signer = StacksTransactionSigner::new(&stx_tx);
+    tx_signer.sign_origin(&private_key).unwrap();
+    tx_signer.get_tx().unwrap()
+}
+
 /// Given the blocks and block-commits for a reward cycle, replay the sortitions on the given
 /// TestPeer, always processing the first block of the reward cycle before processing all
 /// subsequent blocks in random order.
@@ -612,6 +648,67 @@ impl<'a> TestPeer<'a> {
         block
     }
 
+    pub fn mine_tenure<F>(&mut self, block_builder: F) -> Vec<(NakamotoBlock, u64, ExecutionCost)>
+    where
+        F: FnMut(
+            &mut TestMiner,
+            &mut StacksChainState,
+            &SortitionDB,
+            &[(NakamotoBlock, u64, ExecutionCost)],
+        ) -> Vec<StacksTransaction>,
+    {
+        let (burn_ops, mut tenure_change, miner_key) =
+            self.begin_nakamoto_tenure(TenureChangeCause::BlockFound);
+        let (burn_height, _, consensus_hash) = self.next_burnchain_block(burn_ops.clone());
+        let pox_constants = self.sortdb().pox_constants.clone();
+        let first_burn_height = self.sortdb().first_block_height;
+        let mut test_signers = self.config.test_signers.clone().unwrap();
+
+        info!(
+            "Burnchain block produced: {burn_height}, in_prepare_phase?: {}, first_reward_block?: {}",
+            pox_constants.is_in_prepare_phase(first_burn_height, burn_height),
+            pox_constants.is_naka_signing_cycle_start(first_burn_height, burn_height)
+        );
+        let vrf_proof = self.make_nakamoto_vrf_proof(miner_key);
+
+        tenure_change.tenure_consensus_hash = consensus_hash.clone();
+        tenure_change.burn_view_consensus_hash = consensus_hash.clone();
+
+        let nakamoto_tip =
+            if let Some(nakamoto_parent_tenure) = self.nakamoto_parent_tenure_opt.as_ref() {
+                nakamoto_parent_tenure.last().as_ref().unwrap().block_id()
+            } else {
+                let tip = {
+                    let chainstate = &mut self.stacks_node.as_mut().unwrap().chainstate;
+                    let sort_db = self.sortdb.as_mut().unwrap();
+                    NakamotoChainState::get_canonical_block_header(chainstate.db(), sort_db)
+                        .unwrap()
+                        .unwrap()
+                };
+                tip.index_block_hash()
+            };
+
+        let miner_addr = self.miner.origin_address().unwrap();
+        let miner_acct = self.get_account(&nakamoto_tip, &miner_addr.to_account_principal());
+
+        let tenure_change_tx = self
+            .miner
+            .make_nakamoto_tenure_change_with_nonce(tenure_change.clone(), miner_acct.nonce);
+
+        let coinbase_tx =
+            self.miner
+                .make_nakamoto_coinbase_with_nonce(None, vrf_proof, miner_acct.nonce + 1);
+
+        self.make_nakamoto_tenure_and(
+            tenure_change_tx,
+            coinbase_tx,
+            &mut test_signers,
+            |_| {},
+            block_builder,
+            |_| true,
+        )
+    }
+
     pub fn single_block_tenure<S, F, G>(
         &mut self,
         sender_key: &StacksPrivateKey,
@@ -764,6 +861,439 @@ fn block_descendant() {
     );
 }
 
+#[test]
+fn block_info_primary_testnet() {
+    block_info_tests(true)
+}
+
+#[test]
+fn block_info_other_testnet() {
+    block_info_tests(false)
+}
+
+fn block_info_tests(use_primary_testnet: bool) {
+    let private_key = StacksPrivateKey::from_seed(&[2]);
+    let addr = StacksAddress::p2pkh(false, &StacksPublicKey::from_private(&private_key));
+
+    let num_stackers: u32 = 4;
+    let mut signing_key_seed = num_stackers.to_be_bytes().to_vec();
+    signing_key_seed.extend_from_slice(&[1, 1, 1, 1]);
+    let signing_key = StacksPrivateKey::from_seed(signing_key_seed.as_slice());
+    let test_stackers = (0..num_stackers)
+        .map(|index| TestStacker {
+            signer_private_key: signing_key.clone(),
+            stacker_private_key: StacksPrivateKey::from_seed(&index.to_be_bytes()),
+            amount: u64::MAX as u128 - 10000,
+            pox_addr: Some(PoxAddress::Standard(
+                StacksAddress::new(
+                    C32_ADDRESS_VERSION_TESTNET_SINGLESIG,
+                    Hash160::from_data(&index.to_be_bytes()),
+                ),
+                Some(AddressHashMode::SerializeP2PKH),
+            )),
+            max_amount: None,
+        })
+        .collect::<Vec<_>>();
+    let test_signers = TestSigners::new(vec![signing_key]);
+    let mut pox_constants = TestPeerConfig::default().burnchain.pox_constants;
+    pox_constants.reward_cycle_length = 10;
+    pox_constants.v2_unlock_height = 21;
+    pox_constants.pox_3_activation_height = 26;
+    pox_constants.v3_unlock_height = 27;
+    pox_constants.pox_4_activation_height = 28;
+
+    let chain_id = if use_primary_testnet {
+        CHAIN_ID_TESTNET
+    } else {
+        CHAIN_ID_TESTNET + 1
+    };
+    let mut boot_plan =
+        NakamotoBootPlan::new(&format!("{}.{use_primary_testnet}", function_name!()))
+            .with_test_stackers(test_stackers.clone())
+            .with_test_signers(test_signers.clone())
+            .with_private_key(private_key)
+            .with_network_id(chain_id);
+    boot_plan.pox_constants = pox_constants;
+
+    // Supply an empty vec to make sure we have no nakamoto blocks when this test begins
+    let mut peer = boot_plan.boot_into_nakamoto_peer(vec![], None);
+
+    let clar1_contract = "
+       (define-read-only (get-info (height uint)) (get-block-info? id-header-hash height))
+    ";
+    let clar3_contract = "
+       (define-read-only (get-info (height uint)) (get-stacks-block-info? id-header-hash height))
+    ";
+
+    let clar1_contract_name = "clar1";
+    let clar3_contract_name = "clar3";
+
+    let clar1_contract_id = QualifiedContractIdentifier {
+        issuer: addr.clone().into(),
+        name: clar1_contract_name.into(),
+    };
+    let clar3_contract_id = QualifiedContractIdentifier {
+        issuer: addr.clone().into(),
+        name: clar3_contract_name.into(),
+    };
+
+    let get_tip_info = |peer: &mut TestPeer| {
+        peer.with_db_state(|sortdb, _, _, _| {
+            let (tip_ch, tip_bh, tip_height) =
+                SortitionDB::get_canonical_stacks_chain_tip_hash_and_height(sortdb.conn()).unwrap();
+            let tip_block_id = StacksBlockId::new(&tip_ch, &tip_bh);
+            Ok((tip_block_id, tip_height))
+        })
+        .unwrap()
+    };
+
+    let get_info = |peer: &mut TestPeer,
+                    version: ClarityVersion,
+                    query_ht: u64,
+                    tip_block_id: &StacksBlockId| {
+        let contract_id = match version {
+            ClarityVersion::Clarity1 => &clar1_contract_id,
+            ClarityVersion::Clarity2 => panic!(),
+            ClarityVersion::Clarity3 => &clar3_contract_id,
+        };
+        peer.with_db_state(|sortdb, chainstate, _, _| {
+            let sortdb_handle = sortdb.index_handle_at_tip();
+            let output = chainstate
+                .clarity_eval_read_only(
+                    &sortdb_handle,
+                    &tip_block_id,
+                    contract_id,
+                    &format!("(get-info u{query_ht})"),
+                )
+                .expect_optional()
+                .unwrap()
+                .map(|value| StacksBlockId::from_vec(&value.expect_buff(32).unwrap()).unwrap());
+
+            info!("At stacks block {tip_block_id}, {contract_id} returned {output:?}");
+
+            Ok(output)
+        })
+        .unwrap()
+    };
+
+    let (last_2x_block_id, last_2x_block_ht) = get_tip_info(&mut peer);
+
+    peer.mine_tenure(|miner, chainstate, sortdb, blocks_so_far| {
+        if blocks_so_far.len() > 0 {
+            return vec![];
+        }
+        info!("Producing first nakamoto block, publishing our three contracts");
+        let account = get_account(chainstate, sortdb, &addr);
+        let tx_0 = make_contract(
+            chainstate,
+            clar1_contract_name,
+            clar1_contract,
+            &private_key,
+            ClarityVersion::Clarity1,
+            account.nonce,
+            1000,
+        );
+        let tx_1 = make_contract(
+            chainstate,
+            clar3_contract_name,
+            clar3_contract,
+            &private_key,
+            ClarityVersion::Clarity3,
+            account.nonce + 1,
+            1000,
+        );
+
+        vec![tx_0, tx_1]
+    });
+
+    let (tenure_1_start_block_id, tenure_1_block_ht) = get_tip_info(&mut peer);
+    assert_eq!(
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            last_2x_block_ht,
+            &tenure_1_start_block_id
+        )
+        .unwrap(),
+        last_2x_block_id,
+    );
+    assert_eq!(
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity3,
+            last_2x_block_ht,
+            &tenure_1_start_block_id
+        )
+        .unwrap(),
+        last_2x_block_id,
+    );
+    assert!(get_info(
+        &mut peer,
+        ClarityVersion::Clarity1,
+        tenure_1_block_ht,
+        &tenure_1_start_block_id
+    )
+    .is_none());
+    assert!(get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_1_block_ht,
+        &tenure_1_start_block_id
+    )
+    .is_none());
+
+    let recipient_addr = StacksAddress::p2pkh(
+        false,
+        &StacksPublicKey::from_private(&StacksPrivateKey::from_seed(&[2, 1, 2])),
+    );
+
+    let tenure_2_blocks: Vec<_> = peer
+        .mine_tenure(|miner, chainstate, sortdb, blocks_so_far| {
+            if blocks_so_far.len() > 3 {
+                return vec![];
+            }
+            info!("Producing block #{} in Tenure #2", blocks_so_far.len());
+            let account = get_account(chainstate, sortdb, &addr);
+            let tx_0 = make_token_transfer(
+                chainstate,
+                sortdb,
+                &private_key,
+                account.nonce,
+                100,
+                1,
+                &recipient_addr,
+            );
+
+            vec![tx_0]
+        })
+        .into_iter()
+        .map(|(block, ..)| block.header.block_id())
+        .collect();
+
+    let (tenure_2_last_block_id, tenure_2_last_block_ht) = get_tip_info(&mut peer);
+
+    assert_eq!(&tenure_2_last_block_id, tenure_2_blocks.last().unwrap());
+
+    let c3_tenure1_from_tenure2 = get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_1_block_ht,
+        &tenure_2_blocks[0],
+    )
+    .unwrap();
+    let c1_tenure1_from_tenure2 = get_info(
+        &mut peer,
+        ClarityVersion::Clarity1,
+        tenure_1_block_ht,
+        &tenure_2_blocks[0],
+    )
+    .unwrap();
+
+    // note, since tenure_1 only has one block in it, tenure_1_block_ht is *also* the tenure height, so this should return the
+    // same value regardless of the `primary_tesnet` flag
+    assert_eq!(c1_tenure1_from_tenure2, c3_tenure1_from_tenure2);
+    assert_eq!(c1_tenure1_from_tenure2, tenure_1_start_block_id);
+
+    let tenure_2_start_block_ht = tenure_1_block_ht + 1;
+    let tenure_2_tenure_ht = tenure_1_block_ht + 1;
+
+    // make sure we can't look up block info from the block we're evaluating at
+    if use_primary_testnet {
+        assert!(get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_start_block_ht,
+            &tenure_2_blocks[0]
+        )
+        .is_none());
+    } else {
+        assert!(get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_tenure_ht,
+            &tenure_2_blocks[0]
+        )
+        .is_none());
+    }
+    assert!(get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_2_start_block_ht,
+        &tenure_2_blocks[0]
+    )
+    .is_none());
+
+    // but we can from the next block in the tenure
+    let c1_tenure_2_start_block = if use_primary_testnet {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_start_block_ht,
+            &tenure_2_blocks[1],
+        )
+        .unwrap()
+    } else {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_tenure_ht,
+            &tenure_2_blocks[1],
+        )
+        .unwrap()
+    };
+    let c3_tenure_2_start_block = get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_2_start_block_ht,
+        &tenure_2_blocks[1],
+    )
+    .unwrap();
+    assert_eq!(c1_tenure_2_start_block, c3_tenure_2_start_block);
+    assert_eq!(&c1_tenure_2_start_block, &tenure_2_blocks[0]);
+
+    // try to query the middle block from the last block in the tenure
+    let c1_tenure_2_mid_block = if use_primary_testnet {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_start_block_ht + 1,
+            &tenure_2_blocks[2],
+        )
+    } else {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_start_block_ht + 1,
+            &tenure_2_blocks[2],
+        )
+    };
+    let c3_tenure_2_mid_block = get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_2_start_block_ht + 1,
+        &tenure_2_blocks[2],
+    )
+    .unwrap();
+    assert_eq!(&c3_tenure_2_mid_block, &tenure_2_blocks[1]);
+    if use_primary_testnet {
+        assert_eq!(c1_tenure_2_mid_block.unwrap(), c3_tenure_2_mid_block);
+    } else {
+        // if interpreted as a tenure-height, this will return none, because there's no tenure at height +1 yet
+        assert!(c1_tenure_2_mid_block.is_none());
+
+        // query the tenure height again from the latest block for good measure
+        let start_block_result = get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_2_tenure_ht,
+            &tenure_2_blocks[2],
+        )
+        .unwrap();
+        assert_eq!(&start_block_result, &tenure_2_blocks[0]);
+    }
+
+    let tenure_3_tenure_ht = tenure_2_tenure_ht + 1;
+    let tenure_3_start_block_ht =
+        tenure_2_start_block_ht + u64::try_from(tenure_2_blocks.len()).unwrap();
+
+    let tenure_3_blocks: Vec<_> = peer
+        .mine_tenure(|miner, chainstate, sortdb, blocks_so_far| {
+            if blocks_so_far.len() > 3 {
+                return vec![];
+            }
+            info!("Producing block #{} in Tenure #3", blocks_so_far.len());
+            let account = get_account(chainstate, sortdb, &addr);
+            let tx_0 = make_token_transfer(
+                chainstate,
+                sortdb,
+                &private_key,
+                account.nonce,
+                100,
+                1,
+                &recipient_addr,
+            );
+
+            vec![tx_0]
+        })
+        .into_iter()
+        .map(|(block, ..)| block.header.block_id())
+        .collect();
+
+    let (tenure_3_last_block_id, tenure_3_last_block_ht) = get_tip_info(&mut peer);
+
+    assert_eq!(&tenure_3_last_block_id, tenure_3_blocks.last().unwrap());
+    assert_eq!(tenure_3_start_block_ht, tenure_2_last_block_ht + 1);
+
+    // query the current tenure information from the middle block
+    let c1_tenure_3_start_block = if use_primary_testnet {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_3_start_block_ht,
+            &tenure_3_blocks[1],
+        )
+        .unwrap()
+    } else {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_3_tenure_ht,
+            &tenure_3_blocks[1],
+        )
+        .unwrap()
+    };
+    let c3_tenure_3_start_block = get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_3_start_block_ht,
+        &tenure_3_blocks[1],
+    )
+    .unwrap();
+    assert_eq!(c1_tenure_3_start_block, c3_tenure_3_start_block);
+    assert_eq!(&c1_tenure_3_start_block, &tenure_3_blocks[0]);
+
+    // try to query the middle block from the last block in the tenure
+    let c1_tenure_3_mid_block = if use_primary_testnet {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_3_start_block_ht + 1,
+            &tenure_3_blocks[2],
+        )
+    } else {
+        get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_3_start_block_ht + 1,
+            &tenure_3_blocks[2],
+        )
+    };
+    let c3_tenure_3_mid_block = get_info(
+        &mut peer,
+        ClarityVersion::Clarity3,
+        tenure_3_start_block_ht + 1,
+        &tenure_3_blocks[2],
+    )
+    .unwrap();
+    assert_eq!(&c3_tenure_3_mid_block, &tenure_3_blocks[1]);
+    if use_primary_testnet {
+        assert_eq!(c1_tenure_3_mid_block.unwrap(), c3_tenure_3_mid_block);
+    } else {
+        // if interpreted as a tenure-height, this will return none, because there's no tenure at height +1 yet
+        assert!(c1_tenure_3_mid_block.is_none());
+
+        // query the tenure height again from the latest block for good measure
+        let start_block_result = get_info(
+            &mut peer,
+            ClarityVersion::Clarity1,
+            tenure_3_tenure_ht,
+            &tenure_3_blocks[2],
+        )
+        .unwrap();
+        assert_eq!(&start_block_result, &tenure_3_blocks[0]);
+    }
+}
+
 #[test]
 // Test PoX Reward and Punish treatment in nakamoto
 //   - create a 12 address PoX reward set
diff --git a/stackslib/src/chainstate/nakamoto/tests/node.rs b/stackslib/src/chainstate/nakamoto/tests/node.rs
index e7d6fef03f..6f929e0031 100644
--- a/stackslib/src/chainstate/nakamoto/tests/node.rs
+++ b/stackslib/src/chainstate/nakamoto/tests/node.rs
@@ -231,7 +231,7 @@ impl TestMiner {
                 Some(vrf_proof),
             ),
         );
-        tx_coinbase.chain_id = 0x80000000;
+        tx_coinbase.chain_id = self.chain_id;
         tx_coinbase.anchor_mode = TransactionAnchorMode::OnChainOnly;
         tx_coinbase.auth.set_origin_nonce(nonce);
 
@@ -258,7 +258,7 @@ impl TestMiner {
             self.as_transaction_auth().unwrap(),
             TransactionPayload::TenureChange(tenure_change),
         );
-        tx_tenure_change.chain_id = 0x80000000;
+        tx_tenure_change.chain_id = self.chain_id;
         tx_tenure_change.anchor_mode = TransactionAnchorMode::OnChainOnly;
         tx_tenure_change.auth.set_origin_nonce(nonce);
 
diff --git a/stackslib/src/chainstate/stacks/boot/contract_tests.rs b/stackslib/src/chainstate/stacks/boot/contract_tests.rs
index 20abf84856..cf1924844c 100644
--- a/stackslib/src/chainstate/stacks/boot/contract_tests.rs
+++ b/stackslib/src/chainstate/stacks/boot/contract_tests.rs
@@ -649,6 +649,14 @@ impl HeadersDB for TestSimHeadersDB {
         // if the block is defined at all, then return a constant
         self.get_burn_block_height_for_block(id_bhh).map(|_| 3000)
     }
+
+    fn get_stacks_height_for_tenure_height(
+        &self,
+        _tip: &StacksBlockId,
+        tenure_height: u32,
+    ) -> Option<u32> {
+        Some(tenure_height)
+    }
 }
 
 #[test]
diff --git a/stackslib/src/chainstate/stacks/tests/mod.rs b/stackslib/src/chainstate/stacks/tests/mod.rs
index cda74cb46d..8b66c019f0 100644
--- a/stackslib/src/chainstate/stacks/tests/mod.rs
+++ b/stackslib/src/chainstate/stacks/tests/mod.rs
@@ -1050,7 +1050,7 @@ pub fn make_coinbase_with_nonce(
             None,
         ),
     );
-    tx_coinbase.chain_id = 0x80000000;
+    tx_coinbase.chain_id = miner.chain_id;
     tx_coinbase.anchor_mode = TransactionAnchorMode::OnChainOnly;
     tx_coinbase.auth.set_origin_nonce(nonce);
 
@@ -1147,7 +1147,7 @@ pub fn make_contract_call(
         .unwrap(),
     );
 
-    tx_contract_call.chain_id = 0x80000000;
+    tx_contract_call.chain_id = miner.chain_id;
     tx_contract_call.auth.set_origin_nonce(miner.get_nonce());
 
     if miner.test_with_tx_fees {
@@ -1179,7 +1179,7 @@ pub fn make_token_transfer(
         TransactionPayload::TokenTransfer((*recipient).clone().into(), amount, (*memo).clone()),
     );
 
-    tx_stx_transfer.chain_id = 0x80000000;
+    tx_stx_transfer.chain_id = miner.chain_id;
     tx_stx_transfer
         .auth
         .set_origin_nonce(nonce.unwrap_or(miner.get_nonce()));
diff --git a/stackslib/src/net/api/tests/getinfo.rs b/stackslib/src/net/api/tests/getinfo.rs
index 7d8aeff01c..89054e3453 100644
--- a/stackslib/src/net/api/tests/getinfo.rs
+++ b/stackslib/src/net/api/tests/getinfo.rs
@@ -50,7 +50,7 @@ fn test_try_parse_request() {
         .try_parse_request(&parsed_preamble.expect_request(), &bytes[offset..])
         .unwrap();
 
-    // parsed request consumes headers that would not be in a constructed reqeuest
+    // parsed request consumes headers that would not be in a constructed request
     parsed_request.clear_headers();
     parsed_request.add_header(
         "X-Canonical-Stacks-Tip-Height".to_string(),
@@ -63,10 +63,10 @@ fn test_try_parse_request() {
 
 #[test]
 fn test_getinfo_compat() {
-    let old_getinfo_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false}"#;
-    let getinfo_no_pubkey_hash_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key":"029b27d345e7bd2a6627262cefe6e97d9bc482f41ec32ec76a7bec391bb441798d"}"#;
-    let getinfo_no_pubkey_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key_hash":"046e6f832a83ff0da4a550907d3a44412cc1e4bf"}"#;
-    let getinfo_full_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key":"029b27d345e7bd2a6627262cefe6e97d9bc482f41ec32ec76a7bec391bb441798d","node_public_key_hash":"046e6f832a83ff0da4a550907d3a44412cc1e4bf"}"#;
+    let old_getinfo_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false, "tenure_height": 42}"#;
+    let getinfo_no_pubkey_hash_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key":"029b27d345e7bd2a6627262cefe6e97d9bc482f41ec32ec76a7bec391bb441798d", "tenure_height": 42}"#;
+    let getinfo_no_pubkey_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key_hash":"046e6f832a83ff0da4a550907d3a44412cc1e4bf", "tenure_height": 0}"#;
+    let getinfo_full_json = r#"{"peer_version":402653189,"pox_consensus":"b712eb731b613eebae814a8f416c5c15bc8391ec","burn_block_height":727631,"stable_pox_consensus":"53b5ed79842080500d7d83daa36aa1069dedf983","stable_burn_block_height":727624,"server_version":"stacks-node 0.0.1 (feat/faster-inv-generation:68f33190a, release build, linux [x86_64])","network_id":1,"parent_network_id":3652501241,"stacks_tip_height":52537,"stacks_tip":"b3183f2ac588e12319ff0fde78f97e62c92a218d87828c35710c29aaf7adbedc","stacks_tip_consensus_hash":"b712eb731b613eebae814a8f416c5c15bc8391ec","genesis_chainstate_hash":"74237aa39aa50a83de11a4f53e9d3bb7d43461d1de9873f402e5453ae60bc59b","unanchored_tip":"e76f68d607480e9984b4062b2691fb60a88423177898f5780b40ace17ae8982a","unanchored_seq":0,"exit_at_block_height":null,"is_fully_synced":false,"node_public_key":"029b27d345e7bd2a6627262cefe6e97d9bc482f41ec32ec76a7bec391bb441798d","node_public_key_hash":"046e6f832a83ff0da4a550907d3a44412cc1e4bf", "tenure_height": 2423}"#;
 
     // they all parse
     for json_obj in &[
@@ -102,4 +102,6 @@ fn test_try_make_response() {
         Some(1)
     );
     let resp = response.decode_peer_info().unwrap();
+
+    assert_eq!(resp.tenure_height, 1);
 }
diff --git a/stackslib/src/net/api/tests/gettenureinfo.rs b/stackslib/src/net/api/tests/gettenureinfo.rs
index db53a5daca..51a8a11785 100644
--- a/stackslib/src/net/api/tests/gettenureinfo.rs
+++ b/stackslib/src/net/api/tests/gettenureinfo.rs
@@ -25,7 +25,6 @@ use stacks_common::types::net::PeerHost;
 use stacks_common::types::Address;
 
 use super::test_rpc;
-use crate::net::api::getinfo::RPCPeerInfoData;
 use crate::net::api::tests::TestRPC;
 use crate::net::api::*;
 use crate::net::connection::ConnectionOptions;
diff --git a/stackslib/src/net/tests/mod.rs b/stackslib/src/net/tests/mod.rs
index 07227c930e..6e61e7e610 100644
--- a/stackslib/src/net/tests/mod.rs
+++ b/stackslib/src/net/tests/mod.rs
@@ -48,7 +48,8 @@ use crate::chainstate::nakamoto::tests::node::TestStacker;
 use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState};
 use crate::chainstate::stacks::address::PoxAddress;
 use crate::chainstate::stacks::boot::test::{
-    key_to_stacks_addr, make_pox_4_lockup, make_signer_key_signature, with_sortdb,
+    key_to_stacks_addr, make_pox_4_lockup, make_pox_4_lockup_chain_id, make_signer_key_signature,
+    with_sortdb,
 };
 use crate::chainstate::stacks::boot::{
     MINERS_NAME, SIGNERS_VOTING_FUNCTION_NAME, SIGNERS_VOTING_NAME,
@@ -66,6 +67,7 @@ use crate::net::relay::{BlockAcceptResponse, Relayer};
 use crate::net::stackerdb::StackerDBConfig;
 use crate::net::test::{TestEventObserver, TestPeer, TestPeerConfig};
 use crate::util_lib::boot::boot_code_id;
+use crate::util_lib::signed_structured_data::pox4::make_pox_4_signer_key_signature;
 
 /// One step of a simulated Nakamoto node's bootup procedure.
 #[derive(Debug, PartialEq, Clone)]
@@ -91,6 +93,7 @@ pub struct NakamotoBootPlan {
     pub num_peers: usize,
     /// Whether to add an initial balance for `private_key`'s account
     pub add_default_balance: bool,
+    pub network_id: u32,
 }
 
 impl NakamotoBootPlan {
@@ -106,6 +109,7 @@ impl NakamotoBootPlan {
             observer: Some(TestEventObserver::new()),
             num_peers: 0,
             add_default_balance: true,
+            network_id: TestPeerConfig::default().network_id,
         }
     }
 
@@ -114,6 +118,11 @@ impl NakamotoBootPlan {
         self
     }
 
+    pub fn with_network_id(mut self, network_id: u32) -> Self {
+        self.network_id = network_id;
+        self
+    }
+
     pub fn with_pox_constants(mut self, cycle_length: u32, prepare_length: u32) -> Self {
         let new_consts = PoxConstants::new(
             cycle_length,
@@ -328,6 +337,7 @@ impl NakamotoBootPlan {
         observer: Option<&'a TestEventObserver>,
     ) -> (TestPeer<'a>, Vec<TestPeer>) {
         let mut peer_config = TestPeerConfig::new(&self.test_name, 0, 0);
+        peer_config.network_id = self.network_id;
         peer_config.private_key = self.private_key.clone();
         let addr = StacksAddress::from_public_keys(
             C32_ADDRESS_VERSION_TESTNET_SINGLESIG,
@@ -487,16 +497,19 @@ impl NakamotoBootPlan {
                     .clone()
                     .unwrap_or(default_pox_addr.clone());
                 let max_amount = test_stacker.max_amount.unwrap_or(u128::MAX);
-                let signature = make_signer_key_signature(
+                let signature = make_pox_4_signer_key_signature(
                     &pox_addr,
                     &test_stacker.signer_private_key,
                     reward_cycle.into(),
                     &crate::util_lib::signed_structured_data::pox4::Pox4SignatureTopic::StackStx,
-                    12_u128,
+                    peer.config.network_id,
+                    12,
                     max_amount,
                     1,
-                );
-                make_pox_4_lockup(
+                )
+                .unwrap()
+                .to_rsv();
+                make_pox_4_lockup_chain_id(
                     &test_stacker.stacker_private_key,
                     0,
                     test_stacker.amount,
@@ -507,6 +520,7 @@ impl NakamotoBootPlan {
                     Some(signature),
                     max_amount,
                     1,
+                    peer.config.network_id,
                 )
             })
             .collect();
diff --git a/stackslib/src/net/tests/relay/nakamoto.rs b/stackslib/src/net/tests/relay/nakamoto.rs
index fb9db70d5b..606f1f3fb2 100644
--- a/stackslib/src/net/tests/relay/nakamoto.rs
+++ b/stackslib/src/net/tests/relay/nakamoto.rs
@@ -54,7 +54,6 @@ use crate::chainstate::stacks::tests::{
 use crate::chainstate::stacks::{Error as ChainstateError, *};
 use crate::clarity_vm::clarity::ClarityConnection;
 use crate::core::*;
-use crate::net::api::getinfo::RPCPeerInfoData;
 use crate::net::asn::*;
 use crate::net::chat::*;
 use crate::net::codec::*;
diff --git a/testnet/stacks-node/Cargo.toml b/testnet/stacks-node/Cargo.toml
index dca09b7b82..0f434565e1 100644
--- a/testnet/stacks-node/Cargo.toml
+++ b/testnet/stacks-node/Cargo.toml
@@ -49,6 +49,8 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
 mutants = "0.0.3"
 tiny_http = "0.12.0"
 http-types = "2.12"
+tempfile = "3.3"
+mockito = "1.5"
 
 [dependencies.rusqlite]
 workspace = true
diff --git a/testnet/stacks-node/src/config.rs b/testnet/stacks-node/src/config.rs
index 004b30fc94..dc3c12b97d 100644
--- a/testnet/stacks-node/src/config.rs
+++ b/testnet/stacks-node/src/config.rs
@@ -550,6 +550,16 @@ impl Config {
                 &burnchain.pox_constants
             );
         }
+        let activation_reward_cycle = burnchain
+            .block_height_to_reward_cycle(epoch_30.start_height)
+            .expect("FATAL: Epoch 3.0 starts before the first burnchain block");
+        if activation_reward_cycle < 2 {
+            panic!(
+                "FATAL: Epoch 3.0 must start at or after the second reward cycle. Epoch 3.0 start set to: {}. PoX Parameters: {:?}",
+                epoch_30.start_height,
+                &burnchain.pox_constants
+            );
+        }
     }
 
     /// Connect to the MempoolDB using the configured cost estimation
@@ -955,6 +965,18 @@ impl Config {
         })
     }
 
+    /// Returns the path working directory path, and ensures it exists.
+    pub fn get_working_dir(&self) -> PathBuf {
+        let path = PathBuf::from(&self.node.working_dir);
+        fs::create_dir_all(&path).unwrap_or_else(|_| {
+            panic!(
+                "Failed to create working directory at {}",
+                path.to_string_lossy()
+            )
+        });
+        path
+    }
+
     fn get_burnchain_path(&self) -> PathBuf {
         let mut path = PathBuf::from(&self.node.working_dir);
         path.push(&self.burnchain.mode);
diff --git a/testnet/stacks-node/src/event_dispatcher.rs b/testnet/stacks-node/src/event_dispatcher.rs
index faf437c444..43714f3573 100644
--- a/testnet/stacks-node/src/event_dispatcher.rs
+++ b/testnet/stacks-node/src/event_dispatcher.rs
@@ -16,6 +16,7 @@
 
 use std::collections::hash_map::Entry;
 use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
 use std::sync::mpsc::{channel, Receiver, Sender};
 use std::sync::Mutex;
 use std::thread::sleep;
@@ -25,6 +26,7 @@ use clarity::vm::analysis::contract_interface_builder::build_contract_interface;
 use clarity::vm::costs::ExecutionCost;
 use clarity::vm::events::{FTEventType, NFTEventType, STXEventType};
 use clarity::vm::types::{AssetIdentifier, QualifiedContractIdentifier, Value};
+use rusqlite::{params, Connection};
 use serde_json::json;
 use stacks::burnchains::{PoxConstants, Txid};
 use stacks::chainstate::burn::operations::BlockstackOperationType;
@@ -56,6 +58,7 @@ use stacks::net::http::HttpRequestContents;
 use stacks::net::httpcore::{send_http_request, StacksHttpRequest};
 use stacks::net::stackerdb::StackerDBEventDispatcher;
 use stacks::util::hash::to_hex;
+use stacks::util_lib::db::Error as db_error;
 use stacks_common::bitvec::BitVec;
 use stacks_common::codec::StacksMessageCodec;
 use stacks_common::types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockId};
@@ -68,7 +71,12 @@ use super::config::{EventKeyType, EventObserverConfig};
 
 #[derive(Debug, Clone)]
 struct EventObserver {
+    /// Path to the database where pending payloads are stored. If `None`, then
+    /// the database is not used and events are not recoverable across restarts.
+    db_path: Option<PathBuf>,
+    /// URL to which events will be sent
     endpoint: String,
+    /// Timeout for sending events to this observer
     timeout: Duration,
 }
 
@@ -313,22 +321,106 @@ impl RewardSetEventPayload {
     }
 }
 
+#[cfg(test)]
+static TEST_EVENT_OBSERVER_SKIP_RETRY: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
+
 impl EventObserver {
-    pub fn send_payload(&self, payload: &serde_json::Value, path: &str) {
+    fn init_db(db_path: &str) -> Result<Connection, db_error> {
+        let conn = Connection::open(db_path)?;
+        conn.execute(
+            "CREATE TABLE IF NOT EXISTS pending_payloads (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                url TEXT NOT NULL,
+                payload TEXT NOT NULL,
+                timeout INTEGER NOT NULL
+            )",
+            [],
+        )?;
+        Ok(conn)
+    }
+
+    fn insert_payload(
+        conn: &Connection,
+        url: &str,
+        payload: &serde_json::Value,
+        timeout: Duration,
+    ) -> Result<(), db_error> {
+        let payload_text = payload.to_string();
+        let timeout_ms: u64 = timeout.as_millis().try_into().expect("Timeout too large");
+        conn.execute(
+            "INSERT INTO pending_payloads (url, payload, timeout) VALUES (?1, ?2, ?3)",
+            params![url, payload_text, timeout_ms],
+        )?;
+        Ok(())
+    }
+
+    fn get_pending_payloads(
+        conn: &Connection,
+    ) -> Result<Vec<(i64, String, serde_json::Value, u64)>, db_error> {
+        let mut stmt =
+            conn.prepare("SELECT id, url, payload, timeout FROM pending_payloads ORDER BY id")?;
+        let payload_iter = stmt.query_and_then(
+            [],
+            |row| -> Result<(i64, String, serde_json::Value, u64), db_error> {
+                let id: i64 = row.get(0)?;
+                let url: String = row.get(1)?;
+                let payload_text: String = row.get(2)?;
+                let payload: serde_json::Value = serde_json::from_str(&payload_text)
+                    .map_err(|e| db_error::SerializationError(e))?;
+                let timeout_ms: u64 = row.get(3)?;
+                Ok((id, url, payload, timeout_ms))
+            },
+        )?;
+        payload_iter.collect()
+    }
+
+    fn delete_payload(conn: &Connection, id: i64) -> Result<(), db_error> {
+        conn.execute("DELETE FROM pending_payloads WHERE id = ?1", params![id])?;
+        Ok(())
+    }
+
+    fn process_pending_payloads(conn: &Connection) {
+        let pending_payloads = match Self::get_pending_payloads(conn) {
+            Ok(payloads) => payloads,
+            Err(e) => {
+                error!(
+                    "Event observer: failed to retrieve pending payloads from database";
+                    "error" => ?e
+                );
+                return;
+            }
+        };
+
+        for (id, url, payload, timeout_ms) in pending_payloads {
+            let timeout = Duration::from_millis(timeout_ms);
+            Self::send_payload_directly(&payload, &url, timeout);
+
+            #[cfg(test)]
+            if TEST_EVENT_OBSERVER_SKIP_RETRY
+                .lock()
+                .unwrap()
+                .unwrap_or(false)
+            {
+                warn!("Fault injection: delete_payload");
+                return;
+            }
+
+            if let Err(e) = Self::delete_payload(conn, id) {
+                error!(
+                    "Event observer: failed to delete pending payload from database";
+                    "error" => ?e
+                );
+            }
+        }
+    }
+
+    fn send_payload_directly(payload: &serde_json::Value, full_url: &str, timeout: Duration) {
         debug!(
-            "Event dispatcher: Sending payload"; "url" => %path, "payload" => ?payload
+            "Event dispatcher: Sending payload"; "url" => %full_url, "payload" => ?payload
         );
 
-        let url = {
-            let joined_components = if path.starts_with('/') {
-                format!("{}{}", &self.endpoint, path)
-            } else {
-                format!("{}/{}", &self.endpoint, path)
-            };
-            let url = format!("http://{}", joined_components);
-            Url::parse(&url)
-                .unwrap_or_else(|_| panic!("Event dispatcher: unable to parse {} as a URL", url))
-        };
+        let url = Url::parse(full_url)
+            .unwrap_or_else(|_| panic!("Event dispatcher: unable to parse {} as a URL", full_url));
 
         let host = url.host_str().expect("Invalid URL: missing host");
         let port = url.port_or_known_default().unwrap_or(80);
@@ -347,7 +439,7 @@ impl EventObserver {
             .unwrap_or_else(|_| panic!("FATAL: failed to encode infallible data as HTTP request"));
             request.add_header("Connection".into(), "close".into());
 
-            match send_http_request(host, port, request, self.timeout) {
+            match send_http_request(host, port, request, timeout) {
                 Ok(response) => {
                     if response.preamble().status_code == 200 {
                         debug!(
@@ -367,11 +459,71 @@ impl EventObserver {
                     );
                 }
             }
+
+            #[cfg(test)]
+            if TEST_EVENT_OBSERVER_SKIP_RETRY
+                .lock()
+                .unwrap()
+                .unwrap_or(false)
+            {
+                warn!("Fault injection: skipping retry of payload");
+                return;
+            }
+
             sleep(backoff);
             backoff *= 2;
         }
     }
 
+    fn new(working_dir: Option<PathBuf>, endpoint: String, timeout: Duration) -> Self {
+        let db_path = if let Some(mut db_path) = working_dir {
+            db_path.push("event_observers.sqlite");
+
+            Self::init_db(
+                db_path
+                    .to_str()
+                    .expect("Failed to convert chainstate path to string"),
+            )
+            .expect("Failed to initialize database for event observer");
+            Some(db_path)
+        } else {
+            None
+        };
+
+        EventObserver {
+            db_path,
+            endpoint,
+            timeout,
+        }
+    }
+
+    /// Send the payload to the given URL.
+    /// Before sending this payload, any pending payloads in the database will be sent first.
+    pub fn send_payload(&self, payload: &serde_json::Value, path: &str) {
+        // Construct the full URL
+        let url_str = if path.starts_with('/') {
+            format!("{}{}", &self.endpoint, path)
+        } else {
+            format!("{}/{}", &self.endpoint, path)
+        };
+        let full_url = format!("http://{}", url_str);
+
+        if let Some(db_path) = &self.db_path {
+            let conn =
+                Connection::open(db_path).expect("Failed to open database for event observer");
+
+            // Insert the new payload into the database
+            Self::insert_payload(&conn, &full_url, payload, self.timeout)
+                .expect("Failed to insert payload into event observer database");
+
+            // Process all pending payloads
+            Self::process_pending_payloads(&conn);
+        } else {
+            // No database, just send the payload
+            Self::send_payload_directly(payload, &full_url, self.timeout);
+        }
+    }
+
     fn make_new_mempool_txs_payload(transactions: Vec<StacksTransaction>) -> serde_json::Value {
         let raw_txs = transactions
             .into_iter()
@@ -1403,12 +1555,13 @@ impl EventDispatcher {
         }
     }
 
-    pub fn register_observer(&mut self, conf: &EventObserverConfig) {
+    pub fn register_observer(&mut self, conf: &EventObserverConfig, working_dir: PathBuf) {
         info!("Registering event observer at: {}", conf.endpoint);
-        let event_observer = EventObserver {
-            endpoint: conf.endpoint.clone(),
-            timeout: Duration::from_millis(conf.timeout_ms),
-        };
+        let event_observer = EventObserver::new(
+            Some(working_dir),
+            conf.endpoint.clone(),
+            Duration::from_millis(conf.timeout_ms),
+        );
 
         let observer_index = self.registered_observers.len() as u16;
 
@@ -1492,16 +1645,14 @@ mod test {
     use stacks::util::secp256k1::MessageSignature;
     use stacks_common::bitvec::BitVec;
     use stacks_common::types::chainstate::{BurnchainHeaderHash, StacksBlockId};
+    use tempfile::tempdir;
     use tiny_http::{Method, Response, Server, StatusCode};
 
     use super::*;
 
     #[test]
     fn build_block_processed_event() {
-        let observer = EventObserver {
-            endpoint: "nowhere".to_string(),
-            timeout: Duration::from_secs(3),
-        };
+        let observer = EventObserver::new(None, "nowhere".to_string(), Duration::from_secs(3));
 
         let filtered_events = vec![];
         let block = StacksBlock::genesis_block();
@@ -1559,10 +1710,7 @@ mod test {
 
     #[test]
     fn test_block_processed_event_nakamoto() {
-        let observer = EventObserver {
-            endpoint: "nowhere".to_string(),
-            timeout: Duration::from_secs(3),
-        };
+        let observer = EventObserver::new(None, "nowhere".to_string(), Duration::from_secs(3));
 
         let filtered_events = vec![];
         let mut block_header = NakamotoBlockHeader::empty();
@@ -1679,6 +1827,231 @@ mod test {
         listener.local_addr().unwrap().port()
     }
 
+    #[test]
+    fn test_init_db() {
+        let dir = tempdir().unwrap();
+        let db_path = dir.path().join("test_init_db.sqlite");
+        let db_path_str = db_path.to_str().unwrap();
+
+        // Call init_db
+        let conn_result = EventObserver::init_db(db_path_str);
+        assert!(conn_result.is_ok(), "Failed to initialize the database");
+
+        // Check that the database file exists
+        assert!(db_path.exists(), "Database file was not created");
+
+        // Check that the table exists
+        let conn = conn_result.unwrap();
+        let mut stmt = conn
+            .prepare(
+                "SELECT name FROM sqlite_master WHERE type='table' AND name='pending_payloads'",
+            )
+            .unwrap();
+        let table_exists = stmt.exists([]).unwrap();
+        assert!(table_exists, "Table 'pending_payloads' does not exist");
+    }
+
+    #[test]
+    fn test_insert_and_get_pending_payloads() {
+        let dir = tempdir().unwrap();
+        let db_path = dir.path().join("test_payloads.sqlite");
+        let db_path_str = db_path.to_str().unwrap();
+
+        let conn = EventObserver::init_db(db_path_str).expect("Failed to initialize the database");
+
+        let url = "http://example.com/api";
+        let payload = json!({"key": "value"});
+        let timeout = Duration::from_secs(5);
+
+        // Insert payload
+        let insert_result = EventObserver::insert_payload(&conn, url, &payload, timeout);
+        assert!(insert_result.is_ok(), "Failed to insert payload");
+
+        // Get pending payloads
+        let pending_payloads =
+            EventObserver::get_pending_payloads(&conn).expect("Failed to get pending payloads");
+        assert_eq!(pending_payloads.len(), 1, "Expected one pending payload");
+
+        let (_id, retrieved_url, retrieved_payload, timeout_ms) = &pending_payloads[0];
+        assert_eq!(retrieved_url, url, "URL does not match");
+        assert_eq!(retrieved_payload, &payload, "Payload does not match");
+        assert_eq!(
+            *timeout_ms,
+            timeout.as_millis() as u64,
+            "Timeout does not match"
+        );
+    }
+
+    #[test]
+    fn test_delete_payload() {
+        let dir = tempdir().unwrap();
+        let db_path = dir.path().join("test_delete_payload.sqlite");
+        let db_path_str = db_path.to_str().unwrap();
+
+        let conn = EventObserver::init_db(db_path_str).expect("Failed to initialize the database");
+
+        let url = "http://example.com/api";
+        let payload = json!({"key": "value"});
+        let timeout = Duration::from_secs(5);
+
+        // Insert payload
+        EventObserver::insert_payload(&conn, url, &payload, timeout)
+            .expect("Failed to insert payload");
+
+        // Get pending payloads
+        let pending_payloads =
+            EventObserver::get_pending_payloads(&conn).expect("Failed to get pending payloads");
+        assert_eq!(pending_payloads.len(), 1, "Expected one pending payload");
+
+        let (id, _, _, _) = pending_payloads[0];
+
+        // Delete payload
+        let delete_result = EventObserver::delete_payload(&conn, id);
+        assert!(delete_result.is_ok(), "Failed to delete payload");
+
+        // Verify that the pending payloads list is empty
+        let pending_payloads =
+            EventObserver::get_pending_payloads(&conn).expect("Failed to get pending payloads");
+        assert_eq!(pending_payloads.len(), 0, "Expected no pending payloads");
+    }
+
+    #[test]
+    fn test_process_pending_payloads() {
+        use mockito::Matcher;
+
+        let dir = tempdir().unwrap();
+        let db_path = dir.path().join("test_process_payloads.sqlite");
+        let db_path_str = db_path.to_str().unwrap();
+
+        let conn = EventObserver::init_db(db_path_str).expect("Failed to initialize the database");
+
+        let payload = json!({"key": "value"});
+        let timeout = Duration::from_secs(5);
+
+        // Create a mock server
+        let mut server = mockito::Server::new();
+        let _m = server
+            .mock("POST", "/api")
+            .match_header("content-type", Matcher::Regex("application/json.*".into()))
+            .match_body(Matcher::Json(payload.clone()))
+            .with_status(200)
+            .create();
+
+        let url = &format!("{}/api", &server.url());
+
+        // Insert payload
+        EventObserver::insert_payload(&conn, url, &payload, timeout)
+            .expect("Failed to insert payload");
+
+        // Process pending payloads
+        EventObserver::process_pending_payloads(&conn);
+
+        // Verify that the pending payloads list is empty
+        let pending_payloads =
+            EventObserver::get_pending_payloads(&conn).expect("Failed to get pending payloads");
+        assert_eq!(pending_payloads.len(), 0, "Expected no pending payloads");
+
+        // Verify that the mock was called
+        _m.assert();
+    }
+
+    #[test]
+    fn test_new_event_observer_with_db() {
+        let dir = tempdir().unwrap();
+        let working_dir = dir.path().to_path_buf();
+
+        let endpoint = "http://example.com".to_string();
+        let timeout = Duration::from_secs(5);
+
+        let observer = EventObserver::new(Some(working_dir.clone()), endpoint.clone(), timeout);
+
+        // Verify fields
+        assert_eq!(observer.endpoint, endpoint);
+        assert_eq!(observer.timeout, timeout);
+
+        // Verify that the database was initialized
+        let mut db_path = working_dir;
+        db_path.push("event_observers.sqlite");
+        assert!(db_path.exists(), "Database file was not created");
+    }
+
+    #[test]
+    fn test_new_event_observer_without_db() {
+        let endpoint = "http://example.com".to_string();
+        let timeout = Duration::from_secs(5);
+
+        let observer = EventObserver::new(None, endpoint.clone(), timeout);
+
+        // Verify fields
+        assert_eq!(observer.endpoint, endpoint);
+        assert_eq!(observer.timeout, timeout);
+        assert!(observer.db_path.is_none(), "Expected db_path to be None");
+    }
+
+    #[test]
+    fn test_send_payload_with_db() {
+        use mockito::Matcher;
+
+        let dir = tempdir().unwrap();
+        let working_dir = dir.path().to_path_buf();
+        let payload = json!({"key": "value"});
+
+        // Create a mock server
+        let mut server = mockito::Server::new();
+        let _m = server
+            .mock("POST", "/test")
+            .match_header("content-type", Matcher::Regex("application/json.*".into()))
+            .match_body(Matcher::Json(payload.clone()))
+            .with_status(200)
+            .create();
+
+        let endpoint = server.url().strip_prefix("http://").unwrap().to_string();
+        let timeout = Duration::from_secs(5);
+
+        let observer = EventObserver::new(Some(working_dir.clone()), endpoint, timeout);
+
+        // Call send_payload
+        observer.send_payload(&payload, "/test");
+
+        // Verify that the payload was sent and database is empty
+        _m.assert();
+
+        // Verify that the database is empty
+        let db_path = observer.db_path.unwrap();
+        let db_path_str = db_path.to_str().unwrap();
+        let conn = Connection::open(db_path_str).expect("Failed to open database");
+        let pending_payloads =
+            EventObserver::get_pending_payloads(&conn).expect("Failed to get pending payloads");
+        assert_eq!(pending_payloads.len(), 0, "Expected no pending payloads");
+    }
+
+    #[test]
+    fn test_send_payload_without_db() {
+        use mockito::Matcher;
+
+        let timeout = Duration::from_secs(5);
+        let payload = json!({"key": "value"});
+
+        // Create a mock server
+        let mut server = mockito::Server::new();
+        let _m = server
+            .mock("POST", "/test")
+            .match_header("content-type", Matcher::Regex("application/json.*".into()))
+            .match_body(Matcher::Json(payload.clone()))
+            .with_status(200)
+            .create();
+
+        let endpoint = server.url().strip_prefix("http://").unwrap().to_string();
+
+        let observer = EventObserver::new(None, endpoint, timeout);
+
+        // Call send_payload
+        observer.send_payload(&payload, "/test");
+
+        // Verify that the payload was sent
+        _m.assert();
+    }
+
     #[test]
     fn test_send_payload_success() {
         let port = get_random_port();
@@ -1701,10 +2074,8 @@ mod test {
             tx.send(()).unwrap();
         });
 
-        let observer = EventObserver {
-            endpoint: format!("127.0.0.1:{}", port),
-            timeout: Duration::from_secs(3),
-        };
+        let observer =
+            EventObserver::new(None, format!("127.0.0.1:{}", port), Duration::from_secs(3));
 
         let payload = json!({"key": "value"});
 
@@ -1752,10 +2123,8 @@ mod test {
             }
         });
 
-        let observer = EventObserver {
-            endpoint: format!("127.0.0.1:{}", port),
-            timeout: Duration::from_secs(3),
-        };
+        let observer =
+            EventObserver::new(None, format!("127.0.0.1:{}", port), Duration::from_secs(3));
 
         let payload = json!({"key": "value"});
 
@@ -1799,10 +2168,7 @@ mod test {
             }
         });
 
-        let observer = EventObserver {
-            endpoint: format!("127.0.0.1:{}", port),
-            timeout,
-        };
+        let observer = EventObserver::new(None, format!("127.0.0.1:{}", port), timeout);
 
         let payload = json!({"key": "value"});
 
@@ -1830,4 +2196,99 @@ mod test {
         rx.recv_timeout(Duration::from_secs(5))
             .expect("Server did not receive request in time");
     }
+
+    #[test]
+    fn test_send_payload_with_db_force_restart() {
+        let port = get_random_port();
+        let timeout = Duration::from_secs(3);
+        let dir = tempdir().unwrap();
+        let working_dir = dir.path().to_path_buf();
+
+        // Set up a channel to notify when the server has processed the request
+        let (tx, rx) = channel();
+
+        info!("Starting mock server on port {}", port);
+        // Start a mock server in a separate thread
+        let server = Server::http(format!("127.0.0.1:{}", port)).unwrap();
+        thread::spawn(move || {
+            let mut attempt = 0;
+            let mut _request_holder = None;
+            while let Ok(mut request) = server.recv() {
+                attempt += 1;
+                match attempt {
+                    1 => {
+                        debug!("Mock server received request attempt 1");
+                        // Do not reply, forcing the sender to timeout and retry,
+                        // but don't drop the request or it will receive a 500 error,
+                        _request_holder = Some(request);
+                    }
+                    2 => {
+                        debug!("Mock server received request attempt 2");
+
+                        // Verify the payload
+                        let mut payload = String::new();
+                        request.as_reader().read_to_string(&mut payload).unwrap();
+                        let expected_payload = r#"{"key":"value"}"#;
+                        assert_eq!(payload, expected_payload);
+
+                        // Simulate a successful response on the second attempt
+                        let response = Response::from_string("HTTP/1.1 200 OK");
+                        request.respond(response).unwrap();
+                    }
+                    3 => {
+                        debug!("Mock server received request attempt 3");
+
+                        // Verify the payload
+                        let mut payload = String::new();
+                        request.as_reader().read_to_string(&mut payload).unwrap();
+                        let expected_payload = r#"{"key":"value2"}"#;
+                        assert_eq!(payload, expected_payload);
+
+                        // Simulate a successful response on the second attempt
+                        let response = Response::from_string("HTTP/1.1 200 OK");
+                        request.respond(response).unwrap();
+
+                        // When we receive attempt 3 (message 1, re-sent message 1, message 2),
+                        // notify the test that the request was processed successfully
+                        tx.send(()).unwrap();
+                        break;
+                    }
+                    _ => panic!("Unexpected request attempt"),
+                }
+            }
+        });
+
+        let observer = EventObserver::new(
+            Some(working_dir.clone()),
+            format!("127.0.0.1:{}", port),
+            timeout,
+        );
+
+        let payload = json!({"key": "value"});
+        let payload2 = json!({"key": "value2"});
+
+        // Disable retrying so that it sends the payload only once
+        // and that payload will be ignored by the test server.
+        TEST_EVENT_OBSERVER_SKIP_RETRY.lock().unwrap().replace(true);
+
+        info!("Sending payload 1");
+
+        // Send the payload
+        observer.send_payload(&payload, "/test");
+
+        // Re-enable retrying
+        TEST_EVENT_OBSERVER_SKIP_RETRY
+            .lock()
+            .unwrap()
+            .replace(false);
+
+        info!("Sending payload 2");
+
+        // Send another payload
+        observer.send_payload(&payload2, "/test");
+
+        // Wait for the server to process the requests
+        rx.recv_timeout(Duration::from_secs(5))
+            .expect("Server did not receive request in time");
+    }
 }
diff --git a/testnet/stacks-node/src/nakamoto_node/miner.rs b/testnet/stacks-node/src/nakamoto_node/miner.rs
index af539db5b1..a08c0ab353 100644
--- a/testnet/stacks-node/src/nakamoto_node/miner.rs
+++ b/testnet/stacks-node/src/nakamoto_node/miner.rs
@@ -531,22 +531,6 @@ impl BlockMinerThread {
             ))
         })?;
 
-        let tip = SortitionDB::get_block_snapshot_consensus(
-            sort_db.conn(),
-            &new_block.header.consensus_hash,
-        )
-        .map_err(|e| {
-            NakamotoNodeError::SigningCoordinatorFailure(format!(
-                "Failed to retrieve chain tip: {:?}",
-                e
-            ))
-        })
-        .and_then(|result| {
-            result.ok_or_else(|| {
-                NakamotoNodeError::SigningCoordinatorFailure("Failed to retrieve chain tip".into())
-            })
-        })?;
-
         let reward_set = self.load_signer_set()?;
 
         if self.config.get_node_config(false).mock_mining {
@@ -574,7 +558,7 @@ impl BlockMinerThread {
 
         let signature = coordinator.run_sign_v0(
             new_block,
-            &tip,
+            &self.burn_block,
             &self.burnchain,
             &sort_db,
             &mut chain_state,
@@ -1201,6 +1185,7 @@ impl BlockMinerThread {
     }
 
     /// Check if the tenure needs to change -- if so, return a BurnchainTipChanged error
+    /// The tenure should change if there is a new burnchain tip with a valid sortition
     fn check_burn_tip_changed(&self, sortdb: &SortitionDB) -> Result<(), NakamotoNodeError> {
         let cur_burn_chain_tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn())
             .expect("FATAL: failed to query sortition DB for canonical burn chain tip");
diff --git a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs
index f570009be5..2694d1d9ca 100644
--- a/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs
+++ b/testnet/stacks-node/src/nakamoto_node/sign_coordinator.rs
@@ -251,11 +251,11 @@ impl SignCoordinator {
     }
 
     /// Check if the tenure needs to change
-    fn check_burn_tip_changed(sortdb: &SortitionDB, consensus_hash: &ConsensusHash) -> bool {
+    fn check_burn_tip_changed(sortdb: &SortitionDB, burn_block: &BlockSnapshot) -> bool {
         let cur_burn_chain_tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn())
             .expect("FATAL: failed to query sortition DB for canonical burn chain tip");
 
-        if cur_burn_chain_tip.consensus_hash != *consensus_hash {
+        if cur_burn_chain_tip.consensus_hash != burn_block.consensus_hash {
             info!("SignCoordinator: Cancel signature aggregation; burnchain tip has changed");
             true
         } else {
@@ -365,7 +365,7 @@ impl SignCoordinator {
                 return Ok(stored_block.header.signer_signature);
             }
 
-            if Self::check_burn_tip_changed(&sortdb, &burn_tip.consensus_hash) {
+            if Self::check_burn_tip_changed(&sortdb, &burn_tip) {
                 debug!("SignCoordinator: Exiting due to new burnchain tip");
                 return Err(NakamotoNodeError::BurnchainTipChanged);
             }
diff --git a/testnet/stacks-node/src/node.rs b/testnet/stacks-node/src/node.rs
index 39095a51d5..1895912ba5 100644
--- a/testnet/stacks-node/src/node.rs
+++ b/testnet/stacks-node/src/node.rs
@@ -340,7 +340,7 @@ impl Node {
         let mut event_dispatcher = EventDispatcher::new();
 
         for observer in &config.events_observers {
-            event_dispatcher.register_observer(observer);
+            event_dispatcher.register_observer(observer, config.get_working_dir());
         }
 
         let burnchain_config = config.get_burnchain();
diff --git a/testnet/stacks-node/src/run_loop/nakamoto.rs b/testnet/stacks-node/src/run_loop/nakamoto.rs
index 6e3222d99d..04afdd79ee 100644
--- a/testnet/stacks-node/src/run_loop/nakamoto.rs
+++ b/testnet/stacks-node/src/run_loop/nakamoto.rs
@@ -93,7 +93,7 @@ impl RunLoop {
 
         let mut event_dispatcher = EventDispatcher::new();
         for observer in config.events_observers.iter() {
-            event_dispatcher.register_observer(observer);
+            event_dispatcher.register_observer(observer, config.get_working_dir());
         }
 
         Self {
diff --git a/testnet/stacks-node/src/run_loop/neon.rs b/testnet/stacks-node/src/run_loop/neon.rs
index 331e7e597c..a18a61988b 100644
--- a/testnet/stacks-node/src/run_loop/neon.rs
+++ b/testnet/stacks-node/src/run_loop/neon.rs
@@ -236,7 +236,7 @@ impl RunLoop {
 
         let mut event_dispatcher = EventDispatcher::new();
         for observer in config.events_observers.iter() {
-            event_dispatcher.register_observer(observer);
+            event_dispatcher.register_observer(observer, config.get_working_dir());
         }
 
         Self {
diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
index 93f4ac4106..e13a2ae27d 100644
--- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs
+++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
@@ -70,6 +70,7 @@ use stacks::net::api::getstackers::GetStackersResponse;
 use stacks::net::api::postblock_proposal::{
     BlockValidateReject, BlockValidateResponse, NakamotoBlockProposal, ValidateRejectCode,
 };
+use stacks::types::chainstate::StacksBlockId;
 use stacks::util::hash::hex_bytes;
 use stacks::util_lib::boot::boot_code_id;
 use stacks::util_lib::signed_structured_data::pox4::{
@@ -5399,6 +5400,11 @@ fn check_block_heights() {
 
     let info = get_chain_info_result(&naka_conf).unwrap();
     info!("Chain info: {:?}", info);
+
+    // With the first Nakamoto block, the chain tip and the number of tenures
+    // must be the same (before Nakamoto every block counts as a tenure)
+    assert_eq!(info.tenure_height, info.stacks_tip_height);
+
     let mut last_burn_block_height;
     let mut last_stacks_block_height = info.stacks_tip_height as u128;
     let mut last_tenure_height = last_stacks_block_height as u128;
@@ -5532,6 +5538,9 @@ fn check_block_heights() {
         );
         last_tenure_height = bh1;
 
+        let info = get_chain_info_result(&naka_conf).unwrap();
+        assert_eq!(info.tenure_height, bh3 as u64);
+
         let sbh = heights3
             .get("stacks-block-height")
             .unwrap()
@@ -5640,6 +5649,9 @@ fn check_block_heights() {
                 "Tenure height should not have changed"
             );
 
+            let info = get_chain_info_result(&naka_conf).unwrap();
+            assert_eq!(info.tenure_height, bh3 as u64);
+
             let sbh = heights3
                 .get("stacks-block-height")
                 .unwrap()
@@ -5680,6 +5692,9 @@ fn check_block_heights() {
         "Should have mined 1 + (1 + interim_blocks_per_tenure) * tenure_count nakamoto blocks"
     );
 
+    let info = get_chain_info_result(&naka_conf).unwrap();
+    assert_eq!(info.tenure_height, block_height_pre_3_0 + tenure_count);
+
     coord_channel
         .lock()
         .expect("Mutex poisoned")
@@ -6921,10 +6936,10 @@ fn continue_tenure_extend() {
     // setup sender + recipient for a test stx transfer
     let sender_addr = tests::to_addr(&sender_sk);
     let send_amt = 1000;
-    let send_fee = 100;
+    let send_fee = 200;
     naka_conf.add_initial_balance(
         PrincipalData::from(sender_addr.clone()).to_string(),
-        send_amt * 2 + send_fee,
+        (send_amt + send_fee) * 20,
     );
     let sender_signer_sk = Secp256k1PrivateKey::new();
     let sender_signer_addr = tests::to_addr(&sender_signer_sk);
@@ -6934,6 +6949,7 @@ fn continue_tenure_extend() {
     );
     let recipient = PrincipalData::from(StacksAddress::burn_address(false));
     let stacker_sk = setup_stacker(&mut naka_conf);
+    let mut transfer_nonce = 0;
 
     test_observer::spawn();
     test_observer::register_any(&mut naka_conf);
@@ -7054,7 +7070,7 @@ fn continue_tenure_extend() {
     // Submit a TX
     let transfer_tx = make_stacks_transfer(
         &sender_sk,
-        0,
+        transfer_nonce,
         send_fee,
         naka_conf.burnchain.chain_id,
         &recipient,
@@ -7116,6 +7132,26 @@ fn continue_tenure_extend() {
     })
     .unwrap();
 
+    // Mine 3 nakamoto blocks
+    for i in 0..3 {
+        info!("Triggering Nakamoto blocks after extend ({})", i + 1);
+        transfer_nonce += 1;
+        let transfer_tx = make_stacks_transfer(
+            &sender_sk,
+            transfer_nonce,
+            send_fee,
+            naka_conf.burnchain.chain_id,
+            &recipient,
+            send_amt,
+        );
+        submit_tx(&http_origin, &transfer_tx);
+        wait_for(10, || {
+            let sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
+            Ok(sender_nonce >= transfer_nonce)
+        })
+        .expect("Timed out waiting for transfer TX to confirm");
+    }
+
     info!("Resuming commit ops to mine regular tenures.");
     test_skip_commit_op.0.lock().unwrap().replace(false);
 
@@ -7153,7 +7189,9 @@ fn continue_tenure_extend() {
     let mut tenure_extends = vec![];
     let mut tenure_block_founds = vec![];
     let mut transfer_tx_included = false;
+    let mut last_block_had_extend = false;
     for block in test_observer::get_blocks() {
+        let mut has_extend = false;
         for tx in block["transactions"].as_array().unwrap() {
             let raw_tx = tx["raw_tx"].as_str().unwrap();
             if raw_tx == &transfer_tx_hex {
@@ -7167,12 +7205,21 @@ fn continue_tenure_extend() {
             let parsed = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap();
             match &parsed.payload {
                 TransactionPayload::TenureChange(payload) => match payload.cause {
-                    TenureChangeCause::Extended => tenure_extends.push(parsed),
-                    TenureChangeCause::BlockFound => tenure_block_founds.push(parsed),
+                    TenureChangeCause::Extended => {
+                        has_extend = true;
+                        tenure_extends.push(parsed);
+                    }
+                    TenureChangeCause::BlockFound => {
+                        if last_block_had_extend {
+                            panic!("Expected a Nakamoto block to happen after tenure extend block");
+                        }
+                        tenure_block_founds.push(parsed);
+                    }
                 },
                 _ => {}
             };
         }
+        last_block_had_extend = has_extend;
     }
     assert!(
         !tenure_extends.is_empty(),
@@ -7220,6 +7267,147 @@ fn continue_tenure_extend() {
     run_loop_thread.join().unwrap();
 }
 
+fn get_block_times(
+    naka_conf: &Config,
+    sender_addr: &StacksAddress,
+    block_height: u128,
+    tenure_height: u128,
+) -> (u128, u128, u128, u128, u128, u128, u128) {
+    let contract0_name = "test-contract-0";
+    let contract1_name = "test-contract-1";
+    let contract3_name = "test-contract-3";
+
+    info!("Getting block times at block {block_height}, tenure {tenure_height}...");
+
+    let time0_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract0_name,
+        "get-time",
+        vec![&clarity::vm::Value::UInt(tenure_height)],
+    );
+    let time0 = time0_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time_now0_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract0_name,
+        "get-last-time",
+        vec![],
+    );
+    let time0_now = time_now0_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time1_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract1_name,
+        "get-time",
+        vec![&clarity::vm::Value::UInt(tenure_height)],
+    );
+    let time1 = time1_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time1_now_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract1_name,
+        "get-last-time",
+        vec![],
+    );
+    let time1_now = time1_now_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time3_tenure_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract3_name,
+        "get-tenure-time",
+        vec![&clarity::vm::Value::UInt(block_height)],
+    );
+    let time3_tenure = time3_tenure_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time3_block_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract3_name,
+        "get-block-time",
+        vec![&clarity::vm::Value::UInt(block_height)],
+    );
+    let time3_block = time3_block_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    let time3_now_tenure_value = call_read_only(
+        &naka_conf,
+        &sender_addr,
+        contract3_name,
+        "get-last-tenure-time",
+        vec![],
+    );
+    let time3_now_tenure = time3_now_tenure_value
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_u128()
+        .unwrap();
+
+    info!("Reported times:";
+        "time0" => time0,
+        "time0_now" => time0_now,
+        "time1" => time1,
+        "time1_now" => time1_now,
+        "time3_block" => time3_block,
+        "time3_tenure" => time3_tenure,
+        "time3_now_tenure" => time3_now_tenure
+    );
+
+    assert_eq!(
+        time0, time1,
+        "Time from pre- and post-epoch 3.0 contracts should match"
+    );
+    assert_eq!(
+        time0_now, time1_now,
+        "Time from pre- and post-epoch 3.0 contracts should match"
+    );
+    assert_eq!(time0_now, time1_now, "Time should match across contracts");
+
+    (
+        time0,
+        time0_now,
+        time1,
+        time1_now,
+        time3_tenure,
+        time3_block,
+        time3_now_tenure,
+    )
+}
+
 #[test]
 #[ignore]
 /// Verify the timestamps using `get-block-info?`, `get-stacks-block-info?`, and `get-tenure-info?`.
@@ -7232,6 +7420,7 @@ fn check_block_times() {
     let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None);
     let http_origin = format!("http://{}", &naka_conf.node.rpc_bind);
     naka_conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
+    naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1;
     let sender_sk = Secp256k1PrivateKey::new();
     let sender_signer_sk = Secp256k1PrivateKey::new();
     let sender_signer_addr = tests::to_addr(&sender_signer_sk);
@@ -7244,7 +7433,7 @@ fn check_block_times() {
     let deploy_fee = 3000;
     naka_conf.add_initial_balance(
         PrincipalData::from(sender_addr.clone()).to_string(),
-        3 * deploy_fee + (send_amt + send_fee) * 2,
+        3 * deploy_fee + (send_amt + send_fee) * 12,
     );
     naka_conf.add_initial_balance(
         PrincipalData::from(sender_signer_addr.clone()).to_string(),
@@ -7267,7 +7456,6 @@ fn check_block_times() {
     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();
@@ -7284,8 +7472,10 @@ fn check_block_times() {
 
     // Deploy this version with the Clarity 1 / 2 before epoch 3
     let contract0_name = "test-contract-0";
-    let contract_clarity1 =
-        "(define-read-only (get-time (height uint)) (get-block-info? time height))";
+    let contract_clarity1 = r#"
+        (define-read-only (get-time (height uint)) (get-block-info? time height))
+        (define-read-only (get-last-time) (get-block-info? time (- block-height u1)))
+    "#;
 
     let contract_tx0 = make_contract_publish(
         &sender_sk,
@@ -7312,6 +7502,19 @@ fn check_block_times() {
     info!("Nakamoto miner started...");
     blind_signer(&naka_conf, &signers, proposals_submitted);
 
+    let epochs = naka_conf.burnchain.epochs.clone().unwrap();
+    let epoch_3 = &epochs[StacksEpoch::find_epoch_by_id(&epochs, StacksEpochId::Epoch30).unwrap()];
+    let epoch_3_start = epoch_3.start_height;
+    let mut last_stacks_block_height = 0;
+    let mut last_tenure_height = 0;
+    next_block_and(&mut btc_regtest_controller, 60, || {
+        let info = get_chain_info_result(&naka_conf).unwrap();
+        last_stacks_block_height = info.stacks_tip_height as u128;
+        last_tenure_height = last_stacks_block_height + 1;
+        Ok(info.burn_block_height == epoch_3_start)
+    })
+    .unwrap();
+
     let time0_value = call_read_only(
         &naka_conf,
         &sender_addr,
@@ -7327,8 +7530,6 @@ fn check_block_times() {
         .unwrap();
     info!("Time from pre-epoch 3.0: {}", time0);
 
-    wait_for_first_naka_block_commit(60, &commits_submitted);
-
     // This version uses the Clarity 1 / 2 function
     let contract1_name = "test-contract-1";
     let contract_tx1 = make_contract_publish_versioned(
@@ -7345,9 +7546,11 @@ fn check_block_times() {
 
     // This version uses the Clarity 3 functions
     let contract3_name = "test-contract-3";
-    let contract_clarity3 =
-        "(define-read-only (get-block-time (height uint)) (get-stacks-block-info? time height))
-         (define-read-only (get-tenure-time (height uint)) (get-tenure-info? time height))";
+    let contract_clarity3 = r#"
+        (define-read-only (get-block-time (height uint)) (get-stacks-block-info? time height))
+        (define-read-only (get-tenure-time (height uint)) (get-tenure-info? time height))
+        (define-read-only (get-last-tenure-time) (get-tenure-info? time (- stacks-block-height u1)))
+    "#;
 
     let contract_tx3 = make_contract_publish(
         &sender_sk,
@@ -7360,258 +7563,150 @@ fn check_block_times() {
     submit_tx(&http_origin, &contract_tx3);
     sender_nonce += 1;
 
-    // sleep to ensure seconds have changed
-    thread::sleep(Duration::from_secs(3));
-
-    next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel)
-        .unwrap();
-
-    // make sure that the contracts are published
-    wait_for(30, || {
+    let mut stacks_block_height = 0;
+    wait_for(60, || {
         let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
-        Ok(cur_sender_nonce >= sender_nonce)
+        let info = get_chain_info_result(&naka_conf).unwrap();
+        stacks_block_height = info.stacks_tip_height as u128;
+        Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce)
     })
     .expect("Timed out waiting for contracts to publish");
+    last_stacks_block_height = stacks_block_height;
 
-    let info = get_chain_info_result(&naka_conf).unwrap();
-    info!("Chain info: {:?}", info.stacks_tip_height);
-    let last_stacks_block_height = info.stacks_tip_height as u128;
-    let last_tenure_height = last_stacks_block_height as u128;
-
-    let time0_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let time0 = time0_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
+    // Repeat these tests for 5 tenures
+    for _ in 0..5 {
+        next_block_and(&mut btc_regtest_controller, 60, || {
+            let info = get_chain_info_result(&naka_conf).unwrap();
+            stacks_block_height = info.stacks_tip_height as u128;
+            Ok(stacks_block_height > last_stacks_block_height)
+        })
         .unwrap();
+        last_stacks_block_height = stacks_block_height;
+        last_tenure_height += 1;
+        info!("New tenure {last_tenure_height}, Stacks height: {last_stacks_block_height}");
 
-    let time1_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let time1 = time1_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert_eq!(
-        time0, time1,
-        "Time from pre- and post-epoch 3.0 contracts should match"
-    );
+        let (time0, time0_now, _time1, _time1_now, time3_tenure, time3_block, time3_now_tenure) =
+            get_block_times(
+                &naka_conf,
+                &sender_addr,
+                last_stacks_block_height - 1,
+                last_tenure_height - 1,
+            );
 
-    let time3_tenure_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-tenure-time",
-        vec![&clarity::vm::Value::UInt(last_tenure_height - 2)],
-    );
-    let time3_tenure = time3_tenure_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert_eq!(
-        time0, time3_tenure,
-        "Tenure time should match Clarity 2 block time"
-    );
+        assert_eq!(
+            time0, time3_tenure,
+            "Tenure time should match Clarity 2 block time"
+        );
+        assert_eq!(
+            time0_now, time3_now_tenure,
+            "Clarity 3 tenure time should match Clarity 2 block time in the first block of a tenure"
+        );
 
-    let time3_block_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let time3_block = time3_block_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
+        // Mine a Nakamoto block
+        info!("Mining Nakamoto block");
 
-    // Sleep to ensure the seconds have changed
-    thread::sleep(Duration::from_secs(2));
+        // submit a tx so that the miner will mine an extra block
+        let transfer_tx = make_stacks_transfer(
+            &sender_sk,
+            sender_nonce,
+            send_fee,
+            naka_conf.burnchain.chain_id,
+            &recipient,
+            send_amt,
+        );
+        sender_nonce += 1;
+        submit_tx(&http_origin, &transfer_tx);
 
-    // Mine a Nakamoto block
-    info!("Mining Nakamoto block");
+        // wait for the block to be mined
+        wait_for(30, || {
+            let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
+            let info = get_chain_info_result(&naka_conf).unwrap();
+            stacks_block_height = info.stacks_tip_height as u128;
+            Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce)
+        })
+        .expect("Timed out waiting for block");
+        last_stacks_block_height = stacks_block_height;
+
+        info!("New Stacks block {last_stacks_block_height} in tenure {last_tenure_height}");
+
+        let (
+            time0a,
+            _time0a_now,
+            _time1a,
+            _time1a_now,
+            _time3a_tenure,
+            time3a_block,
+            time3a_now_tenure,
+        ) = get_block_times(
+            &naka_conf,
+            &sender_addr,
+            last_stacks_block_height - 1,
+            last_tenure_height - 1,
+        );
 
-    // submit a tx so that the miner will mine an extra block
-    let transfer_tx = make_stacks_transfer(
-        &sender_sk,
-        sender_nonce,
-        send_fee,
-        naka_conf.burnchain.chain_id,
-        &recipient,
-        send_amt,
-    );
-    sender_nonce += 1;
-    submit_tx(&http_origin, &transfer_tx);
+        assert_eq!(
+            time0a, time0,
+            "get-block-info? time should not have changed"
+        );
+        assert!(
+            time3a_block - time3_block >= 1,
+            "get-stacks-block-info? time should have changed"
+        );
 
-    // make sure that the contracts are published
-    wait_for(30, || {
-        let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
-        Ok(cur_sender_nonce >= sender_nonce)
-    })
-    .expect("Timed out waiting for transfer to complete");
-
-    let info = get_chain_info_result(&naka_conf).unwrap();
-    info!("Chain info: {:?}", info.stacks_tip_height);
-    let last_stacks_block_height = info.stacks_tip_height as u128;
+        // Mine a Nakamoto block
+        info!("Mining Nakamoto block");
 
-    let time0a_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let time0a = time0a_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert!(
-        time0a - time0 >= 1,
-        "get-block-info? time should have changed. time_0 = {time0}. time_0_a = {time0a}"
-    );
-
-    let time1a_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let time1a = time1a_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert_eq!(
-        time0a, time1a,
-        "Time from pre- and post-epoch 3.0 contracts should match"
-    );
-
-    let time3a_block_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let time3a_block = time3a_block_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert!(
-        time3a_block - time3_block >= 1,
-        "get-stacks-block-info? time should have changed"
-    );
-
-    // Sleep to ensure the seconds have changed
-    thread::sleep(Duration::from_secs(1));
-
-    // Mine a Nakamoto block
-    info!("Mining Nakamoto block");
-    let blocks_processed_before = coord_channel
-        .lock()
-        .expect("Mutex poisoned")
-        .get_stacks_blocks_processed();
+        // submit a tx so that the miner will mine an extra block
+        let transfer_tx = make_stacks_transfer(
+            &sender_sk,
+            sender_nonce,
+            send_fee,
+            naka_conf.burnchain.chain_id,
+            &recipient,
+            send_amt,
+        );
+        submit_tx(&http_origin, &transfer_tx);
+        sender_nonce += 1;
 
-    // submit a tx so that the miner will mine an extra block
-    let transfer_tx = make_stacks_transfer(
-        &sender_sk,
-        sender_nonce,
-        send_fee,
-        naka_conf.burnchain.chain_id,
-        &recipient,
-        send_amt,
-    );
-    submit_tx(&http_origin, &transfer_tx);
+        // wait for the block to be mined
+        wait_for(30, || {
+            let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
+            let info = get_chain_info_result(&naka_conf).unwrap();
+            stacks_block_height = info.stacks_tip_height as u128;
+            Ok(stacks_block_height > last_stacks_block_height && cur_sender_nonce == sender_nonce)
+        })
+        .expect("Timed out waiting for block");
+        last_stacks_block_height = stacks_block_height;
+
+        let (
+            time0b,
+            _time0b_now,
+            _time1b,
+            _time1b_now,
+            _time3b_tenure,
+            time3b_block,
+            time3b_now_tenure,
+        ) = get_block_times(
+            &naka_conf,
+            &sender_addr,
+            last_stacks_block_height - 1,
+            last_tenure_height - 1,
+        );
 
-    loop {
-        let blocks_processed = coord_channel
-            .lock()
-            .expect("Mutex poisoned")
-            .get_stacks_blocks_processed();
-        if blocks_processed > blocks_processed_before {
-            break;
-        }
-        thread::sleep(Duration::from_millis(100));
+        assert_eq!(
+            time0b, time0a,
+            "get-block-info? time should not have changed"
+        );
+        assert!(
+            time3b_block - time3a_block >= 1,
+            "get-stacks-block-info? time should have changed"
+        );
+        assert_eq!(
+            time3b_now_tenure, time3a_now_tenure,
+            "get-tenure-info? time should not have changed"
+        );
     }
 
-    let time0b_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height)],
-    );
-    let time0b = time0b_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert_eq!(
-        time0a, time0b,
-        "get-block-info? time should not have changed"
-    );
-
-    let time1b_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height)],
-    );
-    let time1b = time1b_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-    assert_eq!(
-        time0b, time1b,
-        "Time from pre- and post-epoch 3.0 contracts should match"
-    );
-
-    let time3b_block_value = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-time",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height)],
-    );
-    let time3b_block = time3b_block_value
-        .expect_optional()
-        .unwrap()
-        .unwrap()
-        .expect_u128()
-        .unwrap();
-
-    assert!(
-        time3b_block - time3a_block >= 1,
-        "get-stacks-block-info? time should have changed"
-    );
-
     coord_channel
         .lock()
         .expect("Mutex poisoned")
@@ -7626,6 +7721,8 @@ fn assert_block_info(
     miner: &Value,
     miner_spend: &clarity::vm::Value,
 ) {
+    info!("block info tuple data: {tuple0:#?}");
+
     assert!(tuple0
         .get("burnchain-header-hash")
         .unwrap()
@@ -7700,6 +7797,17 @@ fn assert_block_info(
     );
 }
 
+fn parse_block_id(optional_buff32: &Value) -> StacksBlockId {
+    let bytes = optional_buff32
+        .clone()
+        .expect_optional()
+        .unwrap()
+        .unwrap()
+        .expect_buff(32)
+        .unwrap();
+    StacksBlockId::from_vec(&bytes).unwrap()
+}
+
 #[test]
 #[ignore]
 /// Verify all properties in `get-block-info?`, `get-stacks-block-info?`, and `get-tenure-info?`.
@@ -7710,6 +7818,8 @@ fn check_block_info() {
 
     let mut signers = TestSigners::default();
     let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None);
+    // change the chain id so that it isn't the same as primary testnet
+    naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1;
     let http_origin = format!("http://{}", &naka_conf.node.rpc_bind);
     naka_conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
     let sender_sk = Secp256k1PrivateKey::new();
@@ -7732,6 +7842,7 @@ fn check_block_info() {
     );
     let recipient = PrincipalData::from(StacksAddress::burn_address(false));
     let stacker_sk = setup_stacker(&mut naka_conf);
+    let contract3_name = "test-contract-3";
 
     test_observer::spawn();
     test_observer::register_any(&mut naka_conf);
@@ -7762,6 +7873,36 @@ fn check_block_info() {
 
     let mut sender_nonce = 0;
 
+    let get_block_info = |contract_name: &str, query_height: u128| {
+        let result = call_read_only(
+            &naka_conf,
+            &sender_addr,
+            contract_name,
+            "get-block-info",
+            vec![&clarity::vm::Value::UInt(query_height)],
+        );
+        result.expect_tuple().unwrap().data_map
+    };
+
+    let get_tenure_info = |query_height: u128| {
+        let result = call_read_only(
+            &naka_conf,
+            &sender_addr,
+            contract3_name,
+            "get-tenure-info",
+            vec![&clarity::vm::Value::UInt(query_height)],
+        );
+        result.expect_tuple().unwrap().data_map
+    };
+
+    let (chainstate, _) = StacksChainState::open(
+        naka_conf.is_mainnet(),
+        naka_conf.burnchain.chain_id,
+        &naka_conf.get_chainstate_path_str(),
+        None,
+    )
+    .unwrap();
+
     let miner = clarity::vm::Value::Principal(
         PrincipalData::parse_standard_principal("ST25WA53N4PWF8XZGQH2J5A4CGCWV4JADPM8MHTRV")
             .unwrap()
@@ -7771,7 +7912,7 @@ fn check_block_info() {
 
     // Deploy this version with the Clarity 1 / 2 before epoch 3
     let contract0_name = "test-contract-0";
-    let contract_clarity1 = "(define-read-only (get-info (height uint))
+    let contract_clarity1 = "(define-read-only (get-block-info (height uint))
             {
                 burnchain-header-hash: (get-block-info? burnchain-header-hash height),
                 id-header-hash: (get-block-info? id-header-hash height),
@@ -7784,6 +7925,25 @@ fn check_block_info() {
                 miner-spend-winner: (get-block-info? miner-spend-winner height),
             }
         )";
+    // This version uses the Clarity 3 functions
+    let contract_clarity3 = "(define-read-only (get-block-info (height uint))
+            {
+                id-header-hash: (get-stacks-block-info? id-header-hash height),
+                header-hash: (get-stacks-block-info? header-hash height),
+                time: (get-stacks-block-info? time height),
+            }
+        )
+        (define-read-only (get-tenure-info (height uint))
+            {
+                burnchain-header-hash: (get-tenure-info? burnchain-header-hash height),
+                miner-address: (get-tenure-info? miner-address height),
+                time: (get-tenure-info? time height),
+                vrf-seed: (get-tenure-info? vrf-seed height),
+                block-reward: (get-tenure-info? block-reward height),
+                miner-spend-total: (get-tenure-info? miner-spend-total height),
+                miner-spend-winner: (get-tenure-info? miner-spend-winner height),
+            }
+        )";
 
     let contract_tx0 = make_contract_publish(
         &sender_sk,
@@ -7805,20 +7965,13 @@ fn check_block_info() {
         &mut btc_regtest_controller,
     );
 
-    info!("Bootstrapped to Epoch-3.0 boundary, starting nakamoto miner");
+    let info = get_chain_info(&naka_conf);
+    let last_pre_nakamoto_block_height = info.stacks_tip_height.into();
 
-    info!("Nakamoto miner started...");
     blind_signer(&naka_conf, &signers, proposals_submitted);
 
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(1)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
-    info!("Info from pre-epoch 3.0: {:?}", tuple0);
+    let c0_block_ht_1_pre_3 = get_block_info(contract0_name, 1);
+    info!("Info from pre-epoch 3.0: {:?}", c0_block_ht_1_pre_3);
 
     wait_for_first_naka_block_commit(60, &commits_submitted);
 
@@ -7836,27 +7989,6 @@ fn check_block_info() {
     sender_nonce += 1;
     submit_tx(&http_origin, &contract_tx1);
 
-    // This version uses the Clarity 3 functions
-    let contract3_name = "test-contract-3";
-    let contract_clarity3 = "(define-read-only (get-block-info (height uint))
-            {
-                id-header-hash: (get-stacks-block-info? id-header-hash height),
-                header-hash: (get-stacks-block-info? header-hash height),
-                time: (get-stacks-block-info? time height),
-            }
-        )
-        (define-read-only (get-tenure-info (height uint))
-            {
-                burnchain-header-hash: (get-tenure-info? burnchain-header-hash height),
-                miner-address: (get-tenure-info? miner-address height),
-                time: (get-tenure-info? time height),
-                vrf-seed: (get-tenure-info? vrf-seed height),
-                block-reward: (get-tenure-info? block-reward height),
-                miner-spend-total: (get-tenure-info? miner-spend-total height),
-                miner-spend-winner: (get-tenure-info? miner-spend-winner height),
-            }
-        )";
-
     let contract_tx3 = make_contract_publish(
         &sender_sk,
         sender_nonce,
@@ -7869,8 +8001,6 @@ fn check_block_info() {
     submit_tx(&http_origin, &contract_tx3);
 
     // sleep to ensure seconds have changed
-    thread::sleep(Duration::from_secs(3));
-
     next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel)
         .unwrap();
 
@@ -7881,93 +8011,148 @@ fn check_block_info() {
     })
     .expect("Timed out waiting for contracts to publish");
 
-    let info = get_chain_info_result(&naka_conf).unwrap();
-    info!("Chain info: {:?}", info);
-    let last_stacks_block_height = info.stacks_tip_height as u128;
+    // the first test we want to do is around the behavior of
+    //  looking up 2.x blocks.
+
+    // look up block height 1 with all 3 contracts after nakamoto activates
+    let c0_block_ht_1_post_3 = get_block_info(contract0_name, 1);
+    let c1_block_ht_1_post_3 = get_block_info(contract1_name, 1);
+    let c3_block_ht_1_post_3 = get_block_info(contract3_name, 1);
+    assert_eq!(c0_block_ht_1_post_3, c0_block_ht_1_pre_3);
+    assert_eq!(c0_block_ht_1_post_3, c1_block_ht_1_post_3);
+    for (key, value) in c3_block_ht_1_post_3.iter() {
+        assert_eq!(&c0_block_ht_1_post_3[key], value);
+    }
 
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
-    assert_block_info(&tuple0, &miner, &miner_spend);
+    // look up last 2.x height with all 3 contracts
+    let c0_last_2x_block = get_block_info(contract0_name, last_pre_nakamoto_block_height);
+    let c1_last_2x_block = get_block_info(contract1_name, last_pre_nakamoto_block_height);
+    let c3_last_2x_block = get_block_info(contract3_name, last_pre_nakamoto_block_height);
+    assert_eq!(c0_last_2x_block, c1_last_2x_block);
+    for (key, value) in c3_last_2x_block.iter() {
+        assert_eq!(&c0_last_2x_block[key], value);
+    }
 
-    let result1 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let tuple1 = result1.expect_tuple().unwrap().data_map;
-    assert_eq!(tuple0, tuple1);
+    // now we want to test the behavior of the first block in a tenure
+    // so, we'll issue a bitcoin block, and not submit any transactions
+    // (which will keep the miner from issuing any blocks after the first
+    //  one in the tenure)
 
-    let result3_tenure = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-tenure-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let tuple3_tenure0 = result3_tenure.expect_tuple().unwrap().data_map;
-    assert_eq!(
-        tuple3_tenure0.get("burnchain-header-hash"),
-        tuple0.get("burnchain-header-hash")
-    );
-    assert_eq!(
-        tuple3_tenure0.get("miner-address"),
-        tuple0.get("miner-address")
-    );
-    assert_eq!(tuple3_tenure0.get("time"), tuple0.get("time"));
-    assert_eq!(tuple3_tenure0.get("vrf-seed"), tuple0.get("vrf-seed"));
-    assert_eq!(
-        tuple3_tenure0.get("block-reward"),
-        tuple0.get("block-reward")
-    );
-    assert_eq!(
-        tuple3_tenure0.get("miner-spend-total"),
-        tuple0.get("miner-spend-total")
-    );
-    assert_eq!(
-        tuple3_tenure0.get("miner-spend-winner"),
-        tuple0.get("miner-spend-winner")
-    );
+    let info = get_chain_info(&naka_conf);
+    info!("Chain info: {:?}", info);
+    let last_stacks_block_height = info.stacks_tip_height as u128;
+    let last_stacks_tip = StacksBlockId::new(&info.stacks_tip_consensus_hash, &info.stacks_tip);
+    let last_tenure_height: u128 =
+        NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &last_stacks_tip)
+            .unwrap()
+            .unwrap()
+            .into();
+    let last_tenure_start_block_header = NakamotoChainState::get_tenure_start_block_header(
+        &mut chainstate.index_conn(),
+        &last_stacks_tip,
+        &info.stacks_tip_consensus_hash,
+    )
+    .unwrap()
+    .unwrap();
+    let last_tenure_start_block_id = last_tenure_start_block_header.index_block_hash();
+    let last_tenure_start_block_ht = last_tenure_start_block_header.stacks_block_height.into();
 
-    let result3_block = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 2)],
-    );
-    let tuple3_block1 = result3_block.expect_tuple().unwrap().data_map;
-    assert_eq!(
-        tuple3_block1.get("id-header-hash"),
-        tuple0.get("id-header-hash")
-    );
-    assert_eq!(tuple3_block1.get("header-hash"), tuple0.get("header-hash"));
-    assert!(tuple3_block1
-        .get("time")
+    // lets issue the next bitcoin block
+    next_block_and_process_new_stacks_block(&mut btc_regtest_controller, 60, &coord_channel)
+        .unwrap();
+
+    let info = get_chain_info(&naka_conf);
+    info!("Chain info: {:?}", info);
+    let cur_stacks_block_height = info.stacks_tip_height as u128;
+    let cur_stacks_tip = StacksBlockId::new(&info.stacks_tip_consensus_hash, &info.stacks_tip);
+    let cur_tenure_height: u128 =
+        NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &cur_stacks_tip)
+            .unwrap()
+            .unwrap()
+            .into();
+    let cur_tenure_start_block_id = NakamotoChainState::get_tenure_start_block_header(
+        &mut chainstate.index_conn(),
+        &cur_stacks_tip,
+        &info.stacks_tip_consensus_hash,
+    )
+    .unwrap()
+    .unwrap()
+    .index_block_hash();
+
+    assert_eq!(cur_tenure_start_block_id, cur_stacks_tip);
+    assert_eq!(cur_stacks_block_height, last_stacks_block_height + 1);
+    assert_eq!(cur_tenure_height, last_tenure_height + 1);
+
+    // first checks: get-block-info with the current tenure height should return None
+    let c0_cur_tenure = get_block_info(contract0_name, cur_tenure_height);
+    let c1_cur_tenure = get_block_info(contract1_name, cur_tenure_height);
+    // contract 3 uses the current stacks block height rather than current tenure.
+    let c3_cur_tenure = get_block_info(contract3_name, cur_stacks_block_height);
+    let c3_cur_tenure_ti = get_tenure_info(cur_stacks_block_height);
+    assert!(c0_cur_tenure["id-header-hash"]
+        .clone()
+        .expect_optional()
         .unwrap()
+        .is_none());
+    assert!(c1_cur_tenure["id-header-hash"]
         .clone()
         .expect_optional()
         .unwrap()
-        .is_some());
-
-    // Sleep to ensure the seconds have changed
-    thread::sleep(Duration::from_secs(1));
-
-    // Mine a Nakamoto block
-    info!("Mining Nakamoto block");
-    let blocks_processed_before = coord_channel
-        .lock()
-        .expect("Mutex poisoned")
-        .get_stacks_blocks_processed();
+        .is_none());
+    assert!(c3_cur_tenure["id-header-hash"]
+        .clone()
+        .expect_optional()
+        .unwrap()
+        .is_none());
+    assert!(c3_cur_tenure_ti["burnchain-header-hash"]
+        .clone()
+        .expect_optional()
+        .unwrap()
+        .is_none());
+
+    // second checks: get-block-info with prior tenure height should return Some
+    let c0_last_tenure = get_block_info(contract0_name, last_tenure_height);
+    let c1_last_tenure = get_block_info(contract1_name, last_tenure_height);
+    // contract 3 uses the current stacks block height rather than current tenure.
+    let c3_last_tenure_bi = get_block_info(contract3_name, last_stacks_block_height);
+    let c3_last_tenure_ti = get_tenure_info(last_stacks_block_height);
+    let c3_last_tenure_start_bi = get_block_info(contract3_name, last_tenure_start_block_ht);
+
+    // assert that c0 and c1 returned some data
+    assert_block_info(&c0_last_tenure, &miner, &miner_spend);
+    assert_block_info(&c1_last_tenure, &miner, &miner_spend);
+    assert_eq!(c0_last_tenure, c1_last_tenure);
+
+    let c3_fetched_id_hash = parse_block_id(&c3_last_tenure_bi["id-header-hash"]);
+    assert_eq!(c3_fetched_id_hash, last_stacks_tip);
+
+    // c0 and c1 should have different block info data than c3
+    assert_ne!(
+        c0_last_tenure["header-hash"],
+        c3_last_tenure_bi["header-hash"]
+    );
+    assert_ne!(
+        c0_last_tenure["id-header-hash"],
+        c3_last_tenure_bi["id-header-hash"]
+    );
+    assert_ne!(c0_last_tenure["time"], c3_last_tenure_bi["time"]);
+    // c0 and c1 should have the same burn data as the *tenure info* lookup in c3
+    for (key, value) in c3_last_tenure_ti.iter() {
+        assert_eq!(&c0_last_tenure[key], value);
+    }
+    // c0 and c1 should have the same header hash data as the *block info* lookup in c3 using last tenure start block ht
+    for key in ["header-hash", "id-header-hash"] {
+        assert_eq!(&c0_last_tenure[key], &c3_last_tenure_start_bi[key]);
+    }
+    // c0 should have the same index hash as last_tenure start block id
+    assert_eq!(
+        parse_block_id(&c0_last_tenure["id-header-hash"]),
+        last_tenure_start_block_id
+    );
 
-    // submit a tx so that the miner will mine an extra block
+    // Now we want to test the behavior of a new nakamoto block within the same tenure
+    // We'll force a nakamoto block by submitting a transfer, then waiting for the nonce to bump
+    info!("Mining an interim nakamoto block");
     let transfer_tx = make_stacks_transfer(
         &sender_sk,
         sender_nonce,
@@ -7979,108 +8164,94 @@ fn check_block_info() {
     sender_nonce += 1;
     submit_tx(&http_origin, &transfer_tx);
 
-    loop {
-        let blocks_processed = coord_channel
-            .lock()
-            .expect("Mutex poisoned")
-            .get_stacks_blocks_processed();
-        if blocks_processed > blocks_processed_before {
-            break;
-        }
-        thread::sleep(Duration::from_millis(100));
-    }
-
-    let info = get_chain_info_result(&naka_conf).unwrap();
-    info!("Chain info: {:?}", info);
-    let last_stacks_block_height = info.stacks_tip_height as u128;
-
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
-    assert_block_info(&tuple0, &miner, &miner_spend);
-
-    let result1 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let tuple1 = result1.expect_tuple().unwrap().data_map;
-    assert_eq!(tuple0, tuple1);
+    wait_for(30, || {
+        thread::sleep(Duration::from_secs(1));
+        let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
+        Ok(cur_sender_nonce >= sender_nonce)
+    })
+    .expect("Failed to process the submitted transfer tx in a new nakamoto block");
 
-    let result3_tenure = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-tenure-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let tuple3_tenure1 = result3_tenure.expect_tuple().unwrap().data_map;
-    // There should have been a tenure change, so these should be different.
-    assert_ne!(tuple3_tenure0, tuple3_tenure1);
+    let info = get_chain_info(&naka_conf);
+    let interim_stacks_block_height = info.stacks_tip_height as u128;
+    let interim_stacks_tip = StacksBlockId::new(&info.stacks_tip_consensus_hash, &info.stacks_tip);
+    let interim_tenure_height: u128 =
+        NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &interim_stacks_tip)
+            .unwrap()
+            .unwrap()
+            .into();
+    let interim_tenure_start_block_id = NakamotoChainState::get_tenure_start_block_header(
+        &mut chainstate.index_conn(),
+        &interim_stacks_tip,
+        &info.stacks_tip_consensus_hash,
+    )
+    .unwrap()
+    .unwrap()
+    .index_block_hash();
+    assert_eq!(interim_tenure_height, cur_tenure_height);
+    assert_eq!(interim_tenure_start_block_id, cur_tenure_start_block_id);
+    assert_eq!(interim_stacks_block_height, cur_stacks_block_height + 1);
+
+    // querying the same block heights that returned data before should yield the identical result
     assert_eq!(
-        tuple3_tenure1.get("burnchain-header-hash"),
-        tuple0.get("burnchain-header-hash")
+        c0_last_tenure,
+        get_block_info(contract0_name, last_tenure_height)
     );
     assert_eq!(
-        tuple3_tenure1.get("miner-address"),
-        tuple0.get("miner-address")
+        c1_last_tenure,
+        get_block_info(contract1_name, last_tenure_height)
     );
-    assert_eq!(tuple3_tenure1.get("time"), tuple0.get("time"));
-    assert_eq!(tuple3_tenure1.get("vrf-seed"), tuple0.get("vrf-seed"));
     assert_eq!(
-        tuple3_tenure1.get("block-reward"),
-        tuple0.get("block-reward")
+        c3_last_tenure_bi,
+        get_block_info(contract3_name, last_stacks_block_height)
     );
+    assert_eq!(c3_last_tenure_ti, get_tenure_info(last_stacks_block_height));
     assert_eq!(
-        tuple3_tenure1.get("miner-spend-total"),
-        tuple0.get("miner-spend-total")
-    );
+        c3_last_tenure_start_bi,
+        get_block_info(contract3_name, last_tenure_start_block_ht)
+    );
+
+    // querying for the current tenure should work now though
+    let c0_cur_tenure = get_block_info(contract0_name, cur_tenure_height);
+    let c1_cur_tenure = get_block_info(contract1_name, cur_tenure_height);
+    // contract 3 uses the current stacks block height rather than current tenure.
+    let c3_cur_tenure = get_block_info(contract3_name, cur_stacks_block_height);
+    let c3_cur_tenure_ti = get_tenure_info(cur_stacks_block_height);
+    assert_block_info(&c0_cur_tenure, &miner, &miner_spend);
+    assert_block_info(&c1_cur_tenure, &miner, &miner_spend);
+    assert_eq!(c0_cur_tenure, c1_cur_tenure);
+
+    // c0 and c1 should have the same header hash data as the *block info* lookup in c3 using cur_stacks_block
+    //  (because cur_stacks_tip == cur_tenure_start_block_id, as was asserted before)
+    for key in ["header-hash", "id-header-hash"] {
+        assert_eq!(&c0_cur_tenure[key], &c3_cur_tenure[key]);
+    }
+    // c0 should have the same index hash as cur_tenure start block id
     assert_eq!(
-        tuple3_tenure1.get("miner-spend-winner"),
-        tuple0.get("miner-spend-winner")
+        parse_block_id(&c0_cur_tenure["id-header-hash"]),
+        cur_tenure_start_block_id,
+        "c0 should have the same index hash as cur_tenure_start_block_id"
     );
+    // c0 and c1 should have the same burn data as the *tenure info* lookup in c3
+    for (key, value) in c3_cur_tenure_ti.iter() {
+        assert_eq!(&c0_cur_tenure[key], value);
+    }
 
-    let result3_block = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let tuple3_block2 = result3_block.expect_tuple().unwrap().data_map;
-    // There should have been a block change, so these should be different.
-    assert_ne!(tuple3_block1, tuple3_block2);
-    assert_eq!(
-        tuple3_block2.get("id-header-hash"),
-        tuple0.get("id-header-hash")
-    );
-    assert_eq!(tuple3_block2.get("header-hash"), tuple0.get("header-hash"));
-    assert!(tuple3_block2
-        .get("time")
+    let c3_interim_bi = get_block_info(contract3_name, interim_stacks_block_height);
+    let c3_interim_ti = get_tenure_info(interim_stacks_block_height);
+    assert!(c3_interim_bi["id-header-hash"]
+        .clone()
+        .expect_optional()
         .unwrap()
+        .is_none());
+    assert!(c3_interim_ti["burnchain-header-hash"]
         .clone()
         .expect_optional()
         .unwrap()
-        .is_some());
+        .is_none());
 
-    // Sleep to ensure the seconds have changed
-    thread::sleep(Duration::from_secs(1));
-
-    // Mine a Nakamoto block
-    info!("Mining Nakamoto block");
-    let blocks_processed_before = coord_channel
-        .lock()
-        .expect("Mutex poisoned")
-        .get_stacks_blocks_processed();
-
-    // submit a tx so that the miner will mine an extra block
+    // Now we'll mine one more interim block so that we can test that the stacks-block-info outputs update
+    //  again.
+    info!("Mining a second interim nakamoto block");
     let transfer_tx = make_stacks_transfer(
         &sender_sk,
         sender_nonce,
@@ -8089,75 +8260,72 @@ fn check_block_info() {
         &recipient,
         send_amt,
     );
+    sender_nonce += 1;
     submit_tx(&http_origin, &transfer_tx);
 
-    loop {
-        let blocks_processed = coord_channel
-            .lock()
-            .expect("Mutex poisoned")
-            .get_stacks_blocks_processed();
-        if blocks_processed > blocks_processed_before {
-            break;
-        }
-        thread::sleep(Duration::from_millis(100));
-    }
-
-    let info = get_chain_info_result(&naka_conf).unwrap();
-    info!("Chain info: {:?}", info);
-    let last_stacks_block_height = info.stacks_tip_height as u128;
-
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
-    assert_block_info(&tuple0, &miner, &miner_spend);
+    wait_for(30, || {
+        thread::sleep(Duration::from_secs(1));
+        let cur_sender_nonce = get_account(&http_origin, &to_addr(&sender_sk)).nonce;
+        Ok(cur_sender_nonce >= sender_nonce)
+    })
+    .expect("Failed to process the submitted transfer tx in a new nakamoto block");
 
-    let result1 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
+    let info = get_chain_info(&naka_conf);
+    assert_eq!(
+        info.stacks_tip_height as u128,
+        interim_stacks_block_height + 1
     );
-    let tuple1 = result1.expect_tuple().unwrap().data_map;
-    assert_eq!(tuple0, tuple1);
 
-    let result3_tenure = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-tenure-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
+    // querying for the current tenure should work the same as before
+    assert_eq!(
+        c0_cur_tenure,
+        get_block_info(contract0_name, cur_tenure_height)
     );
-    let tuple3_tenure1a = result3_tenure.expect_tuple().unwrap().data_map;
-    assert_eq!(tuple3_tenure1, tuple3_tenure1a);
-
-    let result3_block = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract3_name,
-        "get-block-info",
-        vec![&clarity::vm::Value::UInt(last_stacks_block_height - 1)],
+    assert_eq!(
+        c1_cur_tenure,
+        get_block_info(contract1_name, cur_tenure_height)
     );
-    let tuple3_block3 = result3_block.expect_tuple().unwrap().data_map;
-    // There should have been a block change, so these should be different.
-    assert_ne!(tuple3_block3, tuple3_block2);
+    // contract 3 uses the current stacks block height rather than current tenure.
     assert_eq!(
-        tuple3_block3.get("id-header-hash"),
-        tuple0.get("id-header-hash")
+        c3_cur_tenure,
+        get_block_info(contract3_name, cur_stacks_block_height)
     );
-    assert_eq!(tuple3_block3.get("header-hash"), tuple0.get("header-hash"));
-    assert!(tuple3_block3
-        .get("time")
+    assert_eq!(c3_cur_tenure_ti, get_tenure_info(cur_stacks_block_height));
+
+    // querying using the first interim's block height should now work in contract 3
+    let c3_interim_bi = get_block_info(contract3_name, interim_stacks_block_height);
+    let c3_interim_ti = get_tenure_info(interim_stacks_block_height);
+
+    // it will *not* work in contracts 1 and 2
+    let c0_interim = get_block_info(contract0_name, interim_stacks_block_height);
+    let c1_interim = get_block_info(contract1_name, interim_stacks_block_height);
+    assert!(c0_interim["id-header-hash"]
+        .clone()
+        .expect_optional()
         .unwrap()
+        .is_none());
+    assert!(c1_interim["id-header-hash"]
         .clone()
         .expect_optional()
         .unwrap()
-        .is_some());
+        .is_none());
+
+    assert_eq!(c3_interim_ti, c3_cur_tenure_ti, "Tenure info should be the same whether queried using the starting block or the interim block height");
+
+    // c0 and c1 should have different block info data than the interim block
+    assert_ne!(c0_cur_tenure["header-hash"], c3_interim_bi["header-hash"]);
+    assert_ne!(
+        c0_cur_tenure["id-header-hash"],
+        c3_interim_bi["id-header-hash"]
+    );
+    assert_ne!(c0_cur_tenure["time"], c3_interim_bi["time"]);
+
+    // c3 should have gotten the interim's tip
+    assert_eq!(
+        parse_block_id(&c3_interim_bi["id-header-hash"]),
+        interim_stacks_tip,
+        "Contract 3 should be able to fetch the StacksBlockId of the tip"
+    );
 
     coord_channel
         .lock()
@@ -8243,6 +8411,7 @@ fn check_block_info_rewards() {
     let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None);
     let http_origin = format!("http://{}", &naka_conf.node.rpc_bind);
     naka_conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
+    naka_conf.burnchain.chain_id = CHAIN_ID_TESTNET + 1;
     let sender_sk = Secp256k1PrivateKey::new();
     let sender_signer_sk = Secp256k1PrivateKey::new();
     let sender_signer_addr = tests::to_addr(&sender_signer_sk);
@@ -8295,7 +8464,7 @@ fn check_block_info_rewards() {
 
     // Deploy this version with the Clarity 1 / 2 before epoch 3
     let contract0_name = "test-contract-0";
-    let contract_clarity1 = "(define-read-only (get-info (height uint))
+    let contract_clarity1 = "(define-read-only (get-block-info (height uint))
             {
                 burnchain-header-hash: (get-block-info? burnchain-header-hash height),
                 id-header-hash: (get-block-info? id-header-hash height),
@@ -8320,6 +8489,17 @@ fn check_block_info_rewards() {
     sender_nonce += 1;
     submit_tx(&http_origin, &contract_tx0);
 
+    let get_block_info = |contract_name: &str, query_height: u128| {
+        let result = call_read_only(
+            &naka_conf,
+            &sender_addr,
+            contract_name,
+            "get-block-info",
+            vec![&clarity::vm::Value::UInt(query_height)],
+        );
+        result.expect_tuple().unwrap().data_map
+    };
+
     boot_to_epoch_3(
         &naka_conf,
         &blocks_processed,
@@ -8334,14 +8514,7 @@ fn check_block_info_rewards() {
     info!("Nakamoto miner started...");
     blind_signer(&naka_conf, &signers, proposals_submitted);
 
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(1)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
+    let tuple0 = get_block_info(contract0_name, 1);
     info!("Info from pre-epoch 3.0: {:?}", tuple0);
 
     wait_for_first_naka_block_commit(60, &commits_submitted);
@@ -8455,8 +8628,22 @@ fn check_block_info_rewards() {
 
     let info = get_chain_info_result(&naka_conf).unwrap();
     info!("Chain info: {:?}", info);
+    let (chainstate, _) = StacksChainState::open(
+        naka_conf.is_mainnet(),
+        naka_conf.burnchain.chain_id,
+        &naka_conf.get_chainstate_path_str(),
+        None,
+    )
+    .unwrap();
+
     let last_stacks_block_height = info.stacks_tip_height as u128;
     let last_nakamoto_block = last_stacks_block_height;
+    let last_stacks_tip = StacksBlockId::new(&info.stacks_tip_consensus_hash, &info.stacks_tip);
+    let last_nakamoto_block_tenure_height: u128 =
+        NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &last_stacks_tip)
+            .unwrap()
+            .unwrap()
+            .into();
 
     // Mine more than 2 burn blocks to get the last block's reward matured
     // (only 2 blocks maturation time in tests)
@@ -8477,36 +8664,32 @@ fn check_block_info_rewards() {
     let last_stacks_block_height = info.stacks_tip_height as u128;
     let blocks = test_observer::get_blocks();
 
+    let last_stacks_tip = StacksBlockId::new(&info.stacks_tip_consensus_hash, &info.stacks_tip);
+    let last_tenure_height: u128 =
+        NakamotoChainState::get_coinbase_height(&mut chainstate.index_conn(), &last_stacks_tip)
+            .unwrap()
+            .unwrap()
+            .into();
+
     // Check the block reward is now matured in one of the tenure-change blocks
     let mature_height = last_stacks_block_height - 4;
     let expected_reward = get_expected_reward_for_height(&blocks, mature_height);
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(mature_height)],
+    let tuple0 = get_block_info(contract0_name, last_tenure_height - 4);
+    info!(
+        "block rewards";
+        "fetched" => %tuple0["block-reward"],
+        "expected" => expected_reward,
     );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
     assert_eq!(
-        tuple0
-            .get("block-reward")
-            .unwrap()
+        tuple0["block-reward"]
             .clone()
             .expect_optional()
             .unwrap()
             .unwrap(),
-        Value::UInt(expected_reward as u128)
+        Value::UInt(expected_reward)
     );
 
-    let result1 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(mature_height)],
-    );
-    let tuple1 = result1.expect_tuple().unwrap().data_map;
+    let tuple1 = get_block_info(contract1_name, last_tenure_height - 4);
     assert_eq!(tuple0, tuple1);
 
     let result3_tenure = call_read_only(
@@ -8517,41 +8700,23 @@ fn check_block_info_rewards() {
         vec![&clarity::vm::Value::UInt(mature_height)],
     );
     let tuple3_tenure = result3_tenure.expect_tuple().unwrap().data_map;
-    assert_eq!(
-        tuple3_tenure.get("block-reward"),
-        tuple0.get("block-reward")
-    );
+    assert_eq!(tuple3_tenure["block-reward"], tuple0["block-reward"]);
 
     // Check the block reward is now matured in one of the Nakamoto blocks
     let expected_reward = get_expected_reward_for_height(&blocks, last_nakamoto_block);
 
-    let result0 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract0_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_nakamoto_block)],
-    );
-    let tuple0 = result0.expect_tuple().unwrap().data_map;
+    let tuple0 = get_block_info(contract0_name, last_nakamoto_block_tenure_height);
+
     assert_eq!(
-        tuple0
-            .get("block-reward")
-            .unwrap()
+        tuple0["block-reward"]
             .clone()
             .expect_optional()
             .unwrap()
             .unwrap(),
-        Value::UInt(expected_reward as u128)
+        Value::UInt(expected_reward)
     );
 
-    let result1 = call_read_only(
-        &naka_conf,
-        &sender_addr,
-        contract1_name,
-        "get-info",
-        vec![&clarity::vm::Value::UInt(last_nakamoto_block)],
-    );
-    let tuple1 = result1.expect_tuple().unwrap().data_map;
+    let tuple1 = get_block_info(contract1_name, last_nakamoto_block_tenure_height);
     assert_eq!(tuple0, tuple1);
 
     let result3_tenure = call_read_only(
@@ -8562,10 +8727,7 @@ fn check_block_info_rewards() {
         vec![&clarity::vm::Value::UInt(last_nakamoto_block)],
     );
     let tuple3_tenure = result3_tenure.expect_tuple().unwrap().data_map;
-    assert_eq!(
-        tuple3_tenure.get("block-reward"),
-        tuple0.get("block-reward")
-    );
+    assert_eq!(tuple3_tenure["block-reward"], tuple0["block-reward"]);
 
     coord_channel
         .lock()
diff --git a/testnet/stacks-node/src/tests/signer/v0.rs b/testnet/stacks-node/src/tests/signer/v0.rs
index 89192d274d..753883bead 100644
--- a/testnet/stacks-node/src/tests/signer/v0.rs
+++ b/testnet/stacks-node/src/tests/signer/v0.rs
@@ -34,6 +34,7 @@ use stacks::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader, NakamotoC
 use stacks::chainstate::stacks::address::PoxAddress;
 use stacks::chainstate::stacks::boot::MINERS_NAME;
 use stacks::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState, StacksHeaderInfo};
+use stacks::chainstate::stacks::{StacksTransaction, TenureChangeCause, TransactionPayload};
 use stacks::codec::StacksMessageCodec;
 use stacks::core::{StacksEpochId, CHAIN_ID_TESTNET};
 use stacks::libstackerdb::StackerDBChunkData;
@@ -42,7 +43,7 @@ use stacks::net::api::postblock_proposal::{ValidateRejectCode, TEST_VALIDATE_STA
 use stacks::net::relay::fault_injection::set_ignore_block;
 use stacks::types::chainstate::{StacksAddress, StacksBlockId, StacksPrivateKey, StacksPublicKey};
 use stacks::types::PublicKey;
-use stacks::util::hash::MerkleHashFunc;
+use stacks::util::hash::{hex_bytes, MerkleHashFunc};
 use stacks::util::secp256k1::{Secp256k1PrivateKey, Secp256k1PublicKey};
 use stacks::util_lib::boot::boot_code_id;
 use stacks::util_lib::signed_structured_data::pox4::{
@@ -78,7 +79,7 @@ use crate::tests::neon_integrations::{
     get_account, get_chain_info, get_chain_info_opt, next_block_and_wait,
     run_until_burnchain_height, submit_tx, submit_tx_fallible, test_observer,
 };
-use crate::tests::{self, make_stacks_transfer};
+use crate::tests::{self, gen_random_port, make_stacks_transfer};
 use crate::{nakamoto_node, BitcoinRegtestController, BurnchainController, Config, Keychain};
 
 impl SignerTest<SpawnedSigner> {
@@ -1449,10 +1450,10 @@ fn multiple_miners() {
     let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
     let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
 
-    let node_1_rpc = 51024;
-    let node_1_p2p = 51023;
-    let node_2_rpc = 51026;
-    let node_2_p2p = 51025;
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
 
     let localhost = "127.0.0.1";
     let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
@@ -1735,13 +1736,14 @@ fn miner_forking() {
     let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
     let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
 
-    let node_1_rpc = 51024;
-    let node_1_p2p = 51023;
-    let node_2_rpc = 51026;
-    let node_2_p2p = 51025;
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
 
-    let node_1_rpc_bind = format!("127.0.0.1:{}", node_1_rpc);
-    let node_2_rpc_bind = format!("127.0.0.1:{}", node_2_rpc);
+    let localhost = "127.0.0.1";
+    let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
+    let node_2_rpc_bind = format!("{localhost}:{node_2_rpc}");
     let mut node_2_listeners = Vec::new();
 
     // partition the signer set so that ~half are listening and using node 1 for RPC and events,
@@ -1764,11 +1766,10 @@ fn miner_forking() {
                 Duration::from_secs(first_proposal_burn_block_timing);
         },
         |config| {
-            let localhost = "127.0.0.1";
-            config.node.rpc_bind = format!("{}:{}", localhost, node_1_rpc);
-            config.node.p2p_bind = format!("{}:{}", localhost, node_1_p2p);
-            config.node.data_url = format!("http://{}:{}", localhost, node_1_rpc);
-            config.node.p2p_address = format!("{}:{}", localhost, node_1_p2p);
+            config.node.rpc_bind = format!("{localhost}:{node_1_rpc}");
+            config.node.p2p_bind = format!("{localhost}:{node_1_p2p}");
+            config.node.data_url = format!("http://{localhost}:{node_1_rpc}");
+            config.node.p2p_address = format!("{localhost}:{node_1_p2p}");
 
             config.node.seed = btc_miner_1_seed.clone();
             config.node.local_peer_seed = btc_miner_1_seed.clone();
@@ -2744,13 +2745,14 @@ fn multiple_miners_mock_sign_epoch_25() {
     let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
     let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
 
-    let node_1_rpc = 51024;
-    let node_1_p2p = 51023;
-    let node_2_rpc = 51026;
-    let node_2_p2p = 51025;
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
 
-    let node_1_rpc_bind = format!("127.0.0.1:{}", node_1_rpc);
-    let node_2_rpc_bind = format!("127.0.0.1:{}", node_2_rpc);
+    let localhost = "127.0.0.1";
+    let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
+    let node_2_rpc_bind = format!("{localhost}:{node_2_rpc}");
     let mut node_2_listeners = Vec::new();
 
     // partition the signer set so that ~half are listening and using node 1 for RPC and events,
@@ -2768,11 +2770,10 @@ fn multiple_miners_mock_sign_epoch_25() {
             signer_config.node_host = node_host.to_string();
         },
         |config| {
-            let localhost = "127.0.0.1";
-            config.node.rpc_bind = format!("{}:{}", localhost, node_1_rpc);
-            config.node.p2p_bind = format!("{}:{}", localhost, node_1_p2p);
-            config.node.data_url = format!("http://{}:{}", localhost, node_1_rpc);
-            config.node.p2p_address = format!("{}:{}", localhost, node_1_p2p);
+            config.node.rpc_bind = format!("{localhost}:{node_1_rpc}");
+            config.node.p2p_bind = format!("{localhost}:{node_1_p2p}");
+            config.node.data_url = format!("http://{localhost}:{node_1_rpc}");
+            config.node.p2p_address = format!("{localhost}:{node_1_p2p}");
 
             config.node.seed = btc_miner_1_seed.clone();
             config.node.local_peer_seed = btc_miner_1_seed.clone();
@@ -3469,10 +3470,10 @@ fn multiple_miners_with_nakamoto_blocks() {
     let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
     let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
 
-    let node_1_rpc = 51024;
-    let node_1_p2p = 51023;
-    let node_2_rpc = 51026;
-    let node_2_p2p = 51025;
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
 
     let localhost = "127.0.0.1";
     let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
@@ -3746,14 +3747,14 @@ fn partial_tenure_fork() {
     let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
     let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
 
-    let node_1_rpc = 51024;
-    let node_1_p2p = 51023;
-    let node_2_rpc = 51026;
-    let node_2_p2p = 51025;
-
-    let node_1_rpc_bind = format!("127.0.0.1:{}", node_1_rpc);
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
 
     let localhost = "127.0.0.1";
+    let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
+
     // All signers are listening to node 1
     let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
         num_signers,
@@ -5077,6 +5078,143 @@ fn miner_recovers_when_broadcast_block_delay_across_tenures_occurs() {
     assert_ne!(block_n_2, block_n);
 }
 
+#[test]
+#[ignore]
+/// Test that we can mine a tenure extend and then continue mining afterwards.
+fn continue_after_tenure_extend() {
+    if env::var("BITCOIND_TEST") != Ok("1".into()) {
+        return;
+    }
+
+    tracing_subscriber::registry()
+        .with(fmt::layer())
+        .with(EnvFilter::from_default_env())
+        .init();
+
+    info!("------------------------- Test Setup -------------------------");
+    let num_signers = 5;
+    let sender_sk = Secp256k1PrivateKey::new();
+    let sender_addr = tests::to_addr(&sender_sk);
+    let recipient = PrincipalData::from(StacksAddress::burn_address(false));
+    let send_amt = 100;
+    let send_fee = 180;
+    let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new(
+        num_signers,
+        vec![(sender_addr.clone(), (send_amt + send_fee) * 5)],
+    );
+    let timeout = Duration::from_secs(200);
+    let coord_channel = signer_test.running_nodes.coord_channel.clone();
+    let http_origin = format!("http://{}", &signer_test.running_nodes.conf.node.rpc_bind);
+
+    signer_test.boot_to_epoch_3();
+
+    info!("------------------------- Mine Normal Tenure -------------------------");
+    signer_test.mine_and_verify_confirmed_naka_block(timeout, num_signers);
+
+    info!("------------------------- Extend Tenure -------------------------");
+    signer_test
+        .running_nodes
+        .nakamoto_test_skip_commit_op
+        .0
+        .lock()
+        .unwrap()
+        .replace(true);
+
+    // It's possible that we have a pending block commit already.
+    // Mine two BTC blocks to "flush" this commit.
+    let burn_height = signer_test
+        .stacks_client
+        .get_peer_info()
+        .expect("Failed to get peer info")
+        .burn_block_height;
+    for i in 0..2 {
+        info!(
+            "------------- After pausing commits, triggering 2 BTC blocks: ({} of 2) -----------",
+            i + 1
+        );
+
+        let blocks_processed_before = coord_channel
+            .lock()
+            .expect("Mutex poisoned")
+            .get_stacks_blocks_processed();
+        signer_test
+            .running_nodes
+            .btc_regtest_controller
+            .build_next_block(1);
+
+        wait_for(60, || {
+            let blocks_processed_after = coord_channel
+                .lock()
+                .expect("Mutex poisoned")
+                .get_stacks_blocks_processed();
+            Ok(blocks_processed_after > blocks_processed_before)
+        })
+        .expect("Timed out waiting for tenure extend block");
+    }
+
+    wait_for(30, || {
+        let new_burn_height = signer_test
+            .stacks_client
+            .get_peer_info()
+            .expect("Failed to get peer info")
+            .burn_block_height;
+        Ok(new_burn_height == burn_height + 2)
+    })
+    .expect("Timed out waiting for burnchain to advance");
+
+    // The last block should have a single instruction in it, the tenure extend
+    let blocks = test_observer::get_blocks();
+    let last_block = blocks.last().unwrap();
+    let transactions = last_block["transactions"].as_array().unwrap();
+    let tx = transactions.first().expect("No transactions in block");
+    let raw_tx = tx["raw_tx"].as_str().unwrap();
+    let tx_bytes = hex_bytes(&raw_tx[2..]).unwrap();
+    let parsed = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap();
+    match &parsed.payload {
+        TransactionPayload::TenureChange(payload)
+            if payload.cause == TenureChangeCause::Extended => {}
+        _ => panic!("Expected tenure extend transaction, got {:?}", parsed),
+    };
+
+    // Verify that the miner can continue mining in the tenure with the tenure extend
+    info!("------------------------- Mine After Tenure Extend -------------------------");
+    let mut sender_nonce = 0;
+    let mut blocks_processed_before = coord_channel
+        .lock()
+        .expect("Mutex poisoned")
+        .get_stacks_blocks_processed();
+    for _ in 0..5 {
+        // submit a tx so that the miner will mine an extra block
+        let transfer_tx = make_stacks_transfer(
+            &sender_sk,
+            sender_nonce,
+            send_fee,
+            signer_test.running_nodes.conf.burnchain.chain_id,
+            &recipient,
+            send_amt,
+        );
+        sender_nonce += 1;
+        submit_tx(&http_origin, &transfer_tx);
+
+        info!("Submitted transfer tx and waiting for block proposal");
+        wait_for(30, || {
+            let blocks_processed_after = coord_channel
+                .lock()
+                .expect("Mutex poisoned")
+                .get_stacks_blocks_processed();
+            Ok(blocks_processed_after > blocks_processed_before)
+        })
+        .expect("Timed out waiting for block proposal");
+        blocks_processed_before = coord_channel
+            .lock()
+            .expect("Mutex poisoned")
+            .get_stacks_blocks_processed();
+        info!("Block {blocks_processed_before} processed, continuing");
+    }
+
+    signer_test.shutdown();
+}
+
 #[test]
 #[ignore]
 /// Test that signers can successfully sign a block proposal in the 0th tenure of a reward cycle
@@ -5170,3 +5308,285 @@ fn signing_in_0th_tenure_of_reward_cycle() {
     }
     assert_eq!(signer_test.get_current_reward_cycle(), next_reward_cycle);
 }
+
+/// This test involves two miners with a custom chain id, each mining tenures with 6 blocks each.
+/// Half of the signers are attached to each miner, so the test also verifies that
+/// the signers' messages successfully make their way to the active miner.
+#[test]
+#[ignore]
+fn multiple_miners_with_custom_chain_id() {
+    let num_signers = 5;
+    let max_nakamoto_tenures = 20;
+    let inter_blocks_per_tenure = 5;
+
+    // setup sender + recipient for a test stx transfer
+    let sender_sk = Secp256k1PrivateKey::new();
+    let sender_addr = tests::to_addr(&sender_sk);
+    let send_amt = 1000;
+    let send_fee = 180;
+    let recipient = PrincipalData::from(StacksAddress::burn_address(false));
+
+    let btc_miner_1_seed = vec![1, 1, 1, 1];
+    let btc_miner_2_seed = vec![2, 2, 2, 2];
+    let btc_miner_1_pk = Keychain::default(btc_miner_1_seed.clone()).get_pub_key();
+    let btc_miner_2_pk = Keychain::default(btc_miner_2_seed.clone()).get_pub_key();
+
+    let node_1_rpc = gen_random_port();
+    let node_1_p2p = gen_random_port();
+    let node_2_rpc = gen_random_port();
+    let node_2_p2p = gen_random_port();
+
+    let localhost = "127.0.0.1";
+    let node_1_rpc_bind = format!("{localhost}:{node_1_rpc}");
+    let node_2_rpc_bind = format!("{localhost}:{node_2_rpc}");
+    let mut node_2_listeners = Vec::new();
+    let chain_id = 0x87654321;
+    // partition the signer set so that ~half are listening and using node 1 for RPC and events,
+    //  and the rest are using node 2
+    let mut signer_test: SignerTest<SpawnedSigner> = SignerTest::new_with_config_modifications(
+        num_signers,
+        vec![(
+            sender_addr.clone(),
+            (send_amt + send_fee) * max_nakamoto_tenures * inter_blocks_per_tenure,
+        )],
+        |signer_config| {
+            let node_host = if signer_config.endpoint.port() % 2 == 0 {
+                &node_1_rpc_bind
+            } else {
+                &node_2_rpc_bind
+            };
+            signer_config.node_host = node_host.to_string();
+            signer_config.chain_id = Some(chain_id)
+        },
+        |config| {
+            config.node.rpc_bind = format!("{localhost}:{node_1_rpc}");
+            config.node.p2p_bind = format!("{localhost}:{node_1_p2p}");
+            config.node.data_url = format!("http://{localhost}:{node_1_rpc}");
+            config.node.p2p_address = format!("{localhost}:{node_1_p2p}");
+            config.miner.wait_on_interim_blocks = Duration::from_secs(5);
+            config.node.pox_sync_sample_secs = 30;
+            config.burnchain.chain_id = chain_id;
+
+            config.node.seed = btc_miner_1_seed.clone();
+            config.node.local_peer_seed = btc_miner_1_seed.clone();
+            config.burnchain.local_mining_public_key = Some(btc_miner_1_pk.to_hex());
+            config.miner.mining_key = Some(Secp256k1PrivateKey::from_seed(&[1]));
+
+            config.events_observers.retain(|listener| {
+                let Ok(addr) = std::net::SocketAddr::from_str(&listener.endpoint) else {
+                    warn!(
+                        "Cannot parse {} to a socket, assuming it isn't a signer-listener binding",
+                        listener.endpoint
+                    );
+                    return true;
+                };
+                if addr.port() % 2 == 0 || addr.port() == test_observer::EVENT_OBSERVER_PORT {
+                    return true;
+                }
+                node_2_listeners.push(listener.clone());
+                false
+            })
+        },
+        Some(vec![btc_miner_1_pk.clone(), btc_miner_2_pk.clone()]),
+        None,
+    );
+    let blocks_mined1 = signer_test.running_nodes.nakamoto_blocks_mined.clone();
+
+    let conf = signer_test.running_nodes.conf.clone();
+    let mut conf_node_2 = conf.clone();
+    conf_node_2.node.rpc_bind = format!("{localhost}:{node_2_rpc}");
+    conf_node_2.node.p2p_bind = format!("{localhost}:{node_2_p2p}");
+    conf_node_2.node.data_url = format!("http://{localhost}:{node_2_rpc}");
+    conf_node_2.node.p2p_address = format!("{localhost}:{node_2_p2p}");
+    conf_node_2.node.seed = btc_miner_2_seed.clone();
+    conf_node_2.burnchain.local_mining_public_key = Some(btc_miner_2_pk.to_hex());
+    conf_node_2.node.local_peer_seed = btc_miner_2_seed.clone();
+    conf_node_2.miner.mining_key = Some(Secp256k1PrivateKey::from_seed(&[2]));
+    conf_node_2.node.miner = true;
+    conf_node_2.events_observers.clear();
+    conf_node_2.events_observers.extend(node_2_listeners);
+
+    assert!(!conf_node_2.events_observers.is_empty());
+
+    let node_1_sk = Secp256k1PrivateKey::from_seed(&conf.node.local_peer_seed);
+    let node_1_pk = StacksPublicKey::from_private(&node_1_sk);
+
+    conf_node_2.node.working_dir = format!("{}-{}", conf_node_2.node.working_dir, "1");
+
+    conf_node_2.node.set_bootstrap_nodes(
+        format!("{}@{}", &node_1_pk.to_hex(), conf.node.p2p_bind),
+        conf.burnchain.chain_id,
+        conf.burnchain.peer_version,
+    );
+
+    let http_origin = format!("http://{}", &conf.node.rpc_bind);
+
+    let mut run_loop_2 = boot_nakamoto::BootRunLoop::new(conf_node_2.clone()).unwrap();
+    let run_loop_stopper_2 = run_loop_2.get_termination_switch();
+    let rl2_coord_channels = run_loop_2.coordinator_channels();
+    let Counters {
+        naka_submitted_commits: rl2_commits,
+        naka_mined_blocks: blocks_mined2,
+        ..
+    } = run_loop_2.counters();
+    let run_loop_2_thread = thread::Builder::new()
+        .name("run_loop_2".into())
+        .spawn(move || run_loop_2.start(None, 0))
+        .unwrap();
+
+    signer_test.boot_to_epoch_3();
+
+    wait_for(120, || {
+        let Some(node_1_info) = get_chain_info_opt(&conf) else {
+            return Ok(false);
+        };
+        let Some(node_2_info) = get_chain_info_opt(&conf_node_2) else {
+            return Ok(false);
+        };
+        Ok(node_1_info.stacks_tip_height == node_2_info.stacks_tip_height)
+    })
+    .expect("Timed out waiting for follower to catch up to the miner");
+
+    let pre_nakamoto_peer_1_height = get_chain_info(&conf).stacks_tip_height;
+
+    info!("------------------------- Reached Epoch 3.0 -------------------------");
+
+    // due to the random nature of mining sortitions, the way this test is structured
+    //  is that we keep track of how many tenures each miner produced, and once enough sortitions
+    //  have been produced such that each miner has produced 3 tenures, we stop and check the
+    //  results at the end
+    let rl1_coord_channels = signer_test.running_nodes.coord_channel.clone();
+    let rl1_commits = signer_test.running_nodes.commits_submitted.clone();
+
+    let miner_1_pk = StacksPublicKey::from_private(conf.miner.mining_key.as_ref().unwrap());
+    let miner_2_pk = StacksPublicKey::from_private(conf_node_2.miner.mining_key.as_ref().unwrap());
+    let mut btc_blocks_mined = 1;
+    let mut miner_1_tenures = 0;
+    let mut miner_2_tenures = 0;
+    let mut sender_nonce = 0;
+    while !(miner_1_tenures >= 3 && miner_2_tenures >= 3) {
+        if btc_blocks_mined > max_nakamoto_tenures {
+            panic!("Produced {btc_blocks_mined} sortitions, but didn't cover the test scenarios, aborting");
+        }
+        let blocks_processed_before =
+            blocks_mined1.load(Ordering::SeqCst) + blocks_mined2.load(Ordering::SeqCst);
+        signer_test.mine_block_wait_on_processing(
+            &[&rl1_coord_channels, &rl2_coord_channels],
+            &[&rl1_commits, &rl2_commits],
+            Duration::from_secs(30),
+        );
+        btc_blocks_mined += 1;
+
+        // wait for the new block to be processed
+        wait_for(60, || {
+            let blocks_processed =
+                blocks_mined1.load(Ordering::SeqCst) + blocks_mined2.load(Ordering::SeqCst);
+            Ok(blocks_processed > blocks_processed_before)
+        })
+        .unwrap();
+
+        info!(
+            "Nakamoto blocks mined: {}",
+            blocks_mined1.load(Ordering::SeqCst) + blocks_mined2.load(Ordering::SeqCst)
+        );
+
+        // mine the interim blocks
+        info!("Mining interim blocks");
+        for interim_block_ix in 0..inter_blocks_per_tenure {
+            let blocks_processed_before =
+                blocks_mined1.load(Ordering::SeqCst) + blocks_mined2.load(Ordering::SeqCst);
+            // submit a tx so that the miner will mine an extra block
+            let transfer_tx = make_stacks_transfer(
+                &sender_sk,
+                sender_nonce,
+                send_fee,
+                signer_test.running_nodes.conf.burnchain.chain_id,
+                &recipient,
+                send_amt,
+            );
+            sender_nonce += 1;
+            submit_tx(&http_origin, &transfer_tx);
+
+            wait_for(60, || {
+                let blocks_processed =
+                    blocks_mined1.load(Ordering::SeqCst) + blocks_mined2.load(Ordering::SeqCst);
+                Ok(blocks_processed > blocks_processed_before)
+            })
+            .unwrap();
+            info!(
+                "Mined interim block {}:{}",
+                btc_blocks_mined, interim_block_ix
+            );
+        }
+
+        let blocks = get_nakamoto_headers(&conf);
+        let mut seen_burn_hashes = HashSet::new();
+        miner_1_tenures = 0;
+        miner_2_tenures = 0;
+        for header in blocks.iter() {
+            if seen_burn_hashes.contains(&header.burn_header_hash) {
+                continue;
+            }
+            seen_burn_hashes.insert(header.burn_header_hash.clone());
+
+            let header = header.anchored_header.as_stacks_nakamoto().unwrap();
+            if miner_1_pk
+                .verify(
+                    header.miner_signature_hash().as_bytes(),
+                    &header.miner_signature,
+                )
+                .unwrap()
+            {
+                miner_1_tenures += 1;
+            }
+            if miner_2_pk
+                .verify(
+                    header.miner_signature_hash().as_bytes(),
+                    &header.miner_signature,
+                )
+                .unwrap()
+            {
+                miner_2_tenures += 1;
+            }
+        }
+        info!(
+            "Miner 1 tenures: {}, Miner 2 tenures: {}",
+            miner_1_tenures, miner_2_tenures
+        );
+    }
+
+    info!(
+        "New chain info 1: {:?}",
+        get_chain_info(&signer_test.running_nodes.conf)
+    );
+
+    info!("New chain info 2: {:?}", get_chain_info(&conf_node_2));
+
+    let peer_1_height = get_chain_info(&conf).stacks_tip_height;
+    let peer_2_height = get_chain_info(&conf_node_2).stacks_tip_height;
+    info!("Peer height information"; "peer_1" => peer_1_height, "peer_2" => peer_2_height, "pre_naka_height" => pre_nakamoto_peer_1_height);
+    assert_eq!(peer_1_height, peer_2_height);
+    assert_eq!(
+        peer_1_height,
+        pre_nakamoto_peer_1_height + (btc_blocks_mined - 1) * (inter_blocks_per_tenure + 1)
+    );
+    assert_eq!(
+        btc_blocks_mined,
+        u64::try_from(miner_1_tenures + miner_2_tenures).unwrap()
+    );
+
+    // Verify both nodes have the correct chain id
+    let miner1_info = get_chain_info(&signer_test.running_nodes.conf);
+    assert_eq!(miner1_info.network_id, chain_id);
+
+    let miner2_info = get_chain_info(&conf_node_2);
+    assert_eq!(miner2_info.network_id, chain_id);
+
+    rl2_coord_channels
+        .lock()
+        .expect("Mutex poisoned")
+        .stop_chains_coordinator();
+    run_loop_stopper_2.store(false, Ordering::SeqCst);
+    run_loop_2_thread.join().unwrap();
+    signer_test.shutdown();
+}