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 4541ec0d314..519533012ea 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/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/stacks-node/src/tests/signer/mod.rs b/stacks-node/src/tests/signer/mod.rs index d8db0e960d0..aa64ea9824e 100644 --- a/stacks-node/src/tests/signer/mod.rs +++ b/stacks-node/src/tests/signer/mod.rs @@ -585,7 +585,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 +643,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 +906,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 +987,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()); diff --git a/stacks-node/src/tests/signer/v0.rs b/stacks-node/src/tests/signer/v0.rs index c3aeaa886a0..53ca77eb87c 100644 --- a/stacks-node/src/tests/signer/v0.rs +++ b/stacks-node/src/tests/signer/v0.rs @@ -19566,3 +19566,177 @@ 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(); +} diff --git a/stacks-signer/CHANGELOG.md b/stacks-signer/CHANGELOG.md index 632a924a8d7..cd8410c838f 100644 --- a/stacks-signer/CHANGELOG.md +++ b/stacks-signer/CHANGELOG.md @@ -5,7 +5,13 @@ 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 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..1d9402eaabc 100644 --- a/stacks-signer/src/v0/signer_state.rs +++ b/stacks-signer/src/v0/signer_state.rs @@ -386,8 +386,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"