diff --git a/.cargo/config.toml b/.cargo/config.toml index 440c785d471..7f5642cf5e4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,7 @@ [alias] stacks-node = "run --package stacks-node --" fmt-stacks = "fmt -- --config group_imports=StdExternalCrate,imports_granularity=Module" -clippy-stacks = "clippy -p stx-genesis -p libstackerdb -p stacks-signer -p pox-locking -p clarity-types -p clarity -p libsigner -p stacks-common -p clarity-cli -p stacks-cli -p stacks-inspect --no-deps --tests --all-features -- -D warnings" +clippy-stacks = "clippy -p stx-genesis -p libstackerdb -p stacks-signer -p pox-locking -p clarity-types -p clarity -p libsigner -p stacks-common -p clarity-cli -p stacks-cli -p stacks-inspect --no-deps --tests --all-features -- -D warnings -Aclippy::unnecessary_lazy_evaluations" clippy-stackslib = "clippy -p stackslib --no-deps -- -Aclippy::all -Wclippy::indexing_slicing -Wclippy::nonminimal_bool -Wclippy::clone_on_copy" # Uncomment to improve performance slightly, at the cost of portability diff --git a/CHANGELOG.md b/CHANGELOG.md index adc282f18ba..bcf131c5379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to the versioning scheme outlined in the [README.md](README.md). -## [Unreleased] +## [3.3.0.0.4] + +### Added + +- New `/v3/tenures/tip_metadata` endpoint for returning some metadata along with the normal tenure tip information. + + +## [3.3.0.0.3] ### Added diff --git a/Cargo.lock b/Cargo.lock index 756c1f1388b..186a415f610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,9 +610,9 @@ dependencies = [ [[package]] name = "clarity" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ - "clarity-types 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity-types 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "integer-sqrt", "lazy_static", "regex", @@ -624,7 +624,7 @@ dependencies = [ "serde_json", "serde_stacker", "slog", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", ] [[package]] @@ -663,7 +663,7 @@ dependencies = [ [[package]] name = "clarity-types" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ "lazy_static", "regex", @@ -672,7 +672,7 @@ dependencies = [ "serde_derive", "serde_json", "slog", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", ] [[package]] @@ -1875,19 +1875,19 @@ dependencies = [ [[package]] name = "libsigner" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ - "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "hashbrown 0.15.2", "lazy_static", "libc", - "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "serde", "serde_json", "sha2 0.10.8", "slog", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", - "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", + "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "thiserror", "tiny_http", ] @@ -1916,12 +1916,12 @@ dependencies = [ [[package]] name = "libstackerdb" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ - "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "serde", "sha2 0.10.8", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", ] [[package]] @@ -2431,11 +2431,11 @@ dependencies = [ [[package]] name = "pox-locking" version = "2.4.0" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ - "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "slog", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", ] [[package]] @@ -3363,7 +3363,7 @@ dependencies = [ [[package]] name = "stacks-common" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ "chrono", "curve25519-dalek", @@ -3421,7 +3421,7 @@ dependencies = [ "lazy_static", "libc", "libsigner 0.0.1", - "libsigner 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "libsigner 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "madhouse", "mockito", "mutants", @@ -3438,12 +3438,12 @@ dependencies = [ "serial_test", "slog", "stacks-common 0.0.1", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "stacks-inspect", "stacks-signer 0.0.1", - "stacks-signer 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-signer 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "stackslib 0.0.1", - "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "stdext", "stx-genesis", "tempfile", @@ -3493,15 +3493,15 @@ dependencies = [ [[package]] name = "stacks-signer" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ "backoff", "clap", - "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "hashbrown 0.15.2", "lazy_static", - "libsigner 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", - "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "libsigner 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", + "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "rand 0.8.5", "rand_core 0.6.4", "reqwest", @@ -3510,8 +3510,8 @@ dependencies = [ "serde_json", "slog", "slog-term", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", - "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", + "stackslib 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "thiserror", "toml", "tracing", @@ -3566,17 +3566,17 @@ dependencies = [ [[package]] name = "stackslib" version = "0.0.1" -source = "git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5#b3efa588571e168d2d154790e1c57d9e3e64adc5" +source = "git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed#7baa99ac3120111d1166c1a6c91abbd7aee8dbed" dependencies = [ "chrono", - "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "clarity 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "ed25519-dalek", "lazy_static", - "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "libstackerdb 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "mio 0.6.23", "nix", "percent-encoding", - "pox-locking 2.4.0 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "pox-locking 2.4.0 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "rand 0.8.5", "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -3589,7 +3589,7 @@ dependencies = [ "sha2 0.10.8", "siphasher", "slog", - "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=b3efa588571e168d2d154790e1c57d9e3e64adc5)", + "stacks-common 0.0.1 (git+https://github.com/stacks-network/stacks-core.git?rev=7baa99ac3120111d1166c1a6c91abbd7aee8dbed)", "time", "toml", "url", diff --git a/clarity-types/src/errors/mod.rs b/clarity-types/src/errors/mod.rs index 4db200c3121..668f7d01043 100644 --- a/clarity-types/src/errors/mod.rs +++ b/clarity-types/src/errors/mod.rs @@ -190,6 +190,8 @@ pub enum RuntimeError { PoxAlreadyLocked, /// Block time unavailable during execution. BlockTimeNotAvailable, + /// A Clarity string used as a token name for a post-condition is not a valid Clarity name. + BadTokenName(String), } #[derive(Debug, PartialEq)] diff --git a/clarity-types/src/tests/types/signatures.rs b/clarity-types/src/tests/types/signatures.rs index a41dbf6ed38..ba64e1ff17a 100644 --- a/clarity-types/src/tests/types/signatures.rs +++ b/clarity-types/src/tests/types/signatures.rs @@ -12,7 +12,7 @@ // // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::collections::HashSet; +use std::collections::BTreeSet; use crate::errors::analysis::CommonCheckErrorKind; use crate::representations::CONTRACT_MAX_NAME_LENGTH; @@ -370,7 +370,7 @@ fn test_least_supertype() { }), ]; let list_union2 = ListUnionType(callables2.clone().into()); - let list_union_merged = ListUnionType(HashSet::from_iter( + let list_union_merged = ListUnionType(BTreeSet::from_iter( [callables, callables2].concat().iter().cloned(), )); let callable_principals = [ diff --git a/clarity-types/src/types/signatures.rs b/clarity-types/src/types/signatures.rs index 13ce2becc0a..2afd7d5e495 100644 --- a/clarity-types/src/types/signatures.rs +++ b/clarity-types/src/types/signatures.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use std::collections::btree_map::Entry; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::hash::Hash; use std::sync::Arc; use std::{cmp, fmt}; @@ -293,7 +293,7 @@ pub enum TypeSignature { // data structure to maintain the set of types in the list, so that when // we reach the place where the coercion needs to happen, we can perform // the check -- see `concretize` method. - ListUnionType(HashSet), + ListUnionType(BTreeSet), // This is used only below epoch 2.1. It has been replaced by CallableType. TraitReferenceType(TraitIdentifier), } @@ -326,7 +326,7 @@ pub enum StringSubtype { UTF8(StringUTF8Length), } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord)] pub enum CallableSubtype { Principal(QualifiedContractIdentifier), Trait(TraitIdentifier), @@ -1223,7 +1223,7 @@ impl TypeSignature { if x == y { Ok(a.clone()) } else { - Ok(ListUnionType(HashSet::from([x.clone(), y.clone()]))) + Ok(ListUnionType(BTreeSet::from([x.clone(), y.clone()]))) } } (ListUnionType(l), CallableType(c)) | (CallableType(c), ListUnionType(l)) => { diff --git a/clarity/src/vm/functions/post_conditions.rs b/clarity/src/vm/functions/post_conditions.rs index 59a91f9d03a..9beb952528c 100644 --- a/clarity/src/vm/functions/post_conditions.rs +++ b/clarity/src/vm/functions/post_conditions.rs @@ -15,7 +15,9 @@ use std::collections::HashMap; +use clarity_types::errors::RuntimeError; use clarity_types::types::{AssetIdentifier, PrincipalData, StandardPrincipalData}; +use clarity_types::ClarityName; use crate::vm::analysis::type_checker::v2_1::natives::post_conditions::MAX_ALLOWANCES; use crate::vm::contexts::AssetMap; @@ -135,7 +137,12 @@ fn eval_allowance( }; let asset_name = eval(&rest[1], env, context)?; - let asset_name = asset_name.expect_string_ascii()?.as_str().into(); + let asset_name = match ClarityName::try_from(asset_name.expect_string_ascii()?) { + Ok(name) => name, + Err(_) => { + return Err(RuntimeError::BadTokenName(rest[1].to_string()).into()); + } + }; let asset = AssetIdentifier { contract_identifier, @@ -165,7 +172,12 @@ fn eval_allowance( }; let asset_name = eval(&rest[1], env, context)?; - let asset_name = asset_name.expect_string_ascii()?.as_str().into(); + let asset_name = match ClarityName::try_from(asset_name.expect_string_ascii()?) { + Ok(name) => name, + Err(_) => { + return Err(RuntimeError::BadTokenName(rest[1].to_string()).into()); + } + }; let asset = AssetIdentifier { contract_identifier, diff --git a/docs/rpc/components/schemas/tenure-tip-metadata.schema.yaml b/docs/rpc/components/schemas/tenure-tip-metadata.schema.yaml new file mode 100644 index 00000000000..08fc53401fe --- /dev/null +++ b/docs/rpc/components/schemas/tenure-tip-metadata.schema.yaml @@ -0,0 +1,124 @@ +description: | + JSON encoding of `BlockHeaderWithMetadata` returned by /v3/tenures/tip_metadata. + Exactly one variant property will be present: either `Epoch2` or `Nakamoto`. +type: object +required: + - anchored_header +properties: + burn_view: + type: string + description: Hex-encoded bitcoin block hash + anchored_header: + oneOf: + - title: Epoch2HeaderVariant + type: object + required: [Epoch2] + additionalProperties: false + properties: + Epoch2: + type: object + description: Header structure for a Stacks 2.x anchored block. + required: + - version + - total_work + - proof + - parent_block + - parent_microblock + - parent_microblock_sequence + - tx_merkle_root + - state_index_root + - microblock_pubkey_hash + properties: + version: + type: integer + minimum: 0 + total_work: + type: object + required: [burn, work] + properties: + burn: + type: integer + format: uint64 + work: + type: integer + format: uint64 + proof: + type: string + description: Hex-encoded VRF proof + parent_block: + type: string + description: 32-byte hex of the parent block header hash + parent_microblock: + type: string + description: 32-byte hex of the parent microblock header hash + parent_microblock_sequence: + type: integer + tx_merkle_root: + type: string + description: Hex-encoded merkle root of the transactions in the block + state_index_root: + type: string + description: Hex-encoded MARF trie root after this block + microblock_pubkey_hash: + type: string + description: Hash160 (20-byte hex) of the microblock public key + additionalProperties: false + - title: NakamotoHeaderVariant + type: object + required: [Nakamoto] + additionalProperties: false + properties: + Nakamoto: + type: object + description: Header structure for a Nakamoto-epoch Stacks block. + required: + - version + - chain_length + - burn_spent + - consensus_hash + - parent_block_id + - tx_merkle_root + - state_index_root + - timestamp + - miner_signature + - signer_signature + - pox_treatment + properties: + version: + type: integer + minimum: 0 + chain_length: + type: integer + format: uint64 + description: Number of ancestor blocks including Stacks 2.x blocks + burn_spent: + type: integer + format: uint64 + description: Total BTC spent by the sortition that elected this block + consensus_hash: + type: string + description: 20-byte hex consensus hash that identifies the tenure + parent_block_id: + type: string + description: 32-byte hex identifier of the parent block (hash+consensus) + tx_merkle_root: + type: string + description: Hex-encoded merkle root of all transactions in the block + state_index_root: + type: string + description: Hex-encoded MARF trie root after this block + timestamp: + type: integer + description: Unix timestamp (seconds) + miner_signature: + type: string + description: Recoverable ECDSA signature from the miner + signer_signature: + type: array + description: Signer-set signatures over the block header + items: + type: string + pox_treatment: + type: string + description: Bit-vector, hex-encoded, indicating PoX reward treatment + additionalProperties: false diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 4c52d2eff70..22876be7837 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -156,6 +156,8 @@ components: $ref: ./components/schemas/tenure-info.schema.yaml TenureTip: $ref: ./components/schemas/tenure-tip.schema.yaml + TenureTipMetadata: + $ref: ./components/schemas/tenure-tip-metadata.schema.yaml GetStackerSet: $ref: ./components/schemas/get-stacker-set.schema.yaml TenureBlocks: @@ -2062,6 +2064,37 @@ paths: "500": $ref: "#/components/responses/InternalServerError" + /v3/tenures/tip_metadata/{consensus_hash}: + get: + summary: Get tenure tip with metadata + tags: + - Blocks + security: [] + operationId: getTenureTipMetadata + description: | + Get the tip block and associated metadata of a tenure identified by consensus hash. + parameters: + - name: consensus_hash + in: path + required: true + description: Consensus hash (40 characters) + schema: + type: string + pattern: "^[0-9a-f]{40}$" + responses: + "200": + description: Tenure tip block information + content: + application/json: + schema: + $ref: "#/components/schemas/TenureTipMetadata" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + /v2/transactions/unconfirmed/{txid}: get: summary: Get unconfirmed transaction diff --git a/libsigner/src/v0/messages.rs b/libsigner/src/v0/messages.rs index 36a5dd05a6f..700a892cddf 100644 --- a/libsigner/src/v0/messages.rs +++ b/libsigner/src/v0/messages.rs @@ -52,7 +52,7 @@ use stacks_common::types::chainstate::StacksBlockId; use stacks_common::util::hash::{Hash160, Sha512Trunc256Sum}; use crate::stacks_common::types::PublicKey; -use crate::v0::signer_state::ReplayTransactionSet; +use crate::v0::signer_state::{ReplayTransactionSet, SignerStateMachine}; use crate::{ BlockProposal, MessageSlotID as MessageSlotIDTrait, SignerMessage as SignerMessageTrait, VERSION_STRING, @@ -614,18 +614,58 @@ pub enum StateMachineUpdateMinerState { /// The signer doesn't believe there's any valid miner NoValidMiner, } - impl StateMachineUpdate { - /// Construct a StateMachineUpdate message, checking to ensure that the - /// supplied content is supported by the supplied protocol versions. + /// Create a `StateMachineUpdate` for **outbound** messages. + /// + /// The content version **must exactly match** the negotiated protocol version: + /// `min(active_signer_protocol_version, local_supported_signer_protocol_version)`. + /// + /// This ensures the message we send conforms strictly to what both sides have agreed upon. pub fn new( active_signer_protocol_version: u64, local_supported_signer_protocol_version: u64, content: StateMachineUpdateContent, ) -> Result { - if !content.is_protocol_version_compatible(active_signer_protocol_version) { - return Err(CodecError::DeserializeError(format!("StateMachineUpdateContent is incompatible with protocol version: {active_signer_protocol_version}"))); + let negotiated = + active_signer_protocol_version.min(local_supported_signer_protocol_version); + let version = content.version(); + + if version != negotiated { + return Err(CodecError::DeserializeError(format!( + "Outbound content version {version} does not match negotiated protocol version {negotiated} \ + (active={active_signer_protocol_version}, local_supported={local_supported_signer_protocol_version})" + ))); + } + + Ok(Self { + active_signer_protocol_version, + local_supported_signer_protocol_version, + content, + no_manual_construct: PhantomData, + }) + } + + /// Create a `StateMachineUpdate` for **inbound** messages. + /// + /// The content version must be **less than or equal to** the negotiated protocol version + /// (`min(active_signer_protocol_version, local_supported_signer_protocol_version)`). + /// Older versions are explicitly allowed for backward compatibility. + fn new_inbound( + active_signer_protocol_version: u64, + local_supported_signer_protocol_version: u64, + content: StateMachineUpdateContent, + ) -> Result { + let negotiated = + active_signer_protocol_version.min(local_supported_signer_protocol_version); + let version = content.version(); + + if content.version() > negotiated { + return Err(CodecError::DeserializeError(format!( + "Inbound content version {version} exceeds negotiated protocol version {negotiated} \ + (active={active_signer_protocol_version}, local_supported={local_supported_signer_protocol_version})" + ))); } + Ok(Self { active_signer_protocol_version, local_supported_signer_protocol_version, @@ -692,12 +732,45 @@ impl StacksMessageCodec for StateMachineUpdateMinerState { } impl StateMachineUpdateContent { - // Is the protocol version specified one that uses self's content? - fn is_protocol_version_compatible(&self, version: u64) -> bool { + /// Attempt to create a new state machine update content with the specified version + pub fn new( + version: u64, + current_miner: StateMachineUpdateMinerState, + state_machine: &SignerStateMachine, + ) -> Result { + let content = match version { + 0 => StateMachineUpdateContent::V0 { + burn_block: state_machine.burn_block.clone(), + burn_block_height: state_machine.burn_block_height, + current_miner, + }, + 1 => StateMachineUpdateContent::V1 { + burn_block: state_machine.burn_block.clone(), + burn_block_height: state_machine.burn_block_height, + current_miner, + replay_transactions: state_machine.tx_replay_set.clone().unwrap_or_default(), + }, + 2 => StateMachineUpdateContent::V2 { + burn_block: state_machine.burn_block.clone(), + burn_block_height: state_machine.burn_block_height, + current_miner, + replay_transactions: state_machine.tx_replay_set.clone().unwrap_or_default(), + }, + other => { + return Err(CodecError::DeserializeError(format!( + "Signer protocol version is unknown: {other}" + ))) + } + }; + Ok(content) + } + + /// Get the underlying version of the state machine update content + pub fn version(&self) -> u64 { match self { - Self::V0 { .. } => version == 0, - Self::V1 { .. } => version == 1, - Self::V2 { .. } => version == 2, + Self::V0 { .. } => 0, + Self::V1 { .. } => 1, + Self::V2 { .. } => 2, } } @@ -832,8 +905,8 @@ impl StacksMessageCodec for StateMachineUpdate { } fn consensus_deserialize(fd: &mut R) -> Result { - let active_signer_protocol_version = read_next(fd)?; - let local_supported_signer_protocol_version = read_next(fd)?; + let active_signer_protocol_version: u64 = read_next(fd)?; + let local_supported_signer_protocol_version: u64 = read_next(fd)?; let content_len: u32 = read_next(fd)?; if content_len > STATE_MACHINE_UPDATE_MAX_SIZE { return Err(CodecError::DeserializeError(format!( @@ -844,12 +917,12 @@ impl StacksMessageCodec for StateMachineUpdate { .expect("FATAL: cannot process signer messages when usize < u32"); let mut buffer = vec![0u8; buffer_len]; fd.read_exact(&mut buffer).map_err(CodecError::ReadError)?; - let content = StateMachineUpdateContent::deserialize( - &mut buffer.as_slice(), - active_signer_protocol_version, - )?; + let negotiated = + active_signer_protocol_version.min(local_supported_signer_protocol_version); + let content = StateMachineUpdateContent::deserialize(&mut buffer.as_slice(), negotiated)?; - Self::new( + // We use the inbound constructor here as we need to allow for older versions + Self::new_inbound( active_signer_protocol_version, local_supported_signer_protocol_version, content, @@ -2424,23 +2497,65 @@ mod test { #[test] fn version_check_state_machine_update() { - let error = StateMachineUpdate::new( - 1, - 3, - StateMachineUpdateContent::V0 { - burn_block: ConsensusHash([0x55; 20]), - burn_block_height: 100, - current_miner: StateMachineUpdateMinerState::ActiveMiner { - current_miner_pkh: Hash160([0xab; 20]), - tenure_id: ConsensusHash([0x44; 20]), - parent_tenure_id: ConsensusHash([0x22; 20]), - parent_tenure_last_block: StacksBlockId([0x33; 32]), - parent_tenure_last_block_height: 1, - }, + let content = StateMachineUpdateContent::V1 { + burn_block: ConsensusHash([0x55; 20]), + burn_block_height: 100, + current_miner: StateMachineUpdateMinerState::ActiveMiner { + current_miner_pkh: Hash160([0xab; 20]), + tenure_id: ConsensusHash([0x44; 20]), + parent_tenure_id: ConsensusHash([0x22; 20]), + parent_tenure_last_block: StacksBlockId([0x33; 32]), + parent_tenure_last_block_height: 1, }, - ) - .unwrap_err(); + replay_transactions: vec![], + }; + // We active version does not support the content + let error = StateMachineUpdate::new(0, 1, content.clone()).unwrap_err(); + assert!(matches!(error, CodecError::DeserializeError(_))); + // The content should be the min of the active/local versions but it is lower + let error = StateMachineUpdate::new(2, 3, content.clone()).unwrap_err(); + assert!(matches!(error, CodecError::DeserializeError(_))); + // The content should be the min of the active/local versions but it is greater + let error = StateMachineUpdate::new(2, 0, content.clone()).unwrap_err(); + assert!(matches!(error, CodecError::DeserializeError(_))); + // the content version is equal to the min of the active/local versions + assert!(StateMachineUpdate::new(1, 2, content.clone()).is_ok()) + } + + #[test] + fn version_check_state_machine_update_inbound() { + let content_v1 = StateMachineUpdateContent::V1 { + burn_block: ConsensusHash([0x55; 20]), + burn_block_height: 100, + current_miner: StateMachineUpdateMinerState::ActiveMiner { + current_miner_pkh: Hash160([0xab; 20]), + tenure_id: ConsensusHash([0x44; 20]), + parent_tenure_id: ConsensusHash([0x22; 20]), + parent_tenure_last_block: StacksBlockId([0x33; 32]), + parent_tenure_last_block_height: 1, + }, + replay_transactions: vec![], + }; + + // Inbound: content version exceeds negotiated version → should reject + + // Case 1: Content version (1) > negotiated (0) → reject + let error = StateMachineUpdate::new_inbound(0, 1, content_v1.clone()).unwrap_err(); assert!(matches!(error, CodecError::DeserializeError(_))); + + // Case 2: Content version (1) > negotiated (0) → reject (swapped active/local) + let error = StateMachineUpdate::new_inbound(1, 0, content_v1.clone()).unwrap_err(); + assert!(matches!(error, CodecError::DeserializeError(_))); + + // Case 3: Content version (1) == negotiated (1) → accept + assert!(StateMachineUpdate::new_inbound(1, 1, content_v1.clone()).is_ok()); + assert!(StateMachineUpdate::new_inbound(1, 2, content_v1.clone()).is_ok()); + assert!(StateMachineUpdate::new_inbound(3, 1, content_v1.clone()).is_ok()); + + // Case 4: Content version (1) < negotiated (2) → accept (backward compatibility) + assert!(StateMachineUpdate::new_inbound(2, 2, content_v1.clone()).is_ok()); + assert!(StateMachineUpdate::new_inbound(2, 5, content_v1.clone()).is_ok()); + assert!(StateMachineUpdate::new_inbound(10, 10, content_v1.clone()).is_ok()); } #[test] diff --git a/stacks-node/Cargo.toml b/stacks-node/Cargo.toml index a43393aa622..84d60b6624b 100644 --- a/stacks-node/Cargo.toml +++ b/stacks-node/Cargo.toml @@ -32,11 +32,11 @@ async-std = { version = "1.6", optional = true, features = ["attributes"] } http-types = { version = "2.12", default-features = false, optional = true } thiserror = { workspace = true } -# This dependency is used for the multiversion integration tests which live behind the build-v3-1-0-0-13 feature flag -signer_v3_3_0_0_1 = { package = "stacks-signer", git = "https://github.com/stacks-network/stacks-core.git", rev="b3efa588571e168d2d154790e1c57d9e3e64adc5", optional = true, features = ["testing", "default"]} -libsigner_v3_3_0_0_1 = { package = "libsigner", git = "https://github.com/stacks-network/stacks-core.git", rev="b3efa588571e168d2d154790e1c57d9e3e64adc5", optional = true} -stacks_v3_3_0_0_1 = { package = "stackslib", git = "https://github.com/stacks-network/stacks-core.git", rev="b3efa588571e168d2d154790e1c57d9e3e64adc5", optional = true, features = ["testing", "default"]} -stacks_common_v3_3_0_0_1 = { package = "stacks-common", git = "https://github.com/stacks-network/stacks-core.git", rev="b3efa588571e168d2d154790e1c57d9e3e64adc5", optional = true, features = ["testing", "default"]} +# This dependency is used for the multiversion integration tests which live behind the build-v3-1-0-0-4 feature flag +signer_v3_3_0_0_4 = { package = "stacks-signer", git = "https://github.com/stacks-network/stacks-core.git", rev="7baa99ac3120111d1166c1a6c91abbd7aee8dbed", optional = true, features = ["testing", "default"]} +libsigner_v3_3_0_0_4 = { package = "libsigner", git = "https://github.com/stacks-network/stacks-core.git", rev="7baa99ac3120111d1166c1a6c91abbd7aee8dbed", optional = true} +stacks_v3_3_0_0_4 = { package = "stackslib", git = "https://github.com/stacks-network/stacks-core.git", rev="7baa99ac3120111d1166c1a6c91abbd7aee8dbed", optional = true, features = ["testing", "default"]} +stacks_common_v3_3_0_0_4 = { package = "stacks-common", git = "https://github.com/stacks-network/stacks-core.git", rev="7baa99ac3120111d1166c1a6c91abbd7aee8dbed", optional = true, features = ["testing", "default"]} [target.'cfg(not(any(target_os = "macos", target_os="windows", target_arch = "arm")))'.dependencies] tikv-jemallocator = {workspace = true} @@ -68,7 +68,7 @@ slog_json = ["stacks/slog_json", "stacks-common/slog_json", "clarity/slog_json"] prod-genesis-chainstate = [] default = [] testing = ["stacks-common/testing", "stacks/testing", "clarity/testing"] -build-signer-v3-3-0-0-1 = ["signer_v3_3_0_0_1", "libsigner_v3_3_0_0_1", "stacks_v3_3_0_0_1", "stacks_common_v3_3_0_0_1"] +build-signer-v3-3-0-0-4 = ["signer_v3_3_0_0_4", "libsigner_v3_3_0_0_4", "stacks_v3_3_0_0_4", "stacks_common_v3_3_0_0_4"] [package.metadata.pinny] allowed = ["bitcoind", "flaky", "slow"] diff --git a/stacks-node/src/tests/signer/mod.rs b/stacks-node/src/tests/signer/mod.rs index d8db0e960d0..973d63834a7 100644 --- a/stacks-node/src/tests/signer/mod.rs +++ b/stacks-node/src/tests/signer/mod.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . mod commands; -#[cfg(feature = "build-signer-v3-3-0-0-1")] +#[cfg(feature = "build-signer-v3-3-0-0-4")] pub mod multiversion; pub mod v0; @@ -63,6 +63,7 @@ use stacks_signer::config::{build_signer_config_tomls, GlobalConfig as SignerCon use stacks_signer::runloop::{SignerResult, State, StateInfo}; use stacks_signer::signerdb::SignerDb; use stacks_signer::v0::signer_state::LocalStateMachine; +use stacks_signer::v0::tests::TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION; use stacks_signer::{Signer, SpawnedSigner}; use super::nakamoto_integrations::{ @@ -585,7 +586,8 @@ impl SignerTest { let latest_block = self .stacks_client .get_tenure_tip(&sortition_prior.consensus_hash) - .unwrap(); + .unwrap() + .anchored_header; let latest_block_id = StacksBlockId::new(&sortition_prior.consensus_hash, &latest_block.block_hash()); @@ -642,7 +644,8 @@ impl SignerTest { let latest_block = self .stacks_client .get_tenure_tip(sortition_prior.stacks_parent_ch.as_ref().unwrap()) - .unwrap(); + .unwrap() + .anchored_header; let latest_block_id = StacksBlockId::new( sortition_prior.stacks_parent_ch.as_ref().unwrap(), &latest_block.block_hash(), @@ -904,7 +907,8 @@ impl SignerTest { let latest_block = self .stacks_client .get_tenure_tip(&sortition_prior.consensus_hash) - .unwrap(); + .unwrap() + .anchored_header; let latest_block_id = StacksBlockId::new(&sortition_prior.consensus_hash, &latest_block.block_hash()); @@ -984,7 +988,8 @@ impl SignerTest { let latest_block = self .stacks_client .get_tenure_tip(&sortition_parent.consensus_hash) - .unwrap(); + .unwrap() + .anchored_header; let latest_block_id = StacksBlockId::new(&sortition_parent.consensus_hash, &latest_block.block_hash()); @@ -1414,10 +1419,14 @@ impl SignerTest { .iter() .zip(self.signer_configs.clone()) .map(|(privk, config)| { - ( - StacksAddress::p2pkh(false, &StacksPublicKey::from_private(privk)), - config.supported_signer_protocol_version, - ) + let public_key = StacksPublicKey::from_private(privk); + let pinned_versions = TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION.get(); + let version = if let Some(pinned_version) = pinned_versions.get(&public_key) { + *pinned_version + } else { + config.supported_signer_protocol_version + }; + (StacksAddress::p2pkh(false, &public_key), version) }) .collect() } @@ -1705,6 +1714,22 @@ impl SignerTest { .txid; Some(Txid::from_bitcoin_tx_hash(parent_txid)) } + /// Restart the signer at `idx` with a new supported protocol version. + pub fn restart_signer_with_supported_version(&mut self, idx: usize, version: u64) { + let mut cfg = self.stop_signer(idx); + cfg.supported_signer_protocol_version = version; + self.restart_signer(idx, cfg); + } + + /// Restart the first `n` signers with a new supported protocol version. + /// Restarts in reverse index order so removals/insertions don't shift upcoming indices. + /// Waits for all signers to re-register after restarts. + pub fn restart_first_n_signers_with_supported_version(&mut self, n: usize, version: u64) { + for idx in (0..n).rev() { + self.restart_signer_with_supported_version(idx, version); + } + self.wait_for_registered(); + } } fn setup_stx_btc_node( diff --git a/stacks-node/src/tests/signer/multiversion.rs b/stacks-node/src/tests/signer/multiversion.rs index 152b9747357..b02686b2c6b 100644 --- a/stacks-node/src/tests/signer/multiversion.rs +++ b/stacks-node/src/tests/signer/multiversion.rs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Stacks Open Internet Foundation +// Copyright (C) 2026 Stacks Open Internet Foundation // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -13,7 +13,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . use std::sync::mpsc::TryRecvError; -use std::thread; use std::time::Duration; use libsigner::v0::messages::{ @@ -21,16 +20,19 @@ use libsigner::v0::messages::{ SignerMessageMetadata, }; use libsigner::v0::signer_state::{MinerState, ReplayTransactionSet, SignerStateMachine}; -use libsigner_v3_3_0_0_1::v0::messages::SignerMessage as OldSignerMessage; +use libsigner_v3_3_0_0_4::v0::messages::SignerMessage as OldSignerMessage; +use signer_v3_3_0_0_4::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION as OldSupportedVersion; use stacks::chainstate::stacks::StacksTransaction; use stacks::util::hash::{Hash160, Sha512Trunc256Sum}; use stacks::util::secp256k1::{MessageSignature, Secp256k1PrivateKey}; use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId}; -use stacks_common_v3_3_0_0_1::codec::StacksMessageCodec as OldStacksMessageCodec; +use stacks_common_v3_3_0_0_4::codec::StacksMessageCodec as OldStacksMessageCodec; use stacks_signer::runloop::{RewardCycleInfo, State, StateInfo}; -use stacks_signer::v0::signer_state::LocalStateMachine; +use stacks_signer::v0::signer_state::{ + LocalStateMachine, SUPPORTED_SIGNER_PROTOCOL_VERSION as NewSupportedVersion, +}; use stacks_signer::v0::SpawnedSigner; -use {libsigner_v3_3_0_0_1, signer_v3_3_0_0_1, stacks_common_v3_3_0_0_1, stacks_v3_3_0_0_1}; +use {libsigner_v3_3_0_0_4, signer_v3_3_0_0_4, stacks_common_v3_3_0_0_4, stacks_v3_3_0_0_4}; use super::SpawnedSignerTrait; use crate::stacks_common::codec::StacksMessageCodec; @@ -41,24 +43,24 @@ use crate::tests::{self}; use crate::Keychain; pub enum MultiverSpawnedSigner { - V33001(signer_v3_3_0_0_1::v0::SpawnedSigner), + V33004(signer_v3_3_0_0_4::v0::SpawnedSigner), Current(SpawnedSigner), } pub enum ReceiveResult { - V33001(Result), + V33004(Result), Current(Result), } -// Helper function to convert libsigner_v3_3_0_0_1 miner state to current miner state -pub fn miner_state_v3_3_0_0_1_to_current( - miner_state: &libsigner_v3_3_0_0_1::v0::signer_state::MinerState, +// Helper function to convert libsigner_v3_3_0_0_4 miner state to current miner state +pub fn miner_state_v3_3_0_0_4_to_current( + miner_state: &libsigner_v3_3_0_0_4::v0::signer_state::MinerState, ) -> MinerState { match miner_state { - libsigner_v3_3_0_0_1::v0::signer_state::MinerState::NoValidMiner => { + libsigner_v3_3_0_0_4::v0::signer_state::MinerState::NoValidMiner => { MinerState::NoValidMiner } - libsigner_v3_3_0_0_1::v0::signer_state::MinerState::ActiveMiner { + libsigner_v3_3_0_0_4::v0::signer_state::MinerState::ActiveMiner { current_miner_pkh, tenure_id, parent_tenure_id, @@ -75,29 +77,29 @@ pub fn miner_state_v3_3_0_0_1_to_current( } // Helper function to convert from one to the other -pub fn stacks_transaction_v3_3_0_0_1_to_current( - tx: &stacks_v3_3_0_0_1::chainstate::stacks::StacksTransaction, +pub fn stacks_transaction_v3_3_0_0_4_to_current( + tx: &stacks_v3_3_0_0_4::chainstate::stacks::StacksTransaction, ) -> StacksTransaction { let tx_bytes = tx.serialize_to_vec(); StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap() } -// Helper function to convert libsigner_v3_1_0_0_13 signer state machine to current signer state machine -pub fn signer_state_update_v3_3_0_0_1_to_current( - update: &signer_v3_3_0_0_1::v0::signer_state::StateMachineUpdate, +// Helper function to convert libsigner_v3_3_0_0_4 signer state machine to current signer state machine +pub fn signer_state_update_v3_3_0_0_4_to_current( + update: &signer_v3_3_0_0_4::v0::signer_state::StateMachineUpdate, ) -> stacks_signer::v0::signer_state::StateMachineUpdate { let serialized = serde_json::to_string(update).unwrap(); serde_json::from_str(&serialized).unwrap() } -// Helper function to convert libsigner_v3_1_0_0_13 signer state machine to current signer state machine -pub fn signer_state_machine_v3_3_0_0_1_to_current( - machine: &libsigner_v3_3_0_0_1::v0::signer_state::SignerStateMachine, +// Helper function to convert libsigner_v3_3_0_0_4 signer state machine to current signer state machine +pub fn signer_state_machine_v3_3_0_0_4_to_current( + machine: &libsigner_v3_3_0_0_4::v0::signer_state::SignerStateMachine, ) -> SignerStateMachine { SignerStateMachine { burn_block: ConsensusHash(machine.burn_block.0), burn_block_height: machine.burn_block_height, - current_miner: miner_state_v3_3_0_0_1_to_current(&machine.current_miner), + current_miner: miner_state_v3_3_0_0_4_to_current(&machine.current_miner), active_signer_protocol_version: machine.active_signer_protocol_version, tx_replay_set: ReplayTransactionSet::new( machine @@ -105,28 +107,28 @@ pub fn signer_state_machine_v3_3_0_0_1_to_current( .clone() .unwrap_or_default() .iter() - .map(stacks_transaction_v3_3_0_0_1_to_current) + .map(stacks_transaction_v3_3_0_0_4_to_current) .collect(), ), } } -// Helper function to convert signer_v3_1_0_0_13 local state machines to current local state machines -pub fn local_state_machine_v3_3_0_0_1_to_current( - state_machine: &signer_v3_3_0_0_1::v0::signer_state::LocalStateMachine, +// Helper function to convert signer_v3_3_0_0_4 local state machines to current local state machines +pub fn local_state_machine_v3_3_0_0_4_to_current( + state_machine: &signer_v3_3_0_0_4::v0::signer_state::LocalStateMachine, ) -> LocalStateMachine { match state_machine { - signer_v3_3_0_0_1::v0::signer_state::LocalStateMachine::Uninitialized => { + signer_v3_3_0_0_4::v0::signer_state::LocalStateMachine::Uninitialized => { LocalStateMachine::Uninitialized } - signer_v3_3_0_0_1::v0::signer_state::LocalStateMachine::Pending { prior, update } => { + signer_v3_3_0_0_4::v0::signer_state::LocalStateMachine::Pending { prior, update } => { LocalStateMachine::Pending { - prior: signer_state_machine_v3_3_0_0_1_to_current(prior), - update: signer_state_update_v3_3_0_0_1_to_current(update), + prior: signer_state_machine_v3_3_0_0_4_to_current(prior), + update: signer_state_update_v3_3_0_0_4_to_current(update), } } - signer_v3_3_0_0_1::v0::signer_state::LocalStateMachine::Initialized(machine) => { - LocalStateMachine::Initialized(signer_state_machine_v3_3_0_0_1_to_current(machine)) + signer_v3_3_0_0_4::v0::signer_state::LocalStateMachine::Initialized(machine) => { + LocalStateMachine::Initialized(signer_state_machine_v3_3_0_0_4_to_current(machine)) } } } @@ -137,9 +139,17 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { fn new(c: stacks_signer::config::GlobalConfig) -> Self { if c.endpoint.port() % 2 == 0 { + debug!( + "Spawning current version signer for endpoint {}", + c.endpoint + ); Self::Current(SpawnedSigner::new(c)) } else { - let config = signer_v3_3_0_0_1::config::GlobalConfig { + debug!( + "Spawning v3_3_0_0_4 version signer for endpoint {}", + c.endpoint + ); + let config = signer_v3_3_0_0_4::config::GlobalConfig { node_host: c.node_host, endpoint: c.endpoint, stacks_private_key: serde_json::from_value( @@ -150,7 +160,7 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { serde_json::to_value(&c.stacks_address).unwrap(), ) .unwrap(), - network: signer_v3_3_0_0_1::config::Network::Testnet, + network: signer_v3_3_0_0_4::config::Network::Testnet, event_timeout: c.event_timeout, auth_password: c.auth_password, db_path: c.db_path, @@ -171,16 +181,17 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { reset_replay_set_after_fork_blocks: c.reset_replay_set_after_fork_blocks, stackerdb_timeout: c.stackerdb_timeout, supported_signer_protocol_version: c.supported_signer_protocol_version, + read_count_idle_timeout: c.read_count_idle_timeout, }; - Self::V33001(signer_v3_3_0_0_1::v0::SpawnedSigner::new(config)) + Self::V33004(signer_v3_3_0_0_4::v0::SpawnedSigner::new(config)) } } fn try_recv(&self) -> Self::ReceiveResult { match self { - MultiverSpawnedSigner::V33001(spawned_signer) => { + MultiverSpawnedSigner::V33004(spawned_signer) => { let result = spawned_signer.res_recv.try_recv().map_err(|_| ()); - ReceiveResult::V33001(result) + ReceiveResult::V33004(result) } MultiverSpawnedSigner::Current(spawned_signer) => { ReceiveResult::Current(spawned_signer.res_recv.try_recv()) @@ -190,7 +201,7 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { fn stop(self) -> Option { match self { - MultiverSpawnedSigner::V33001(spawned_signer) => spawned_signer.stop().map(|_| ()), + MultiverSpawnedSigner::V33004(spawned_signer) => spawned_signer.stop().map(|_| ()), MultiverSpawnedSigner::Current(spawned_signer) => spawned_signer.stop().map(|_| ()), } } @@ -199,13 +210,13 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { result: Self::ReceiveResult, ) -> Option { match result { - ReceiveResult::V33001(signer_result) => { - let Ok(signer_v3_3_0_0_1::runloop::SignerResult::StatusCheck(state_info)) = + ReceiveResult::V33004(signer_result) => { + let Ok(signer_v3_3_0_0_4::runloop::SignerResult::StatusCheck(state_info)) = signer_result else { return None; }; - let signer_v3_3_0_0_1::runloop::StateInfo { + let signer_v3_3_0_0_4::runloop::StateInfo { runloop_state, reward_cycle_info, running_signers, @@ -215,11 +226,11 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { } = state_info; Some(StateInfo { runloop_state: match runloop_state { - signer_v3_3_0_0_1::runloop::State::Uninitialized => State::Uninitialized, - signer_v3_3_0_0_1::runloop::State::NoRegisteredSigners => { + signer_v3_3_0_0_4::runloop::State::Uninitialized => State::Uninitialized, + signer_v3_3_0_0_4::runloop::State::NoRegisteredSigners => { State::NoRegisteredSigners } - signer_v3_3_0_0_1::runloop::State::RegisteredSigners => { + signer_v3_3_0_0_4::runloop::State::RegisteredSigners => { State::RegisteredSigners } }, @@ -247,7 +258,7 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { *i, machine .as_ref() - .map(local_state_machine_v3_3_0_0_1_to_current), + .map(local_state_machine_v3_3_0_0_4_to_current), ) }) .collect(), @@ -319,13 +330,10 @@ fn old_version_parses_new_messages() { as_block_accepted.response_data.reject_reason.to_string(), new_msg.response_data.reject_reason.to_string() ); + let empty_vec: Vec = vec![]; assert_eq!( - as_block_accepted.response_data.unknown_bytes, - new_msg - .response_data - .tenure_extend_read_count_timestamp - .to_be_bytes() - .to_vec() + as_block_accepted.response_data.unknown_bytes, // No difference between versions at the moment + empty_vec ); let serialized_old_msg = old_msg.serialize_to_vec(); @@ -403,7 +411,6 @@ fn with_new_miners(supported_signer_protocol_version: u64 .submit_transfer_tx(&sender_sk, send_fee, send_amt) .unwrap(); wait_for(120, || { - thread::sleep(Duration::from_secs(1)); Ok(get_account(&signer_test.running_nodes.rpc_origin(), &sender_addr).nonce > nonce) }) .expect("Timed out waiting for interim block to be mined"); @@ -455,3 +462,297 @@ fn with_new_miners(supported_signer_protocol_version: u64 get_chain_info(&signer_test.running_nodes.conf) ); } + +#[test] +#[ignore] // Remove when ready to run in CI +/// Test with 40% new signers and 60% old signers that the chain still progresses +/// with ALL signers signing the proposed blocks. +fn mixed_signer_set_40_percent_new_60_percent_old() { + // We want: 40% new (current), 60% old (v3.3.0.0.4) + // Let's use 10 signers total for clean percentages: 4 new, 6 old + assert!( + OldSupportedVersion < NewSupportedVersion, + "Test setup error: old supported version should be less than new supported version" + ); + let num_signers = 10; + let num_new_signers = 4; // 40% + let num_old_signers = 6; // 60% + + // Let's just do 3 tenures + let nmb_tenures = 3; + + let btc_miner_seed = vec![2, 2, 2, 2]; + let btc_miner_pk = Keychain::default(btc_miner_seed.clone()).get_pub_key(); + + let localhost = "127.0.0.1"; + let node_rpc_port = 30500; + let node_p2p_port = 30501; + + // Custom signer config: decide old vs new based on signer index + let signer_test: SignerTest = SignerTest::new_with_config_modifications( + num_signers, + vec![], + |signer_config| { + // Use port to determine version: even port -> current (new), odd -> old + // Initially the signer port number is simply sequential + let signer_index = signer_config.endpoint.port() % num_signers as u16; + // Update the ports to enforce versioning + let base_port = 40000 + signer_index * 10; + let endpoint_port = if signer_index < num_new_signers as u16 { + signer_config.supported_signer_protocol_version = NewSupportedVersion; + base_port // even: new version + } else { + signer_config.supported_signer_protocol_version = OldSupportedVersion; + base_port + 1 // odd: old version + }; + signer_config.endpoint = format!("{localhost}:{endpoint_port}").parse().unwrap(); + }, + |node_config| { + node_config.node.rpc_bind = format!("{localhost}:{node_rpc_port}"); + node_config.node.p2p_bind = format!("{localhost}:{node_p2p_port}"); + node_config.node.data_url = format!("http://{localhost}:{node_rpc_port}"); + node_config.node.p2p_address = format!("{localhost}:{node_p2p_port}"); + + node_config.node.seed = btc_miner_seed.clone(); + node_config.node.local_peer_seed = btc_miner_seed.clone(); + node_config.burnchain.local_mining_public_key = Some(btc_miner_pk.to_hex()); + + node_config.miner.wait_on_interim_blocks = Some(Duration::from_secs(10)); + node_config.burnchain.pox_reward_length = Some(30); + node_config.node.pox_sync_sample_secs = 30; + }, + Some(vec![btc_miner_pk.clone()]), + None, + ); + info!("--------------- Started mixed signer test: {num_new_signers} new, {num_old_signers} old signers ---------------"); + // Boot to Nakamoto (Epoch 3.0) + signer_test.boot_to_epoch_3(); + let start_chain = get_chain_info(&signer_test.running_nodes.conf); + test_observer::clear(); + + // Mine several Nakamoto tenures to trigger signing rounds + for tenure in 1..=nmb_tenures { + info!("--------------- Mining Nakamoto tenure #{tenure} ---------------"); + + // Mine a tenure-start block (should trigger signer voting) + signer_test.mine_nakamoto_block(Duration::from_secs(60), false); + } + // Wait for all signatures to come in from every signer per tenure + wait_for(60, || { + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let nmb_signatures = stackerdb_events + .iter() + .map(|ev| ev.modified_slots.iter()) + .flatten() + .filter_map(|chunk| { + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + return None; + }; + let SignerMessage::BlockResponse(BlockResponse::Accepted(accepted)) = message + else { + return None; + }; + Some(accepted) + }) + .count(); + Ok(nmb_signatures >= num_signers * nmb_tenures) + }) + .expect("Expected every signer to sign every tenure-start block"); + // Now analyze stackerdb events to count old vs new BlockResponse versions + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let state_machine_updates = stackerdb_events + .iter() + .map(|ev| ev.modified_slots.iter()) + .flatten() + .filter_map(|chunk| { + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + return None; + }; + let SignerMessage::StateMachineUpdate(update) = message else { + return None; + }; + Some(update) + }) + .collect::>(); + info!("--------------- StackerDB State Machine Updates ---------------"); + let nmb_old_versions = state_machine_updates + .iter() + .filter(|update| update.local_supported_signer_protocol_version == OldSupportedVersion) + .count(); + let nmb_current_versions = state_machine_updates + .iter() + .filter(|update| update.local_supported_signer_protocol_version == NewSupportedVersion) + .count(); + assert!( + nmb_old_versions >= state_machine_updates.len() * 6 / 10, + "Expected 60% of signers to be configured to support only the old protocol version" + ); + assert!( + nmb_current_versions >= state_machine_updates.len() * 4 / 10, + "Expected 40% of signers to be configured to support the new protocol version" + ); + + let end_chain = get_chain_info(&signer_test.running_nodes.conf); + info!("Final chain info: {end_chain:#?}",); + + assert_eq!( + end_chain.burn_block_height, + start_chain.burn_block_height + nmb_tenures as u64, + "Chain should have progressed by {nmb_tenures} burn blocks" + ); + assert_eq!( + end_chain.stacks_tip_height, + start_chain.stacks_tip_height + nmb_tenures as u64, + "Chain should have progressed by {nmb_tenures} stacks blocks", + ); + assert_ne!(end_chain.stacks_tip, start_chain.stacks_tip); +} + +#[test] +#[ignore] // Remove when ready to run in CI +/// Test with 80% new signers and 20% old signers that the chain still progresses +/// with ALL signers signing the proposed blocks. +fn mixed_signer_set_80_percent_new_20_percent_old() { + // We want: 80% new (current), 20% old (v3.3.0.0.4) + // Let's use 10 signers total for clean percentages: 8 new, 2 old + assert!( + OldSupportedVersion < NewSupportedVersion, + "Test setup error: old supported version should be less than new supported version" + ); + let num_signers = 10; + let num_new_signers = 8; // 80% + let num_old_signers = 2; // 20% + + // Let's just do 3 tenures + let nmb_tenures = 3; + + let btc_miner_seed = vec![2, 2, 2, 2]; + let btc_miner_pk = Keychain::default(btc_miner_seed.clone()).get_pub_key(); + + let localhost = "127.0.0.1"; + let node_rpc_port = 30500; + let node_p2p_port = 30501; + + // Custom signer config: decide old vs new based on signer index + let signer_test: SignerTest = SignerTest::new_with_config_modifications( + num_signers, + vec![], + |signer_config| { + // Use port to determine version: even port -> current (new), odd -> old + // Initially the signer port number is simply sequential + let signer_index = signer_config.endpoint.port() % num_signers as u16; + // Update the ports to enforce versioning + let base_port = 40000 + signer_index * 10; + let endpoint_port = if signer_index < num_new_signers as u16 { + signer_config.supported_signer_protocol_version = NewSupportedVersion; + base_port // even: new version + } else { + signer_config.supported_signer_protocol_version = OldSupportedVersion; + base_port + 1 // odd: old version + }; + signer_config.endpoint = format!("{localhost}:{endpoint_port}").parse().unwrap(); + }, + |node_config| { + node_config.node.rpc_bind = format!("{localhost}:{node_rpc_port}"); + node_config.node.p2p_bind = format!("{localhost}:{node_p2p_port}"); + node_config.node.data_url = format!("http://{localhost}:{node_rpc_port}"); + node_config.node.p2p_address = format!("{localhost}:{node_p2p_port}"); + + node_config.node.seed = btc_miner_seed.clone(); + node_config.node.local_peer_seed = btc_miner_seed.clone(); + node_config.burnchain.local_mining_public_key = Some(btc_miner_pk.to_hex()); + + node_config.miner.wait_on_interim_blocks = Some(Duration::from_secs(10)); + node_config.burnchain.pox_reward_length = Some(30); + node_config.node.pox_sync_sample_secs = 30; + }, + Some(vec![btc_miner_pk.clone()]), + None, + ); + info!("--------------- Started mixed signer test: {num_new_signers} new, {num_old_signers} old signers ---------------"); + // Boot to Nakamoto (Epoch 3.0) + signer_test.boot_to_epoch_3(); + let start_chain = get_chain_info(&signer_test.running_nodes.conf); + test_observer::clear(); + + // Mine several Nakamoto tenures to trigger signing rounds + for tenure in 1..=nmb_tenures { + info!("--------------- Mining Nakamoto tenure #{tenure} ---------------"); + + // Mine a tenure-start block (should trigger signer voting) + signer_test.mine_nakamoto_block(Duration::from_secs(60), false); + } + // Wait for all signatures to come in from every signer per tenure + wait_for(60, || { + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let nmb_signatures = stackerdb_events + .iter() + .map(|ev| ev.modified_slots.iter()) + .flatten() + .filter_map(|chunk| { + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + return None; + }; + let SignerMessage::BlockResponse(BlockResponse::Accepted(accepted)) = message + else { + return None; + }; + Some(accepted) + }) + .count(); + Ok(nmb_signatures >= num_signers * nmb_tenures) + }) + .expect("Expected every signer to sign every tenure-start block"); + // Now analyze stackerdb events to count old vs new BlockResponse versions + let stackerdb_events = test_observer::get_stackerdb_chunks(); + let state_machine_updates = stackerdb_events + .iter() + .map(|ev| ev.modified_slots.iter()) + .flatten() + .filter_map(|chunk| { + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + return None; + }; + let SignerMessage::StateMachineUpdate(update) = message else { + return None; + }; + Some(update) + }) + .collect::>(); + info!("--------------- StackerDB State Machine Updates ---------------"); + let nmb_old_versions = state_machine_updates + .iter() + .filter(|update| update.local_supported_signer_protocol_version == OldSupportedVersion) + .count(); + let nmb_current_versions = state_machine_updates + .iter() + .filter(|update| update.local_supported_signer_protocol_version == NewSupportedVersion) + .count(); + assert!( + nmb_old_versions >= state_machine_updates.len() * 2 / 10, + "Expected 20% of signers to be configured to support only the old protocol version" + ); + assert!( + nmb_current_versions >= state_machine_updates.len() * 8 / 10, + "Expected 80% of signers to be configured to support the new protocol version" + ); + + let end_chain = get_chain_info(&signer_test.running_nodes.conf); + info!("Final chain info: {end_chain:#?}",); + + assert_eq!( + end_chain.burn_block_height, + start_chain.burn_block_height + nmb_tenures as u64, + "Chain should have progressed by {nmb_tenures} burn blocks" + ); + assert_eq!( + end_chain.stacks_tip_height, + start_chain.stacks_tip_height + nmb_tenures as u64, + "Chain should have progressed by {nmb_tenures} stacks blocks", + ); + assert_ne!(end_chain.stacks_tip, start_chain.stacks_tip); +} diff --git a/stacks-node/src/tests/signer/v0.rs b/stacks-node/src/tests/signer/v0.rs index 9b6f50e5da5..c16fe821138 100644 --- a/stacks-node/src/tests/signer/v0.rs +++ b/stacks-node/src/tests/signer/v0.rs @@ -1,4 +1,4 @@ -// Copyright (C) 2020-2024 Stacks Open Internet Foundation +// Copyright (C) 2020-2026 Stacks Open Internet Foundation // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -1571,24 +1571,35 @@ pub fn wait_for_state_machine_update( signer_addresses: &[(StacksAddress, u64)], ) -> Result<(), String> { wait_for(timeout_secs, || { - let mut found_updates = HashSet::new(); + let mut found_updates: HashSet = HashSet::new(); let stackerdb_events = test_observer::get_stackerdb_chunks(); for chunk in stackerdb_events .into_iter() .flat_map(|chunk| chunk.modified_slots) { - let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) - .expect("Failed to deserialize SignerMessage"); + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + continue; + }; let SignerMessage::StateMachineUpdate(update) = message else { continue; }; let Some((address, version)) = signer_addresses .iter() - .find(|(addr, _)| chunk.verify(addr).unwrap()) + .find(|(addr, _)| chunk.verify(addr).unwrap_or(false)) else { continue; }; - let (burn_block, burn_block_height, current_miner) = match (version, &update.content) { + + let negotiated = update + .active_signer_protocol_version + .min(update.local_supported_signer_protocol_version); + if negotiated != *version { + continue; + } + + let (burn_block, burn_block_height, current_miner) = match (negotiated, &update.content) + { ( 0, StateMachineUpdateContent::V0 { @@ -1643,7 +1654,7 @@ pub fn wait_for_state_machine_update( } }; // We only need one update to match our conditions - found_updates.insert(address); + found_updates.insert(address.clone()); } Ok(found_updates.len() > signer_addresses.len() * 7 / 10) }) @@ -1656,24 +1667,34 @@ pub fn wait_for_state_machine_update_by_miner_tenure_id( signer_addresses: &[(StacksAddress, u64)], ) -> Result<(), String> { wait_for(timeout_secs, || { - let mut found_updates = HashSet::new(); + let mut found_updates: HashSet = HashSet::new(); let stackerdb_events = test_observer::get_stackerdb_chunks(); for chunk in stackerdb_events .into_iter() .flat_map(|chunk| chunk.modified_slots) { - let message = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) - .expect("Failed to deserialize SignerMessage"); + let Ok(message) = SignerMessage::consensus_deserialize(&mut chunk.data.as_slice()) + else { + continue; + }; let SignerMessage::StateMachineUpdate(update) = message else { continue; }; let Some((address, version)) = signer_addresses .iter() - .find(|(addr, _)| chunk.verify(addr).unwrap()) + .find(|(addr, _)| chunk.verify(addr).unwrap_or(false)) else { continue; }; - match (version, &update.content) { + // Version sanity + expectation: + let negotiated = update + .active_signer_protocol_version + .min(update.local_supported_signer_protocol_version); + debug!("Found update from address {address} with version {version}. We expect version {negotiated}."); + if negotiated != *version { + continue; + } + match (negotiated, &update.content) { ( 0, StateMachineUpdateContent::V0 { @@ -1696,10 +1717,10 @@ pub fn wait_for_state_machine_update_by_miner_tenure_id( }, ) => { if tenure_id == expected_tenure_id { - found_updates.insert(address); + found_updates.insert(address.clone()); } } - (_, _) => {} + (_, _) => continue, }; } Ok(found_updates.len() > signer_addresses.len() * 7 / 10) @@ -1813,7 +1834,14 @@ fn block_proposal_rejection() { if signer_signature_hash == block_signer_signature_hash_1 { found_signer_signature_hash_1 = true; assert_eq!(reason_code, RejectCode::SortitionViewMismatch,); - assert_eq!(response_data.reject_reason, RejectReason::InvalidBitvec); + assert!( + matches!( + response_data.reject_reason, + RejectReason::ConsensusHashMismatch { .. } + ), + "Unexpected reject reason: {}", + response_data.reject_reason + ); } else if signer_signature_hash == block_signer_signature_hash_2 { found_signer_signature_hash_2 = true; assert!(matches!( @@ -7429,7 +7457,8 @@ fn empty_tenure_delayed() { .collect(); assert_eq!(signer_slot_ids.len(), num_signers); - // The miner's proposed block should get rejected by all the signers + // The miner's proposed block should get rejected by all the signers as the consensus hash does not match + // the active miner's tenure id let mut found_rejections = Vec::new(); wait_for(short_timeout.as_secs(), || { for slot_id in signer_slot_ids.iter() { @@ -7455,7 +7484,7 @@ fn empty_tenure_delayed() { })) = latest_msg { assert_eq!(reason_code, RejectCode::SortitionViewMismatch); - assert_eq!(response_data.reject_reason, RejectReason::InvalidMiner); + assert!(matches!(response_data.reject_reason, RejectReason::ConsensusHashMismatch { .. }), "Unexpected reject reason: {}", response_data.reject_reason); assert_eq!(metadata.server_version, VERSION_STRING.to_string()); found_rejections.push(*slot_id); } else { @@ -15559,7 +15588,7 @@ fn non_blocking_minority_configured_to_favour_incoming_miner() { /// - The stacks tip advances to N+3 #[test] #[ignore] -fn non_blocking_minority_configured_to_favour_prev_miner() { +fn non_blocking_minority_configured_to_favour_prev_miner_v1() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -15593,7 +15622,10 @@ fn non_blocking_minority_configured_to_favour_prev_miner() { config.miner.block_commit_delay = Duration::from_secs(0); }, ); - + let all_signers = miners.signer_test.signer_test_pks(); + // Pin all the signers to version 1; + let pinned_signers = all_signers.iter().map(|key| (key.clone(), 1)).collect(); + TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION.set(pinned_signers); let (conf_1, _) = miners.get_node_configs(); let (miner_pk_1, miner_pk_2) = miners.get_miner_public_keys(); let (miner_pkh_1, miner_pkh_2) = miners.get_miner_public_key_hashes(); @@ -15755,6 +15787,247 @@ fn non_blocking_minority_configured_to_favour_prev_miner() { miners.shutdown(); } +/// Test a scenario where a non-blocking majority of signers are configured to favour the previous miner +/// extending their tenure when the incoming miner is slow to propose a block. The incoming miner should succeed +/// and its subsequent blocks should be be approved. +/// Two miners boot to Nakamoto. +/// Miner 1 wins the first tenure A. +/// Miner 1 proposes a block N with a TenureChangeCause::BlockFound +/// Signers accept and the stacks tip advances to N +/// Miner 2 wins the second tenure B. +/// A minority of signers mark miner 2 as invalid. +/// Miner 1 proposes block N+1' with a TenureChangeCause::Extended +/// ALL signers reject block N+1' +/// Miner 2 proposes block N+1 with a TenureChangeCause::BlockFound +/// ALL signers accept block N+1. +/// Miner 2 proposes block N+2 with a transfer tx +/// ALL signers should accept block N+2. +/// Miner 1 wins the third tenure C. +/// Miner 1 proposes block N+3 with a TenureChangeCause::BlockFound +/// Signers accept and the stacks tip advances to N+3 +/// +/// Asserts: +/// - Block N contains the TenureChangeCause::BlockFound +/// - Block N+1' contains a TenureChangeCause::Extended and is rejected +/// - Block N+1 contains the TenureChangeCause::BlockFound +/// - Block N+2 is accepted. +/// - Block N+3 contains the TenureChangeCause::BlockFound. +/// - The stacks tip advances to N+3 +#[test] +#[ignore] +fn non_blocking_minority_configured_to_favour_prev_miner() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let num_signers = 5; + let non_block_minority = num_signers * 2 / 10; + let num_txs = 1; + + let favour_prev_miner_block_proposal_timeout = Duration::from_secs(20); + let favour_incoming_miner_block_proposal_timeout = Duration::from_secs(500); + // Make sure the miner attempts to extend after the minority mark the incoming as invalid + let tenure_extend_wait_timeout = favour_prev_miner_block_proposal_timeout; + + let mut miners = MultipleMinerTest::new_with_config_modifications( + num_signers, + num_txs, + |signer_config| { + let port = signer_config.endpoint.port(); + // Note signer ports are based on the number of them, the first being 3000, the last being 3000 + num_signers - 1 + if port < 3000 + non_block_minority as u16 { + signer_config.block_proposal_timeout = favour_prev_miner_block_proposal_timeout; + } else { + signer_config.block_proposal_timeout = favour_incoming_miner_block_proposal_timeout; + } + }, + |config| { + config.miner.tenure_extend_wait_timeout = tenure_extend_wait_timeout; + config.miner.block_commit_delay = Duration::from_secs(0); + }, + |config| { + config.miner.block_commit_delay = Duration::from_secs(0); + }, + ); + let all_signers = miners.signer_test.signer_test_pks(); + let non_blocking_minority_signers = &all_signers[..non_block_minority]; + let non_blocking_signer_versions: Vec<_> = miners + .signer_test + .signer_addresses_versions() + .into_iter() + .filter(|(address, _)| { + non_blocking_minority_signers + .iter() + .find(|pubkey| &StacksAddress::p2pkh(false, pubkey) == address) + .is_some() + }) + .collect(); + let (conf_1, _) = miners.get_node_configs(); + let (miner_pk_1, miner_pk_2) = miners.get_miner_public_keys(); + let (miner_pkh_1, miner_pkh_2) = miners.get_miner_public_key_hashes(); + + let rl1_skip_commit_op = miners + .signer_test + .running_nodes + .counters + .naka_skip_commit_op + .clone(); + let rl2_skip_commit_op = miners.rl2_counters.naka_skip_commit_op.clone(); + + info!("------------------------- Pause Miner 2's Block Commits -------------------------"); + + // Make sure Miner 2 cannot win a sortition at first. + rl2_skip_commit_op.set(true); + + miners.boot_to_epoch_3(); + + let burnchain = conf_1.get_burnchain(); + let sortdb = burnchain.open_sortition_db(true).unwrap(); + + let get_burn_height = || { + SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .block_height + }; + let starting_peer_height = get_chain_info(&conf_1).stacks_tip_height; + let starting_burn_height = get_burn_height(); + let mut btc_blocks_mined = 0; + + info!("------------------------- Pause Miner 1's Block Commit -------------------------"); + // Make sure miner 1 doesn't submit any further block commits for the next tenure BEFORE mining the bitcoin block + rl1_skip_commit_op.set(true); + + info!("------------------------- Miner 1 Mines a Normal Tenure A -------------------------"); + miners + .mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 30) + .expect("Failed to mine BTC block and Tenure Change Tx Block"); + btc_blocks_mined += 1; + + // assure we have a successful sortition that miner 1 won + verify_sortition_winner(&sortdb, &miner_pkh_1); + + info!("------------------------- Submit Miner 2 Block Commit -------------------------"); + miners.submit_commit_miner_2(&sortdb); + // Pause the block proposal broadcast so that miner 2 will be unable to broadcast its + // tenure change proposal BEFORE miner 1 attempts to extend. + TEST_BROADCAST_PROPOSAL_STALL.set(vec![miner_pk_2.clone()]); + + let stacks_height_before = miners.get_peer_stacks_tip_height(); + info!("------------------------- Miner 2 Wins Tenure B -------------------------"; + "stacks_height_before" => %stacks_height_before); + test_observer::clear(); + miners + .mine_bitcoin_blocks_and_confirm(&sortdb, 1, 30) + .expect("Failed to start Tenure B"); + btc_blocks_mined += 1; + + assert_eq!(stacks_height_before, miners.get_peer_stacks_tip_height()); + + // assure we have a successful sortition that miner 2 won + verify_sortition_winner(&sortdb, &miner_pkh_2); + info!( + "------------------------- Wait for Miner 1 to think Miner 2 is Invalid -------------------------" + ); + // Make sure that miner 1 thinks miner 2 is invalid. + std::thread::sleep(tenure_extend_wait_timeout.add(Duration::from_secs(1))); + let get_burn_consensus_hash = || { + SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) + .unwrap() + .consensus_hash + }; + // Lets make sure our non blocking minority tries to mark the miner invalid + wait_for_state_machine_update( + 30, + &get_burn_consensus_hash(), + miners.get_peer_info().burn_block_height, + Some((miner_pkh_1.clone(), stacks_height_before - 1)), + &non_blocking_signer_versions, + ) + .expect("Timed out waiting for minority signers to send a state update"); + + info!("------------------------- Wait for Miner 1's Block N+1' to be Proposed ------------------------"; + "stacks_height_before" => %stacks_height_before); + + let miner_1_block_n_1_prime = + wait_for_block_proposal(30, stacks_height_before + 1, &miner_pk_1) + .expect("Miner 1 failed to propose block N+1'"); + assert!(miner_1_block_n_1_prime + .try_get_tenure_change_payload() + .unwrap() + .cause + .is_eq(&TenureChangeCause::Extended)); + + info!("------------------------- Verify that Miner 1's Block N+1' was Rejected by ALL signers ------------------------"); + wait_for_block_rejections_from_signers( + 30, + &miner_1_block_n_1_prime.header.signer_signature_hash(), + &all_signers, + ) + .expect("Failed to reach rejection consensus for Miner 1's Block N+1'"); + + assert_eq!(stacks_height_before, miners.get_peer_stacks_tip_height()); + + info!("------------------------- Wait for Miner 2's Block N+1 BlockFound to be Proposed and Approved------------------------"; + "stacks_height_before" => %stacks_height_before + ); + + TEST_BROADCAST_PROPOSAL_STALL.set(vec![]); + + let miner_2_block_n_1 = + wait_for_block_pushed_by_miner_key(30, stacks_height_before + 1, &miner_pk_2) + .expect("Miner 2's block N+1 was not mined"); + let peer_info = miners.get_peer_info(); + assert_eq!(peer_info.stacks_tip, miner_2_block_n_1.header.block_hash()); + assert_eq!(peer_info.stacks_tip_height, stacks_height_before + 1); + + info!("------------------------- Verify ALL the Signer's Accepted Miner 2's Block N+1 -------------------------"); + wait_for_block_acceptance_from_signers( + 30, + &miner_2_block_n_1.header.signer_signature_hash(), + &all_signers, + ) + .expect("Failed to get expected acceptances for Miner 2's block N+1."); + info!( + "------------------------- Verify BlockFound in Miner 2's Block N+1 -------------------------" + ); + verify_last_block_contains_tenure_change_tx(TenureChangeCause::BlockFound); + + info!("------------------------- Miner 2 Mines Block N+2 with Transfer Tx -------------------------"); + let stacks_height_before = miners.get_peer_stacks_tip_height(); + miners + .send_and_mine_transfer_tx(30) + .expect("Failed to Mine Block N+2"); + + let miner_2_block_n_2 = + wait_for_block_pushed_by_miner_key(30, stacks_height_before + 1, &miner_pk_2) + .expect("Miner 2's block N+1 was not mined"); + let peer_info = miners.get_peer_info(); + assert_eq!(peer_info.stacks_tip, miner_2_block_n_2.header.block_hash()); + assert_eq!(peer_info.stacks_tip_height, stacks_height_before + 1); + + info!("------------------------- Unpause Miner 1's Block Commits -------------------------"); + miners.submit_commit_miner_1(&sortdb); + + info!("------------------------- Miner 1 Mines a Normal Tenure C -------------------------"); + miners + .mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 30) + .expect("Failed to start Tenure C and mine block N+3"); + btc_blocks_mined += 1; + + // assure we have a successful sortition that miner 1 won + verify_sortition_winner(&sortdb, &miner_pkh_1); + + info!( + "------------------------- Confirm Burn and Stacks Block Heights -------------------------" + ); + assert_eq!(get_burn_height(), starting_burn_height + btc_blocks_mined); + assert_eq!( + miners.get_peer_stacks_tip_height(), + starting_peer_height + 4 + ); + miners.shutdown(); +} + /// Test a scenario where: /// Two miners boot to Nakamoto. /// Sortition occurs. Miner 1 wins. @@ -17885,136 +18158,193 @@ fn reorging_signers_capitulate_to_nonreorging_signers_during_tenure_fork() { ); } -/// Tests that signers are able to upgrade or downgrade their active protocol version numbers based on -/// the majority of other signers current local supported version numbers -#[test] -#[ignore] -fn rollover_signer_protocol_version() { +fn mine_burn_block_and_confirm_signer_rollover( + signer_test: &mut SignerTest, + expected_versions: &[(StacksAddress, u64)], +) { + test_observer::clear(); + TEST_MINE_SKIP.set(true); + info!("------------------------- Mine Bitcoin Block -------------------------"); + signer_test.mine_bitcoin_block(); + let tip = SortitionDB::get_canonical_burn_chain_tip( + signer_test + .running_nodes + .conf + .get_burnchain() + .open_sortition_db(true) + .unwrap() + .conn(), + ) + .unwrap(); + wait_for_state_machine_update( + 60, + &tip.consensus_hash, + tip.block_height, + None, + &expected_versions, + ) + .expect("Timed out waiting for signers to send their state updates after a bitcoin block"); + let info = signer_test.get_peer_info(); + info!( + "------------------------- Mine Tenure Change Stacks Transaction -------------------------" + ); + TEST_MINE_SKIP.set(false); + let expected_miner = StacksPublicKey::from_private( + &signer_test + .running_nodes + .conf + .miner + .mining_key + .clone() + .unwrap(), + ); + + let block = wait_for_block_pushed_by_miner_key(60, info.stacks_tip_height + 1, &expected_miner) + .expect("Failed to mine block after upgraded version number."); + wait_for_block_acceptance_from_signers( + 30, + &block.header.signer_signature_hash(), + &signer_test.signer_test_pks(), + ) + .expect("Failed to confirm all signers accepted block"); +} + +/// Tests that signers negotiate their **active** signer protocol version based on the +/// **majority of locally supported** signer protocol versions. +/// +/// Scenario (10 signers): +/// 1) Baseline: all signers start on `SUPPORTED_SIGNER_PROTOCOL_VERSION` and can sign blocks. +/// 2) Downgrade 30%: non-blocking minority downgraded. Stays at `SUPPORTED_SIGNER_PROTOCOL_VERSION`. +/// 3) Downgrade 40%: blocking minority downgraded. Downgrades to `SUPPORTED_SIGNER_PROTOCOL_VERSION - 1`. +/// 4) Upgrade 70%: majority upgraded. Upgrades back to `SUPPORTED_SIGNER_PROTOCOL_VERSION`. +#[test] +#[ignore] +fn downgrade_signer_protocol_version() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } - let num_signers = 5; + // To make the math easier, use 10 signers + let num_signers: usize = 10; - let signer_test: SignerTest = SignerTest::new(num_signers, vec![]); + // Integer percent helper (e.g. 30 => 3 when num_signers=10) + fn pct(num_signers: usize, percent: usize) -> usize { + (num_signers * percent) / 100 + } + + // Boot with the most recent supported version + let mut signer_test: SignerTest = SignerTest::new(num_signers, vec![]); signer_test.boot_to_epoch_3(); - let conf = signer_test.running_nodes.conf.clone(); + info!( + "------------------------- Confirm miner tenure continues and all signers use protocol {SUPPORTED_SIGNER_PROTOCOL_VERSION} -------------------------" + ); + let versions = signer_test.signer_addresses_versions(); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &versions); - let burnchain = conf.get_burnchain(); - let sortdb = burnchain.open_sortition_db(true).unwrap(); + let downgraded_version = SUPPORTED_SIGNER_PROTOCOL_VERSION.saturating_sub(1); + let downgraded_versions = signer_test + .signer_addresses_versions() + .into_iter() + .map(|(addr, _)| (addr, downgraded_version)) + .collect::>(); + info!("------------------------- Downgrading Signer Versions to {downgraded_version} for 30 Percent of Signers -------------------------"); + // Since only 30 percent downgraded. Active protocol version should stay the same + signer_test + .restart_first_n_signers_with_supported_version(pct(num_signers, 30), downgraded_version); + let versions = signer_test.signer_addresses_versions(); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &versions); + info!("------------------------- Downgrading Signer Versions to {downgraded_version} for 40 Percent of Signers -------------------------"); + // A blocking minority has downgraded, so expect everyone to negotiate to downgraded_version. + signer_test + .restart_first_n_signers_with_supported_version(pct(num_signers, 40), downgraded_version); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &downgraded_versions); - let all_signers = signer_test.signer_test_pks(); - info!( - "------------------------- Miner Tenure Starts and Mines Block N-------------------------" + info!("------------------------- Upgrade Signer Versions to {SUPPORTED_SIGNER_PROTOCOL_VERSION} for 70 Percent of Signers -------------------------"); + signer_test.restart_first_n_signers_with_supported_version( + pct(num_signers, 70), + SUPPORTED_SIGNER_PROTOCOL_VERSION, ); - test_observer::clear(); - signer_test.mine_and_verify_confirmed_naka_block(Duration::from_secs(30), num_signers, true); + let versions = signer_test.signer_addresses_versions(); + // A majority of signers have upgraded, expect everyone to negotiate back to the supported version. + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &versions); + signer_test.shutdown(); +} - let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); - let burn_consensus_hash = tip.consensus_hash; - let burn_height = tip.block_height; +/// Tests the latest activation rollover behavior across different activation +/// percentages, ensuring that block signing continues as old signers can still communicate with new signers. +/// +/// Scenario (10 signers): +/// 1) 30% activated: non-blocking minority activated. +/// 2) 40% activated: blocking minority activated . +/// 3) 60% activated: blocking minority unactivated. +/// 4) 70% activated: non-blocking minority unactivated. +/// 5) 100% activated: all signers activated. +#[test] +#[ignore] +fn rollover_signer_protocol_version() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } - info!("------------------------- Confirm Miner is the Active Miner in Update and All Signers Are Using Protocol Number {SUPPORTED_SIGNER_PROTOCOL_VERSION} -------------------------"); - // Verify that signers first sent a bitcoin block update - wait_for_state_machine_update( - 60, - &burn_consensus_hash, - burn_height, - None, - &signer_test.signer_addresses_versions(), - ) - .expect("Timed out waiting for signers to send a state update for block N"); + // To make the math easier, use 10 signers + let num_signers: usize = 10; - test_observer::clear(); - let downgraded_version = SUPPORTED_SIGNER_PROTOCOL_VERSION.saturating_sub(1); - info!("------------------------- Downgrading Signer Versions to {downgraded_version} for 20 Percent of Signers -------------------------"); - // Take a non blocking minority of signers (20%) and downgrade their version number - let pinned_signers: Vec<_> = all_signers - .iter() - .take(num_signers * 2 / 10) - .cloned() - .collect(); - let pinned_signers_versions: HashMap = pinned_signers - .iter() - .map(|signer| (signer.clone(), downgraded_version)) - .collect(); - TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION.set(pinned_signers_versions); + // Integer percent helper (e.g. 30 => 3 when num_signers=10) + fn pct(num_signers: usize, percent: usize) -> usize { + (num_signers * percent) / 100 + } - info!("------------------------- Confirm Signers Still Manage to Sign a Stacks Block With Misaligned Version Numbers -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(Duration::from_secs(30), num_signers, true); + let old_version = SUPPORTED_SIGNER_PROTOCOL_VERSION.saturating_sub(1); + // Start with 30% of signers activated + let mut signer_test: SignerTest = SignerTest::new_with_config_modifications( + num_signers, + vec![], + |signer_config| { + // 30% of 10 is 3 signers + let activated_signers = pct(num_signers, 30) as u16; - let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); - let burn_consensus_hash = tip.consensus_hash; - let burn_height = tip.block_height; - // Only one signer is downgraded so the active protocol version remains the same. - wait_for_state_machine_update( - 60, - &burn_consensus_hash, - burn_height, + // signer_nmb in [0, num_signers-1] + let signer_nmb = signer_config.endpoint.port() % num_signers as u16; + + let signer_version = if signer_nmb < activated_signers { + SUPPORTED_SIGNER_PROTOCOL_VERSION + } else { + old_version + }; + + signer_config.supported_signer_protocol_version = signer_version; + }, + |_| {}, None, - &signer_test.signer_addresses_versions(), - ) - .expect("Timed out waiting for signers to send their downgraded state update for block N+1"); + None, + ); - test_observer::clear(); - info!("------------------------- Confirm Signer Version Downgrades Fully Once 70 percent of Signers Downgrade -------------------------"); - let pinned_signers: Vec<_> = all_signers - .iter() - .take(num_signers * 7 / 10) - .cloned() - .collect(); - let pinned_signers_versions: HashMap = pinned_signers - .iter() - .map(|signer| (signer.clone(), downgraded_version)) - .collect(); - TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION.set(pinned_signers_versions); + signer_test.boot_to_epoch_3(); - // Not strictly necessary, but makes it easier to logic out if miner doesn't send a proposal until signers are on same page... - TEST_MINE_SKIP.set(true); - info!("------------------------- Confirm Signers Sent Downgraded State Machine Updates -------------------------"); - // Cannot use any built in functions that call mine_nakamoto_block since it expects signer updates matching the majority version and we are manually messing with these versions - signer_test.mine_bitcoin_block(); - let tip = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()).unwrap(); - let burn_consensus_hash = tip.consensus_hash; - let burn_height = tip.block_height; - // Confirm ALL signers downgrade their supported version and then send a corresponding message in that version message - let downgraded_versions: Vec<_> = signer_test + let old_versions: Vec<_> = signer_test .signer_addresses_versions() .into_iter() - .map(|(address, _)| (address, downgraded_version)) + .map(|(address, _)| (address, old_version)) .collect(); - wait_for_state_machine_update( - 60, - &burn_consensus_hash, - burn_height, - None, - &downgraded_versions, - ) - .expect("Timed out waiting for signers to send their state update for block N+2"); - let info = signer_test.get_peer_info(); - info!("------------------------- Confirm Signers Sign The Block After Complete Downgraded Version Number -------------------------"); - TEST_MINE_SKIP.set(false); - let expected_miner = StacksPublicKey::from_private( - &signer_test - .running_nodes - .conf - .miner - .mining_key - .clone() - .unwrap(), - ); - let block = wait_for_block_pushed_by_miner_key(60, info.stacks_tip_height + 1, &expected_miner) - .expect("Failed to mine block after downgraded version number."); - // Expect ALL signers even after downgrade to approve the proposed blocks - wait_for_block_acceptance_from_signers(30, &block.header.signer_signature_hash(), &all_signers) - .expect("Failed to confirm all signers accepted last block"); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &old_versions); - info!("------------------------- Reset All Signers to {SUPPORTED_SIGNER_PROTOCOL_VERSION} -------------------------"); - TEST_PIN_SUPPORTED_SIGNER_PROTOCOL_VERSION.set(HashMap::new()); - test_observer::clear(); - info!("------------------------- Confirm Signers Sign The Block After Upgraded Version Number -------------------------"); - signer_test.mine_and_verify_confirmed_naka_block(Duration::from_secs(30), num_signers, true); + for percent in [40, 60, 70] { + info!("------------------------- Upgrade to {SUPPORTED_SIGNER_PROTOCOL_VERSION} for {percent}% of signers -------------------------"); + signer_test.restart_first_n_signers_with_supported_version( + pct(num_signers, percent), + SUPPORTED_SIGNER_PROTOCOL_VERSION, + ); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &old_versions); + } + + info!("------------------------- Upgrade to {SUPPORTED_SIGNER_PROTOCOL_VERSION} for 100% of signers -------------------------"); + signer_test.restart_first_n_signers_with_supported_version( + num_signers, + SUPPORTED_SIGNER_PROTOCOL_VERSION, + ); + let new_versions = signer_test.signer_addresses_versions(); + mine_burn_block_and_confirm_signer_rollover(&mut signer_test, &new_versions); signer_test.shutdown(); } @@ -18867,19 +19197,37 @@ fn signers_do_not_commit_unless_threshold_precommitted() { let ignore_signers: Vec<_> = ignore_slice.to_vec(); let pre_commit_signers: Vec<_> = pre_commit_slice.to_vec(); TEST_IGNORE_ALL_BLOCK_PROPOSALS.set(ignore_signers); + // Pause the miner proposing blocks because we need to make sure that the global signer state updates before we evaluate the proposal to ensure + // it is isn't immediately rejected under global signer state conditions + TEST_BROADCAST_PROPOSAL_STALL.set(vec![miner_pk.clone()]); test_observer::clear(); let blocks_before = test_observer::get_mined_nakamoto_blocks().len(); let height_before = signer_test.get_peer_info().stacks_tip_height; + info!("------------------------- Start Tenure A -------------------------"); next_block_and( &mut signer_test.running_nodes.btc_regtest_controller, 30, || Ok(test_observer::get_mined_nakamoto_blocks().len() > blocks_before), ) .unwrap(); + let peer_after = signer_test.get_peer_info(); + wait_for_state_machine_update( + 30, + &peer_after.pox_consensus, + peer_after.burn_block_height, + None, + &signer_test.signer_addresses_versions(), + ) + .expect("Failed to update to tenure A"); + TEST_BROADCAST_PROPOSAL_STALL.set(vec![]); + + info!("------------------------- Wait for block proposal N -------------------------"); let proposal = wait_for_block_proposal(30, height_before + 1, &miner_pk) .expect("Timed out waiting for block proposal"); let hash = proposal.header.signer_signature_hash(); + + info!("------------------------- Wait for pre-commits for N ({hash})-------------------------"); wait_for_block_pre_commits_from_signers(30, &hash, &pre_commit_signers) .expect("Timed out waiting for pre-commits"); assert!( @@ -19535,6 +19883,180 @@ fn tenure_extend_after_stale_commit_same_miner_then_no_winner() { signer_test.shutdown(); } +#[test] +#[ignore] +fn read_count_extend_after_burn_view_change() { + 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 num_txs = 5; + let idle_timeout = Duration::from_secs(30); + let mut miners = MultipleMinerTest::new_with_config_modifications( + num_signers, + num_txs, + |signer_config| { + signer_config.block_proposal_timeout = Duration::from_secs(60); + signer_config.first_proposal_burn_block_timing = Duration::from_secs(0); + // use a different timeout to ensure that the correct timeout + // is read by the miner + signer_config.tenure_idle_timeout = Duration::from_secs(36000); + signer_config.read_count_idle_timeout = idle_timeout; + }, + |config| { + config.miner.block_commit_delay = Duration::from_secs(0); + let epochs = config.burnchain.epochs.as_mut().unwrap(); + let epoch_30_height = epochs[StacksEpochId::Epoch30].start_height; + epochs[StacksEpochId::Epoch30].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch31].start_height = epoch_30_height; + epochs[StacksEpochId::Epoch31].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch32].start_height = epoch_30_height; + epochs[StacksEpochId::Epoch32].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch33].start_height = epoch_30_height; + }, + |config| { + config.miner.block_commit_delay = Duration::from_secs(0); + config.miner.tenure_extend_cost_threshold = 0; + config.miner.read_count_extend_cost_threshold = 0; + + let epochs = config.burnchain.epochs.as_mut().unwrap(); + let epoch_30_height = epochs[StacksEpochId::Epoch30].start_height; + epochs[StacksEpochId::Epoch30].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch31].start_height = epoch_30_height; + epochs[StacksEpochId::Epoch31].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch32].start_height = epoch_30_height; + epochs[StacksEpochId::Epoch32].end_height = epoch_30_height; + epochs[StacksEpochId::Epoch33].start_height = epoch_30_height; + }, + ); + + let (conf_1, _) = miners.get_node_configs(); + let (miner_pk_1, _) = miners.get_miner_public_keys(); + let (miner_pkh_1, miner_pkh_2) = miners.get_miner_public_key_hashes(); + + miners.pause_commits_miner_2(); + miners.boot_to_epoch_3(); + + miners.pause_commits_miner_1(); + + let sortdb = conf_1.get_burnchain().open_sortition_db(true).unwrap(); + + miners + .mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 60) + .unwrap(); + + miners.submit_commit_miner_1(&sortdb); + + info!("------------------------- Miner 1 Wins Tenure A -------------------------"); + miners + .mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 60) + .unwrap(); + verify_sortition_winner(&sortdb, &miner_pkh_1); + miners.send_and_mine_transfer_tx(60).unwrap(); + let tip_a_height = miners.get_peer_stacks_tip_height(); + let prev_tip = get_chain_info(&conf_1); + + info!("------------------------- Miner 2 Wins Tenure B -------------------------"); + miners.submit_commit_miner_2(&sortdb); + miners + .mine_bitcoin_block_and_tenure_change_tx(&sortdb, TenureChangeCause::BlockFound, 60) + .unwrap(); + verify_sortition_winner(&sortdb, &miner_pkh_2); + miners.send_and_mine_transfer_tx(60).unwrap(); + let tip_b_height = miners.get_peer_stacks_tip_height(); + let tenure_b_ch = miners.get_peer_stacks_tip_ch(); + + info!("------------------------- Miner 1 Wins Tenure C with stale commit -------------------------"); + + // We can't use `submit_commit_miner_1` here because we are using the stale view + { + TEST_MINER_COMMIT_TIP.set(Some((prev_tip.pox_consensus, prev_tip.stacks_tip))); + let rl1_commits_before = miners + .signer_test + .running_nodes + .counters + .naka_submitted_commits + .load(Ordering::SeqCst); + + miners + .signer_test + .running_nodes + .counters + .naka_skip_commit_op + .set(false); + + wait_for(30, || { + let commits_after = miners + .signer_test + .running_nodes + .counters + .naka_submitted_commits + .load(Ordering::SeqCst); + let last_commit_tip = miners + .signer_test + .running_nodes + .counters + .naka_submitted_commit_last_stacks_tip + .load(Ordering::SeqCst); + + Ok(commits_after > rl1_commits_before && last_commit_tip == prev_tip.stacks_tip_height) + }) + .expect("Timed out waiting for miner 1 to submit a commit op"); + + miners + .signer_test + .running_nodes + .counters + .naka_skip_commit_op + .set(true); + TEST_MINER_COMMIT_TIP.set(None); + } + + miners + .mine_bitcoin_blocks_and_confirm(&sortdb, 1, 60) + .unwrap(); + verify_sortition_winner(&sortdb, &miner_pkh_1); + + info!( + "------------------------- Miner 1's proposal for C is rejected -------------------------" + ); + let proposed_block = wait_for_block_proposal(60, tip_a_height + 1, &miner_pk_1).unwrap(); + wait_for_block_global_rejection( + 60, + &proposed_block.header.signer_signature_hash(), + num_signers, + ) + .unwrap(); + + assert_eq!(miners.get_peer_stacks_tip_ch(), tenure_b_ch); + + info!("------------------------- Miner 2 Extends Tenure B -------------------------"); + wait_for_tenure_change_tx(60, TenureChangeCause::Extended, tip_b_height + 1).unwrap(); + + let final_height = miners.get_peer_stacks_tip_height(); + assert_eq!(miners.get_peer_stacks_tip_ch(), tenure_b_ch); + assert!(final_height >= tip_b_height + 1); + + info!("---- Waiting for a tenure extend ----"); + + // Now, wait for a block with a tenure extend + wait_for(idle_timeout.as_secs() + 10, || { + Ok(last_block_contains_tenure_change_tx( + TenureChangeCause::ExtendedReadCount, + )) + }) + .expect("Timed out waiting for a block with a tenure extend"); + + miners.shutdown(); +} + #[test] #[ignore] /// Scenario: burn block arrives while miner is in the middle of mining a block. diff --git a/stacks-signer/CHANGELOG.md b/stacks-signer/CHANGELOG.md index 632a924a8d7..d9ab23687cf 100644 --- a/stacks-signer/CHANGELOG.md +++ b/stacks-signer/CHANGELOG.md @@ -5,11 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to the versioning scheme outlined in the [README.md](README.md). -## [Unreleased] +## [3.3.0.0.4.0] + +### Fixed + +- Correct calculation of burn-view change status using the new tip with metadata endpoint. + +## [3.3.0.0.3.0] ### Changed - Avoid sending duplicate block acceptance messages when additional pre-commits arrive +- Upgraded `SUPPORTED_SIGNER_PROTOCOL_VERSION` to 2. +- Set `GLOBAL_SIGNER_STATE_ACTIVATION_VERSION` to 2, activating global signer state agreement block processing. ## [3.3.0.0.2.0] diff --git a/stacks-signer/src/chainstate/mod.rs b/stacks-signer/src/chainstate/mod.rs index 4cd59a71244..83b9d81cb95 100644 --- a/stacks-signer/src/chainstate/mod.rs +++ b/stacks-signer/src/chainstate/mod.rs @@ -365,7 +365,7 @@ impl SortitionData { } let tip = match client.get_tenure_tip(tenure_id) { - Ok(tip) => tip, + Ok(tip) => tip.anchored_header, Err(e) => { warn!( "Failed to fetch the tenure tip for the parent tenure: {e:?}. Assuming proposal is higher than the parent tenure for now."; diff --git a/stacks-signer/src/chainstate/tests/mod.rs b/stacks-signer/src/chainstate/tests/mod.rs index 135745390cf..86b2de6bffb 100644 --- a/stacks-signer/src/chainstate/tests/mod.rs +++ b/stacks-signer/src/chainstate/tests/mod.rs @@ -13,7 +13,37 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; +use blockstack_lib::net::api::get_tenure_tip_meta::BlockHeaderWithMetadata; +use stacks_common::util::secp256k1::{MessageSignature, Secp256k1PrivateKey}; + /// Tests for chainstate v1 implementation mod v1; /// Tests for chainstate v2 implementation mod v2; + +pub fn make_parent_header_meta( + miner_sk: &Secp256k1PrivateKey, + block: &mut NakamotoBlock, +) -> BlockHeaderWithMetadata { + let mut parent_block_header = NakamotoBlockHeader { + version: block.header.version, + chain_length: block.header.chain_length - 1, + burn_spent: block.header.burn_spent, + consensus_hash: block.header.consensus_hash.clone(), + parent_block_id: block.header.parent_block_id.clone(), + tx_merkle_root: block.header.tx_merkle_root.clone(), + state_index_root: block.header.state_index_root, + timestamp: block.header.timestamp, + miner_signature: MessageSignature::empty(), + signer_signature: vec![], + pox_treatment: block.header.pox_treatment.clone(), + }; + + parent_block_header.sign_miner(miner_sk).unwrap(); + block.header.parent_block_id = parent_block_header.block_id(); + BlockHeaderWithMetadata { + anchored_header: parent_block_header.clone().into(), + burn_view: Some(block.header.consensus_hash.clone()), + } +} diff --git a/stacks-signer/src/chainstate/tests/v1.rs b/stacks-signer/src/chainstate/tests/v1.rs index fbd769f8f7f..14d1a56c43d 100644 --- a/stacks-signer/src/chainstate/tests/v1.rs +++ b/stacks-signer/src/chainstate/tests/v1.rs @@ -15,6 +15,8 @@ use std::fs; use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; @@ -26,6 +28,7 @@ use blockstack_lib::chainstate::stacks::{ TransactionSpendingCondition, TransactionVersion, }; use blockstack_lib::core::test_util::make_stacks_transfer_tx; +use blockstack_lib::net::api::get_tenure_tip_meta::BlockHeaderWithMetadata; use blockstack_lib::net::api::get_tenures_fork_info::TenureForkingInfo; use blockstack_lib::net::api::getsortition::SortitionInfo; use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId, StacksAddress}; @@ -43,6 +46,7 @@ use stacks_common::util::hash::{Hash160, Sha512Trunc256Sum}; use stacks_common::util::secp256k1::MessageSignature; use stacks_common::{function_name, info}; +use crate::chainstate::tests::make_parent_header_meta; use crate::chainstate::v1::{SortitionMinerStatus, SortitionState, SortitionsView}; use crate::chainstate::{ProposalEvalConfig, SortitionData}; use crate::client::tests::MockServerClient; @@ -317,9 +321,11 @@ fn reorg_timing_testing( ) }); header_clone.chain_length -= 1; - let response = crate::client::tests::build_get_tenure_tip_response( - &StacksBlockHeaderTypes::Nakamoto(header_clone), - ); + let tenure_tip_resp = BlockHeaderWithMetadata { + burn_view: Some(header_clone.consensus_hash.clone()), + anchored_header: StacksBlockHeaderTypes::Nakamoto(header_clone), + }; + let response = crate::client::tests::build_get_tenure_tip_response(&tenure_tip_resp); crate::client::tests::write_response(server, response.as_bytes()); server = crate::client::tests::mock_server_from_config(&config); @@ -444,93 +450,117 @@ fn make_tenure_change_tx(payload: TenureChangePayload) -> StacksTransaction { } #[test] -fn check_proposal_tenure_extend_invalid_conditions() { - let (stacks_client, mut signer_db, block_sk, mut view, mut block) = - setup_test_environment(function_name!()); - block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.burn_view_consensus_hash = view.cur_sortition.data.consensus_hash.clone(); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) +fn check_tenure_extend_invalid_conditions() { + check_tenure_extend(|view, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.burn_view_consensus_hash = view.cur_sortition.data.consensus_hash.clone(); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) .expect_err("Proposal should not validate"); +} - let mut extend_payload = make_tenure_change_payload(); - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) +#[test] +fn check_tenure_extend_burn_view_change() { + check_tenure_extend(|_view, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) .expect("Proposal should validate"); +} - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedRuntime; - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) +#[test] +fn check_tenure_extend_unsupported_cause() { + check_tenure_extend(|_view, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.cause = TenureChangeCause::ExtendedRuntime; + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) .expect_err("Proposal should not validate on SIP-034 extension"); +} - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedRuntime; - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) +#[test] +fn check_tenure_extend_no_burn_change_during_read_count() { + check_tenure_extend(|view, block| { + // make sure that enough time has passed for the read count to be extended + // if the rest of the proposal was valid (in this case, its not) + view.config.read_count_idle_timeout = Duration::ZERO; + + let mut extend_payload = make_tenure_change_payload(); + extend_payload.cause = TenureChangeCause::ExtendedRuntime; + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) .expect_err("Proposal should not validate"); +} - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedReadCount; - extend_payload.burn_view_consensus_hash = view.cur_sortition.data.consensus_hash.clone(); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); +#[test] +fn check_tenure_extend_read_count() { + check_tenure_extend(|view, block| { + let mut extend_payload = make_tenure_change_payload(); + // make sure that enough time has passed for the read count to be extended + view.config.read_count_idle_timeout = Duration::ZERO; + extend_payload.cause = TenureChangeCause::ExtendedReadCount; + extend_payload.burn_view_consensus_hash = view.cur_sortition.data.consensus_hash.clone(); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) + .expect("Proposal should validate"); +} + +fn check_tenure_extend(make_payload: F) -> Result<(), RejectReason> +where + F: Fn(&mut SortitionsView, &NakamotoBlock) -> TenureChangePayload, +{ + let MockServerClient { + server, + client: stacks_client, + config: _, + } = MockServerClient::new(); + let (_stacks_client, mut signer_db, block_sk, mut view, mut block) = + setup_test_environment(function_name!()); + let mut parent_block_header = make_parent_header_meta(&block_sk, &mut block); + parent_block_header.burn_view = Some(view.cur_sortition.data.consensus_hash.clone()); + let response = crate::client::tests::build_get_tenure_tip_response(&parent_block_header); + + block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); + let mut payload = make_payload(&mut view, &block); + payload.previous_tenure_end = block.header.parent_block_id.clone(); + let tx = make_tenure_change_tx(payload); block.txs = vec![tx]; block.header.sign_miner(&block_sk).unwrap(); - view.config.read_count_idle_timeout = Duration::ZERO; - view.check_proposal( + let exit_flag = Arc::new(AtomicBool::new(false)); + let moved_exit_flag = exit_flag.clone(); + + let serve = std::thread::spawn(move || { + crate::client::tests::write_response_nonblockinig( + &server, + response.as_bytes(), + moved_exit_flag, + ); + }); + + let result = view.check_proposal( &stacks_client, &mut signer_db, &block, false, ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + ); + + exit_flag.store(true, Ordering::SeqCst); + serve.join().unwrap(); + result } #[test] @@ -774,8 +804,18 @@ fn check_proposal_refresh() { #[test] fn check_proposal_with_extend_during_replay() { - let (stacks_client, mut signer_db, block_sk, mut view, mut block) = + let MockServerClient { + server, + client: stacks_client, + config: _, + } = MockServerClient::new(); + + let (_stacks_client, mut signer_db, block_sk, mut view, mut block) = setup_test_environment(function_name!()); + + let parent_block_header = make_parent_header_meta(&block_sk, &mut block); + let response = crate::client::tests::build_get_tenure_tip_response(&parent_block_header); + block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); let mut extend_payload = make_tenure_change_payload(); extend_payload.burn_view_consensus_hash = view.cur_sortition.data.consensus_hash.clone(); @@ -796,6 +836,12 @@ fn check_proposal_with_extend_during_replay() { ); let replay_set = ReplayTransactionSet::new(vec![replay_tx]); block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal(&stacks_client, &mut signer_db, &block, false, replay_set) - .expect("Proposal should validate"); + + let j = std::thread::spawn(move || { + view.check_proposal(&stacks_client, &mut signer_db, &block, false, replay_set) + .expect("Proposal should validate"); + }); + + crate::client::tests::write_response(server, response.as_bytes()); + j.join().unwrap(); } diff --git a/stacks-signer/src/chainstate/tests/v2.rs b/stacks-signer/src/chainstate/tests/v2.rs index 5f541e1e72e..4002ae9058b 100644 --- a/stacks-signer/src/chainstate/tests/v2.rs +++ b/stacks-signer/src/chainstate/tests/v2.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; use std::fs; use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use blockstack_lib::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader}; @@ -45,6 +47,7 @@ use stacks_common::util::hash::{Hash160, Sha512Trunc256Sum}; use stacks_common::util::secp256k1::MessageSignature; use stacks_common::{function_name, info}; +use crate::chainstate::tests::make_parent_header_meta; use crate::chainstate::v2::{GlobalStateView, SortitionState}; use crate::chainstate::{ProposalEvalConfig, SignerChainstateError, SortitionData}; use crate::client::tests::MockServerClient; @@ -374,80 +377,121 @@ fn make_tenure_change_tx(payload: TenureChangePayload) -> StacksTransaction { } } -#[test] -fn check_proposal_tenure_extend() { - let (stacks_client, mut signer_db, block_sk, mut block, cur_sortition, _, mut sortitions_view) = - setup_test_environment(function_name!()); +fn check_tenure_extend(make_payload: F) -> Result<(), RejectReason> +where + F: Fn(&mut SortitionState, &NakamotoBlock) -> TenureChangePayload, +{ + let MockServerClient { + server, + client: stacks_client, + config: _, + } = MockServerClient::new(); + let ( + _stacks_client, + mut signer_db, + block_sk, + mut block, + mut cur_sortition, + _, + mut sortitions_view, + ) = setup_test_environment(function_name!()); block.header.consensus_hash = cur_sortition.data.consensus_hash.clone(); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.burn_view_consensus_hash = cur_sortition.data.consensus_hash.clone(); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect_err("Proposal should not validate"); + let mut parent_block_header = make_parent_header_meta(&block_sk, &mut block); + parent_block_header.burn_view = Some(cur_sortition.data.consensus_hash.clone()); + let response = crate::client::tests::build_get_tenure_tip_response(&parent_block_header); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); + let mut payload = make_payload(&mut cur_sortition, &block); + payload.previous_tenure_end = block.header.parent_block_id.clone(); + let tx = make_tenure_change_tx(payload); block.txs = vec![tx]; - block.header.miner_signature = block_sk - .sign(block.header.miner_signature_hash().as_bytes()) - .unwrap(); - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect("Proposal should validate"); + block.header.sign_miner(&block_sk).unwrap(); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedRuntime; - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.miner_signature = block_sk - .sign(block.header.miner_signature_hash().as_bytes()) - .unwrap(); - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect_err("Proposal should not validate"); + let exit_flag = Arc::new(AtomicBool::new(false)); + let moved_exit_flag = exit_flag.clone(); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedRuntime; - extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.miner_signature = block_sk - .sign(block.header.miner_signature_hash().as_bytes()) - .unwrap(); - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect_err("Proposal should not validate"); + let serve = std::thread::spawn(move || { + crate::client::tests::write_response_nonblockinig( + &server, + response.as_bytes(), + moved_exit_flag, + ); + }); - let mut extend_payload = make_tenure_change_payload(); - extend_payload.cause = TenureChangeCause::ExtendedReadCount; - extend_payload.burn_view_consensus_hash = cur_sortition.data.consensus_hash; - extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); - extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); - let tx = make_tenure_change_tx(extend_payload); - block.txs = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - sortitions_view.config.read_count_idle_timeout = Duration::ZERO; - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect("Proposal should validate"); + sortitions_view.config.read_count_idle_timeout = Duration::from_secs(0); + let result = sortitions_view.check_proposal(&stacks_client, &mut signer_db, &block); + + exit_flag.store(true, Ordering::SeqCst); + serve.join().unwrap(); + result +} + +#[test] +fn check_tenure_extend_burn_view_change() { + check_tenure_extend(|_, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) + .expect("Proposal should validate"); +} + +#[test] +fn check_tenure_extend_unsupported_cause() { + check_tenure_extend(|_, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.cause = TenureChangeCause::ExtendedRuntime; + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) + .expect_err("Proposal should not validate"); +} + +#[test] +fn check_tenure_extend_no_burn_change_during_read_count() { + check_tenure_extend(|_, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.cause = TenureChangeCause::ExtendedRuntime; + extend_payload.burn_view_consensus_hash = ConsensusHash([64; 20]); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) + .expect_err("Proposal should not validate"); +} + +#[test] +fn check_tenure_extend_read_count() { + check_tenure_extend(|view, block| { + let mut extend_payload = make_tenure_change_payload(); + extend_payload.cause = TenureChangeCause::ExtendedReadCount; + extend_payload.burn_view_consensus_hash = view.data.consensus_hash.clone(); + extend_payload.tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload.prev_tenure_consensus_hash = block.header.consensus_hash.clone(); + extend_payload + }) + .expect("Proposal should validate"); } #[test] fn check_proposal_with_extend_during_replay() { - let (stacks_client, mut signer_db, block_sk, mut block, cur_sortition, _, mut sortitions_view) = - setup_test_environment(function_name!()); + let MockServerClient { + server, + client: stacks_client, + config: _, + } = MockServerClient::new(); + + let rand_int = server.local_addr().unwrap().port(); + + let (_, mut signer_db, block_sk, mut block, cur_sortition, _, mut sortitions_view) = + setup_test_environment(&format!("{}_{rand_int}", function_name!())); + + let parent_block_header = make_parent_header_meta(&block_sk, &mut block); + let response = crate::client::tests::build_get_tenure_tip_response(&parent_block_header); + block.header.consensus_hash = cur_sortition.data.consensus_hash.clone(); let mut extend_payload = make_tenure_change_payload(); extend_payload.burn_view_consensus_hash = cur_sortition.data.consensus_hash.clone(); @@ -469,9 +513,14 @@ fn check_proposal_with_extend_during_replay() { sortitions_view.signer_state.tx_replay_set = replay_set; - sortitions_view - .check_proposal(&stacks_client, &mut signer_db, &block) - .expect("Proposal should validate"); + let j = std::thread::spawn(move || { + sortitions_view + .check_proposal(&stacks_client, &mut signer_db, &block) + .expect("Proposal should validate"); + }); + + crate::client::tests::write_response(server, response.as_bytes()); + j.join().unwrap(); } #[test] diff --git a/stacks-signer/src/chainstate/v1.rs b/stacks-signer/src/chainstate/v1.rs index b1d4347fcbe..2383d38fe6c 100644 --- a/stacks-signer/src/chainstate/v1.rs +++ b/stacks-signer/src/chainstate/v1.rs @@ -351,8 +351,16 @@ impl SortitionsView { // (1) if this is the most recent sortition, an extend is allowed if it changes the burnchain view // (2) if this is the most recent sortition, an extend is allowed if enough time has passed to refresh the block limit let sortition_consensus_hash = &proposed_by.state().data.consensus_hash; - let changed_burn_view = - &tenure_extend.burn_view_consensus_hash != sortition_consensus_hash; + let tenure_tip = client.get_tenure_tip(sortition_consensus_hash) + .map_err(|e| { + warn!("Could not load current tenure tip while evaluating a tenure-extend; cannot approve."; "err" => %e); + RejectReason::InvalidTenureExtend + })?; + let Some(current_burn_view) = tenure_tip.burn_view else { + warn!("Tenure-extend attempted in tenure without burn-view."); + return Err(RejectReason::InvalidTenureExtend); + }; + let changed_burn_view = tenure_extend.burn_view_consensus_hash != current_burn_view; let extend_timestamp = signer_db.calculate_full_extend_timestamp( self.config.tenure_idle_timeout, block, @@ -374,6 +382,17 @@ impl SortitionsView { ); return Err(RejectReason::InvalidTenureExtend); } + + warn!( + "Miner block proposal contains a tenure extend, but the conditions for allowing a tenure extend are not met. Considering proposal invalid."; + "proposed_block_consensus_hash" => %block.header.consensus_hash, + "signer_signature_hash" => %block.header.signer_signature_hash(), + "extend_timestamp" => extend_timestamp, + "epoch_time" => epoch_time, + "is_in_replay" => is_in_replay, + "changed_burn_view" => changed_burn_view, + "enough_time_passed" => enough_time_passed, + ); } // is there a read-count tenure extend in this block? @@ -383,8 +402,16 @@ impl SortitionsView { { // burn view changes are not allowed during read-count tenure extends let sortition_consensus_hash = &proposed_by.state().data.consensus_hash; - let changed_burn_view = - &tenure_extend.burn_view_consensus_hash != sortition_consensus_hash; + let tenure_tip = client.get_tenure_tip(sortition_consensus_hash) + .map_err(|e| { + warn!("Could not load current tenure tip while evaluating a tenure-extend; cannot approve."; "err" => %e); + RejectReason::InvalidTenureExtend + })?; + let Some(current_burn_view) = tenure_tip.burn_view else { + warn!("Tenure-extend attempted in tenure without burn-view."); + return Err(RejectReason::InvalidTenureExtend); + }; + let changed_burn_view = tenure_extend.burn_view_consensus_hash != current_burn_view; if changed_burn_view { warn!( "Miner block proposal contains a read-count extend, but the conditions for allowing a tenure extend are not met. Considering proposal invalid."; diff --git a/stacks-signer/src/chainstate/v2.rs b/stacks-signer/src/chainstate/v2.rs index 226b7076359..54063c464a3 100644 --- a/stacks-signer/src/chainstate/v2.rs +++ b/stacks-signer/src/chainstate/v2.rs @@ -208,7 +208,16 @@ impl GlobalStateView { // (1) if this is the most recent sortition, an extend is allowed if it changes the burnchain view // (2) if this is the most recent sortition, an extend is allowed if enough time has passed to refresh the block limit // (3) if we are in replay, an extend is allowed - let changed_burn_view = &tenure_extend.burn_view_consensus_hash != tenure_id; + let tenure_tip = client.get_tenure_tip(tenure_id) + .map_err(|e| { + warn!("Could not load current tenure tip while evaluating a tenure-extend; cannot approve."; "err" => %e); + RejectReason::InvalidTenureExtend + })?; + let Some(current_burn_view) = tenure_tip.burn_view else { + warn!("Tenure-extend attempted in tenure without burn-view."); + return Err(RejectReason::InvalidTenureExtend); + }; + let changed_burn_view = tenure_extend.burn_view_consensus_hash != current_burn_view; let extend_timestamp = signer_db.calculate_full_extend_timestamp( self.config.tenure_idle_timeout, block, @@ -238,7 +247,16 @@ impl GlobalStateView { .filter(|extend| extend.cause.is_read_count_extend()) { // burn view changes are not allowed during read-count tenure extends - let changed_burn_view = &tenure_extend.burn_view_consensus_hash != tenure_id; + let tenure_tip = client.get_tenure_tip(tenure_id) + .map_err(|e| { + warn!("Could not load current tenure tip while evaluating a tenure-extend; cannot approve."; "err" => %e); + RejectReason::InvalidTenureExtend + })?; + let Some(current_burn_view) = tenure_tip.burn_view else { + warn!("Tenure-extend attempted in tenure without burn-view."); + return Err(RejectReason::InvalidTenureExtend); + }; + let changed_burn_view = tenure_extend.burn_view_consensus_hash != current_burn_view; if changed_burn_view { warn!( "Miner block proposal contains a read-count extend, but the conditions for allowing a tenure extend are not met. Considering proposal invalid."; diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index 9c893237be6..6845bfd8e2a 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -134,9 +134,11 @@ pub(crate) mod tests { use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; use blockstack_lib::chainstate::stacks::boot::POX_4_NAME; - use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; + use blockstack_lib::net::api::get_tenure_tip_meta::BlockHeaderWithMetadata; use blockstack_lib::net::api::getinfo::RPCPeerInfoData; use blockstack_lib::net::api::getpoxinfo::{ RPCPoxCurrentCycleInfo, RPCPoxEpoch, RPCPoxInfoData, RPCPoxNextCycleInfo, @@ -172,7 +174,18 @@ pub(crate) mod tests { pub fn new() -> Self { let mut config = GlobalConfig::load_from_file("./src/tests/conf/signer-0.toml").unwrap(); - let (server, mock_server_addr) = mock_server_random(); + let (server, mock_server_addr) = { + let mut iter = 0usize; + loop { + iter += 1; + if let Some(x) = mock_server_random() { + break x; + } + if iter > 10 { + panic!("Could not get a port for mock server"); + } + } + }; config.node_host = mock_server_addr.to_string(); let client = StacksClient::from(&config); @@ -196,13 +209,15 @@ pub(crate) mod tests { } /// Create a mock server on a random port and return the socket addr - pub fn mock_server_random() -> (TcpListener, SocketAddr) { + pub fn mock_server_random() -> Option<(TcpListener, SocketAddr)> { let mut mock_server_addr = SocketAddr::from(([127, 0, 0, 1], 0)); // Ask the OS to assign a random port to listen on by passing 0 - let server = TcpListener::bind(mock_server_addr).unwrap(); + let server = TcpListener::bind(mock_server_addr) + .inspect_err(|e| warn!("Failed to bind mock RPC server"; "err" => ?e)) + .ok()?; mock_server_addr.set_port(server.local_addr().unwrap().port()); - (server, mock_server_addr) + Some((server, mock_server_addr)) } /// Create a mock server on a same port as in the config @@ -222,6 +237,35 @@ pub(crate) mod tests { request_bytes } + /// Continually accept requests and write `bytes` as a response. + /// Exits when exit flag is true + /// Panics on unexpected errors + pub fn write_response_nonblockinig( + mock_server: &TcpListener, + bytes: &[u8], + exit: Arc, + ) { + mock_server.set_nonblocking(true).unwrap(); + let mut request_bytes = [0u8; 1024]; + + while !exit.load(Ordering::SeqCst) { + let mut stream = match mock_server.accept() { + Ok((stream, ..)) => stream, + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + std::thread::sleep(Duration::from_millis(100)); + continue; + } + panic!("Unexpected network error in mock server: {e:?}"); + } + }; + debug!("Reading request..."); + let _ = stream.read(&mut request_bytes).unwrap(); + debug!("Writing a response..."); + stream.write_all(bytes).unwrap(); + } + } + pub fn generate_random_consensus_hash() -> ConsensusHash { let rng = rand::thread_rng(); let bytes: Vec = rng.sample_iter::(Standard).take(20).collect(); @@ -445,7 +489,7 @@ pub(crate) mod tests { } } - pub fn build_get_tenure_tip_response(header_types: &StacksBlockHeaderTypes) -> String { + pub fn build_get_tenure_tip_response(header_types: &BlockHeaderWithMetadata) -> String { let response_json = serde_json::to_string(header_types).expect("Failed to serialize tenure tip info"); format!("HTTP/1.1 200 OK\n\n{response_json}") diff --git a/stacks-signer/src/client/stacks_client.rs b/stacks-signer/src/client/stacks_client.rs index 682e29000b6..1654f6e60be 100644 --- a/stacks-signer/src/client/stacks_client.rs +++ b/stacks-signer/src/client/stacks_client.rs @@ -17,9 +17,9 @@ use std::collections::{HashMap, VecDeque}; use blockstack_lib::chainstate::nakamoto::NakamotoBlock; use blockstack_lib::chainstate::stacks::boot::{NakamotoSignerEntry, SIGNERS_NAME}; -use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; use blockstack_lib::chainstate::stacks::{StacksTransaction, TransactionVersion}; use blockstack_lib::net::api::callreadonly::CallReadOnlyResponse; +use blockstack_lib::net::api::get_tenure_tip_meta::BlockHeaderWithMetadata; use blockstack_lib::net::api::get_tenures_fork_info::{ TenureForkingInfo, RPC_TENURE_FORKING_INFO_PATH, }; @@ -161,7 +161,7 @@ impl StacksClient { pub fn get_tenure_tip( &self, tenure_id: &ConsensusHash, - ) -> Result { + ) -> Result { debug!("StacksClient: Getting tenure tip"; "consensus_hash" => %tenure_id, ); @@ -721,7 +721,10 @@ impl StacksClient { } fn tenure_tip_path(&self, consensus_hash: &ConsensusHash) -> String { - format!("{}/v3/tenures/tip/{consensus_hash}", self.http_origin) + format!( + "{}/v3/tenures/tip_metadata/{consensus_hash}", + self.http_origin + ) } } @@ -736,6 +739,7 @@ mod tests { use blockstack_lib::chainstate::stacks::boot::{ NakamotoSignerEntry, PoxStartCycleInfo, RewardSet, }; + use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; use clarity::types::chainstate::{StacksBlockId, TrieHash}; use clarity::util::hash::Sha512Trunc256Sum; use clarity::util::secp256k1::MessageSignature; @@ -1077,7 +1081,7 @@ mod tests { fn get_tenure_tip_should_succeed() { let mock = MockServerClient::new(); let consensus_hash = ConsensusHash([15; 20]); - let header = StacksBlockHeaderTypes::Nakamoto(NakamotoBlockHeader { + let anchored_header = StacksBlockHeaderTypes::Nakamoto(NakamotoBlockHeader { version: 1, chain_length: 10, burn_spent: 10, @@ -1090,10 +1094,15 @@ mod tests { signer_signature: vec![], pox_treatment: BitVec::ones(1).unwrap(), }); - let response = build_get_tenure_tip_response(&header); + let with_metadata = BlockHeaderWithMetadata { + anchored_header, + burn_view: Some(ConsensusHash([15; 20])), + }; + + let response = build_get_tenure_tip_response(&with_metadata); let h = spawn(move || mock.client.get_tenure_tip(&consensus_hash)); write_response(mock.server, response.as_bytes()); - assert_eq!(h.join().unwrap().unwrap(), header); + assert_eq!(h.join().unwrap().unwrap(), with_metadata); } #[test] diff --git a/stacks-signer/src/tests/signer_state.rs b/stacks-signer/src/tests/signer_state.rs index 77c1a8aaa7f..4678c31bddc 100644 --- a/stacks-signer/src/tests/signer_state.rs +++ b/stacks-signer/src/tests/signer_state.rs @@ -18,6 +18,7 @@ use std::time::{Duration, SystemTime}; use blockstack_lib::chainstate::nakamoto::NakamotoBlockHeader; use blockstack_lib::chainstate::stacks::db::StacksBlockHeaderTypes; +use blockstack_lib::net::api::get_tenure_tip_meta::BlockHeaderWithMetadata; use blockstack_lib::net::api::get_tenures_fork_info::TenureForkingInfo; use blockstack_lib::net::api::getsortition::SortitionInfo; use clarity::types::chainstate::{ @@ -242,11 +243,11 @@ fn check_capitulate_miner_view() { ); }); - let expected_result = StacksBlockHeaderTypes::Nakamoto(NakamotoBlockHeader { + let anchored_header = StacksBlockHeaderTypes::Nakamoto(NakamotoBlockHeader { version: 1, chain_length: parent_tenure_last_block_height, burn_spent: 0, - consensus_hash: parent_tenure_id, + consensus_hash: parent_tenure_id.clone(), parent_block_id: parent_tenure_last_block, tx_merkle_root: Sha512Trunc256Sum([0u8; 32]), state_index_root: TrieHash([0u8; 32]), @@ -256,6 +257,11 @@ fn check_capitulate_miner_view() { pox_treatment: BitVec::ones(1).unwrap(), }); + let expected_result = BlockHeaderWithMetadata { + anchored_header, + burn_view: Some(parent_tenure_id), + }; + let to_send = build_get_tenure_tip_response(&expected_result); for _ in 0..2 { crate::client::tests::write_response(server, to_send.as_bytes()); @@ -471,9 +477,11 @@ fn check_miner_inactivity_timeout() { let to_send_2 = format!("HTTP/1.1 200 OK\n\n{json_payload}"); // Then it will grab the tip of the prior sortition - let expected_result = StacksBlockHeaderTypes::Nakamoto(genesis_block); - let json_payload = serde_json::to_string(&expected_result).unwrap(); - let to_send_3 = format!("HTTP/1.1 200 OK\n\n{json_payload}"); + let expected_result = BlockHeaderWithMetadata { + burn_view: Some(genesis_block.consensus_hash.clone()), + anchored_header: StacksBlockHeaderTypes::Nakamoto(genesis_block), + }; + let to_send_3 = build_get_tenure_tip_response(&expected_result); let MockServerClient { mut server, diff --git a/stacks-signer/src/v0/signer_state.rs b/stacks-signer/src/v0/signer_state.rs index bc2190d5c89..6a63e472700 100644 --- a/stacks-signer/src/v0/signer_state.rs +++ b/stacks-signer/src/v0/signer_state.rs @@ -48,9 +48,9 @@ use crate::client::{ClientError, CurrentAndLastSortition, StackerDB, StacksClien use crate::signerdb::{BlockValidatedByReplaySet, SignerDb}; /// This is the latest supported protocol version for this signer binary -pub static SUPPORTED_SIGNER_PROTOCOL_VERSION: u64 = 1; +pub static SUPPORTED_SIGNER_PROTOCOL_VERSION: u64 = 2; /// The version at which global signer state activates -pub static GLOBAL_SIGNER_STATE_ACTIVATION_VERSION: u64 = u64::MAX; +pub static GLOBAL_SIGNER_STATE_ACTIVATION_VERSION: u64 = 2; /// Vec of pubkeys that should ignore checking for a bitcoin fork #[cfg(any(test, feature = "testing"))] @@ -187,30 +187,23 @@ impl LocalStateMachine { MinerState::NoValidMiner => StateMachineUpdateMinerState::NoValidMiner, }; - let content = match state_machine.active_signer_protocol_version { - 0 => StateMachineUpdateContent::V0 { - burn_block: state_machine.burn_block.clone(), - burn_block_height: state_machine.burn_block_height, - current_miner, - }, - 1 => StateMachineUpdateContent::V1 { - burn_block: state_machine.burn_block.clone(), - burn_block_height: state_machine.burn_block_height, - current_miner, - replay_transactions: state_machine.tx_replay_set.clone().unwrap_or_default(), - }, - 2 => StateMachineUpdateContent::V2 { - burn_block: state_machine.burn_block.clone(), - burn_block_height: state_machine.burn_block_height, - current_miner, - replay_transactions: state_machine.tx_replay_set.clone().unwrap_or_default(), - }, - other => { - return Err(CodecError::DeserializeError(format!( - "Active signer protocol version is unknown: {other}" - ))) - } + let active_signer_protocol_version = state_machine.active_signer_protocol_version; + // Make sure we fall back to our local supported version in the case of the other signers being ahead of us + // so we can still participate/inform the global state what our view point is + let content_version = if active_signer_protocol_version + > local_supported_signer_protocol_version + { + warn!( + "Cannot create state machine update for future protocol version {active_signer_protocol_version}; falling back to supported version {local_supported_signer_protocol_version}" + ); + local_supported_signer_protocol_version + } else { + active_signer_protocol_version }; + + let content = + StateMachineUpdateContent::new(content_version, current_miner, state_machine)?; + StateMachineUpdateMessage::new( state_machine.active_signer_protocol_version, local_supported_signer_protocol_version, @@ -386,8 +379,8 @@ impl LocalStateMachine { .ok() .map(|header| { ( - header.height(), - StacksBlockId::new(parent_tenure_id, &header.block_hash()), + header.anchored_header.height(), + StacksBlockId::new(parent_tenure_id, &header.anchored_header.block_hash()), ) }); let signerdb_last_block = SortitionData::get_tenure_last_block_info( diff --git a/stackslib/src/chainstate/tests/runtime_tests.rs b/stackslib/src/chainstate/tests/runtime_tests.rs index b11db2a6cfc..7b808e47873 100644 --- a/stackslib/src/chainstate/tests/runtime_tests.rs +++ b/stackslib/src/chainstate/tests/runtime_tests.rs @@ -65,113 +65,114 @@ fn variant_coverage_report(variant: RuntimeError) { _ = match variant { Arithmetic(_) => Tested(vec![ - arithmetic_sqrti_neg_cdeploy, - arithmetic_sqrti_neg_ccall, - arithmetic_log2_neg_cdeploy, - arithmetic_log2_neg_ccall, - arithmetic_pow_large_cdeploy, - arithmetic_pow_large_ccall, - arithmetic_pow_neg_cdeploy, - arithmetic_pow_neg_ccall, - arithmetic_zero_n_log_n_cdeploy, - arithmetic_zero_n_log_n_ccall, - ]), + arithmetic_sqrti_neg_cdeploy, + arithmetic_sqrti_neg_ccall, + arithmetic_log2_neg_cdeploy, + arithmetic_log2_neg_ccall, + arithmetic_pow_large_cdeploy, + arithmetic_pow_large_ccall, + arithmetic_pow_neg_cdeploy, + arithmetic_pow_neg_ccall, + arithmetic_zero_n_log_n_cdeploy, + arithmetic_zero_n_log_n_ccall, + ]), ArithmeticOverflow => Tested(vec![ - arithmetic_overflow_pow_at_cdeploy, - arithmetic_overflow_pow_ccall, - arithmetic_overflow_mul_cdeploy, - arithmetic_overflow_mul_ccall, - arithmetic_overflow_add_cdeploy, - arithmetic_overflow_add_ccall, - arithmetic_overflow_to_int_cdeploy, - arithmetic_overflow_to_int_ccall, - ft_mint_overflow, - ]), + arithmetic_overflow_pow_at_cdeploy, + arithmetic_overflow_pow_ccall, + arithmetic_overflow_mul_cdeploy, + arithmetic_overflow_mul_ccall, + arithmetic_overflow_add_cdeploy, + arithmetic_overflow_add_ccall, + arithmetic_overflow_to_int_cdeploy, + arithmetic_overflow_to_int_ccall, + ft_mint_overflow, + ]), ArithmeticUnderflow => Tested(vec![ - to_uint_underflow_cdeploy, - to_uint_underflow_ccall, - sub_underflow_cdeploy, - sub_underflow_ccall, - sub_arg_len_underflow_cdeploy, - sub_arg_len_underflow_ccall, - ]), + to_uint_underflow_cdeploy, + to_uint_underflow_ccall, + sub_underflow_cdeploy, + sub_underflow_ccall, + sub_arg_len_underflow_cdeploy, + sub_arg_len_underflow_ccall, + ]), SupplyOverflow(_, _) => Tested(vec![ft_mint_supply_overflow]), SupplyUnderflow(_, _) => Unreachable_Functionally(" Token supply underflow is prevented by design in Clarity. \ All transfer/mint/burn operations use checked arithmetic and balance \ validation, so negative supply is impossible without manual database corruption." - ), + ), DivisionByZero => Tested(vec![ - division_by_zero_mod_cdeploy, - division_by_zero_mod_ccall, - division_by_zero_cdeploy, - division_by_zero_ccall, - ]), + division_by_zero_mod_cdeploy, + division_by_zero_mod_ccall, + division_by_zero_cdeploy, + division_by_zero_ccall, + ]), TypeParseFailure(_) => Tested(vec![ - parse_tests::test_invalid_principal_literal, - principal_wrong_byte_length, - ]), + parse_tests::test_invalid_principal_literal, + principal_wrong_byte_length, + ]), ASTError(_) => Unreachable_Functionally( - "AST errors cannot occur through normal Clarity operations. \ + "AST errors cannot occur through normal Clarity operations. \ They exist only for CLI and testing functions that bypass AST parsing \ that occurs during a typical contract deploy. These wrapped `ParseError` \ are exhaustively covered by (`parse_tests`)." - ), + ), MaxStackDepthReached => Tested(vec![ - stack_depth_too_deep_call_chain_ccall, - stack_depth_too_deep_call_chain_cdeploy - ]), + stack_depth_too_deep_call_chain_ccall, + stack_depth_too_deep_call_chain_cdeploy + ]), MaxContextDepthReached => Unreachable_Functionally( - "The maximum context depth limit cannot be reached through normal Clarity code. \ + "The maximum context depth limit cannot be reached through normal Clarity code. \ Both the call-stack depth limit and the parser's expression-depth limit \ are significantly lower and will trigger first. Only low-level Rust unit tests \ can construct a context deep enough to hit this error." - ), + ), BadTypeConstruction => Unreachable_Functionally( - "BadTypeConstruction is rejected during static analysis at contract-publish time. \ + "BadTypeConstruction is rejected during static analysis at contract-publish time. \ Any value construction that would produce an ill-formed type fails parsing or \ type-checking before the contract is stored on-chain." - ), + ), BadBlockHeight(_) => Unreachable_Functionally( - "All block heights referenced via `at-block` or `get-block-info?` are guaranteed \ + "All block heights referenced via `at-block` or `get-block-info?` are guaranteed \ to exist in the node's historical database during normal execution. \ This error only surfaces if the chainstate is missing blocks or corrupted." - ), + ), NoSuchToken => Unreachable_Functionally( - "NFT operations return `none` when an instance does not exist. \ + "NFT operations return `none` when an instance does not exist. \ The `NoSuchToken` runtime error is only emitted from internal VM assertions \ and cannot be triggered by regular Clarity code unless storage is manually corrupted." - ), + ), NotImplemented => Unreachable_Functionally( - "Indicates use of an unimplemented VM feature. \ + "Indicates use of an unimplemented VM feature. \ Can only be hit by directly invoking unfinished Rust internals – not reachable from Clarity." - ), + ), NoCallerInContext => Unreachable_Functionally( - "Every function call (public, private, or trait) is executed with a valid caller context. \ + "Every function call (public, private, or trait) is executed with a valid caller context. \ This error only appears when the execution environment is manually constructed incorrectly." - ), + ), NoSenderInContext => Unreachable_Functionally( - "Every on-chain transaction and contract-call has a well-defined sender. \ + "Every on-chain transaction and contract-call has a well-defined sender. \ This error only occurs in malformed test harnesses." - ), + ), BadNameValue(_, _) => Unreachable_Functionally( - "Contract, function, trait, and variable names are fully validated during static analysis at publish time. \ + "Contract, function, trait, and variable names are fully validated during static analysis at publish time. \ The runtime only ever encounters already-validated names. \ Only corrupted state or manual VM manipulation can produce this error." - ), + ), UnknownBlockHeaderHash(_) => Tested(vec![unknown_block_header_hash_fork]), BadBlockHash(_) => Tested(vec![bad_block_hash]), UnwrapFailure => Tested(vec![ - unwrap_err_panic_on_ok_runtime, - unwrap_panic_on_err_runtime - ]), + unwrap_err_panic_on_ok_runtime, + unwrap_panic_on_err_runtime + ]), DefunctPoxContract => Tested(vec![defunct_pox_contracts]), PoxAlreadyLocked => Ignored( - "The active PoX contract already returns ERR_STACKING_ALREADY_STACKED for double-locking attempts. \ + "The active PoX contract already returns ERR_STACKING_ALREADY_STACKED for double-locking attempts. \ The VM-level PoxAlreadyLocked error is only triggerable if locking occurs across PoX boundaries. \ This is better suited for unit testing." - ), + ), BlockTimeNotAvailable => Tested(vec![block_time_not_available]), + BadTokenName(_) => Ignored("Error variant tests should be added"), } } diff --git a/stackslib/src/net/api/get_tenure_tip_meta.rs b/stackslib/src/net/api/get_tenure_tip_meta.rs new file mode 100644 index 00000000000..547810571ff --- /dev/null +++ b/stackslib/src/net/api/get_tenure_tip_meta.rs @@ -0,0 +1,158 @@ +// Copyright (C) 2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use regex::{Captures, Regex}; +use stacks_common::types::chainstate::ConsensusHash; + +use crate::chainstate::nakamoto::NakamotoChainState; +use crate::chainstate::stacks::db::StacksBlockHeaderTypes; +use crate::net::http::{ + parse_json, Error, HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, + HttpResponse, HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, +}; +use crate::net::httpcore::{request, RPCRequestHandler, StacksHttpResponse}; +use crate::net::{Error as NetError, StacksNodeState}; + +#[derive(Clone)] +pub struct NakamotoTenureTipMetadataRequestHandler { + pub(crate) consensus_hash: Option, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct BlockHeaderWithMetadata { + pub anchored_header: StacksBlockHeaderTypes, + pub burn_view: Option, +} + +impl NakamotoTenureTipMetadataRequestHandler { + pub fn new() -> Self { + Self { + consensus_hash: None, + } + } +} + +/// Decode the HTTP request +impl HttpRequest for NakamotoTenureTipMetadataRequestHandler { + fn verb(&self) -> &'static str { + "GET" + } + + fn path_regex(&self) -> Regex { + Regex::new(r#"^/v3/tenures/tip_metadata/(?P[0-9a-f]{40})$"#).unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v3/tenures/tip_metadata/:consensus_hash" + } + + /// Try to decode this request. + /// There's nothing to load here, so just make sure the request is well-formed. + fn try_parse_request( + &mut self, + preamble: &HttpRequestPreamble, + captures: &Captures, + query: Option<&str>, + _body: &[u8], + ) -> Result { + if preamble.get_content_length() != 0 { + return Err(Error::DecodeError( + "Invalid Http request: expected 0-length body".to_string(), + )); + } + let consensus_hash = request::get_consensus_hash(captures, "consensus_hash")?; + self.consensus_hash = Some(consensus_hash); + Ok(HttpRequestContents::new().query_string(query)) + } +} + +impl RPCRequestHandler for NakamotoTenureTipMetadataRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.consensus_hash = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + _contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + let consensus_hash = self + .consensus_hash + .take() + .ok_or(NetError::SendError("`consensus_hash` not set".into()))?; + + let tenure_tip_resp = + node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { + let header_info = + match NakamotoChainState::find_highest_known_block_header_in_tenure( + &chainstate, + sortdb, + &consensus_hash, + ) { + Ok(Some(header)) => header, + Ok(None) => { + let msg = format!("No blocks in tenure {}", &consensus_hash); + debug!("{}", &msg); + return Err(StacksHttpResponse::new_error( + &preamble, + &HttpNotFound::new(msg), + )); + } + Err(e) => { + let msg = format!( + "Failed to query tenure blocks by consensus '{}': {:?}", + consensus_hash, &e + ); + error!("{}", &msg); + return Err(StacksHttpResponse::new_error( + &preamble, + &HttpServerError::new(msg), + )); + } + }; + Ok(header_info) + }); + + let tenure_tip = match tenure_tip_resp { + Ok(tenure_tip) => tenure_tip, + Err(response) => { + return response.try_into_contents().map_err(NetError::from); + } + }; + + let preamble = HttpResponsePreamble::ok_json(&preamble); + let body = HttpResponseContents::try_from_json(&BlockHeaderWithMetadata { + anchored_header: tenure_tip.anchored_header, + burn_view: tenure_tip.burn_view, + })?; + + Ok((preamble, body)) + } +} + +/// Decode the HTTP response +impl HttpResponse for NakamotoTenureTipMetadataRequestHandler { + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let tenure_tip: BlockHeaderWithMetadata = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(tenure_tip)?) + } +} diff --git a/stackslib/src/net/api/gettenuretip.rs b/stackslib/src/net/api/gettenuretip.rs index d150ea240a0..595491c40fe 100644 --- a/stackslib/src/net/api/gettenuretip.rs +++ b/stackslib/src/net/api/gettenuretip.rs @@ -166,8 +166,11 @@ impl StacksHttpResponse { pub fn decode_tenure_tip(self) -> Result { let contents = self.get_http_payload_ok()?; let response_json: serde_json::Value = contents.try_into()?; - let tenure_tip: StacksBlockHeaderTypes = serde_json::from_value(response_json) - .map_err(|_e| Error::DecodeError("Failed to decode JSON".to_string()))?; + let tenure_tip: StacksBlockHeaderTypes = + serde_json::from_value(response_json).map_err(|e| { + error!("Failed to decode JSON"; "err" => ?e); + Error::DecodeError("Failed to decode JSON".to_string()) + })?; Ok(tenure_tip) } } diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index a5777a751d9..8e770f563cc 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -20,6 +20,7 @@ use crate::net::Error as NetError; pub mod blockreplay; pub mod callreadonly; pub mod fastcallreadonly; +pub mod get_tenure_tip_meta; pub mod get_tenures_fork_info; pub mod getaccount; pub mod getattachment; @@ -126,6 +127,9 @@ impl StacksHttp { self.register_rpc_endpoint(gettenure::RPCNakamotoTenureRequestHandler::new()); self.register_rpc_endpoint(gettenureinfo::RPCNakamotoTenureInfoRequestHandler::new()); self.register_rpc_endpoint(gettenuretip::RPCNakamotoTenureTipRequestHandler::new()); + self.register_rpc_endpoint( + get_tenure_tip_meta::NakamotoTenureTipMetadataRequestHandler::new(), + ); self.register_rpc_endpoint(gettenureblocks::RPCNakamotoTenureBlocksRequestHandler::new()); self.register_rpc_endpoint( gettenureblocksbyhash::RPCNakamotoTenureBlocksByHashRequestHandler::new(), diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index aacc86a0fcd..c58d4d1a6ad 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -185,8 +185,8 @@ fn convo_send_recv(sender: &mut ConversationHttp, receiver: &mut ConversationHtt let all_relays_flushed = receiver.num_pending_outbound() == 0 && sender.num_pending_outbound() == 0; - let nw = sender.send(&mut pipe_write).unwrap(); - let nr = receiver.recv(&mut pipe_read).unwrap(); + let nw = sender.send(&mut pipe_write).expect("Invalid send"); + let nr = receiver.recv(&mut pipe_read).expect("Invalid receive"); debug!( "test_rpc: all_relays_flushed = {} ({},{}), nr = {}, nw = {}", diff --git a/versions.toml b/versions.toml index 2f541dd0180..bc23c0af1c2 100644 --- a/versions.toml +++ b/versions.toml @@ -1,4 +1,4 @@ # Update these values when a new release is created. # `stacks-common/build.rs` will automatically update `versions.rs` with these values. -stacks_node_version = "3.3.0.0.2" -stacks_signer_version = "3.3.0.0.2.0" +stacks_node_version = "3.3.0.0.4" +stacks_signer_version = "3.3.0.0.4.0"