From 37c311d0e64e4c99d6adbf1dd070e182d07e5779 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 7 Aug 2026 15:12:54 +0200 Subject: [PATCH 01/12] test: add regression test for tx replay removal backward compatibility --- stacks-signer/src/tests/mod.rs | 1 + .../src/tests/tx_replay_removal_compat.rs | 230 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 stacks-signer/src/tests/tx_replay_removal_compat.rs diff --git a/stacks-signer/src/tests/mod.rs b/stacks-signer/src/tests/mod.rs index 837c1a1d5fc..354c63224d6 100644 --- a/stacks-signer/src/tests/mod.rs +++ b/stacks-signer/src/tests/mod.rs @@ -1 +1,2 @@ mod signer_state; +mod tx_replay_removal_compat; diff --git a/stacks-signer/src/tests/tx_replay_removal_compat.rs b/stacks-signer/src/tests/tx_replay_removal_compat.rs new file mode 100644 index 00000000000..2d4b7cb5229 --- /dev/null +++ b/stacks-signer/src/tests/tx_replay_removal_compat.rs @@ -0,0 +1,230 @@ +// 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 . + +//! Compatibility tripwires for the removal of transaction replay. +//! +//! These tests pin the compatibility surfaces that the tx-replay removal must **not** +//! disturb as a first phase. + +use blockstack_lib::chainstate::stacks::{ + StacksTransaction, TokenTransferMemo, TransactionAnchorMode, TransactionAuth, + TransactionPayload, TransactionPostConditionMode, TransactionVersion, +}; +use blockstack_lib::net::api::postblock_proposal::{BlockValidateOk, ValidateRejectCode}; +use clarity::codec::StacksMessageCodec; +use clarity::types::chainstate::{StacksAddress, StacksPrivateKey}; +use clarity::vm::types::PrincipalData; +use libsigner::v0::messages::StateMachineUpdate; +use serde_json::json; + +use crate::signerdb::SignerDb; + +/// Byte layout of a `StateMachineUpdate` carrying a `V2` content payload with an +/// `ActiveMiner`, assembled **by hand** rather than through the Rust types. +/// +/// This is the point of the helper: it encodes the frozen wire format independently of +/// the structs, so it cannot silently drift along with them. If the encoder changes, the +/// round-trip assertion below fails instead of both sides moving together. +fn encode_state_machine_update_v2(replay_txs: &[StacksTransaction]) -> Vec { + let mut content = Vec::new(); + content.extend_from_slice(&[0x55; 20]); // burn_block: ConsensusHash + content.extend_from_slice(&100u64.to_be_bytes()); // burn_block_height + content.push(0x01); // current_miner variant: ActiveMiner + content.extend_from_slice(&[0xab; 20]); // current_miner_pkh: Hash160 + content.extend_from_slice(&[0x44; 20]); // tenure_id: ConsensusHash + content.extend_from_slice(&[0x22; 20]); // parent_tenure_id: ConsensusHash + content.extend_from_slice(&[0x33; 32]); // parent_tenure_last_block: StacksBlockId + content.extend_from_slice(&1u64.to_be_bytes()); // parent_tenure_last_block_height + + // replay_transactions: u32 length prefix, then each transaction + content.extend_from_slice(&(replay_txs.len() as u32).to_be_bytes()); + for tx in replay_txs { + tx.consensus_serialize(&mut content) + .expect("failed to serialize replay transaction"); + } + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&2u64.to_be_bytes()); // active_signer_protocol_version + bytes.extend_from_slice(&2u64.to_be_bytes()); // local_supported_signer_protocol_version + bytes.extend_from_slice(&(content.len() as u32).to_be_bytes()); // content_len + bytes.extend_from_slice(&content); + bytes +} + +fn make_transaction(memo: [u8; 34]) -> StacksTransaction { + let pk = StacksPrivateKey::random(); + StacksTransaction { + version: TransactionVersion::Testnet, + chain_id: 0x80000000, + auth: TransactionAuth::from_p2pkh(&pk).unwrap(), + anchor_mode: TransactionAnchorMode::Any, + post_condition_mode: TransactionPostConditionMode::Allow, + post_conditions: vec![], + payload: TransactionPayload::TokenTransfer( + PrincipalData::from(StacksAddress::burn_address(false)), + 100, + TokenTransferMemo(memo), + ), + } +} + +/// Tripwire 1 — the `StateMachineUpdate` V2 wire format is frozen. +/// +/// A signer that has not upgraded still reads `replay_transactions` off the wire. If a +/// newer binary stops emitting the four-byte empty vector, that read hits EOF, the codec +/// errors, and the **entire** update is dropped — leaving the old signer blind to its +/// peers' burn view and miner state. Emitting the bytes is therefore permanent. +#[test] +fn state_machine_update_v2_wire_format_is_frozen() { + let bytes = encode_state_machine_update_v2(&[]); + + // 20 + 8 + 1 + 20 + 20 + 20 + 32 + 8 + 4 (empty replay vector) = 133 + assert_eq!( + u32::from_be_bytes(bytes[16..20].try_into().unwrap()), + 133, + "V2 content length changed: the wire format is not frozen" + ); + assert_eq!( + &bytes[bytes.len() - 4..], + &[0, 0, 0, 0], + "V2 payload must end in a zero-length replay vector" + ); + + let decoded = StateMachineUpdate::consensus_deserialize(&mut &bytes[..]) + .expect("a V2 update with an empty replay set must decode"); + + let mut reencoded = Vec::new(); + decoded + .consensus_serialize(&mut reencoded) + .expect("re-encoding must succeed"); + assert_eq!( + reencoded, bytes, + "V2 must re-encode byte-identically; older signers parse these bytes" + ); + + assert_eq!(decoded.content.version(), 2); + let (_, burn_block_height) = decoded.content.burn_block_view(); + assert_eq!(burn_block_height, 100); + + // Demonstrate the hazard rather than merely asserting it: drop the empty replay vector + // and re-declare `content_len` accordingly, i.e. exactly the bytes a newer binary would + // emit if it stopped writing the field. The update then fails to decode outright, which + // is what every pre-removal signer on the network would experience. + let mut truncated = bytes[..bytes.len() - 4].to_vec(); + let shortened_content_len = u32::try_from(truncated.len() - 20).unwrap(); + truncated[16..20].copy_from_slice(&shortened_content_len.to_be_bytes()); + assert!( + StateMachineUpdate::consensus_deserialize(&mut &truncated[..]).is_err(), + "omitting the replay vector must break decoding — this is why the four bytes \ + are permanent, not merely conventional" + ); +} + +/// Tripwire 2 — a message from a *pre-removal* signer still decodes. +/// +/// Until every signer has upgraded, peers keep broadcasting populated replay sets. Those +/// messages must still parse, and their non-replay fields must survive intact. +/// +/// NOTE: this asserts decoding **only**, never a byte-identical round trip. After the +/// removal the transactions are read and discarded, so re-encoding legitimately yields the +/// empty vector. Asserting round-trip equality here would pass today and fail at the end of +/// the removal — and the tempting "fix" would be to weaken the test, which is precisely the +/// mistake these tripwires exist to prevent. +#[test] +fn state_machine_update_v2_with_populated_replay_set_still_decodes() { + let txs = vec![make_transaction([1u8; 34]), make_transaction([2u8; 34])]; + let bytes = encode_state_machine_update_v2(&txs); + + let decoded = StateMachineUpdate::consensus_deserialize(&mut &bytes[..]) + .expect("a V2 update carrying a replay set must still decode"); + + assert_eq!(decoded.content.version(), 2); + let (_, burn_block_height) = decoded.content.burn_block_view(); + assert_eq!( + burn_block_height, 100, + "non-replay fields must survive a populated replay set" + ); +} + +/// Tripwire 3 — `/v3/block_proposal` responses still carry the replay fields. +/// +/// `BlockValidateOk` is a plain `Deserialize` with no `#[serde(default)]`, so +/// `replay_tx_exhausted` is **required**. Dropping it from a newer node would make an older +/// signer fail to parse validation responses entirely — silently, since serde's tolerance +/// for *unknown* fields does not extend to *missing* ones. +/// +/// The two assertions are a matched pair: the positive case would fail loudly if the JSON +/// fixture below drifted, so the negative case cannot pass for the wrong reason. +#[test] +fn block_validate_ok_response_still_carries_replay_fields() { + let cost = json!({ + "write_length": 0, "write_count": 0, + "read_length": 0, "read_count": 0, "runtime": 0, + }); + let hash = "0".repeat(64); + + let with_replay_fields = json!({ + "signer_signature_hash": hash, + "cost": cost, + "size": 100, + "validation_time_ms": 10, + "replay_tx_hash": null, + "replay_tx_exhausted": false, + }); + serde_json::from_value::(with_replay_fields) + .expect("a response carrying the replay fields must deserialize"); + + let without_replay_fields = json!({ + "signer_signature_hash": hash, + "cost": cost, + "size": 100, + "validation_time_ms": 10, + }); + assert!( + serde_json::from_value::(without_replay_fields).is_err(), + "replay_tx_exhausted is a required field; a node that stops emitting it \ + breaks block-proposal validation for every signer running an older binary" + ); +} + +/// Tripwire 4 — reject code 7 stays reserved. +/// +/// The discriminant is part of the `/v3/block_proposal` response API. Even once no node +/// emits it, a newer signer must still be able to decode a `7` sent by an older node, so +/// the variant is retained and the number is never reused. +#[test] +fn validate_reject_code_seven_stays_reserved() { + assert_eq!(ValidateRejectCode::InvalidTransactionReplay.to_u8(), 7); + assert_eq!( + ValidateRejectCode::from_u8(7), + Some(ValidateRejectCode::InvalidTransactionReplay), + "code 7 must remain decodable and must never be reassigned" + ); +} + +/// Tripwire 5 — the signer database schema version is pinned. +/// +/// Bumping `SCHEMA_VERSION` is a one-way door: a signer that migrates and then rolls back +/// to an older binary fails to start with "Database schema is newer than SCHEMA_VERSION". +/// Dropping the now-dead `block_validated_by_replay_txs` table is therefore deferred; the +/// table is left in place and only its accessors are removed. +#[test] +fn signer_db_schema_version_is_pinned() { + assert_eq!( + SignerDb::SCHEMA_VERSION, + 19, + "bumping the schema removes the downgrade path; the dead replay table stays for now" + ); +} From 04c9d4c2326e9dcfc73547c220fdfb9237d5c5cd Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 7 Aug 2026 15:18:52 +0200 Subject: [PATCH 02/12] chore: remove tx_replay sample configs --- sample/conf/mainnet-miner-conf.toml | 5 ----- sample/conf/signer/mainnet-signer-conf.toml | 13 ------------- 2 files changed, 18 deletions(-) diff --git a/sample/conf/mainnet-miner-conf.toml b/sample/conf/mainnet-miner-conf.toml index b09f6ff0ced..bb86167f235 100644 --- a/sample/conf/mainnet-miner-conf.toml +++ b/sample/conf/mainnet-miner-conf.toml @@ -364,11 +364,6 @@ wallet_name = "" # --- Advanced / Debugging --- -# Replay expected transactions during block building (experimental). -# WARNING: Cannot be set to true on mainnet (node will fail to start). -# Default: false -# replay_transactions = false - # StackerDB socket timeout for miner operations. # Default: 120 # Units: seconds diff --git a/sample/conf/signer/mainnet-signer-conf.toml b/sample/conf/signer/mainnet-signer-conf.toml index 61432d5aceb..1b7b70adb23 100644 --- a/sample/conf/signer/mainnet-signer-conf.toml +++ b/sample/conf/signer/mainnet-signer-conf.toml @@ -210,19 +210,6 @@ db_path = "/var/lib/stacks-signer/signerdb.sqlite" # Default: false # dry_run = false -# Enforce transaction replay during stacks block validation following a -# bitcoin block reorg (experimental). Ensures that a miner includes the -# expected transactions from reorged stacks blocks that can be replayed. -# Default: false -# validate_with_replay_tx = false - -# Number of bitcoin blocks after a bitcoin fork to reset the replay set. -# Acts as a failsafe to ensure that signers do not permanently prevent -# valid stacks block production based solely on transaction replay. -# Default: 2 -# Units: bitcoin blocks -# reset_replay_set_after_fork_blocks = 2 - # HTTP timeout for StackerDB read/write operations. # Default: 120 # Units: seconds From 07683d3793256d56a97483a881980759917b7ff6 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 7 Aug 2026 15:46:51 +0200 Subject: [PATCH 03/12] chore: drop tx replay integration tests --- .../src/tests/nakamoto_integrations.rs | 294 +- stacks-node/src/tests/signer/mod.rs | 62 - stacks-node/src/tests/signer/v0/mod.rs | 1 - stacks-node/src/tests/signer/v0/tx_replay.rs | 2424 ----------------- 4 files changed, 5 insertions(+), 2776 deletions(-) delete mode 100644 stacks-node/src/tests/signer/v0/tx_replay.rs diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index eff48ab0e1d..806d855ef6d 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -30,10 +30,7 @@ use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, StandardPri use clarity::vm::{ClarityName, ClarityVersion, Value}; use http_types::headers::AUTHORIZATION; use lazy_static::lazy_static; -use libsigner::v0::messages::{ - MessageSlotID, RejectReason, SignerMessage as SignerMessageV0, StateMachineUpdate, - StateMachineUpdateContent, StateMachineUpdateMinerState, -}; +use libsigner::v0::messages::{RejectReason, SignerMessage as SignerMessageV0}; use libsigner::v0::signer_state::ReplayTransactionSet; use libsigner::{SignerSession, StackerDBSession, StacksBlockEvent}; use rand::{thread_rng, Rng}; @@ -74,7 +71,7 @@ use stacks::config::{EventKeyType, InitialBalance}; use stacks::core::mempool::{MemPoolWalkStrategy, MAXIMUM_MEMPOOL_TX_CHAINING}; use stacks::core::test_util::{ insert_tx_in_mempool, make_big_read_count_contract, make_contract_call, - make_contract_publish_versioned, make_stacks_transfer_serialized, make_stacks_transfer_tx, + make_contract_publish_versioned, make_stacks_transfer_serialized, }; use stacks::core::{ EpochList, StacksEpoch, StacksEpochId, BLOCK_LIMIT_MAINNET_10, HELIUM_BLOCK_LIMIT_20, @@ -84,7 +81,7 @@ use stacks::core::{ PEER_VERSION_EPOCH_3_3, PEER_VERSION_EPOCH_3_4, PEER_VERSION_EPOCH_4_0, PEER_VERSION_EPOCH_4_1, PEER_VERSION_TESTNET, }; -use stacks::libstackerdb::{SlotMetadata, StackerDBChunkData}; +use stacks::libstackerdb::SlotMetadata; use stacks::net::api::callreadonly::CallReadOnlyRequestBody; use stacks::net::api::get_tenures_fork_info::TenureForkingInfo; use stacks::net::api::getsigner::GetSignerResponse; @@ -122,9 +119,8 @@ use stacks_signer::v0::SpawnedSigner; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; use crate::nakamoto_node::miner::{ - fault_injection_stall_miner, fault_injection_try_stall_miner, fault_injection_unstall_miner, - TEST_BLOCK_ANNOUNCE_STALL, TEST_BROADCAST_PROPOSAL_STALL, TEST_P2P_BROADCAST_SKIP, - TEST_P2P_BROADCAST_STALL, + fault_injection_stall_miner, fault_injection_unstall_miner, TEST_BLOCK_ANNOUNCE_STALL, + TEST_BROADCAST_PROPOSAL_STALL, TEST_P2P_BROADCAST_SKIP, TEST_P2P_BROADCAST_STALL, }; use crate::nakamoto_node::relayer::TEST_MINER_THREAD_STALL; use crate::neon::Counters; @@ -13429,286 +13425,6 @@ fn empty_mempool_sleep_ms() { run_loop_thread.join().unwrap(); } -#[test] -#[ignore] -/// Test that a miner with config `replay_transactions` set to true and -/// that receives a threshold number of signers indicating they expect the -/// next block to be constructed of the listed replay transactions, it -/// constructs a block of ONLY those transactions -fn miner_constructs_replay_block() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let (mut naka_conf, _miner_account) = naka_neon_integration_conf(None); - let num_senders = 3; - let num_tx_per_sender = 3; - let sender_sks: Vec<_> = (0..num_senders) - .into_iter() - .map(|_| Secp256k1PrivateKey::random()) - .collect(); - let sender_addrs: Vec<_> = sender_sks.iter().map(|sk| tests::to_addr(&sk)).collect(); - let recipient = PrincipalData::from(StacksAddress::burn_address(false)); - let send_amt = 1000; - let send_fee = 180; - let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); - naka_conf.miner.replay_transactions = true; - - for sender_addr in &sender_addrs { - // setup sender for test stx transfers - naka_conf.add_initial_balance( - PrincipalData::from(sender_addr.clone()).to_string(), - (send_amt + send_fee) * num_tx_per_sender, - ); - } - - let signer_sk = Secp256k1PrivateKey::random(); - let signer_addr = tests::to_addr(&signer_sk); - let stacker_sk = setup_stacker(&mut naka_conf); - naka_conf.add_initial_balance(PrincipalData::from(signer_addr.clone()).to_string(), 100000); - - let mut signers = TestSigners::new(vec![signer_sk.clone()]); - - test_observer::spawn(); - test_observer::register( - &mut naka_conf, - &[EventKeyType::AnyEvent, EventKeyType::MinedBlocks], - ); - - let mut btcd_controller = BitcoinCoreController::from_stx_config(&naka_conf); - btcd_controller - .start_bitcoind() - .expect("Failed starting bitcoind"); - let mut btc_regtest_controller = BitcoinRegtestController::new(naka_conf.clone(), None); - btc_regtest_controller.bootstrap_chain(201); - - let mut run_loop = boot_nakamoto::BootRunLoop::new(naka_conf.clone()).unwrap(); - let run_loop_stopper = run_loop.get_termination_switch(); - let Counters { - blocks_processed, - naka_submitted_commits: commits_submitted, - .. - } = run_loop.counters(); - let counters = run_loop.counters(); - - let coord_channel = run_loop.coordinator_channels(); - - let run_loop_thread = thread::spawn(move || run_loop.start(None, 0)); - wait_for_runloop(&blocks_processed); - boot_to_epoch_3( - &naka_conf, - &blocks_processed, - &[stacker_sk.clone()], - &[signer_sk.clone()], - &mut Some(&mut signers), - &mut btc_regtest_controller, - ); - info!("Nakamoto miner started..."); - blind_signer(&naka_conf, &signers, &counters); - - wait_for_first_naka_block_commit(60, &commits_submitted); - - // Pause mining to prevent any of the submitted txs getting mined. - info!("Stalling mining..."); - fault_injection_try_stall_miner(); - let burn_height_before = get_chain_info(&naka_conf).burn_block_height; - // Mine 1 bitcoin block to trigger a new block found transaction - next_block_and(&mut btc_regtest_controller, 60, || { - let burn_height = get_chain_info(&naka_conf).burn_block_height; - Ok(burn_height > burn_height_before) - }) - .expect("Failed to mine bitcoin block"); - - info!( - "Filling mempool with {} txs...", - num_tx_per_sender * num_senders - ); - let mut submitted_txs = HashMap::new(); - for sender_sk in sender_sks { - for sender_nonce in 0..num_tx_per_sender { - let transfer_tx = make_stacks_transfer_tx( - &sender_sk, - sender_nonce, - send_fee, - naka_conf.burnchain.chain_id, - &recipient, - send_amt, - ); - let mut tx_bytes = vec![]; - transfer_tx.consensus_serialize(&mut tx_bytes).unwrap(); - submit_tx(&http_origin, &tx_bytes); - let entry = submitted_txs.entry(sender_nonce).or_insert_with(|| vec![]); - (*entry).push(transfer_tx); - } - } - let nonce_0_txs = submitted_txs.get(&0).unwrap(); - let nonce_1_txs = submitted_txs.get(&1).unwrap(); - let nonce_2_txs = submitted_txs.get(&2).unwrap(); - let succeed_tx_1 = nonce_0_txs[0].clone(); - let succeed_tx_2 = nonce_0_txs[1].clone(); - let fail_tx_3 = nonce_2_txs[1].clone(); - let fail_tx_4 = nonce_2_txs[2].clone(); - let succeed_tx_5 = nonce_1_txs[0].clone(); - let succeed_tx_6 = nonce_2_txs[0].clone(); - // We are not including the third senders nonce 0 transaction nor the second senders nonce 1 transaction therefore attempts to mine either senders nonce 2 transactions will fail. - let replay_transactions = vec![ - succeed_tx_1.clone(), - succeed_tx_2.clone(), - fail_tx_3.clone(), - fail_tx_4.clone(), - succeed_tx_5.clone(), - succeed_tx_6.clone(), - ]; - info!( - "Sending signer state machine update with {} txs...", - replay_transactions.len() - ); - let update = StateMachineUpdate::new( - 1, - 1, - StateMachineUpdateContent::V1 { - burn_block: ConsensusHash([0u8; 20]), - burn_block_height: 1, - current_miner: StateMachineUpdateMinerState::NoValidMiner, - replay_transactions, - }, - ) - .expect("Failed to create update content"); - - let block_height = btc_regtest_controller.get_headers_height(); - let reward_cycle = btc_regtest_controller - .get_burnchain() - .block_height_to_reward_cycle(block_height) - .unwrap(); - write_signer_update( - &naka_conf, - 0, - &signer_sk, - reward_cycle, - update.clone(), - Duration::from_secs(30), - ); - - let observed_before = test_observer::get_mined_nakamoto_blocks().len(); - let blocks_before = test_observer::get_blocks().len(); - assert_eq!(observed_before, 0); - info!("Resuming mining..."); - fault_injection_unstall_miner(); - - info!("Waiting for two stacks block to be mined..."); - wait_for(30, || { - Ok( - test_observer::get_mined_nakamoto_blocks().len() > observed_before + 1 - && test_observer::get_blocks().len() > blocks_before + 1, - ) - }) - .expect("Timed out waiting for two stacks block to be mined"); - - info!("Verifying that a tenure change block was found BEFORE mining the replay txs..."); - let observed_blocks = test_observer::get_mined_nakamoto_blocks(); - let blocks = test_observer::get_blocks(); - let raw_block_found = &blocks[blocks_before]; - let transactions = raw_block_found - .get("transactions") - .unwrap() - .as_array() - .unwrap(); - assert_eq!(transactions.len(), 2); // Should contain a block found and a coinbase - let tx = transactions.first().unwrap(); - let raw_tx = tx.get("raw_tx").unwrap().as_str().unwrap(); - let tx_bytes = hex_bytes(&raw_tx[2..]).unwrap(); - let parsed = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).unwrap(); - let tenure_change = parsed.try_as_tenure_change().unwrap(); - assert!(tenure_change.cause.is_eq(&TenureChangeCause::BlockFound)); - - info!("Verifying next block contains the expected replay txs..."); - let block: StacksBlockEvent = - serde_json::from_value(blocks[blocks_before + 1].clone()).expect("Failed to parse block"); - let tx = block.transactions.get(0).unwrap(); - assert!(matches!( - tx.payload, - TransactionPayload::TenureChange(TenureChangePayload { - cause: TenureChangeCause::Extended, - .. - }) - )); - let block = &observed_blocks[observed_before + 1]; - assert_eq!(block.tx_events.len(), 7); - if let TransactionEvent::Success(tx) = &block.tx_events[1] { - assert_eq!(tx.txid, succeed_tx_1.txid()); - } else { - panic!("Failed to mine the first tx"); - }; - if let TransactionEvent::Success(tx) = &block.tx_events[2] { - assert_eq!(tx.txid, succeed_tx_2.txid()); - } else { - panic!("Failed to mine the second tx"); - }; - if let TransactionEvent::ProcessingError(tx) = &block.tx_events[3] { - assert_eq!(tx.txid, fail_tx_3.txid()); - } else { - panic!("Failed to error on the third tx"); - }; - if let TransactionEvent::ProcessingError(tx) = &block.tx_events[4] { - assert_eq!(tx.txid, fail_tx_4.txid()); - } else { - panic!("Failed to error on the fourth tx"); - }; - if let TransactionEvent::Success(tx) = &block.tx_events[5] { - assert_eq!(tx.txid, succeed_tx_5.txid()); - } else { - panic!("Failed to mine the fifth tx"); - }; - if let TransactionEvent::Success(tx) = &block.tx_events[6] { - assert_eq!(tx.txid, succeed_tx_6.txid()); - } else { - panic!("Failed to mine the sixth tx"); - }; - coord_channel - .lock() - .expect("Mutex poisoned") - .stop_chains_coordinator(); - - run_loop_stopper.store(false, Ordering::SeqCst); - - run_loop_thread.join().unwrap(); -} - -/// Propose a signer update to the miners -fn write_signer_update( - conf: &Config, - signer_slot_id: u32, - signer_sk: &Secp256k1PrivateKey, - reward_cycle: u64, - update: StateMachineUpdate, - timeout: Duration, -) { - let signers_contract_id = - MessageSlotID::StateMachineUpdate.stacker_db_contract(false, reward_cycle); - let mut session = StackerDBSession::new( - &conf.node.rpc_bind, - signers_contract_id, - Duration::from_secs(30), - ); - let message = SignerMessageV0::StateMachineUpdate(update); - - // Submit the update to the signers slot - let mut version = 0; - wait_for(timeout.as_secs(), || { - let mut chunk = - StackerDBChunkData::new(signer_slot_id, version, message.serialize_to_vec()); - chunk - .sign(&signer_sk) - .expect("Failed to sign message chunk"); - debug!("Produced a signature: {:?}", chunk.sig); - let result = session.put_chunk(&chunk).expect("Failed to put chunk"); - version += 1; - debug!("Test Put Chunk ACK: {result:?}"); - Ok(result.accepted) - }) - .expect("Failed to accept signer state update"); -} - /// Test SIP-031 activation /// /// - check epoch 3.2 is active diff --git a/stacks-node/src/tests/signer/mod.rs b/stacks-node/src/tests/signer/mod.rs index 4403332d3b4..feb6b9ffae3 100644 --- a/stacks-node/src/tests/signer/mod.rs +++ b/stacks-node/src/tests/signer/mod.rs @@ -35,7 +35,6 @@ use libsigner::v0::messages::{ use libsigner::v0::signer_state::MinerState; use libsigner::{BlockProposal, SignerEntries, SignerEventTrait}; use serde::{Deserialize, Serialize}; -use stacks::burnchains::Txid; use stacks::chainstate::coordinator::comm::CoordinatorChannels; use stacks::chainstate::nakamoto::signer_set::NakamotoSigners; use stacks::chainstate::nakamoto::NakamotoBlock; @@ -45,7 +44,6 @@ use stacks::config::{Config as NeonConfig, EventKeyType, EventObserverConfig, In use stacks::core::test_util::{ make_contract_call, make_contract_publish, make_stacks_transfer_serialized, }; -use stacks::net::api::getpoxinfo::RPCPoxInfoData; use stacks::net::api::postblock_proposal::{ BlockValidateOk, BlockValidateReject, BlockValidateResponse, }; @@ -1106,35 +1104,6 @@ impl SignerTest { .collect() } - /// Wait for a certain condition to be met for each signer's state machine - pub fn wait_for_signer_state_check( - &self, - timeout: u64, - mut f: impl FnMut(&LocalStateMachine) -> Result, - ) -> Result<(), String> { - wait_for(timeout, || { - let (signer_states, _) = self.get_burn_updated_states(); - let all_pass = signer_states - .iter() - .all(|state| f(state).map_or(false, |ok| ok)); - Ok(all_pass) - }) - } - - pub fn wait_for_replay_set_eq(&self, timeout: u64, expected_txids: Vec) { - self.wait_for_signer_state_check(timeout, |state| { - let Some(replay_set) = state.get_tx_replay_set() else { - return Ok(false); - }; - let txids = replay_set - .iter() - .map(|tx| tx.txid().to_hex()) - .collect::>(); - Ok(txids == expected_txids) - }) - .expect("Timed out waiting for replay set to be equal to expected txids"); - } - /// Replace the test's configured signer st pub fn replace_signers( &mut self, @@ -1649,13 +1618,6 @@ impl SignerTest { .expect("Failed to get peer info") } - /// Get /v2/pox from the node - pub fn get_pox_data(&self) -> RPCPoxInfoData { - self.stacks_client - .get_pox_data() - .expect("Failed to get pox info") - } - pub fn readonly_stackerdb_client(&self, reward_cycle: u64) -> StackerDB { StackerDB::new_normal( &self.running_nodes.conf.node.rpc_bind, @@ -1726,30 +1688,6 @@ impl SignerTest { .expect("Failed to send accept signature"); } - /// Get the txid of the parent block commit transaction for the given miner - pub fn get_parent_block_commit_txid(&self, miner_pk: &StacksPublicKey) -> Option { - let Some(confirmed_utxo) = self - .running_nodes - .btc_regtest_controller - .get_all_utxos(&miner_pk) - .into_iter() - .find(|utxo| utxo.confirmations == 0) - else { - return None; - }; - let unconfirmed_txid = Txid::from_bitcoin_tx_hash(&confirmed_utxo.txid); - let unconfirmed_tx = self - .running_nodes - .btc_regtest_controller - .get_raw_transaction(&unconfirmed_txid); - let parent_txid = &unconfirmed_tx - .input - .get(0) - .expect("First input should exist") - .previous_output - .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); diff --git a/stacks-node/src/tests/signer/v0/mod.rs b/stacks-node/src/tests/signer/v0/mod.rs index c16aa5304ba..a9c60c051fe 100644 --- a/stacks-node/src/tests/signer/v0/mod.rs +++ b/stacks-node/src/tests/signer/v0/mod.rs @@ -131,7 +131,6 @@ pub mod signers_consider_consensus_blocks; pub mod signers_consider_late_proposals; pub mod signers_wait_for_validation; pub mod tenure_extend; -pub mod tx_replay; impl SignerTest { /// Poll until the reward set for the next reward cycle is available. diff --git a/stacks-node/src/tests/signer/v0/tx_replay.rs b/stacks-node/src/tests/signer/v0/tx_replay.rs deleted file mode 100644 index af4bc8ec093..00000000000 --- a/stacks-node/src/tests/signer/v0/tx_replay.rs +++ /dev/null @@ -1,2424 +0,0 @@ -// 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 -// 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 std::env; -use std::sync::atomic::Ordering; -use std::time::Duration; - -use libsigner::v0::messages::RejectReason; -use libsigner::StacksBlockEvent; -use reqwest::header::AUTHORIZATION; -use stacks::burnchains::Txid; -use stacks::chainstate::burn::operations::{BlockstackOperationType, PreStxOp, TransferStxOp}; -use stacks::chainstate::stacks::miner::TEST_EXCLUDE_REPLAY_TXS; -use stacks::chainstate::stacks::{TenureChangeCause, TenureChangePayload, TransactionPayload}; -use stacks::core::test_util::make_big_read_count_contract; -use stacks::core::{StacksEpochId, HELIUM_BLOCK_LIMIT_20}; -use stacks::net::api::gettransaction::TransactionResponse; -use stacks::net::api::postblock_proposal::{ValidateRejectCode, TEST_REJECT_REPLAY_TXS}; -use stacks::types::chainstate::{BurnchainHeaderHash, StacksPublicKey}; -use stacks::util::secp256k1::{Secp256k1PrivateKey, Secp256k1PublicKey}; -use stacks_signer::v0::signer_state::TEST_IGNORE_BITCOIN_FORK_PUBKEYS; -use stacks_signer::v0::SpawnedSigner; - -use super::{SignerTest, *}; -use crate::nakamoto_node::miner::{fault_injection_stall_miner, fault_injection_unstall_miner}; -use crate::operations::BurnchainOpSigner; -use crate::tests::nakamoto_integrations::{next_block_and, wait_for}; -use crate::tests::neon_integrations::{ - get_account, get_chain_info, test_observer, wait_for_tenure_change_tx, -}; -use crate::tests::{self}; -use crate::{BitcoinRegtestController, BurnchainController, Keychain}; -#[test] -#[ignore] -/// Trigger a Bitcoin fork and ensure that the signer -/// both detects the fork and moves into a tx replay state -/// -/// The test flow is: -/// -/// - Mine 10 tenures after epoch 3 -/// - Include a STX transfer in the 10th tenure -/// - Trigger a Bitcoin fork (3 blocks) -/// - Verify that the signer moves into tx replay state -/// - Verify that the signer correctly includes the stx transfer -/// in the tx replay set -/// -/// Then, a second fork scenario is tested, which -/// includes multiple txs across multiple tenures. -fn tx_replay_forking_test() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 100; - let send_fee = 180; - let deploy_fee = 1000000; - let call_fee = 1000; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![( - sender_addr.clone(), - (send_amt + send_fee) * 10 + deploy_fee + call_fee, - )], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 2; - - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - signer_test.check_signer_states_normal(); - - let tip = get_chain_info(conf); - // Make a transfer tx (this will get forked) - let (txid, _) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - let pre_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(pre_fork_1_nonce, 1); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - - let tip_before = signer_test.get_peer_info(); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - - signer_test.wait_for_replay_set_eq(30, vec![txid.clone()]); - - btc_controller.build_next_block(1); - wait_for(30, || { - let tip = signer_test.get_peer_info(); - Ok(tip.stacks_tip_height < tip_before.stacks_tip_height) - }) - .expect("Timed out waiting for stacks tip to decrease"); - - let post_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce; - - signer_test.wait_for_replay_set_eq(30, vec![txid.clone()]); - - // We should have forked 1 tx - assert_eq!(post_fork_1_nonce, pre_fork_1_nonce - 1); - - fault_injection_unstall_miner(); - - // Now, wait for the tx replay set to be cleared - signer_test - .wait_for_signer_state_check(30, |state| { - let tx_replay_set = state.get_tx_replay_set(); - Ok(tx_replay_set.is_none()) - }) - .expect("Timed out waiting for tx replay set to be cleared"); - - // Now, we'll trigger another fork, with more txs, across tenures - - // The forked blocks are: - // Tenure 1: - // - Block with stx transfer - // Tenure 2: - // - Block with contract deploy - // - Block with contract call - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let pre_fork_2_tip = get_chain_info(&conf); - - let contract_code = " - (define-public (call-fn) - (ok true) - ) - "; - let contract_name = "test-contract"; - - let (transfer_txid, transfer_nonce) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .expect("Failed to submit transfer tx"); - signer_test - .wait_for_nonce_increase(&sender_addr, transfer_nonce) - .expect("Failed to wait for nonce increase"); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let (contract_deploy_txid, deploy_nonce) = signer_test - .submit_contract_deploy(&sender_sk, deploy_fee, contract_code, contract_name) - .expect("Failed to submit contract deploy"); - signer_test - .wait_for_nonce_increase(&sender_addr, deploy_nonce) - .expect("Failed to wait for nonce increase"); - - let (contract_call_txid, contract_call_nonce) = signer_test - .submit_contract_call(&sender_sk, call_fee, contract_name, "call-fn", &[]) - .expect("Failed to submit contract call"); - signer_test - .wait_for_nonce_increase(&sender_addr, contract_call_nonce) - .expect("Failed to wait for nonce increase"); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - fault_injection_stall_miner(); - - info!("---- Triggering deeper fork ----"); - - let tip_before = signer_test.get_peer_info(); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(pre_fork_2_tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(4); - - wait_for(30, || { - let tip = signer_test.get_peer_info(); - Ok(tip.stacks_tip_height < tip_before.stacks_tip_height) - }) - .expect("Timed out waiting for stacks tip to decrease"); - - let expected_tx_replay_txids = vec![transfer_txid, contract_deploy_txid, contract_call_txid]; - - signer_test.wait_for_replay_set_eq(30, expected_tx_replay_txids.clone()); - - info!("---- Mining post-fork block to clear tx replay set ----"); - - test_observer::clear(); - - fault_injection_unstall_miner(); - - // Wait for the replay set to be fully cleared (all replayed txs mined) - signer_test - .wait_for_signer_state_check(60, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be cleared"); - - // Verify that all expected replayed txs were mined in the correct - // relative order across the post-fork blocks, and that no other - // user transactions were mined before them. The txs may land in - // different blocks depending on timing, so collect user txids from - // all observed blocks in block-height order and check ordering. - let mined_user_txids: Vec = test_observer::get_blocks() - .iter() - .map(|block| { - let block: StacksBlockEvent = - serde_json::from_value(block.clone()).expect("Failed to parse block"); - block - .transactions - .iter() - .filter(|tx| { - !matches!( - tx.payload, - TransactionPayload::Coinbase(..) | TransactionPayload::TenureChange(..) - ) - }) - .map(|tx| tx.txid().to_hex()) - .collect::>() - }) - .flatten() - .collect(); - - // Replay txs must be the first user transactions mined, in order - assert_eq!( - &mined_user_txids[..expected_tx_replay_txids.len()], - expected_tx_replay_txids.as_slice(), - "Replay txs should be the first user transactions mined, in the expected order" - ); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Trigger a Bitcoin fork and ensure that the signer -/// both detects the fork and moves into a tx replay state -/// and causes the miner to mine the appropriate list of -/// transactions in the subsequent blocks -/// -/// The test flow is: -/// -/// - Mine 10 tenures after epoch 3 -/// - Include a STX transfer in the 10th tenure -/// - Trigger a Bitcoin fork (3 blocks) -/// - Verify that the signer moves into tx replay state -/// - Verify that the signer correctly includes the stx transfer -/// in the tx replay set -/// - Force the miner to ignore replay transactions and attempt -/// to mine a regular block -/// - Verify the signers reject this proposed block due to it -/// missing the replay transactions -/// - Allow the miner to consider the replay transactions -/// - Verify the miner correctly constructs a block containing the -/// tx replay set -/// - Verify the signers approve subsequent blocks -fn tx_replay_reject_invalid_proposals_during_replay() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let sender_sk2 = Secp256k1PrivateKey::from_seed("sender_2".as_bytes()); - let sender_addr2 = tests::to_addr(&sender_sk2); - let send_amt = 100; - let send_fee = 180; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![ - (sender_addr.clone(), send_amt + send_fee), - (sender_addr2, send_amt + send_fee), - ], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - let stacks_miner_pk = StacksPublicKey::from_private(&conf.miner.mining_key.clone().unwrap()); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 2; - - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - let tip = get_chain_info(&conf); - // Make a transfer tx (this will get forked) - let (txid, _) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - let pre_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(pre_fork_1_nonce, 1); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - - signer_test.wait_for_replay_set_eq(30, vec![txid.clone()]); - - let post_fork_1_nonce = get_account(&http_origin, &sender_addr).nonce; - - // We should have forked 1 tx - assert_eq!(post_fork_1_nonce, pre_fork_1_nonce - 1); - - let tip_after_fork = get_chain_info(&conf); - let stacks_height_before = tip_after_fork.stacks_tip_height; - - // Make sure the miner skips replay transactions in its considerations - TEST_EXCLUDE_REPLAY_TXS.set(true); - let (txid_2, _) = signer_test - .submit_transfer_tx(&sender_sk2, send_fee, send_amt) - .unwrap(); - test_observer::clear(); - fault_injection_unstall_miner(); - // First we will get the tenure change block. It shouldn't contain our two transfer transactions. - info!( - "---- Waiting for block pushed at height: {:?} ----", - stacks_height_before + 1 - ); - // This block will just be the tenure change block which signers will approve without issue. - let block = wait_for_block_pushed_by_miner_key(60, stacks_height_before + 1, &stacks_miner_pk) - .expect("Timed out waiting for block pushed after fork"); - assert!(!block.txs().any(|tx| tx.txid().to_string() == txid)); - assert!(!block.txs().any(|tx| tx.txid().to_string() == txid_2)); - info!( - "---- Wait for block proposal at stacks block height {} ----", - stacks_height_before + 2 - ); - // Next the miner will attempt to propose a block that does not contain the necessary replay tx and signers will reject it - let rejected_block = - wait_for_block_proposal_block(30, stacks_height_before + 2, &stacks_miner_pk) - .expect("Timed out waiting for block proposal after fork"); - assert!(rejected_block - .txs() - .any(|tx| tx.txid().to_string() == txid_2)); - info!( - "---- Ensure signers reject block {} due to an invalid transaction replay ----", - rejected_block.header.signer_signature_hash() - ); - wait_for_block_global_rejection_with_reject_reason( - 30, - &rejected_block.header.signer_signature_hash(), - num_signers, - Some(RejectReason::ValidationFailed( - ValidateRejectCode::InvalidTransactionReplay, - )), - ) - .expect("Timed out waiting for global block rejection due to invalid transaction replay"); - TEST_EXCLUDE_REPLAY_TXS.set(false); - info!( - "---- Wait for block pushed at stacks block height {} ----", - stacks_height_before + 2 - ); - let accepted_block = - wait_for_block_pushed_by_miner_key(30, stacks_height_before + 2, &stacks_miner_pk) - .expect("Failed to mine block stacks_height_before + 2"); - info!( - "---- Ensure signers accept block at height {:?} with a valid transaction replay ----", - stacks_height_before + 2 - ); - assert!( - accepted_block.txs().any(|tx| tx.txid().to_string() == txid), - "Block should contain a replay tx" - ); - assert!( - !accepted_block - .txs() - .any(|tx| tx.txid().to_string() == txid_2), - "Block should not contain a non-replay tx" - ); - info!("---- Ensure signers accept block with non-replay tx ----"); - wait_for(30, || { - let blocks = test_observer::get_blocks(); - let block = blocks.last().unwrap(); - let block: StacksBlockEvent = serde_json::from_value(block.clone()).unwrap(); - Ok(block - .transactions - .iter() - .any(|tx| tx.txid().to_string() == txid_2)) - }) - .expect("Timed out waiting for a block with a non-replay tx"); - - info!("---- Ensure signers cleared the tx replay set ----"); - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be cleared"); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Transaction replay test using a stacks-on-bitcoin transaction -/// to demonstrate a replay set that contains an unminable transaction. -/// -/// Test scenario: -/// -/// - Alice sends STX to Bob in a stacks-on-bitcoin transaction -/// - Bob transfers that STX -/// - A fork occurs, which drops Alice's transaction, meaning -/// Bob no longer has STX -/// - The replay set is validated to contain only Bob's transaction -/// - Since the replay set contains no mineable transactions, the -/// replay set is cleared after an initial TenureChange block -fn tx_replay_btc_on_stx_invalidation() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let mut sender_burnop_signer = BurnchainOpSigner::new(sender_sk); - let send_amt = 100; - let send_fee = 180; - let recipient_sk = Secp256k1PrivateKey::from_seed("recipient_1".as_bytes()); - let recipient_addr = tests::to_addr(&recipient_sk); - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr.clone(), (send_amt + send_fee) * 10)], - |c| { - c.validate_with_replay_tx = true; - c.reset_replay_set_after_fork_blocks = 5; - }, - |node_config| { - node_config.node.txindex = true; - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - - let conf = &signer_test.running_nodes.conf; - let mut miner_keychain = Keychain::default(conf.node.seed.clone()).generate_op_signer(); - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let mut btc_controller = BitcoinRegtestController::new(conf.clone(), None); - let submitted_commits = signer_test - .running_nodes - .counters - .naka_submitted_commits - .clone(); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let burnchain = conf.get_burnchain(); - - let tip = signer_test.get_peer_info(); - let pox_info = signer_test.get_pox_data(); - - info!("---- Burnchain ----"; - // "burnchain" => ?conf.burnchain, - "pox_constants" => ?burnchain.pox_constants, - "cycle" => burnchain.pox_constants.reward_cycle_index(0, tip.burn_block_height), - "pox_info" => ?pox_info, - ); - - info!("Submitting first pre-stx op"); - let pre_stx_op = PreStxOp { - output: sender_addr.clone(), - // to be filled in - txid: Txid([0u8; 32]), - vtxindex: 0, - block_height: 0, - burn_header_hash: BurnchainHeaderHash([0u8; 32]), - }; - - assert!( - btc_controller - .submit_operation( - StacksEpochId::Epoch30, - BlockstackOperationType::PreStx(pre_stx_op), - &mut miner_keychain, - ) - .is_ok(), - "Pre-stx operation should submit successfully" - ); - - let pre_fork_tenures = 10; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - info!("Submitting transfer STX op"); - let recipient_balance = send_amt + send_fee; - let transfer_stx_op = TransferStxOp { - sender: sender_addr, - recipient: recipient_addr.clone(), - transfered_ustx: recipient_balance.into(), - memo: vec![], - txid: Txid([0u8; 32]), - vtxindex: 0, - block_height: 0, - burn_header_hash: BurnchainHeaderHash([0u8; 32]), - }; - assert!( - btc_controller - .submit_operation( - StacksEpochId::Epoch30, - BlockstackOperationType::TransferStx(transfer_stx_op), - &mut sender_burnop_signer - ) - .is_ok(), - "Transfer STX operation should submit successfully" - ); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - wait_for(30, || { - let account = get_account(&http_origin, &recipient_addr); - Ok(account.balance == recipient_balance.into()) - }) - .expect("Timed out waiting for balance to be updated"); - - info!("---- Submitting transfer STX from recipient ----"); - - let (txid, recipient_nonce) = signer_test - .submit_transfer_tx(&recipient_sk, send_fee, send_amt) - .unwrap(); - - signer_test - .wait_for_nonce_increase(&recipient_addr, recipient_nonce) - .expect("Timed out waiting for STX transfer from recipient"); - - info!("---- Triggering Bitcoin fork ----"); - - let tip = signer_test.get_peer_info(); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height - 2); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(3); - - fault_injection_stall_miner(); - - // we need to mine some blocks to get back to being considered a frequent miner - for i in 0..3 { - let current_burn_height = get_chain_info(&conf).burn_block_height; - info!( - "Mining block #{i} to be considered a frequent miner"; - "current_burn_height" => current_burn_height, - ); - let commits_count = submitted_commits.load(Ordering::SeqCst); - next_block_and(&btc_controller, 60, || { - Ok(submitted_commits.load(Ordering::SeqCst) > commits_count) - }) - .unwrap(); - } - - info!("---- Wait for tx replay set to be updated ----"); - - signer_test - .wait_for_signer_state_check(30, |state| { - let Some(tx_replay_set) = state.get_tx_replay_set() else { - info!("---- No tx replay set"); - return Ok(false); - }; - let len_ok = tx_replay_set.len() == 1; - let txid_ok = tx_replay_set[0].txid().to_hex() == txid; - info!("---- Signer state check ----"; - "tx_replay_set" => ?tx_replay_set, - "len_ok" => len_ok, - "txid_ok" => txid_ok, - ); - Ok(len_ok && txid_ok) - }) - .expect("Timed out waiting for tx replay set to be updated"); - - info!("---- Waiting for tx replay set to be cleared ----"); - test_observer::clear(); - fault_injection_unstall_miner(); - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be cleared"); - - let mut found_block = false; - // Ensure that we don't mine any of the replay transactions in a sufficient amount of elapsed time - let _ = wait_for(30, || { - let blocks = test_observer::get_blocks(); - for block in blocks { - let block: StacksBlockEvent = - serde_json::from_value(block).expect("Failed to parse block"); - for tx in block.transactions { - match tx.payload { - TransactionPayload::TenureChange(TenureChangePayload { - cause: TenureChangeCause::BlockFound, - .. - }) - | TransactionPayload::Coinbase(..) => { - found_block = true; - } - TransactionPayload::TenureChange(TenureChangePayload { - cause: TenureChangeCause::Extended, - .. - }) => { - continue; - } - _ => { - panic!("We should not see any transactions mined beyond tenure change or coinbase txs"); - } - } - } - } - Ok(false) - }); - - assert!(found_block, "Failed to mine the tenure change block"); - // Ensure that in the 30 seconds, the nonce did not increase. This also asserts that no tx replays were mined. - let account = get_account(&http_origin, &recipient_addr); - assert_eq!(account.nonce, 0, "Expected recipient nonce to be 0"); - - // Call `/v3/transaction/{txid}` and verify that `is_canonical` is false - let get_transaction = |txid: &String| { - let url = &format!("{http_origin}/v3/transaction/{txid}"); - info!("Send request: GET {url}"); - reqwest::blocking::Client::new() - .get(url) - .header( - AUTHORIZATION, - conf.connection_options.auth_token.clone().unwrap(), - ) - .send() - .unwrap_or_else(|e| panic!("GET request failed: {e}")) - .json::() - .unwrap() - }; - - let transaction = get_transaction(&txid); - assert!( - !transaction.is_canonical, - "Expected transaction response to be non-canonical" - ); - assert!( - transaction.block_height.is_none(), - "Expected block height of tx response to be none" - ); - - signer_test.shutdown(); -} - -/// Test scenario to ensure that the replay set is cleared -/// if there have been multiple tenures with a stalled replay set. -/// -/// This test is executed by triggering a fork, and then using -/// a test flag to reject any transaction replay blocks. -/// -/// The test mines a number of burn blocks during replay before -/// validating that the replay set is eventually cleared. -#[ignore] -#[test] -fn tx_replay_failsafe() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 100; - let send_fee = 180; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr.clone(), (send_amt + send_fee) * 10)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - - let conf = &signer_test.running_nodes.conf; - let _http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - let miner_pk = btc_controller - .get_mining_pubkey() - .as_deref() - .map(Secp256k1PublicKey::from_hex) - .unwrap() - .unwrap(); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let burnchain = conf.get_burnchain(); - - let tip = signer_test.get_peer_info(); - let pox_info = signer_test.get_pox_data(); - - info!("---- Burnchain ----"; - // "burnchain" => ?conf.burnchain, - "pox_constants" => ?burnchain.pox_constants, - "cycle" => burnchain.pox_constants.reward_cycle_index(0, tip.burn_block_height), - "pox_info" => ?pox_info, - ); - - let pre_fork_tenures = 3; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - info!("---- Submitting STX transfer ----"); - - let tip = get_chain_info(&conf); - // Make a transfer tx (this will get forked) - let (txid, nonce) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - - // Ensure we got a new block with this tx - signer_test - .wait_for_nonce_increase(&sender_addr, nonce) - .expect("Timed out waiting for transfer tx to be mined"); - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - let tip_before = get_chain_info(&conf); - - info!("---- Triggering Bitcoin fork ----"; - "tip.stacks_tip_height" => tip_before.stacks_tip_height, - "tip.burn_block_height" => tip_before.burn_block_height, - ); - - let mut commit_txid: Option = None; - wait_for(30, || { - let Some(txid) = signer_test.get_parent_block_commit_txid(&miner_pk) else { - return Ok(false); - }; - commit_txid = Some(txid); - Ok(true) - }) - .expect("Failed to get unconfirmed tx"); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_before.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(1); - - fault_injection_stall_miner(); - - // Wait for the block commit re-broadcast to be confirmed - wait_for(10, || { - let is_confirmed = btc_controller.is_transaction_confirmed(commit_txid.as_ref().unwrap()); - Ok(is_confirmed) - }) - .expect("Timed out waiting for transaction to be confirmed"); - - let tip_before = get_chain_info(&conf); - - info!("---- Building next block ----"; - "tip_before.stacks_tip_height" => tip_before.stacks_tip_height, - "tip_before.burn_block_height" => tip_before.burn_block_height, - ); - - btc_controller.build_next_block(1); - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height < tip_before.stacks_tip_height) - }) - .expect("Timed out waiting for next block to be mined"); - - info!("---- Wait for tx replay set to be updated ----"); - - signer_test.wait_for_replay_set_eq(30, vec![txid.clone()]); - - let tip_after_fork = get_chain_info(&conf); - - info!("---- Waiting for two tenures, without replay set cleared ----"; - "tip_after_fork.stacks_tip_height" => tip_after_fork.stacks_tip_height, - "tip_after_fork.burn_block_height" => tip_after_fork.burn_block_height - ); - - TEST_REJECT_REPLAY_TXS.set(true); - fault_injection_unstall_miner(); - - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height > tip_after_fork.stacks_tip_height) - }) - .expect("Timed out waiting for one TenureChange block to be mined"); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_some())) - .expect("Expected replay set to still be set"); - - info!("---- Mining a second tenure ----"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height > tip_after_fork.stacks_tip_height + 1) - }) - .expect("Timed out waiting for a TenureChange block to be mined"); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_some())) - .expect("Expected replay set to still be set"); - - info!("---- Mining a third tenure ----"); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height > tip_after_fork.stacks_tip_height + 2) - }) - .expect("Timed out waiting for a TenureChange block to be mined"); - - info!("---- Waiting for tx replay set to be cleared ----"); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Expected replay set to be cleared"); - - signer_test.shutdown(); -} - -/// Simple/fast test scenario for transaction replay. -/// -/// We fork one tenure, which has a STX transfer. The test -/// verifies that the replay set is updated correctly, and then -/// exits. -#[ignore] -#[test] -fn tx_replay_starts_correctly() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 100; - let send_fee = 180; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr.clone(), (send_amt + send_fee) * 10)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - - let conf = &signer_test.running_nodes.conf; - let _http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let tip = signer_test.get_peer_info(); - - info!("---- Tip ----"; - "tip.stacks_tip_height" => tip.stacks_tip_height, - "tip.burn_block_height" => tip.burn_block_height, - ); - - let pre_fork_tenures = 1; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - info!("---- Submitting STX transfer ----"); - - // let tip = get_chain_info(&conf); - // Make a transfer tx (this will get forked) - let (txid, nonce) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - - // Ensure we got a new block with this tx - signer_test - .wait_for_nonce_increase(&sender_addr, nonce) - .expect("Timed out waiting for transfer tx to be mined"); - - let tip_before = get_chain_info(&conf); - - info!("---- Triggering Bitcoin fork ----"; - "tip.stacks_tip_height" => tip_before.stacks_tip_height, - "tip.burn_block_height" => tip_before.burn_block_height, - "tip.consensus_hash" => %tip_before.pox_consensus, - ); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_before.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height < tip_before.stacks_tip_height) - }) - .expect("Timed out waiting for next block to be mined"); - - let tip = get_chain_info(&conf); - - info!("---- Tip after fork ----"; - "tip.stacks_tip_height" => tip.stacks_tip_height, - "tip.burn_block_height" => tip.burn_block_height, - ); - - info!("---- Wait for tx replay set to be updated ----"); - - signer_test.wait_for_replay_set_eq(5, vec![txid.clone()]); - - signer_test.shutdown(); -} - -/// Test scenario where two signers disagree on the tx replay set, -/// which means there is no consensus on the tx replay set. -#[test] -#[ignore] -fn tx_replay_disagreement() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 100; - let send_fee = 180; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr, (send_amt + send_fee) * 10)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - - let conf = &signer_test.running_nodes.conf; - let _http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Beginning test -------------------------"); - - let miner_pk = btc_controller - .get_mining_pubkey() - .as_deref() - .map(Secp256k1PublicKey::from_hex) - .unwrap() - .unwrap(); - - let pre_fork_tenures = 2; - - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - let ignore_bitcoin_fork_keys = signer_test - .signer_stacks_private_keys - .iter() - .enumerate() - .filter_map(|(i, sk)| { - if i % 2 == 0 { - None - } else { - Some(Secp256k1PublicKey::from_private(sk)) - } - }) - .collect::>(); - TEST_IGNORE_BITCOIN_FORK_PUBKEYS.set(ignore_bitcoin_fork_keys); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - let tip = get_chain_info(&conf); - wait_for_state_machine_update_by_miner_tenure_id( - 30, - &tip.pox_consensus, - &signer_test.signer_addresses_versions(), - ) - .expect("Failed to update signers state machines"); - // Make a transfer tx (this will get forked) - let (txid, _) = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - let mut commit_txid: Option = None; - wait_for(30, || { - let Some(txid) = signer_test.get_parent_block_commit_txid(&miner_pk) else { - return Ok(false); - }; - commit_txid = Some(txid); - Ok(true) - }) - .expect("Failed to get unconfirmed tx"); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(1); - - // Wait for the block commit re-broadcast to be confirmed - wait_for(10, || { - let is_confirmed = btc_controller.is_transaction_confirmed(commit_txid.as_ref().unwrap()); - Ok(is_confirmed) - }) - .expect("Timed out waiting for transaction to be confirmed"); - - let tip_before = get_chain_info(&conf); - - info!("---- Building next block ----"; - "tip_before.stacks_tip_height" => tip_before.stacks_tip_height, - "tip_before.burn_block_height" => tip_before.burn_block_height, - ); - - btc_controller.build_next_block(1); - wait_for(30, || { - let tip = get_chain_info(&conf); - Ok(tip.stacks_tip_height < tip_before.stacks_tip_height) - }) - .expect("Timed out waiting for next block to be mined"); - - fault_injection_stall_miner(); - - btc_controller.build_next_block(1); - - // Wait for the signer states to be updated. Odd indexed signers - // should not have a replay set. - wait_for(30, || { - let (signer_states, _) = signer_test.get_burn_updated_states(); - let all_pass = signer_states.iter().enumerate().all(|(i, state)| { - if i % 2 == 0 { - let Some(tx_replay_set) = state.get_tx_replay_set() else { - return false; - }; - tx_replay_set.len() == 1 && tx_replay_set[0].txid().to_hex() == txid - } else { - state.get_tx_replay_set().is_none() - } - }); - Ok(all_pass) - }) - .expect("Timed out waiting for signer states to be updated"); - - let tip = get_chain_info(&conf); - - fault_injection_unstall_miner(); - - // Now, wait for the tx replay set to be cleared - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height >= tip.stacks_tip_height + 2) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - signer_test - .wait_for_signer_state_check(30, |state| { - let tx_replay_set = state.get_tx_replay_set(); - Ok(tx_replay_set.is_none()) - }) - .expect("Timed out waiting for tx replay set to be cleared"); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates that transaction replay can be "solved" using mempool transactions, -/// by coincidence, rather than using the Tx Replay Set as the source. -/// This works because the transactions in the mempool happen to match those in the replay set. -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Submit 2 STX Transfer txs (Tx1, Tx2) in the last tenure -/// - Trigger a Bitcoin fork (3 blocks) -/// - Verify that signers move into tx replay state [Tx1, Tx2] -/// - Force miner to solve replay with mempool [Tx1, Tx2] -fn tx_replay_solved_by_mempool_txs() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 2; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender1_addr.clone(), (send_amt + send_fee) * num_txs)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 2; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - signer_test.check_signer_states_normal(); - - // Make a transfer tx (this will get forked) - let (sender1_tx1, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let (sender1_tx2, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(2, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - fault_injection_stall_miner(); - - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone(), sender1_tx2.clone()]); - - // We should have forked 2 txs - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(0, sender1_nonce_post_fork); - - info!("------------------------- Mine Tx Replay Set -------------------------"); - TEST_EXCLUDE_REPLAY_TXS.set(true); //Force solving Tx Replay with mempool txs - fault_injection_unstall_miner(); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be updated"); - - let sender1_nonce_post_replay = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(2, sender1_nonce_post_replay); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Trigger a Bitcoin fork across reward cycle -/// and ensure that the signers detect the fork, -/// but reject to move into a tx replay state -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 (that is in the middle of reward cycle N) -/// - Mine until the last tenure of the reward cycle N -/// - Include a STX transfer in the last tenure -/// - Mine 1 Bitcoin block in the next reward cycle N+1 -/// - Trigger a Bitcoin fork from reward cycle N (3 blocks) -/// - Verify that signers don't move into tx replay state -/// - In the end, the STX transfer transaction is not replayed -fn tx_replay_rejected_when_forking_across_reward_cycle() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::random(); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 1; - let signer_test: SignerTest = SignerTest::new_with_config_modifications( - num_signers, - vec![(sender_addr.clone(), (send_amt + send_fee) * num_txs)], - |_| {}, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - ); - let conf = signer_test.running_nodes.conf.clone(); - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let burn_chain = btc_controller.get_burnchain(); - let counters = &signer_test.running_nodes.counters; - - signer_test.boot_to_epoch_3(); - info!("------------------------- Reached Epoch 3.0 -------------------------"); - - let burn_block_height = get_chain_info(&conf).burn_block_height; - let initial_reward_cycle = signer_test.get_current_reward_cycle(); - let rc_last_height = burn_chain.nakamoto_last_block_of_cycle(initial_reward_cycle); - - info!("----- Mine to the end of reward cycle {initial_reward_cycle} height {rc_last_height} -----"); - let pre_fork_tenures = rc_last_height - burn_block_height; - for i in 1..=pre_fork_tenures { - info!("Mining pre-fork tenure {i} of {pre_fork_tenures}"); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - signer_test.check_signer_states_normal(); - - info!("----- Submit Stx transfer in last tenure height {rc_last_height} -----"); - // Make a transfer tx that will get forked - let tip = get_chain_info(&conf); - let _ = signer_test - .submit_transfer_tx(&sender_sk, send_fee, send_amt) - .unwrap(); - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - let pre_fork_tx_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(1, pre_fork_tx_nonce); - - info!("----- Mine 1 block in new reward cycle -----"); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - signer_test.check_signer_states_normal(); - - let next_reward_cycle = initial_reward_cycle + 1; - let new_burn_block_height = get_chain_info(&conf).burn_block_height; - assert_eq!(next_reward_cycle, signer_test.get_current_reward_cycle()); - assert_eq!( - new_burn_block_height, - burn_chain.nakamoto_first_block_of_cycle(next_reward_cycle) - ); - - info!("----- Trigger Bitcoin fork -----"); - //Fork on the third-to-last tenure of prev reward cycle - let burn_block_hash_to_fork = btc_controller.get_block_hash(new_burn_block_height - 2); - btc_controller.invalidate_block(&burn_block_hash_to_fork); - btc_controller.build_next_block(3); - - // note, we should still have normal signer states! - signer_test.check_signer_states_normal(); - - //mine throught the fork (just check commits because of naka block mining stalled) - fault_injection_stall_miner(); - - let submitted_commits = counters.naka_submitted_commits.clone(); - for i in 0..3 { - let current_burn_height = get_chain_info(&signer_test.running_nodes.conf).burn_block_height; - info!( - "Mining block #{i} to be considered a frequent miner"; - "current_burn_height" => current_burn_height, - ); - let commits_count = submitted_commits.load(Ordering::SeqCst); - next_block_and(btc_controller, 60, || { - let commits_submitted = submitted_commits.load(Ordering::SeqCst); - Ok(commits_submitted > commits_count - && get_chain_info(&signer_test.running_nodes.conf).burn_block_height - > current_burn_height) - }) - .unwrap(); - } - - let post_fork_tx_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(0, post_fork_tx_nonce); - - info!("----- Check Signers Tx Replay state -----"); - wait_for(30, || { - let (states, _) = signer_test.get_burn_updated_states(); - if states.is_empty() { - return Ok(false); - } - Ok(states - .iter() - .all(|state| state.get_tx_replay_set().is_none())) - }) - .expect("Unable to confirm tx replay state"); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates Tx Replay state is kept by Signers after a fork -/// occurred before the miner start replaying transactions -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Mine 12 tenures (to handle multiple forks in Cycle #12) -/// - Submit a STX transfer (Tx1) in the last tenure -/// - Trigger a Bitcoin fork -/// - Verify that signers move into tx replay state [Tx1] -/// - Trigger a Bitcoin fork -/// - Verify that signers stay into tx replay state [Tx1] -/// - In the end, let the miner solve the Tx Replay Set -fn tx_replay_with_fork_occurred_before_starting_replaying_txs() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 1; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender1_addr.clone(), (send_amt + send_fee) * num_txs)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 12; //go to 2nd tenure of 12th cycle - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - // Make 1 transfer tx (this will get forked) - let (sender1_tx1, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork #1 -------------------------"); - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - - // Signers move in Tx Replay mode - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone()]); - - // We should have forked 1 tx - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(0, sender1_nonce_post_fork); - - info!("------------------------- Triggering Bitcoin Fork #2 -------------------------"); - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - //Signers still are in the initial state of Tx Replay mode - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone()]); - - info!("----------- Solve TX Replay ------------"); - fault_injection_unstall_miner(); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be updated"); - - let sender1_nonce_after_replay = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce_after_replay); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates that the Tx Replay state is preserved by signers after a fork -/// that occurs following an "empty" tenure, -/// but before the miner begins replaying transactions. -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Mine 10 tenures (to handle multiple forks in Cycle #12) -/// - Submit a STX transfer (Tx1) in the last tenure -/// - Trigger a Bitcoin fork -/// - Verify that signers move into tx replay state [Tx1] -/// - Force the miner to mine an "empty" tenure (only Block Found) -/// - Trigger a Bitcoin fork -/// - Verify that signers stay into tx replay state [Tx1] -/// - In the end, let the miner solve the Tx Replay Set -fn tx_replay_with_fork_after_empty_tenures_before_starting_replaying_txs() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 1; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender1_addr.clone(), (send_amt + send_fee) * num_txs)], - |c| { - c.validate_with_replay_tx = true; - c.reset_replay_set_after_fork_blocks = 5; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 10; //go to Tenure #4 in Cycle #12 - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - info!("------------------------- Sending Transactions -------------------------"); - // Make a transfer tx (this will get forked) - let (sender1_tx1, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork #1 -------------------------"); - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - - // Signers moved in Tx Replay mode - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone()]); - - // We should have forked tx1 - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(0, sender1_nonce_post_fork); - - info!("------------------- Produce Empty Tenure -------------------------"); - fault_injection_unstall_miner(); - let tip = get_chain_info(&conf); - _ = wait_for_tenure_change_tx(30, TenureChangeCause::BlockFound, tip.stacks_tip_height + 1); - fault_injection_stall_miner(); - - signer_test - .wait_for_signer_state_check(30, |state| { - let Some(tx_replay_set) = state.get_tx_replay_set() else { - return Ok(false); - }; - let len_ok = tx_replay_set.len() == 1; - let txid_ok = tx_replay_set[0].txid().to_hex() == sender1_tx1; - Ok(len_ok && txid_ok) - }) - .expect("Timed out waiting for tx replay set to be updated"); - - info!("------------------------- Triggering Bitcoin Fork #2 -------------------------"); - test_observer::clear(); - - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - - // Signers still are in Tx Replay mode (as the initial replay state) - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone()]); - - info!("------------------------- Mine Tx Replay Set -------------------------"); - fault_injection_unstall_miner(); - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be updated"); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates Tx Replay Set to be updated from a deepest fork -/// than the one that made Tx Replay to start -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Mine 10 tenures (to handle multiple forks in Cycle #12) -/// - Submit a STX transfer (Tx1) in the last tenure -/// - Mine 3 new tenures -/// - Submit a STX transfer (Tx2) in the last tenure -/// - Trigger a Bitcoin fork (involving Tx2 only) -/// - Verify that signers move into tx replay state [Tx2] -/// - Trigger a Bitcoin fork (deepest to involve Tx1) -/// - Verify that signers update tx replay state to [Tx1, Tx2] -/// - In the end, let the miner solve the Tx Replay Set -fn tx_replay_with_fork_causing_replay_set_to_be_updated() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 2; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender1_addr.clone(), (send_amt + send_fee) * num_txs)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 10; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - // Make 2 transfer txs, each in its own tenure so that can be forked in different forks - let tip_at_tx1 = get_chain_info(&conf); - assert_eq!(241, tip_at_tx1.burn_block_height); - let (sender1_tx1, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let tip_at_tx2 = get_chain_info(&conf); - assert_eq!(242, tip_at_tx2.burn_block_height); - let (sender1_tx2, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(2, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork #1 -------------------------"); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_at_tx2.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(1); - - info!("Wait for block off of shallow fork"); - fault_injection_stall_miner(); - btc_controller.build_next_block(1); - - wait_for(10, || { - let tip = get_chain_info(&conf); - Ok(tip.burn_block_height == 243) - }) - .expect("Timed out waiting for burn block height to be 243"); - - // Signers move in Tx Replay mode - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx2.clone()]); - - // We should have forked one tx (Tx2) - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce_post_fork); - - info!( - "------------------------- Triggering Bitcoin Fork #2 from {} -------------------------", - tip_at_tx1.burn_block_height - ); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_at_tx1.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(4); - wait_for(10, || { - let tip = get_chain_info(&conf); - info!("Burn block height: {}", tip.burn_block_height); - Ok(tip.burn_block_height == 244) - }) - .expect("Timed out waiting for burn block height to be 244"); - - info!("Wait for block off of shallow fork"); - fault_injection_stall_miner(); - - //Signers should update the Tx Replay Set - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone(), sender1_tx2.clone()]); - - info!("----------- Solve TX Replay ------------"); - fault_injection_unstall_miner(); - - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be updated"); - - let sender1_nonce_after_replay = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(2, sender1_nonce_after_replay); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates Tx Replay Set to be cleared from a deepest fork -/// than the one that made Tx Replay to start, that led to -/// previous reward cylce -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Mine 8 tenures (to arrive at Cycle #11 boundary) -/// - Mine 3 more tenures (to enter Cycle #12) -/// - Submit a STX transfer (Tx1) in the last tenure -/// - Trigger a Bitcoin fork (in Cycle #12) -/// - Verify that signers move into tx replay state [Tx1] -/// - Trigger a Bitcoin fork (deepest to involve Cycle #11) -/// - Verify that signers clear the tx replay state -fn tx_replay_with_fork_causing_replay_to_be_cleared_due_to_cycle() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send_amt = 100; - let send_fee = 180; - let num_txs = 2; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender1_addr.clone(), (send_amt + send_fee) * num_txs)], - |c| { - c.validate_with_replay_tx = true; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 8; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - signer_test.check_signer_states_normal(); - } - - let tip_at_rc11 = get_chain_info(&conf); - assert_eq!(239, tip_at_rc11.burn_block_height); - assert_eq!(11, signer_test.get_current_reward_cycle()); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let tip_at_rc12 = get_chain_info(&conf); - assert_eq!(242, tip_at_rc12.burn_block_height); - assert_eq!(12, signer_test.get_current_reward_cycle()); - - // Make 2 transfer txs, each in its own tenure so that can be forked in different forks - let (sender1_tx1, sender1_nonce) = signer_test - .submit_transfer_tx(&sender1_sk, send_fee, send_amt) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, sender1_nonce) - .expect("Expect sender1 nonce increased"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork #1 -------------------------"); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_at_rc12.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - // Signers move in Tx Replay mode - signer_test.wait_for_replay_set_eq(30, vec![sender1_tx1.clone()]); - - // We should have forked one tx (Tx2) - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(0, sender1_nonce_post_fork); - - info!("------------------------- Triggering Bitcoin Fork #2 -------------------------"); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip_at_rc11.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(6); - - info!("Wait for block off of shallow fork"); - - //Signers should clear the Tx Replay Set - signer_test - .wait_for_signer_state_check(30, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be updated"); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates Tx Replay restart from scratch while it is in progress -/// (partially replayed a subset of transaction) and a fork occurs. -/// In this case, partial replay is allowed because of tenure extend, -/// due to Tenure Budget exceeded. -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Deploy 1 Big Contract and mine 2 tenures (to escape fork) -/// - Submit 2 Contract Call txs (Tx1, Tx2) in the last tenure, -/// requiring Tenure Extend due to Tenure Budget exceeded -/// - Trigger a Bitcoin fork -/// - Verify that signers move into tx replay state [Tx1, Tx2] -/// - Force Miner to do a partial replay (only Tx1), -/// blocking Tenure extension -/// - Trigger a Bitcoin fork -/// - In the end, Tx Replay Set is solved from scratch [Tx1, Tx2] -fn tx_replay_with_fork_middle_replay_while_tenure_extending() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let deploy_fee = 1000000; - let call_fee = 1000; - let call_num = 2; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr.clone(), deploy_fee + call_fee * call_num)], - |c| { - c.validate_with_replay_tx = true; - c.tenure_idle_timeout = Duration::from_secs(10); - c.reset_replay_set_after_fork_blocks = 5; - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let stacks_miner_pk = StacksPublicKey::from_private(&conf.miner.mining_key.clone().unwrap()); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - - let pre_fork_tenures = 2; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - signer_test.check_signer_states_normal(); - - info!("---- Deploying big contract ----"); - // First, just deploy the contract in its own tenure - let contract_code = make_big_read_count_contract(HELIUM_BLOCK_LIMIT_20, 50); - let (_deploy_txid, deploy_nonce) = signer_test - .submit_contract_deploy( - &sender_sk, - deploy_fee, - contract_code.as_str(), - "big-contract", - ) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender_addr, deploy_nonce) - .expect("Timed out waiting for nonce to increase"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - // Then, sumbmit 2 Contract Calls that require Tenure Extension to be addressed. - info!("---- Submit big tx1 to be mined ----"); - let (txid1, txid1_nonce) = signer_test - .submit_contract_call(&sender_sk, call_fee, "big-contract", "big-tx", &vec![]) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender_addr, txid1_nonce) - .expect("Timed out waiting for nonce to increase"); - - info!("---- Submit big tx2 to be mined ----"); - let tip = get_chain_info(conf); - - let (txid2, txid2_nonce) = signer_test - .submit_contract_call(&sender_sk, call_fee, "big-contract", "big-tx", &vec![]) - .unwrap(); - - // Tenure Extend happen because of tenure budget exceeded - _ = wait_for_tenure_change_tx(30, TenureChangeCause::Extended, tip.stacks_tip_height + 1); - - signer_test - .wait_for_nonce_increase(&sender_addr, txid2_nonce) - .expect("Timed out waiting for nonce to increase"); - - let sender1_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(3, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - let tip = get_chain_info(conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - let post_fork_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(1, post_fork_nonce); //due to contract deploy tx - - info!("---- Force Partial Tx Replay ----"); - // Only Tx1 is replayed, preventing Tenure Extension stalling the miner - fault_injection_unstall_miner(); - let tip = get_chain_info(&conf); - _ = wait_for_tenure_change_tx(30, TenureChangeCause::BlockFound, tip.stacks_tip_height + 1); - _ = wait_for_block_proposal_block(30, tip.stacks_tip_height + 2, &stacks_miner_pk); - fault_injection_stall_miner(); - - // Signers still waiting for the Tx Replay set to be completed - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - info!("------------------------- Triggering Bitcoin Fork #2 -------------------------"); - //Fork in the middle of Tx Replay - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height - 1); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - fault_injection_stall_miner(); - - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - let post_fork_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(1, post_fork_nonce); //due to contract deploy tx - - info!("---- Waiting for replay set to be cleared ----"); - fault_injection_unstall_miner(); - - signer_test - .wait_for_signer_state_check(60, |state| { - let tx_replay_set = state.get_tx_replay_set(); - Ok(tx_replay_set.is_none()) - }) - .expect("Timed out waiting for tx replay set to be cleared"); - - let post_replay_nonce = get_account(&http_origin, &sender_addr).nonce; - assert_eq!(3, post_replay_nonce); //1 contract deploy tx + 2 contract call txs - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Demonstrates Tx Replay restart from scratch while it is in progress -/// (partially replayed a subset of transaction), other transactions -/// are submitted, and then a fork occurs. -/// In this case, partial replay is allowed because of tenure extend, -/// due to Tenure Budget exceeded. -/// -/// The test flow is: -/// -/// - Boot to Epoch 3 -/// - Deploy 1 Big Contract and mine 2 tenures (to escape fork) -/// - Submit 2 Contract Call txs (Tx1, Tx2) in the last tenure, -/// requiring Tenure Extend due to Tenure Budget exceeded -/// - Trigger a Bitcoin fork -/// - Verify that signers move into tx replay state [Tx1, Tx2] -/// - Force Miner to do a partial replay (only Tx1), -/// blocking Tenure extension -/// - Submit a STX Transfer tx (Tx3) in the last tenure -/// - Trigger a Bitcoin fork -/// - In the end: -/// - first, Tx Replay Set is solved from scratch [Tx1, Tx2] -/// - then, Tx3 is mined normally -fn tx_replay_with_fork_middle_replay_while_tenure_extending_and_new_tx_submitted() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender1_sk = Secp256k1PrivateKey::from_seed("sender_1".as_bytes()); - let sender1_addr = tests::to_addr(&sender1_sk); - let send1_deploy_fee = 1000000; - let send1_call_fee = 1000; - let send1_call_num = 2; - let sender2_sk = Secp256k1PrivateKey::from_seed("sender_2".as_bytes()); - let sender2_addr = tests::to_addr(&sender2_sk); - let send2_amt = 100; - let send2_fee = 180; - let send2_txs = 1; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![ - ( - sender1_addr.clone(), - send1_deploy_fee + send1_call_fee * send1_call_num, - ), - (sender2_addr.clone(), (send2_amt + send2_fee) * send2_txs), - ], - |c| { - c.validate_with_replay_tx = true; - c.tenure_idle_timeout = Duration::from_secs(10); - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let http_origin = format!("http://{}", &conf.node.rpc_bind); - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - let stacks_miner_pk = StacksPublicKey::from_private(&conf.miner.mining_key.clone().unwrap()); - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - info!("------------------------- Beginning test -------------------------"); - let pre_fork_tenures = 2; - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - signer_test.check_signer_states_normal(); - - info!("---- Deploying big contract ----"); - // First, just deploy the contract in its own tenure - let contract_code = make_big_read_count_contract(HELIUM_BLOCK_LIMIT_20, 50); - let (_deploy_txid, deploy_nonce) = signer_test - .submit_contract_deploy( - &sender1_sk, - send1_deploy_fee, - contract_code.as_str(), - "big-contract", - ) - .unwrap(); - signer_test - .wait_for_nonce_increase(&sender1_addr, deploy_nonce) - .expect("Timed out waiting for nonce to increase"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - // Then, sumbmit 2 Contract Calls that require Tenure Extension to be addressed. - info!("---- Waiting for first big tx to be mined ----"); - let (txid1, txid1_nonce) = signer_test - .submit_contract_call( - &sender1_sk, - send1_call_fee, - "big-contract", - "big-tx", - &vec![], - ) - .unwrap(); - - signer_test - .wait_for_nonce_increase(&sender1_addr, txid1_nonce) - .expect("Timed out waiting for nonce to increase"); - - info!("---- Waiting for second big tx to be mined ----"); - let (txid2, txid2_nonce) = signer_test - .submit_contract_call( - &sender1_sk, - send1_call_fee, - "big-contract", - "big-tx", - &vec![], - ) - .unwrap(); - - // Tenure Extend happen because of tenure budget exceeded - let tip = get_chain_info(conf); - _ = wait_for_tenure_change_tx(30, TenureChangeCause::Extended, tip.stacks_tip_height + 1); - - signer_test - .wait_for_nonce_increase(&sender1_addr, txid2_nonce) - .expect("Timed out waiting for nonce to increase"); - - let sender1_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(3, sender1_nonce); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - let tip = get_chain_info(conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(2); - - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - let post_fork_nonce = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, post_fork_nonce); //due to contract deploy tx - - info!("---- Force Partial Tx Replay ----"); - // Only Tx1 is replayed, preventing Tenure Extension stalling the miner - fault_injection_unstall_miner(); - let tip = get_chain_info(&conf); - _ = wait_for_tenure_change_tx(30, TenureChangeCause::BlockFound, tip.stacks_tip_height + 1); - _ = wait_for_block_proposal_block(30, tip.stacks_tip_height + 2, &stacks_miner_pk); - fault_injection_stall_miner(); - - // Signers still waiting for the Tx Replay set to be completed - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - info!("---- New Transaction is Submitted ----"); - // Tx3 reach the mempool, meanwhile mining is stalled - let (_sender2_tx3, sender2_nonce) = signer_test - .submit_transfer_tx(&sender2_sk, send2_fee, send2_amt) - .unwrap(); - - info!("------------------------- Triggering Bitcoin Fork #2 -------------------------"); - //Fork in the middle of Tx Replay - let tip = get_chain_info(&conf); - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - btc_controller.build_next_block(2); - - info!("Wait for block off of shallow fork"); - fault_injection_stall_miner(); - - signer_test.wait_for_replay_set_eq(30, vec![txid1.clone(), txid2.clone()]); - - let sender1_nonce_post_fork = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(1, sender1_nonce_post_fork); //due to contract deploy tx - - let sender2_nonce_post_fork = get_account(&http_origin, &sender2_addr).nonce; - assert_eq!(0, sender2_nonce_post_fork); - - info!("---- Waiting for replay set to be cleared ----"); - fault_injection_unstall_miner(); - - signer_test - .wait_for_signer_state_check(60, |state| { - let tx_replay_set = state.get_tx_replay_set(); - Ok(tx_replay_set.is_none() && get_account(&http_origin, &sender1_addr).nonce >= 3) - }) - .expect("Timed out waiting for tx replay set to be cleared"); - - let sender1_nonce_post_replay = get_account(&http_origin, &sender1_addr).nonce; - assert_eq!(3, sender1_nonce_post_replay); //1 contract deploy tx + 2 contract call txs - - //waiting for Tx3 to be processed normally - signer_test - .wait_for_nonce_increase(&sender2_addr, sender2_nonce) - .expect("Timed out waiting for nonce to increase"); - let sender2_nonce_post_replay = get_account(&http_origin, &sender2_addr).nonce; - assert_eq!(1, sender2_nonce_post_replay); - - signer_test.shutdown(); -} - -#[test] -#[ignore] -/// Trigger a Bitcoin fork that creates a replay set that -/// contains more transactions than can fit into a tenure's budget. -fn tx_replay_budget_exceeded_tenure_extend() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let num_signers = 5; - let sender_sk = - Secp256k1PrivateKey::from_seed(format!("sender_{}", function_name!()).as_bytes()); - let sender_addr = tests::to_addr(&sender_sk); - let send_amt = 1000; - let send_fee = 1000000; - let signer_test: SignerTest = - SignerTest::new_with_config_modifications_and_snapshot( - num_signers, - vec![(sender_addr.clone(), (send_amt + send_fee) * 1000)], - |c| { - c.validate_with_replay_tx = true; - c.tenure_idle_timeout = Duration::from_secs(60); - }, - |node_config| { - node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; - node_config.miner.activated_vrf_key_path = - Some(format!("{}/vrf_key", node_config.node.working_dir)); - }, - None, - None, - Some(function_name!()), - ); - let conf = &signer_test.running_nodes.conf; - let _http_origin = format!("http://{}", &conf.node.rpc_bind); - let _stacks_miner_pk = StacksPublicKey::from_private(&conf.miner.mining_key.clone().unwrap()); - - let btc_controller = &signer_test.running_nodes.btc_regtest_controller; - - if signer_test.bootstrap_snapshot() { - signer_test.shutdown_and_snapshot(); - return; - } - - info!("------------------------- Reached Epoch 3.0 -------------------------"); - let pre_fork_tenures = 1; - - for i in 0..pre_fork_tenures { - info!("Mining pre-fork tenure {} of {pre_fork_tenures}", i + 1); - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - } - - signer_test.check_signer_states_normal(); - - info!("---- Deploying big contract ----"); - - // First, just deploy the contract in its own tenure - let contract_code = make_big_read_count_contract(HELIUM_BLOCK_LIMIT_20, 50); - - let (_deploy_txid, deploy_nonce) = signer_test - .submit_contract_deploy(&sender_sk, 1000000, contract_code.as_str(), "big-contract") - .unwrap(); - - signer_test - .wait_for_nonce_increase(&sender_addr, deploy_nonce) - .expect("Timed out waiting for nonce to increase"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let tip = get_chain_info(conf); - - let (txid1, txid1_nonce) = signer_test - .submit_contract_call(&sender_sk, send_fee, "big-contract", "big-tx", &vec![]) - .unwrap(); - - info!("---- Waiting for first big tx to be mined ----"); - - signer_test - .wait_for_nonce_increase(&sender_addr, txid1_nonce) - .expect("Timed out waiting for nonce to increase"); - - signer_test.mine_nakamoto_block(Duration::from_secs(30), true); - - let (txid2, txid2_nonce) = signer_test - .submit_contract_call(&sender_sk, send_fee, "big-contract", "big-tx", &vec![]) - .unwrap(); - - info!("---- Waiting for second big tx to be mined ----"); - - signer_test - .wait_for_nonce_increase(&sender_addr, txid2_nonce) - .expect("Timed out waiting for nonce to increase"); - - wait_for(30, || { - let new_tip = get_chain_info(&conf); - Ok(new_tip.stacks_tip_height > tip.stacks_tip_height) - }) - .expect("Timed out waiting for transfer tx to be mined"); - - info!("------------------------- Triggering Bitcoin Fork -------------------------"); - - let burn_header_hash_to_fork = btc_controller.get_block_hash(tip.burn_block_height); - btc_controller.invalidate_block(&burn_header_hash_to_fork); - fault_injection_stall_miner(); - btc_controller.build_next_block(3); - - signer_test.wait_for_replay_set_eq(30, vec![txid1, txid2.clone()]); - - // Clear the test observer so we know that if we see txid1 and txid2 again, that it means they were remined - test_observer::clear(); - fault_injection_unstall_miner(); - - info!("---- Waiting for replay set to be cleared ----"); - - // Now, wait for the tx replay set to be cleared - signer_test - .wait_for_signer_state_check(60, |state| Ok(state.get_tx_replay_set().is_none())) - .expect("Timed out waiting for tx replay set to be cleared"); - let mut found_block: Option = None; - wait_for(60, || { - let blocks = test_observer::get_blocks(); - for block in blocks { - let block: StacksBlockEvent = - serde_json::from_value(block.clone()).expect("Failed to parse block"); - if block - .transactions - .iter() - .find(|tx| tx.txid().to_hex() == txid2) - .is_some() - { - found_block = Some(block); - return Ok(true); - } - } - Ok(false) - }) - .expect("Failed to mine the replay txs"); - let block = found_block.expect("Failed to find block with txid2"); - assert_eq!(block.transactions.len(), 2); - assert!(matches!( - block.transactions[0].payload, - TransactionPayload::TenureChange(TenureChangePayload { - cause: TenureChangeCause::Extended, - .. - }) - )); - - signer_test.shutdown(); -} From bc3eb53f727c7fe03aa032bc9a7c114f5f46ca9c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 7 Aug 2026 16:58:30 +0200 Subject: [PATCH 04/12] chore: remove tx replay behaviour on miner side --- contrib/stacks-inspect/src/lib.rs | 1 - stacks-node/src/nakamoto_node/miner.rs | 20 ---- stacks-node/src/tests/signer/v0/mod.rs | 2 - stackslib/src/chainstate/nakamoto/miner.rs | 2 - stackslib/src/chainstate/stacks/miner.rs | 132 ++------------------- stackslib/src/config/mod.rs | 10 -- 6 files changed, 9 insertions(+), 158 deletions(-) diff --git a/contrib/stacks-inspect/src/lib.rs b/contrib/stacks-inspect/src/lib.rs index 949bb79203a..48168f6ca04 100644 --- a/contrib/stacks-inspect/src/lib.rs +++ b/contrib/stacks-inspect/src/lib.rs @@ -648,7 +648,6 @@ pub fn command_try_mine(args: &TryMineArgs, conf: Option<&Config>) { settings, None, 0, - &[], ) .map( |BlockMetadata { diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index 851c0cc2d3a..5dfa2d68efb 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -1645,14 +1645,6 @@ impl BlockMinerThread { // be reset to false. self.reset_mempool_caches = true; - let replay_transactions = if self.config.miner.replay_transactions { - coordinator - .get_signer_global_state() - .map(|state| state.tx_replay_set.unwrap_or_default()) - .unwrap_or_default() - } else { - vec![] - }; // build the block itself let mining_burn_handle = burn_db .index_handle_at_ch(&self.burn_block.consensus_hash) @@ -1692,7 +1684,6 @@ impl BlockMinerThread { // correct signer_signature_hash for `process_mined_nakamoto_block_event` Some(&self.event_dispatcher), signer_bitvec_len, - &replay_transactions, ) .map_err(|e| { if !matches!( @@ -1769,17 +1760,6 @@ impl BlockMinerThread { // if we haven't mined blocks yet, no tenure extends needed return Ok(false); } - let is_replay = self.config.miner.replay_transactions - && coordinator - .get_signer_global_state() - .map(|state| state.tx_replay_set.is_some()) - .unwrap_or(false); - if is_replay { - // we're in replay, we should always TenureExtend - info!("Tenure extend: In replay, always extending tenure"); - return Ok(true); - } - // Do not extend if we have spent a threshold amount of the // budget, since it is not necessary. let usage = self diff --git a/stacks-node/src/tests/signer/v0/mod.rs b/stacks-node/src/tests/signer/v0/mod.rs index a9c60c051fe..8f0c797b7aa 100644 --- a/stacks-node/src/tests/signer/v0/mod.rs +++ b/stacks-node/src/tests/signer/v0/mod.rs @@ -8606,7 +8606,6 @@ fn multiversioned_signer_protocol_version_calculation() { }, |node_config| { node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; }, None, None, @@ -8709,7 +8708,6 @@ fn contract_with_undefined_variable_compat() { }, |node_config| { node_config.miner.block_commit_delay = Duration::from_secs(1); - node_config.miner.replay_transactions = true; node_config.miner.activated_vrf_key_path = Some(format!("{}/vrf_key", node_config.node.working_dir)); }, diff --git a/stackslib/src/chainstate/nakamoto/miner.rs b/stackslib/src/chainstate/nakamoto/miner.rs index 1f066a1c7e7..a4d7cf667f0 100644 --- a/stackslib/src/chainstate/nakamoto/miner.rs +++ b/stackslib/src/chainstate/nakamoto/miner.rs @@ -660,7 +660,6 @@ impl NakamotoBlockBuilder { settings: BlockBuilderSettings, event_observer: Option<&dyn MemPoolEventDispatcher>, signer_bitvec_len: u16, - replay_transactions: &[StacksTransaction], ) -> Result { let (tip_consensus_hash, tip_block_hash, tip_height) = ( parent_stacks_header.consensus_hash.clone(), @@ -745,7 +744,6 @@ impl NakamotoBlockBuilder { &initial_txs, settings, event_observer, - replay_transactions, ) { Ok(x) => x, Err(e) => { diff --git a/stackslib/src/chainstate/stacks/miner.rs b/stackslib/src/chainstate/stacks/miner.rs index f7dafc55f21..8b181887ea0 100644 --- a/stackslib/src/chainstate/stacks/miner.rs +++ b/stackslib/src/chainstate/stacks/miner.rs @@ -79,40 +79,6 @@ fn fault_injection_stall_tx() { #[cfg(not(any(test, feature = "testing")))] fn fault_injection_stall_tx() {} -#[cfg(any(test, feature = "testing"))] -/// Test flag to exclude replay txs from the next block -pub static TEST_EXCLUDE_REPLAY_TXS: LazyLock> = LazyLock::new(TestFlag::default); - -#[cfg(any(test, feature = "testing"))] -/// Test flag to mine specific txs belonging to the replay set -pub static TEST_MINE_ALLOWED_REPLAY_TXS: LazyLock>> = - LazyLock::new(TestFlag::default); - -#[cfg(any(test, feature = "testing"))] -/// Given a tx id, check if it is should be skipped -/// if not listed in `TEST_MINE_ALLOWED_REPLAY_TXS` flag. -/// If flag is empty means no tx should be skipped -fn fault_injection_should_skip_replay_tx(tx_id: Txid) -> bool { - let minable_txs = TEST_MINE_ALLOWED_REPLAY_TXS.get(); - let allowed = - minable_txs.len() == 0 || minable_txs.iter().any(|tx_ids| *tx_ids == tx_id.to_hex()); - if !allowed { - info!( - "Tx skipped due to test flag TEST_MINE_ALLOWED_REPLAY_TXS: {}", - tx_id.to_hex() - ); - } - !allowed -} - -#[cfg(not(any(test, feature = "testing")))] -/// Given a tx id, check if it is should be skipped -/// if not listed in `TEST_MINE_ALLOWED_REPLAY_TXS` flag. -/// If flag is empty means no tx should be skipped -fn fault_injection_should_skip_replay_tx(_tx_id: Txid) -> bool { - false -} - /// Fully-assembled Stacks anchored, block as well as some extra metadata pertaining to how it was /// linked to the burnchain and what view(s) the miner had of the burnchain before and after /// completing the block. @@ -2317,7 +2283,6 @@ impl StacksBlockBuilder { initial_txs: &[StacksTransaction], settings: BlockBuilderSettings, event_observer: Option<&dyn MemPoolEventDispatcher>, - replay_transactions: &[StacksTransaction], ) -> Result<(bool, Vec), Error> { let mut tx_events = Vec::new(); @@ -2351,32 +2316,15 @@ impl StacksBlockBuilder { } } - #[cfg(any(test, feature = "testing"))] - let use_mempool_txs = replay_transactions.is_empty() || TEST_EXCLUDE_REPLAY_TXS.get(); - #[cfg(not(any(test, feature = "testing")))] - let use_mempool_txs = replay_transactions.is_empty(); - - let result = if use_mempool_txs { - select_and_apply_transactions_from_mempool( - epoch_tx, - builder, - mempool, - tip_height, - settings, - event_observer, - receipts_total, - ) - } else { - info!("Miner: constructing block with replay transactions"); - let txs = select_and_apply_transactions_from_vec( - epoch_tx, - builder, - tip_height, - replay_transactions, - receipts_total, - ); - Ok((txs, false)) - }; + let result = select_and_apply_transactions_from_mempool( + epoch_tx, + builder, + mempool, + tip_height, + settings, + event_observer, + receipts_total, + ); match result { Ok((events, blocked)) => { @@ -2465,7 +2413,6 @@ impl StacksBlockBuilder { &[coinbase_tx.clone()], settings, event_observer, - &vec![], ) { Ok(x) => x, Err(e) => { @@ -2952,64 +2899,3 @@ fn select_and_apply_transactions_from_mempool( loop_result?; Ok((tx_events, blocked)) } - -fn select_and_apply_transactions_from_vec( - epoch_tx: &mut ClarityTx, - builder: &mut B, - tip_height: u64, - replay_transactions: &[StacksTransaction], - initial_receipts_total: u64, -) -> Vec { - let mut tx_events = vec![]; - - let mut num_txs = 0; - let mut num_considered = 0; - - debug!("Replay block transaction selection begins (parent height = {tip_height})"); - let mut receipts_total = initial_receipts_total; - for replay_tx in replay_transactions { - fault_injection_stall_tx(); - if fault_injection_should_skip_replay_tx(replay_tx.txid()) { - continue; - } - - let txid = replay_tx.txid(); - let tx_result = builder.try_mine_tx_with_len( - epoch_tx, - replay_tx, - replay_tx.tx_len(), - &BlockLimitFunction::NO_LIMIT_HIT, - &TransactionResourceBudgets::unlimited(), - &mut receipts_total, - ); - let tx_event = tx_result.convert_to_event(); - match tx_result { - TransactionResult::Success(TransactionSuccess { .. }) => { - num_txs += 1; - } - TransactionResult::Skipped(TransactionSkipped { error, .. }) - | TransactionResult::ProcessingError(TransactionError { error, .. }) => { - match &error { - Error::BlockTooBigError | Error::BlockCostLimitError => { - // done mining -- our execution budget is exceeded. - // Make the block from the transactions we did manage - // (We cannot simply skip as this would put the replay txs out of order) - debug!("Block budget exceeded on tx {txid}"); - info!("Miner stopping due to limit reached"); - break; - } - e => { - info!("Failed to apply tx {txid}: {e:?}"); - } - } - } - TransactionResult::Problematic(TransactionProblematic { .. }) => { - info!("Failed to apply problematic tx {txid}"); - } - } - tx_events.push(tx_event); - num_considered += 1; - } - debug!("Replay block transaction selection finished (parent height {tip_height}): {num_txs} transactions selected ({num_considered} considered)"); - tx_events -} diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index f6ed3ffe105..1b628139ffa 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -1025,9 +1025,6 @@ impl Config { None => miner_default_config, }; - if is_mainnet && miner.replay_transactions { - return Err("Attempted to run mainnet node with `replay_transactions` set to true. This feature is still incomplete and may not be enabled on a mainnet node".into()); - } let initial_balances: Vec = match config_file.ustx_balance { Some(balances) => { if is_mainnet && !balances.is_empty() { @@ -3277,9 +3274,6 @@ pub struct MinerConfig { /// @default: [`DEFAULT_MAX_ANALYSIS_TIME_SECS`] /// @units: seconds pub max_analysis_time_secs: u64, - /// TODO: remove this option when its no longer a testing feature and it becomes default behaviour - /// The miner will attempt to replay transactions that a threshold number of signers are expecting in the next block - pub replay_transactions: bool, /// Defines the socket timeout (in seconds) for stackerdb communcation. /// --- /// @default: [`DEFAULT_STACKERDB_TIMEOUT_SECS`] @@ -3360,7 +3354,6 @@ impl Default for MinerConfig { }, max_execution_time_secs: DEFAULT_MAX_EXECUTION_TIME_SECS, max_analysis_time_secs: DEFAULT_MAX_ANALYSIS_TIME_SECS, - replay_transactions: false, stackerdb_timeout: Duration::from_secs(DEFAULT_STACKERDB_TIMEOUT_SECS), max_tenure_bytes: DEFAULT_MAX_TENURE_BYTES, log_skipped_transactions: false, @@ -4444,8 +4437,6 @@ pub struct MinerConfigFile { pub block_rejection_timeout_steps: Option>, pub max_execution_time_secs: Option, pub max_analysis_time_secs: Option, - /// TODO: remove this config option once its no longer a testing feature - pub replay_transactions: Option, pub stackerdb_timeout_secs: Option, pub max_tenure_bytes: Option, pub log_skipped_transactions: Option, @@ -4645,7 +4636,6 @@ impl MinerConfigFile { max_analysis_time_secs: self .max_analysis_time_secs .unwrap_or(miner_default_config.max_analysis_time_secs), - replay_transactions: self.replay_transactions.unwrap_or_default(), stackerdb_timeout: self.stackerdb_timeout_secs.map(Duration::from_secs).unwrap_or(miner_default_config.stackerdb_timeout), max_tenure_bytes: self.max_tenure_bytes.unwrap_or(miner_default_config.max_tenure_bytes), log_skipped_transactions: self.log_skipped_transactions.unwrap_or(miner_default_config.log_skipped_transactions), From 18f871a630f37de14505c320a2f0e0a347dcca46 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 12:20:31 +0200 Subject: [PATCH 05/12] chore: remove tx replay behaviour on signer side with backward compatibility --- .../src/tests/nakamoto_integrations.rs | 77 +-- stacks-node/src/tests/signer/v0/mod.rs | 14 +- .../src/tests/signer/v0/tenure_extend.rs | 2 - stacks-signer/src/chainstate/mod.rs | 4 - stacks-signer/src/chainstate/tests/v1.rs | 208 ++------ stacks-signer/src/chainstate/tests/v2.rs | 51 -- stacks-signer/src/chainstate/v1.rs | 13 +- stacks-signer/src/chainstate/v2.rs | 9 +- stacks-signer/src/client/mod.rs | 2 - stacks-signer/src/client/stacks_client.rs | 16 +- stacks-signer/src/config.rs | 40 -- stacks-signer/src/runloop.rs | 2 - stacks-signer/src/signerdb.rs | 94 +--- stacks-signer/src/tests/signer_state.rs | 3 +- stacks-signer/src/v0/signer.rs | 61 +-- stacks-signer/src/v0/signer_state.rs | 473 +----------------- 16 files changed, 65 insertions(+), 1004 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 806d855ef6d..726cadd2f61 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -31,7 +31,6 @@ use clarity::vm::{ClarityName, ClarityVersion, Value}; use http_types::headers::AUTHORIZATION; use lazy_static::lazy_static; use libsigner::v0::messages::{RejectReason, SignerMessage as SignerMessageV0}; -use libsigner::v0::signer_state::ReplayTransactionSet; use libsigner::{SignerSession, StackerDBSession, StacksBlockEvent}; use rand::{thread_rng, Rng}; use rusqlite::{Connection, OptionalExtension}; @@ -113,7 +112,6 @@ use stacks_common::util::secp256k1::{MessageSignature, Secp256k1PrivateKey, Secp use stacks_common::util::{get_epoch_time_secs, sleep_ms}; use stacks_signer::chainstate::v1::SortitionsView; use stacks_signer::chainstate::ProposalEvalConfig; -use stacks_signer::config::DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS; use stacks_signer::signerdb::{BlockInfo, BlockState, ExtraBlockInfo, SignerDb}; use stacks_signer::v0::SpawnedSigner; @@ -7114,7 +7112,6 @@ fn signer_chainstate() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut sortitions_view = @@ -7126,13 +7123,7 @@ fn signer_chainstate() { last_tenures_proposals { let reject_code = sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - prior_tenure_first, - true, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, prior_tenure_first, true) .expect_err("Sortitions view should reject proposals from prior tenure"); assert_eq!( reject_code, @@ -7141,13 +7132,7 @@ fn signer_chainstate() { ); for block in prior_tenure_interims.iter() { let reject_code = sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - block, - true, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, block, true) .expect_err("Sortitions view should reject proposals from prior tenure"); assert_eq!( reject_code, @@ -7179,13 +7164,7 @@ fn signer_chainstate() { .block_height_to_reward_cycle(burn_block_height) .unwrap(); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &proposal.0, - true, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &proposal.0, true) .expect("Nakamoto integration test produced invalid block proposal"); signer_db .insert_block(&BlockInfo { @@ -7231,13 +7210,7 @@ fn signer_chainstate() { let proposal_interim = get_latest_block_proposal(&naka_conf, &sortdb).unwrap(); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &proposal_interim.0, - true, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &proposal_interim.0, true) .expect("Nakamoto integration test produced invalid block proposal"); // force the view to refresh and check again @@ -7250,7 +7223,6 @@ fn signer_chainstate() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let burn_block_height = SortitionDB::get_canonical_burn_chain_tip(sortdb.conn()) @@ -7262,13 +7234,7 @@ fn signer_chainstate() { let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &proposal_interim.0, - true, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &proposal_interim.0, true) .expect("Nakamoto integration test produced invalid block proposal"); signer_db @@ -7328,18 +7294,11 @@ fn signer_chainstate() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut sortitions_view = SortitionsView::fetch_view(proposal_conf, &signer_client).unwrap(); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &sibling_block, - false, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &sibling_block, false) .expect_err("A sibling of a previously approved block must be rejected."); // Case: the block contains a tenure change, but blocks have already @@ -7388,13 +7347,7 @@ fn signer_chainstate() { ); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &sibling_block, - false, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &sibling_block, false) .expect_err("A sibling of a previously approved block must be rejected."); // Case: the block contains a tenure change, but it doesn't confirm all the blocks of the parent tenure @@ -7449,13 +7402,7 @@ fn signer_chainstate() { ); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &sibling_block, - false, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &sibling_block, false) .expect_err("A sibling of a previously approved block must be rejected."); // Case: the block contains a tenure change, but the parent tenure is a reorg @@ -7513,13 +7460,7 @@ fn signer_chainstate() { ); sortitions_view - .check_proposal( - &signer_client, - &mut signer_db, - &sibling_block, - false, - ReplayTransactionSet::none(), - ) + .check_proposal(&signer_client, &mut signer_db, &sibling_block, false) .expect_err("A sibling of a previously approved block must be rejected."); let start_sortition = &reorg_to_block.header.consensus_hash; diff --git a/stacks-node/src/tests/signer/v0/mod.rs b/stacks-node/src/tests/signer/v0/mod.rs index 8f0c797b7aa..6096c246dbf 100644 --- a/stacks-node/src/tests/signer/v0/mod.rs +++ b/stacks-node/src/tests/signer/v0/mod.rs @@ -77,10 +77,7 @@ use stacks_common::util::sleep_ms; use stacks_signer::chainstate::v1::SortitionsView; use stacks_signer::chainstate::ProposalEvalConfig; use stacks_signer::client::StackerDB; -use stacks_signer::config::{ - build_signer_config_tomls, GlobalConfig as SignerConfig, Network, - DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, -}; +use stacks_signer::config::{build_signer_config_tomls, GlobalConfig as SignerConfig, Network}; use stacks_signer::signerdb::SignerDb; use stacks_signer::v0::signer::TEST_REPEAT_PROPOSAL_RESPONSE; use stacks_signer::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION; @@ -2722,7 +2719,6 @@ fn block_proposal_rejection() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); @@ -5609,7 +5605,6 @@ fn block_validation_response_timeout() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); @@ -5898,7 +5893,6 @@ fn block_validation_pending_table() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); @@ -6220,7 +6214,6 @@ fn incoming_signers_ignore_block_proposals() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); @@ -6396,7 +6389,6 @@ fn outgoing_signers_ignore_block_proposals() { tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; let mut block = NakamotoBlock::new(NakamotoBlockHeader::empty(), vec![]); @@ -8703,9 +8695,7 @@ fn contract_with_undefined_variable_compat() { sender_addr.clone(), (send_amt + send_fee) * 10 + deploy_fee + call_fee, )], - |c| { - c.validate_with_replay_tx = true; - }, + |_| {}, |node_config| { node_config.miner.block_commit_delay = Duration::from_secs(1); node_config.miner.activated_vrf_key_path = diff --git a/stacks-node/src/tests/signer/v0/tenure_extend.rs b/stacks-node/src/tests/signer/v0/tenure_extend.rs index 1c6d1de5de1..7bf4557725f 100644 --- a/stacks-node/src/tests/signer/v0/tenure_extend.rs +++ b/stacks-node/src/tests/signer/v0/tenure_extend.rs @@ -45,7 +45,6 @@ use stacks_common::bitvec::BitVec; use stacks_common::util::sleep_ms; use stacks_signer::chainstate::v1::SortitionsView; use stacks_signer::chainstate::ProposalEvalConfig; -use stacks_signer::config::DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS; use stacks_signer::v0::SpawnedSigner; use stdext::prelude::DurationExt; use tracing_subscriber::{fmt, EnvFilter}; @@ -1055,7 +1054,6 @@ fn sip034_tenure_extend_proposal(allow: bool, extend_types: &[TenureChangeCause] tenure_idle_timeout: Duration::from_secs(300), tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(30), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; diff --git a/stacks-signer/src/chainstate/mod.rs b/stacks-signer/src/chainstate/mod.rs index 4313b41060a..5ea6a90d1e1 100644 --- a/stacks-signer/src/chainstate/mod.rs +++ b/stacks-signer/src/chainstate/mod.rs @@ -88,9 +88,6 @@ pub struct ProposalEvalConfig { pub reorg_attempts_activity_timeout: Duration, /// Time to wait before submitting a block proposal to the stacks-node pub proposal_wait_for_parent_time: Duration, - /// How many blocks after a fork should we reset the replay set, - /// as a failsafe mechanism - pub reset_replay_set_after_fork_blocks: u64, } impl From<&SignerConfig> for ProposalEvalConfig { @@ -103,7 +100,6 @@ impl From<&SignerConfig> for ProposalEvalConfig { reorg_attempts_activity_timeout: value.reorg_attempts_activity_timeout, tenure_idle_timeout_buffer: value.tenure_idle_timeout_buffer, proposal_wait_for_parent_time: value.proposal_wait_for_parent_time, - reset_replay_set_after_fork_blocks: value.reset_replay_set_after_fork_blocks, read_count_idle_timeout: value.read_count_idle_timeout, } } diff --git a/stacks-signer/src/chainstate/tests/v1.rs b/stacks-signer/src/chainstate/tests/v1.rs index 9aea424fd36..ddfde09294e 100644 --- a/stacks-signer/src/chainstate/tests/v1.rs +++ b/stacks-signer/src/chainstate/tests/v1.rs @@ -27,14 +27,12 @@ use blockstack_lib::chainstate::stacks::{ TransactionPayload, TransactionPostConditionMode, TransactionPublicKeyEncoding, 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}; +use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId}; use clarity::util::vrf::VRFProof; use libsigner::v0::messages::RejectReason; -use libsigner::v0::signer_state::ReplayTransactionSet; use libsigner::{BlockProposal, BlockProposalData}; use stacks_common::bitvec::BitVec; use stacks_common::consts::CHAIN_ID_TESTNET; @@ -51,7 +49,6 @@ use crate::chainstate::v1::{SortitionMinerStatus, SortitionState, SortitionsView use crate::chainstate::{ProposalEvalConfig, SortitionData}; use crate::client::tests::MockServerClient; use crate::client::StacksClient; -use crate::config::DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS; use crate::signerdb::{BlockInfo, SignerDb}; fn setup_test_environment( @@ -106,7 +103,6 @@ fn setup_test_environment( tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(3), proposal_wait_for_parent_time: Duration::from_secs(0), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }, }; @@ -151,25 +147,13 @@ fn check_proposal_units() { let (stacks_client, mut signer_db, _, mut view, block) = setup_test_environment(function_name!()); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); view.last_sortition = None; - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); } #[test] @@ -179,14 +163,8 @@ fn check_proposal_miner_pkh_mismatch() { block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); let different_block_sk = StacksPrivateKey::from_seed(&[2, 3]); block.header.sign_miner(&different_block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); block.header.consensus_hash = view .last_sortition @@ -196,14 +174,8 @@ fn check_proposal_miner_pkh_mismatch() { .consensus_hash .clone(); block.header.sign_miner(&different_block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); } fn reorg_timing_testing( @@ -314,15 +286,7 @@ fn reorg_timing_testing( client, config, } = MockServerClient::new(); - let h = std::thread::spawn(move || { - view.check_proposal( - &client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - }); + let h = std::thread::spawn(move || view.check_proposal(&client, &mut signer_db, &block, false)); header_clone.chain_length -= 1; let tenure_tip_resp = BlockHeaderWithMetadata { burn_view: Some(header_clone.consensus_hash.clone()), @@ -360,23 +324,11 @@ fn check_proposal_invalid_status() { setup_test_environment(function_name!()); block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect("Proposal should validate"); view.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedAfterFirstBlock; - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); block.header.consensus_hash = view .last_sortition @@ -386,14 +338,8 @@ fn check_proposal_invalid_status() { .consensus_hash .clone(); block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect_err("Proposal should not validate"); view.cur_sortition.miner_status = SortitionMinerStatus::InvalidatedBeforeFirstBlock; block.header.consensus_hash = view @@ -409,14 +355,8 @@ fn check_proposal_invalid_status() { // the stacks-node to do that (because the stacks-node actually knows whether or not their // parent blocks have been seen before, while the signer state checks are only reasoning about // stacks blocks seen by the signer, which may be a subset) - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect("Proposal should validate"); } fn make_tenure_change_payload() -> TenureChangePayload { @@ -553,13 +493,7 @@ where ); }); - let result = view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ); + let result = view.check_proposal(&stacks_client, &mut signer_db, &block, false); exit_flag.store(true, Ordering::SeqCst); serve.join().unwrap(); @@ -598,43 +532,19 @@ fn check_block_proposal_timeout() { ) .unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &curr_sortition_block, - false, - ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + view.check_proposal(&stacks_client, &mut signer_db, &curr_sortition_block, false) + .expect("Proposal should validate"); - view.check_proposal( - &stacks_client, - &mut signer_db, - &last_sortition_block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &last_sortition_block, false) + .expect_err("Proposal should not validate"); // Sleep a bit to time out the block proposal std::thread::sleep(Duration::from_secs(5)); - view.check_proposal( - &stacks_client, - &mut signer_db, - &curr_sortition_block, - false, - ReplayTransactionSet::none(), - ) - .expect_err("Proposal should not validate"); + view.check_proposal(&stacks_client, &mut signer_db, &curr_sortition_block, false) + .expect_err("Proposal should not validate"); - view.check_proposal( - &stacks_client, - &mut signer_db, - &last_sortition_block, - false, - ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + view.check_proposal(&stacks_client, &mut signer_db, &last_sortition_block, false) + .expect("Proposal should validate"); } #[test] @@ -740,14 +650,8 @@ fn check_proposal_refresh() { setup_test_environment(function_name!()); block.header.consensus_hash = view.cur_sortition.data.consensus_hash.clone(); block.header.sign_miner(&block_sk).unwrap(); - view.check_proposal( - &stacks_client, - &mut signer_db, - &block, - false, - ReplayTransactionSet::none(), - ) - .expect("Proposal should validate"); + view.check_proposal(&stacks_client, &mut signer_db, &block, false) + .expect("Proposal should validate"); let MockServerClient { server, @@ -789,15 +693,7 @@ fn check_proposal_refresh() { ]; view.cur_sortition.data.consensus_hash = ConsensusHash([128; 20]); - let h = std::thread::spawn(move || { - view.check_proposal( - &client, - &mut signer_db, - &block, - true, - ReplayTransactionSet::none(), - ) - }); + let h = std::thread::spawn(move || view.check_proposal(&client, &mut signer_db, &block, true)); crate::client::tests::write_response( server, format!("HTTP/1.1 200 Ok\n\n{}", serde_json::json!(expected_result)).as_bytes(), @@ -805,47 +701,3 @@ fn check_proposal_refresh() { let result = h.join().unwrap(); result.expect("Proposal should validate"); } - -#[test] -fn check_proposal_with_extend_during_replay() { - 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(); - 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.executed_and_skipped_txs_mut() = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - let block_pk = StacksPublicKey::from_private(&block_sk); - - let replay_tx = make_stacks_transfer_tx( - &block_sk, - 0, - 0, - 1, - &StacksAddress::p2pkh(true, &block_pk).into(), - 1000000, - ); - let replay_set = ReplayTransactionSet::new(vec![replay_tx]); - block.header.sign_miner(&block_sk).unwrap(); - - 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 598da282d00..62f58f49e27 100644 --- a/stacks-signer/src/chainstate/tests/v2.rs +++ b/stacks-signer/src/chainstate/tests/v2.rs @@ -27,11 +27,9 @@ use blockstack_lib::chainstate::stacks::{ TransactionPayload, TransactionPostConditionMode, TransactionPublicKeyEncoding, TransactionSpendingCondition, TransactionVersion, }; -use blockstack_lib::core::test_util::make_stacks_transfer_tx; use blockstack_lib::net::api::get_tenures_fork_info::TenureForkingInfo; use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId, StacksAddress}; use clarity::types::PrivateKey; -use clarity::util::secp256k1::Secp256k1PublicKey; use clarity::util::vrf::VRFProof; use libsigner::v0::messages::RejectReason; use libsigner::v0::signer_state::{ @@ -53,7 +51,6 @@ use crate::chainstate::v2::{GlobalStateView, SortitionState}; use crate::chainstate::{ProposalEvalConfig, SignerChainstateError, SortitionData}; use crate::client::tests::MockServerClient; use crate::client::StacksClient; -use crate::config::DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS; use crate::signerdb::tests::tmp_db_path; use crate::signerdb::{BlockInfo, SignerDb}; @@ -101,7 +98,6 @@ fn setup_test_environment( tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(3), proposal_wait_for_parent_time: Duration::from_secs(0), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; @@ -498,53 +494,6 @@ fn check_tenure_extend_read_count() { .expect("Proposal should validate"); } -#[test] -fn check_proposal_with_extend_during_replay() { - 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(); - 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.executed_and_skipped_txs_mut() = vec![tx]; - block.header.sign_miner(&block_sk).unwrap(); - - let replay_tx = make_stacks_transfer_tx( - &block_sk, - 0, - 0, - 1, - &StacksAddress::p2pkh(true, &Secp256k1PublicKey::new()).into(), - 1000000, - ); - let replay_set = ReplayTransactionSet::new(vec![replay_tx]); - - sortitions_view.signer_state.tx_replay_set = replay_set; - - 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] fn check_sortition_timeout() { let signer_db_path = tmp_db_path(); diff --git a/stacks-signer/src/chainstate/v1.rs b/stacks-signer/src/chainstate/v1.rs index 044c319d380..a41368b5e03 100644 --- a/stacks-signer/src/chainstate/v1.rs +++ b/stacks-signer/src/chainstate/v1.rs @@ -19,7 +19,6 @@ use blockstack_lib::chainstate::nakamoto::NakamotoBlock; use blockstack_lib::chainstate::stacks::TenureChangePayload; use blockstack_lib::net::api::getsortition::SortitionInfo; use libsigner::v0::messages::RejectReason; -use libsigner::v0::signer_state::ReplayTransactionSet; use stacks_common::types::chainstate::ConsensusHash; use stacks_common::util::get_epoch_time_secs; use stacks_common::util::hash::Hash160; @@ -132,7 +131,6 @@ impl SortitionsView { signer_db: &mut SignerDb, block: &NakamotoBlock, reset_view_if_wrong_consensus_hash: bool, - replay_set: ReplayTransactionSet, ) -> Result<(), RejectReason> { if self.cur_sortition.miner_status == SortitionMinerStatus::Valid && SortitionState::is_timed_out( @@ -254,7 +252,7 @@ impl SortitionsView { ); self.reset_view(client) .map_err(SignerChainstateError::from)?; - return self.check_proposal(client, signer_db, block, false, replay_set); + return self.check_proposal(client, signer_db, block, false); } warn!( "Miner block proposal has consensus hash that is neither the current or last sortition. Considering invalid."; @@ -368,15 +366,13 @@ impl SortitionsView { ); let epoch_time = get_epoch_time_secs(); let enough_time_passed = epoch_time >= extend_timestamp; - let is_in_replay = replay_set.is_some(); - if !changed_burn_view && !enough_time_passed && !is_in_replay { + if !changed_burn_view && !enough_time_passed { 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, ); @@ -389,7 +385,6 @@ impl SortitionsView { "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, ); @@ -428,15 +423,13 @@ impl SortitionsView { ); let epoch_time = get_epoch_time_secs(); let enough_time_passed = epoch_time >= extend_timestamp; - let is_in_replay = replay_set.is_some(); - if !enough_time_passed && !is_in_replay { + if !enough_time_passed { warn!( "Miner block proposal contains a read-count 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, ); diff --git a/stacks-signer/src/chainstate/v2.rs b/stacks-signer/src/chainstate/v2.rs index 031253ba21d..4ce199933b4 100644 --- a/stacks-signer/src/chainstate/v2.rs +++ b/stacks-signer/src/chainstate/v2.rs @@ -208,7 +208,6 @@ impl GlobalStateView { // in full tenure extends, we need to check: // (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 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); @@ -226,15 +225,13 @@ impl GlobalStateView { ); let epoch_time = get_epoch_time_secs(); let enough_time_passed = epoch_time >= extend_timestamp; - let is_in_replay = self.signer_state.tx_replay_set.is_some(); - if !changed_burn_view && !enough_time_passed && !is_in_replay { + if !changed_burn_view && !enough_time_passed { 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, ); @@ -274,15 +271,13 @@ impl GlobalStateView { ); let epoch_time = get_epoch_time_secs(); let enough_time_passed = epoch_time >= extend_timestamp; - let is_in_replay = self.signer_state.tx_replay_set.is_some(); - if !enough_time_passed && !is_in_replay { + if !enough_time_passed { warn!( "Miner block proposal contains a read-count 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, ); diff --git a/stacks-signer/src/client/mod.rs b/stacks-signer/src/client/mod.rs index 7fecc6c05de..d917386de16 100644 --- a/stacks-signer/src/client/mod.rs +++ b/stacks-signer/src/client/mod.rs @@ -481,8 +481,6 @@ pub(crate) mod tests { block_proposal_max_age_secs: config.block_proposal_max_age_secs, reorg_attempts_activity_timeout: config.reorg_attempts_activity_timeout, proposal_wait_for_parent_time: config.proposal_wait_for_parent_time, - validate_with_replay_tx: config.validate_with_replay_tx, - reset_replay_set_after_fork_blocks: config.reset_replay_set_after_fork_blocks, capitulate_miner_view_timeout: config.capitulate_miner_view_timeout, #[cfg(any(test, feature = "testing"))] supported_signer_protocol_version: SUPPORTED_SIGNER_PROTOCOL_VERSION, diff --git a/stacks-signer/src/client/stacks_client.rs b/stacks-signer/src/client/stacks_client.rs index 0b9c23115d8..d519cd0e1ba 100644 --- a/stacks-signer/src/client/stacks_client.rs +++ b/stacks-signer/src/client/stacks_client.rs @@ -17,7 +17,7 @@ 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::{StacksTransaction, TransactionVersion}; +use blockstack_lib::chainstate::stacks::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::{ @@ -278,11 +278,7 @@ impl StacksClient { } /// Submit the block proposal to the stacks node. The block will be validated and returned via the HTTP endpoint for Block events. - pub fn submit_block_for_validation( - &self, - block: NakamotoBlock, - replay_txs: Option>, - ) -> Result<(), ClientError> { + pub fn submit_block_for_validation(&self, block: NakamotoBlock) -> Result<(), ClientError> { debug!("StacksClient: Submitting block for validation"; "signer_signature_hash" => %block.header.signer_signature_hash(), "block_id" => %block.header.block_id(), @@ -291,7 +287,9 @@ impl StacksClient { let block_proposal = NakamotoBlockProposal { block, chain_id: self.chain_id, - replay_txs, + // Always `None`: transaction replay was removed. The field itself is dropped + // from `NakamotoBlockProposal` in a next commit. + replay_txs: None, }; let timer = crate::monitoring::actions::new_rpc_call_timer( &self.block_proposal_path(), @@ -1023,7 +1021,7 @@ mod tests { let mock = MockServerClient::new(); let header = NakamotoBlockHeader::empty(); let block = NakamotoBlock::new(header, vec![]); - let h = spawn(move || mock.client.submit_block_for_validation(block, None)); + let h = spawn(move || mock.client.submit_block_for_validation(block)); write_response(mock.server, b"HTTP/1.1 200 OK\n\n"); assert!(h.join().unwrap().is_ok()); } @@ -1033,7 +1031,7 @@ mod tests { let mock = MockServerClient::new(); let header = NakamotoBlockHeader::empty(); let block = NakamotoBlock::new(header, vec![]); - let h = spawn(move || mock.client.submit_block_for_validation(block, None)); + let h = spawn(move || mock.client.submit_block_for_validation(block)); write_response(mock.server, b"HTTP/1.1 404 Not Found\n\n"); assert!(h.join().unwrap().is_err()); } diff --git a/stacks-signer/src/config.rs b/stacks-signer/src/config.rs index 917de082f19..f08cc0af764 100644 --- a/stacks-signer/src/config.rs +++ b/stacks-signer/src/config.rs @@ -54,9 +54,6 @@ const DEFAULT_TENURE_IDLE_TIMEOUT_BUFFER_SECS: u64 = 2; /// cannot determine that our stacks-node has processed the parent /// block const DEFAULT_PROPOSAL_WAIT_TIME_FOR_PARENT_SECS: u64 = 15; -/// Default number of blocks after a fork to reset the replay set, -/// as a failsafe mechanism -pub const DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS: u64 = 2; /// Default time (in secs) to wait between updating our local state /// machine view point and capitulating to other signers tenure view const DEFAULT_CAPITULATE_MINER_VIEW_SECS: u64 = 20; @@ -197,11 +194,6 @@ pub struct SignerConfig { /// Time to wait before submitting a block proposal to the stacks-node if we cannot /// determine that the stacks-node has processed the parent pub proposal_wait_for_parent_time: Duration, - /// Whether or not to validate blocks with replay transactions - pub validate_with_replay_tx: bool, - /// How many blocks after a fork should we reset the replay set, - /// as a failsafe mechanism - pub reset_replay_set_after_fork_blocks: u64, /// Time to wait between updating our local state machine view point and capitulating to other signers miner view pub capitulate_miner_view_timeout: Duration, /// The HTTP timeout for read/write operations with StackerDB. @@ -262,11 +254,6 @@ pub struct GlobalConfig { pub proposal_wait_for_parent_time: Duration, /// Is this signer binary going to be running in dry-run mode? pub dry_run: bool, - /// Whether or not to validate blocks with replay transactions - pub validate_with_replay_tx: bool, - /// How many blocks after a fork should we reset the replay set, - /// as a failsafe mechanism - pub reset_replay_set_after_fork_blocks: u64, /// Time to wait between updating our local state machine view point and capitulating to other signers miner view pub capitulate_miner_view_timeout: Duration, /// The HTTP timeout for read/write operations with StackerDB. @@ -439,18 +426,6 @@ struct RawConfigFile { /// --- /// @default: `false` pub dry_run: Option, - /// Whether to validate blocks by replaying transactions. - /// --- - /// @default: `false` - /// @notes: - /// - Experimental feature. Provides additional validation but increases - /// resource usage. - pub validate_with_replay_tx: Option, - /// Number of blocks after a fork to reset the replay set as a failsafe mechanism. - /// --- - /// @default: `2` - /// @units: blocks - pub reset_replay_set_after_fork_blocks: Option, /// Time to wait between updating the local state machine view and capitulating /// to other signers' tenure view. /// --- @@ -597,14 +572,6 @@ impl TryFrom for GlobalConfig { .unwrap_or(DEFAULT_PROPOSAL_WAIT_TIME_FOR_PARENT_SECS), ); - // TODO: remove this before going to mainnet - // https://github.com/stacks-network/stacks-core/issues/6087 - let validate_with_replay_tx = raw_data.validate_with_replay_tx.unwrap_or(false); - - let reset_replay_set_after_fork_blocks = raw_data - .reset_replay_set_after_fork_blocks - .unwrap_or(DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS); - let capitulate_miner_view_timeout = Duration::from_secs( raw_data .capitulate_miner_view_timeout_secs @@ -643,8 +610,6 @@ impl TryFrom for GlobalConfig { tenure_idle_timeout_buffer, read_count_idle_timeout, proposal_wait_for_parent_time, - validate_with_replay_tx, - reset_replay_set_after_fork_blocks, capitulate_miner_view_timeout, stackerdb_timeout, #[cfg(any(test, feature = "testing"))] @@ -936,7 +901,6 @@ db_path = ":memory:" ); let config = GlobalConfig::load_from_str(&config_toml).unwrap(); assert_eq!(config.stacks_address.to_string(), expected_addr); - assert!(!config.validate_with_replay_tx); assert_eq!( config.capitulate_miner_view_timeout, Duration::from_secs(DEFAULT_CAPITULATE_MINER_VIEW_SECS) @@ -952,16 +916,12 @@ endpoint = "localhost:30000" network = "mainnet" auth_password = "abcd" db_path = ":memory:" -validate_with_replay_tx = true -reset_replay_set_after_fork_blocks = 100 capitulate_miner_view_timeout_secs = 1000 "# ); let config = GlobalConfig::load_from_str(&config_toml).unwrap(); assert_eq!(config.stacks_address.to_string(), expected_addr); assert_eq!(config.to_chain_id(), CHAIN_ID_MAINNET); - assert!(config.validate_with_replay_tx); - assert_eq!(config.reset_replay_set_after_fork_blocks, 100); assert_eq!( config.capitulate_miner_view_timeout, Duration::from_secs(1000) diff --git a/stacks-signer/src/runloop.rs b/stacks-signer/src/runloop.rs index fd2c81fba1a..01e479801d0 100644 --- a/stacks-signer/src/runloop.rs +++ b/stacks-signer/src/runloop.rs @@ -329,8 +329,6 @@ impl, T: StacksMessageCodec + Clone + Send + Debug> RunLo block_proposal_max_age_secs: self.config.block_proposal_max_age_secs, reorg_attempts_activity_timeout: self.config.reorg_attempts_activity_timeout, proposal_wait_for_parent_time: self.config.proposal_wait_for_parent_time, - validate_with_replay_tx: self.config.validate_with_replay_tx, - reset_replay_set_after_fork_blocks: self.config.reset_replay_set_after_fork_blocks, capitulate_miner_view_timeout: self.config.capitulate_miner_view_timeout, stackerdb_timeout: self.config.stackerdb_timeout, #[cfg(any(test, feature = "testing"))] diff --git a/stacks-signer/src/signerdb.rs b/stacks-signer/src/signerdb.rs index eb81a97b84c..b8b62af3a42 100644 --- a/stacks-signer/src/signerdb.rs +++ b/stacks-signer/src/signerdb.rs @@ -677,6 +677,8 @@ static ADD_PARENT_BURN_BLOCK_HASH_INDEX: &str = r#" CREATE INDEX IF NOT EXISTS burn_blocks_parent_burn_block_hash_idx on burn_blocks (parent_burn_block_hash); "#; +/// Dead schema: transaction replay was removed and nothing reads or writes this table. +/// To be dropped with a proper bump of `SCHEMA_VERSION`. static ADD_BLOCK_VALIDATED_BY_REPLAY_TXS_TABLE: &str = r#" CREATE TABLE IF NOT EXISTS block_validated_by_replay_txs ( signer_signature_hash TEXT NOT NULL, @@ -2167,38 +2169,6 @@ impl SignerDb { Ok(result) } - /// Insert a block validated by a replay tx - pub fn insert_block_validated_by_replay_tx( - &self, - signer_signature_hash: &Sha512Trunc256Sum, - replay_tx_hash: u64, - replay_tx_exhausted: bool, - ) -> Result<(), DBError> { - self.db.execute( - "INSERT INTO block_validated_by_replay_txs (signer_signature_hash, replay_tx_hash, replay_tx_exhausted) VALUES (?1, ?2, ?3)", - params![ - signer_signature_hash.to_string(), - format!("{replay_tx_hash}"), - replay_tx_exhausted - ], - )?; - Ok(()) - } - - /// Get the replay tx hash for a block validation - pub fn get_was_block_validated_by_replay_tx( - &self, - signer_signature_hash: &Sha512Trunc256Sum, - replay_tx_hash: u64, - ) -> Result, DBError> { - let query = "SELECT replay_tx_hash, replay_tx_exhausted FROM block_validated_by_replay_txs WHERE signer_signature_hash = ? AND replay_tx_hash = ?"; - let args = params![ - signer_signature_hash.to_string(), - format!("{replay_tx_hash}") - ]; - query_row(&self.db, query, args) - } - /// Get the earliest received time at which the signer state update achieved /// a global burn view identified by the provided ConsensusHash pub fn get_burn_block_received_time_from_signers( @@ -2486,25 +2456,6 @@ impl FromRow for PendingBlockValidation { } } -/// A struct used to represent whether a block was validated by a transaction replay set -pub struct BlockValidatedByReplaySet { - /// The hash of the transaction replay set that validated the block - pub replay_tx_hash: String, - /// Whether the transaction replay set exhausted the set of transactions - pub replay_tx_exhausted: bool, -} - -impl FromRow for BlockValidatedByReplaySet { - fn from_row(row: &rusqlite::Row) -> Result { - let replay_tx_hash = row.get_unwrap(0); - let replay_tx_exhausted = row.get_unwrap(1); - Ok(BlockValidatedByReplaySet { - replay_tx_hash, - replay_tx_exhausted, - }) - } -} - #[cfg(any(test, feature = "testing"))] impl SignerDb { /// For tests, fetch all pending block validations @@ -3958,47 +3909,6 @@ pub mod tests { ); } - #[test] - fn insert_block_validated_by_replay_tx() { - let db_path = tmp_db_path(); - let db = SignerDb::new(db_path).expect("Failed to create signer db"); - - let signer_signature_hash = Sha512Trunc256Sum([0; 32]); - let replay_tx_hash = 15559610262907183370_u64; - let replay_tx_exhausted = true; - - db.insert_block_validated_by_replay_tx( - &signer_signature_hash, - replay_tx_hash, - replay_tx_exhausted, - ) - .expect("Failed to insert block validated by replay tx"); - - let result = db - .get_was_block_validated_by_replay_tx(&signer_signature_hash, replay_tx_hash) - .expect("Failed to get block validated by replay tx") - .expect("Expected block validation result to be stored"); - assert_eq!(result.replay_tx_hash, format!("{replay_tx_hash}")); - assert!(result.replay_tx_exhausted); - - let replay_tx_hash = 15559610262907183369_u64; - let replay_tx_exhausted = false; - - db.insert_block_validated_by_replay_tx( - &signer_signature_hash, - replay_tx_hash, - replay_tx_exhausted, - ) - .expect("Failed to insert block validated by replay tx"); - - let result = db - .get_was_block_validated_by_replay_tx(&signer_signature_hash, replay_tx_hash) - .expect("Failed to get block validated by replay tx") - .expect("Expected block validation result to be stored"); - assert_eq!(result.replay_tx_hash, format!("{replay_tx_hash}")); - assert!(!result.replay_tx_exhausted); - } - #[test] fn check_burn_block_received_time_from_signers() { let db_path = tmp_db_path(); diff --git a/stacks-signer/src/tests/signer_state.rs b/stacks-signer/src/tests/signer_state.rs index 51d1e377dbd..43158265c57 100644 --- a/stacks-signer/src/tests/signer_state.rs +++ b/stacks-signer/src/tests/signer_state.rs @@ -41,7 +41,7 @@ use stacks_common::function_name; use crate::chainstate::{ProposalEvalConfig, SortitionData}; use crate::client::tests::{build_get_tenure_tip_response, MockServerClient}; use crate::client::StacksClient; -use crate::config::{GlobalConfig, DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS}; +use crate::config::GlobalConfig; use crate::signerdb::tests::{create_block_override, tmp_db_path}; use crate::signerdb::SignerDb; use crate::v0::signer_state::{LocalStateMachine, NewBurnBlock, StateMachineUpdate}; @@ -1035,7 +1035,6 @@ fn check_miner_inactivity_timeout() { tenure_idle_timeout_buffer: Duration::from_secs(2), reorg_attempts_activity_timeout: Duration::from_secs(3), proposal_wait_for_parent_time: Duration::from_secs(0), - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, read_count_idle_timeout: Duration::from_secs(12000), }; diff --git a/stacks-signer/src/v0/signer.rs b/stacks-signer/src/v0/signer.rs index 38f66a832ba..018de471f30 100644 --- a/stacks-signer/src/v0/signer.rs +++ b/stacks-signer/src/v0/signer.rs @@ -53,9 +53,9 @@ use crate::client::{ClientError, SignerSlotID, StackerDB, StacksClient}; use crate::config::{SignerConfig, SignerConfigMode}; use crate::runloop::SignerResult; use crate::signerdb::{BlockInfo, BlockState, PendingBlockResponses, SignerDb}; +use crate::v0::signer_state::NewBurnBlock; #[cfg(not(any(test, feature = "testing")))] use crate::v0::signer_state::SUPPORTED_SIGNER_PROTOCOL_VERSION; -use crate::v0::signer_state::{NewBurnBlock, ReplayScopeOpt}; use crate::Signer as SignerTrait; /// A global variable that can be used to make signers repeat their proposal @@ -126,12 +126,6 @@ pub struct Signer { recently_processed: RecentlyProcessedBlocks<100>, /// The signer's global state evaluator pub global_state_evaluator: GlobalStateEvaluator, - /// Whether to validate blocks with replay transactions - pub validate_with_replay_tx: bool, - /// Scope of Tx Replay in terms of Burn block boundaries - pub tx_replay_scope: ReplayScopeOpt, - /// The number of blocks after the past tip to reset the replay set - pub reset_replay_set_after_fork_blocks: u64, /// Time to wait between updating our local state machine view point and capitulating to other signers miner view pub capitulate_miner_view_timeout: Duration, /// The last time we capitulated our miner viewpoint @@ -310,9 +304,6 @@ impl SignerTrait for Signer { local_state_machine: signer_state, recently_processed: RecentlyProcessedBlocks::new(), global_state_evaluator, - validate_with_replay_tx: signer_config.validate_with_replay_tx, - tx_replay_scope: None, - reset_replay_set_after_fork_blocks: signer_config.reset_replay_set_after_fork_blocks, capitulate_miner_view_timeout: signer_config.capitulate_miner_view_timeout, last_capitulate_miner_view: SystemTime::now(), #[cfg(any(test, feature = "testing"))] @@ -342,7 +333,7 @@ impl SignerTrait for Signer { if self.reward_cycle <= current_reward_cycle { self.local_state_machine.handle_pending_update(&self.signer_db, stacks_client, &self.proposal_config, - &mut self.tx_replay_scope, &self.global_state_evaluator, local_signer_protocol_version) + &self.global_state_evaluator, local_signer_protocol_version) .unwrap_or_else(|e| error!("{self}: failed to update local state machine for pending update"; "err" => ?e)); } // See if we should capitulate our viewpoint... @@ -653,8 +644,7 @@ impl Signer { burn_block_height: *burn_height, consensus_hash: consensus_hash.clone(), }), - &mut self.tx_replay_scope - , &self.global_state_evaluator, active_signer_protocol_version) + &self.global_state_evaluator, active_signer_protocol_version) .unwrap_or_else(|e| error!("{self}: failed to update local state machine for latest bitcoin block arrival"; "err" => ?e)); *sortition_state = None; } @@ -690,7 +680,7 @@ impl Signer { "total_txs" => transactions.len() ); self.local_state_machine - .stacks_block_arrival(consensus_hash, *block_height, block_id, signer_sighash, &self.signer_db, transactions) + .stacks_block_arrival(consensus_hash, *block_height, block_id) .unwrap_or_else(|e| error!("{self}: failed to update local state machine for latest stacks block arrival"; "err" => ?e)); if let Ok(Some(mut block_info)) = self @@ -882,15 +872,7 @@ impl Signer { // Check if proposal can be rejected now if not valid against sortition view if let Some(sortition_state) = sortition_state { - match sortition_state.check_proposal( - stacks_client, - &mut self.signer_db, - block, - true, - self.global_state_evaluator - .get_global_tx_replay_set() - .unwrap_or_default(), - ) { + match sortition_state.check_proposal(stacks_client, &mut self.signer_db, block, true) { // Error validating block Err(RejectReason::ConnectivityIssues(e)) => { warn!( @@ -1071,12 +1053,8 @@ impl Signer { update: &StateMachineUpdate, received_time: &SystemTime, ) { - let replay_txids = update.content.replay_txids(); let pubkey = signer_public_key.to_hex(); - info!( - "{self}: Received state machine update from signer {pubkey}: {update}"; - "replay_txids" => ?replay_txids - ); + info!("{self}: Received state machine update from signer {pubkey}: {update}"); let address = StacksAddress::p2pkh(self.mainnet, signer_public_key); // Store the state machine update so we can reload it if we crash if let Err(e) = self.signer_db.insert_state_machine_update( @@ -1543,21 +1521,6 @@ impl Signer { { self.submitted_block_proposal = None; } - if let Some(replay_tx_hash) = block_validate_ok.replay_tx_hash { - info!("Inserting block validated by replay tx"; - "signer_signature_hash" => %signer_signature_hash, - "replay_tx_hash" => replay_tx_hash - ); - self.signer_db - .insert_block_validated_by_replay_tx( - signer_signature_hash, - replay_tx_hash, - block_validate_ok.replay_tx_exhausted, - ) - .unwrap_or_else(|e| { - warn!("{self}: Failed to insert block validated by replay tx: {e:?}") - }); - } // For mutability reasons, we need to take the block_info out of the map and add it back after processing let Some(mut block_info) = self.block_lookup_by_reward_cycle(signer_signature_hash) else { // We have not seen this block before. Why are we getting a response for it? @@ -2244,17 +2207,7 @@ impl Signer { debug!("{self}: Cannot confirm that we have processed parent, but we've waited proposal_wait_for_parent_time, will submit proposal"); } } - match stacks_client.submit_block_for_validation( - block.clone(), - if self.validate_with_replay_tx { - self.global_state_evaluator - .get_global_tx_replay_set() - .unwrap_or_default() - .clone_as_optional() - } else { - None - }, - ) { + match stacks_client.submit_block_for_validation(block.clone()) { Ok(_) => { self.submitted_block_proposal = Some((signer_signature_hash, Instant::now())); } diff --git a/stacks-signer/src/v0/signer_state.rs b/stacks-signer/src/v0/signer_state.rs index 29b627a09eb..414c539533d 100644 --- a/stacks-signer/src/v0/signer_state.rs +++ b/stacks-signer/src/v0/signer_state.rs @@ -19,9 +19,6 @@ use std::sync::LazyLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use blockstack_lib::chainstate::burn::ConsensusHashExtensions; -use blockstack_lib::chainstate::stacks::{StacksTransaction, TransactionPayload}; -use blockstack_lib::net::api::get_tenures_fork_info::TenureForkingInfo; -use blockstack_lib::net::api::postblock_proposal::NakamotoBlockProposal; use blockstack_lib::util_lib::db::Error as DBError; #[cfg(any(test, feature = "testing"))] use clarity::util::tests::TestFlag; @@ -35,7 +32,6 @@ use libsigner::v0::signer_state::{ use serde::{Deserialize, Serialize}; use stacks_common::codec::Error as CodecError; use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId}; -use stacks_common::util::hash::Sha512Trunc256Sum; #[cfg(any(test, feature = "testing"))] use stacks_common::util::secp256k1::Secp256k1PublicKey; use stacks_common::{debug, info, warn}; @@ -45,7 +41,7 @@ use crate::chainstate::{ ProposalEvalConfig, SignerChainstateError, SortitionData, SortitionState, SortitionStateVersion, }; use crate::client::{ClientError, CurrentAndLastSortition, StackerDB, StacksClient}; -use crate::signerdb::{BlockValidatedByReplaySet, SignerDb}; +use crate::signerdb::SignerDb; /// This is the latest supported protocol version for this signer binary pub static SUPPORTED_SIGNER_PROTOCOL_VERSION: u64 = 2; @@ -89,52 +85,6 @@ pub struct NewBurnBlock { pub consensus_hash: ConsensusHash, } -/// Represents the scope of Tx Replay in terms of burn block boundaries. -#[derive(Debug, Clone)] -pub struct ReplayScope { - /// The burn block where the fork that originated the transaction replay began. - pub fork_origin: NewBurnBlock, - /// The canonical burn chain tip at the time the transaction replay started. - pub past_tip: NewBurnBlock, -} - -/// Optional `TxReplayScope`, representing the potential absence of a replay scope. -pub type ReplayScopeOpt = Option; - -/// Represents the Tx Replay state -pub enum ReplayState { - /// No replay has started yet, or the previous replay was cleared. - Unset, - /// A replay is currently in progress, with an associated transaction set and scope. - InProgress(ReplayTransactionSet, ReplayScope), -} - -impl ReplayState { - /// Infers the appropriate `ReplayState` based on the contents of the replay transaction set - /// and the optional scope. - /// - /// # Arguments - /// - /// * `replay_set` - A reference to a set of transactions intended for replay. - /// * `scope_opt` - An optional scope defining the boundaries or context for the replay. - /// - /// # Returns - /// - /// * `Some(ReplayState::Unset)` if the `replay_set` is empty. - /// * `Some(ReplayState::InProgress)` if the `replay_set` is non-empty and a `scope` is provided. - /// * `None` if the `replay_set` is non-empty but no `scope` is provided. - /// - Possibly caused by the scope being a local state in the `Signer` struct, which is not persisted. - fn infer_state(replay_set: &ReplayTransactionSet, scope_opt: &ReplayScopeOpt) -> Option { - if replay_set.is_empty() { - return Some(Self::Unset); - } - - scope_opt - .as_ref() - .map(|scope| Self::InProgress(replay_set.clone(), scope.clone())) - } -} - impl LocalStateMachine { /// Initialize a local state machine by querying the local stacks-node /// and signerdb for the current sortition information @@ -151,7 +101,6 @@ impl LocalStateMachine { client, proposal_config, None, - &mut None, eval, active_signer_protocol_version, )?; @@ -259,7 +208,6 @@ impl LocalStateMachine { db: &SignerDb, client: &StacksClient, proposal_config: &ProposalEvalConfig, - tx_replay_scope: &mut ReplayScopeOpt, eval: &GlobalStateEvaluator, local_signer_protocol_version: u64, ) -> Result<(), SignerChainstateError> { @@ -272,7 +220,6 @@ impl LocalStateMachine { client, proposal_config, Some(expected_burn_height), - tx_replay_scope, eval, local_signer_protocol_version, ), @@ -441,9 +388,6 @@ impl LocalStateMachine { ch: &ConsensusHash, height: u64, block_id: &StacksBlockId, - signer_signature_hash: &Sha512Trunc256Sum, - db: &SignerDb, - txs: &Vec, ) -> Result<(), SignerChainstateError> { // set self to uninitialized so that if this function errors, // self is left as uninitialized. @@ -469,38 +413,6 @@ impl LocalStateMachine { } }; - if let Some(replay_set_hash) = NakamotoBlockProposal::tx_replay_hash( - &prior_state_machine.tx_replay_set.clone_as_optional(), - ) { - match db.get_was_block_validated_by_replay_tx(signer_signature_hash, replay_set_hash) { - Ok(Some(BlockValidatedByReplaySet { - replay_tx_exhausted, - .. - })) => { - if replay_tx_exhausted { - // This block was validated by our current state machine's replay set, - // and the block exhausted the replay set. Therefore, clear the tx replay set. - info!("Signer State: Incoming Stacks block exhausted the replay set, clearing the tx replay set"; - "signer_signature_hash" => %signer_signature_hash, - ); - prior_state_machine.tx_replay_set = ReplayTransactionSet::none(); - } - } - Ok(None) => { - info!("Signer State: got a new block during replay that wasn't validated by our replay set. Clearing the local replay set."; - "txs" => ?txs, - ); - prior_state_machine.tx_replay_set = ReplayTransactionSet::none(); - } - Err(e) => { - warn!("Signer State: Failed to check if block was validated by replay tx"; - "err" => ?e, - "signer_signature_hash" => %signer_signature_hash, - ); - } - } - } - let MinerState::ActiveMiner { parent_tenure_id, parent_tenure_last_block, @@ -551,7 +463,6 @@ impl LocalStateMachine { client: &StacksClient, proposal_config: &ProposalEvalConfig, mut expected_burn_block: Option, - tx_replay_scope: &mut ReplayScopeOpt, eval: &GlobalStateEvaluator, local_signer_protocol_version: u64, ) -> Result<(), SignerChainstateError> { @@ -587,7 +498,7 @@ impl LocalStateMachine { let peer_info = client.get_peer_info()?; let next_burn_block_height = peer_info.burn_block_height; let next_burn_block_hash = peer_info.pox_consensus; - let mut tx_replay_set = prior_state_machine.tx_replay_set.clone(); + let tx_replay_set = prior_state_machine.tx_replay_set.clone(); if let Some(expected_burn_block) = expected_burn_block { // If the next height is less than the expected height, we need to wait. @@ -611,46 +522,6 @@ impl LocalStateMachine { }; return Err(ClientError::InvalidResponse(err_msg).into()); } - - let replay_state = match ReplayState::infer_state(&tx_replay_set, tx_replay_scope) { - Some(valid_state) => valid_state, - None => { - warn!( - "Tx Replay: Invalid state due to scope being not set while in replay mode!" - ); - return Err(SignerChainstateError::LocalStateMachineNotReady); - } - }; - - if let Some(new_replay_state) = self.handle_possible_bitcoin_fork( - db, - client, - &expected_burn_block, - &prior_state_machine, - &replay_state, - )? { - match new_replay_state { - ReplayState::Unset => { - tx_replay_set = ReplayTransactionSet::none(); - *tx_replay_scope = None; - } - ReplayState::InProgress(new_txs_set, new_scope) => { - tx_replay_set = new_txs_set; - *tx_replay_scope = Some(new_scope); - } - } - } else if Self::handle_possible_replay_failsafe( - &replay_state, - &expected_burn_block, - proposal_config.reset_replay_set_after_fork_blocks, - ) { - info!( - "Signer state: replay set is stalled after {} tenures. Clearing the replay set.", - proposal_config.reset_replay_set_after_fork_blocks - ); - tx_replay_set = ReplayTransactionSet::none(); - *tx_replay_scope = None; - } } let CurrentAndLastSortition { @@ -1123,344 +994,4 @@ impl LocalStateMachine { } } } - - /// Extract out the tx replay set if it exists - pub fn get_tx_replay_set(&self) -> Option> { - let Self::Initialized(state) = self else { - return None; - }; - state.tx_replay_set.clone_as_optional() - } - - /// Handle a possible bitcoin fork. If a fork is detected, - /// try to handle the possible replay state. - /// - /// # Returns - /// - `Ok(None)` if nothing need to be done about replay - /// - `Ok(Some(ReplayState))` if a change (new or update) to the replay state is required - /// - `Err(SignerChainstateError)` in case of chain state errors - pub fn handle_possible_bitcoin_fork( - &self, - db: &SignerDb, - client: &StacksClient, - expected_burn_block: &NewBurnBlock, - prior_state_machine: &SignerStateMachine, - replay_state: &ReplayState, - ) -> Result, SignerChainstateError> { - if expected_burn_block.burn_block_height > prior_state_machine.burn_block_height { - if Self::new_burn_block_fork_descendency_check( - db, - expected_burn_block, - prior_state_machine.burn_block_height, - prior_state_machine.burn_block.clone(), - ) { - info!("Detected bitcoin fork - prior tip is not parent of new tip."; - "new_tip.burn_block_height" => expected_burn_block.burn_block_height, - "new_tip.consensus_hash" => %expected_burn_block.consensus_hash, - "prior_tip.burn_block_height" => prior_state_machine.burn_block_height, - "prior_tip.consensus_hash" => %prior_state_machine.burn_block, - ); - } else { - return Ok(None); - } - } - if expected_burn_block.consensus_hash == prior_state_machine.burn_block { - // no bitcoin fork, because we're at the same burn block hash as before - return Ok(None); - } - - match replay_state { - ReplayState::Unset => self.handle_fork_for_new_replay( - db, - client, - expected_burn_block, - prior_state_machine, - ), - ReplayState::InProgress(_, scope) => self.handle_fork_on_in_progress_replay( - db, - client, - expected_burn_block, - prior_state_machine, - scope, - ), - } - } - - /// Understand if the fork produces a replay set to be managed - /// - /// # Returns - /// - /// - `Ok(None)` if nothing need to be done - /// - `Ok(Some(ReplayState::InProgress(..)))` in case a replay need to be started - fn handle_fork_for_new_replay( - &self, - db: &SignerDb, - client: &StacksClient, - expected_burn_block: &NewBurnBlock, - prior_state_machine: &SignerStateMachine, - ) -> Result, SignerChainstateError> { - info!("Signer State: fork detected"; - "expected_burn_block.height" => expected_burn_block.burn_block_height, - "expected_burn_block.hash" => %expected_burn_block.consensus_hash, - "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height, - "prior_state_machine.burn_block" => %prior_state_machine.burn_block, - ); - #[cfg(any(test, feature = "testing"))] - { - use clarity::types::chainstate::StacksAddress; - - let ignore_bitcoin_fork = TEST_IGNORE_BITCOIN_FORK_PUBKEYS - .get() - .iter() - .any(|pubkey| &StacksAddress::p2pkh(false, pubkey) == client.get_signer_address()); - if ignore_bitcoin_fork { - warn!("Ignoring bitcoin fork due to test flag"); - return Ok(None); - } - } - - let potential_replay_tip = NewBurnBlock { - burn_block_height: prior_state_machine.burn_block_height, - consensus_hash: prior_state_machine.burn_block.clone(), - }; - - match self.compute_forked_txs_set_in_same_cycle( - db, - client, - expected_burn_block, - &potential_replay_tip, - )? { - None => { - info!("Detected bitcoin fork occurred in previous reward cycle. Tx replay won't be executed"); - Ok(None) - } - Some(replay_set) => { - if replay_set.is_empty() { - info!("Tx Replay: no transactions to be replayed."); - Ok(None) - } else { - let scope = ReplayScope { - fork_origin: expected_burn_block.clone(), - past_tip: potential_replay_tip, - }; - info!("Tx Replay: replay set updated with {} tx(s)", replay_set.len(); - "tx_replay_set" => ?replay_set, - "tx_replay_scope" => ?scope); - let replay_state = - ReplayState::InProgress(ReplayTransactionSet::new(replay_set), scope); - Ok(Some(replay_state)) - } - } - } - } - - /// Understand if the fork produces changes over an in-progress replay - /// - /// # Returns - /// - /// - `Ok(None)` if nothing need to be done - /// - `Ok(Some(ReplayState::Unset))` in case a replay set need to be cleared - /// - `Ok(Some(ReplayState::InProgress(..)))` in case a replay set need to be updated - fn handle_fork_on_in_progress_replay( - &self, - db: &SignerDb, - client: &StacksClient, - expected_burn_block: &NewBurnBlock, - prior_state_machine: &SignerStateMachine, - scope: &ReplayScope, - ) -> Result, SignerChainstateError> { - info!("Tx Replay: detected bitcoin fork while in replay mode. Tryng to handle the fork"; - "expected_burn_block.height" => expected_burn_block.burn_block_height, - "expected_burn_block.hash" => %expected_burn_block.consensus_hash, - "prior_state_machine.burn_block_height" => prior_state_machine.burn_block_height, - "prior_state_machine.burn_block" => %prior_state_machine.burn_block, - ); - - let is_deepest_fork = - expected_burn_block.burn_block_height < scope.fork_origin.burn_block_height; - if !is_deepest_fork { - //if it is within the scope or after - this is not a new fork, but the continue of a reorg - info!("Tx Replay: nothing todo. Reorg in progress!"); - return Ok(None); - } - - let replay_state; - if let Some(replay_set) = self.compute_forked_txs_set_in_same_cycle( - db, - client, - expected_burn_block, - &scope.past_tip, - )? { - let scope = ReplayScope { - fork_origin: expected_burn_block.clone(), - past_tip: scope.past_tip.clone(), - }; - - info!("Tx Replay: replay set updated with {} tx(s)", replay_set.len(); - "tx_replay_set" => ?replay_set, - "tx_replay_scope" => ?scope); - replay_state = ReplayState::InProgress(ReplayTransactionSet::new(replay_set), scope); - } else { - info!("Tx Replay: replay set will be cleared, because the fork involves the previous reward cycle."); - replay_state = ReplayState::Unset; - } - Ok(Some(replay_state)) - } - - /// Retrieves the set of transactions that were part of a Bitcoin fork within the same reward cycle. - /// - /// This method identifies the range of Tenures affected by a fork, from the `fork_tip` down to the `fork_origin` - /// - /// It then verifies whether the fork occurred entirely within the reward cycle related to the `fork_tip`. If so, - /// collect the relevant transactions (skipping TenureChange, Coinbase, and PoisonMicroblock). - /// Otherwise, if fork involve a different reward cycle cancel the search. - /// - /// # Arguments - /// - /// * `db` - A reference to the SignerDb, used to fetch burn block information. - /// * `client` - A reference to a `StacksClient`, used to query chain state and fork information. - /// * `fork_origin` - The burn block that originated the fork. - /// * `fork_tip` - The burn block tip in the fork sequence. - /// - /// # Returns - /// - /// Returns a `Result` containing either: - /// * `Ok(Some(Vec))` — A list of transactions to be considered for replay, or - /// * `Ok(None)` — If the fork occurred outside the current reward cycle, or - /// * `Err(SignerChainstateError)` — If there was an error accessing chain state. - fn compute_forked_txs_set_in_same_cycle( - &self, - db: &SignerDb, - client: &StacksClient, - fork_origin: &NewBurnBlock, - fork_tip: &NewBurnBlock, - ) -> Result>, SignerChainstateError> { - // Determine the tenures that were forked - let mut parent_burn_block_info = db.get_burn_block_by_ch(&fork_tip.consensus_hash)?; - let last_forked_tenure = &fork_tip.consensus_hash; - let mut first_forked_tenure = &fork_tip.consensus_hash; - while parent_burn_block_info.block_height > fork_origin.burn_block_height { - parent_burn_block_info = - db.get_burn_block_by_hash(&parent_burn_block_info.parent_burn_block_hash)?; - first_forked_tenure = &parent_burn_block_info.consensus_hash; - } - let fork_info = client.get_tenure_forking_info(first_forked_tenure, last_forked_tenure)?; - - // Check if fork occurred within current reward cycle. Reject tx replay otherwise. - let reward_cycle_info = client.get_current_reward_cycle_info()?; - - let target_reward_cycle = reward_cycle_info.get_reward_cycle(fork_tip.burn_block_height); - let is_fork_in_current_reward_cycle = fork_info.iter().all(|fork_info| { - let block_height = fork_info.burn_block_height; - let block_rc = reward_cycle_info.get_reward_cycle(block_height); - block_rc == target_reward_cycle - }); - - if !is_fork_in_current_reward_cycle { - info!("Signer State: Detected bitcoin fork occurred in previous reward cycle. Tx replay won't be executed"); - return Ok(None); - } - - Ok(Some(Self::get_forked_txs_from_fork_info(&fork_info))) - } - - fn get_forked_txs_from_fork_info(fork_info: &[TenureForkingInfo]) -> Vec { - // Collect transactions to be replayed across the forked blocks - let mut forked_blocks = fork_info - .iter() - .flat_map(|fork_info| fork_info.nakamoto_blocks.iter().flatten()) - .collect::>(); - forked_blocks.sort_by_key(|block| block.header.chain_length); - let forked_txs = forked_blocks - .iter() - .flat_map(|block| block.txs()) - .filter(|tx| - // Don't include Coinbase, TenureChange, or PoisonMicroblock transactions - !matches!( - tx.payload(), - TransactionPayload::TenureChange(..) - | TransactionPayload::Coinbase(..) - | TransactionPayload::PoisonMicroblock(..) - )) - .map(|tx| tx.tx_ignoring_problematic_state().clone()) - .collect::>(); - forked_txs - } - - /// If it has been `reset_replay_set_after_fork_blocks` burn blocks since the origin of our replay set, and - /// we haven't produced any replay blocks since then, we should reset our replay set - /// - /// Returns a `bool` indicating whether the replay set should be reset. - fn handle_possible_replay_failsafe( - replay_state: &ReplayState, - new_burn_block: &NewBurnBlock, - reset_replay_set_after_fork_blocks: u64, - ) -> bool { - match replay_state { - ReplayState::Unset => { - // not in replay - skip - false - } - ReplayState::InProgress(_, replay_scope) => { - let failsafe_height = - replay_scope.past_tip.burn_block_height + reset_replay_set_after_fork_blocks; - new_burn_block.burn_block_height > failsafe_height - } - } - } - - /// Check if the new burn block is a fork, by checking if the new burn block - /// is a descendant of the prior burn block - fn new_burn_block_fork_descendency_check( - db: &SignerDb, - new_burn_block: &NewBurnBlock, - prior_burn_block_height: u64, - prior_burn_block_ch: ConsensusHash, - ) -> bool { - let max_height_delta = 10; - let height_delta = match new_burn_block - .burn_block_height - .checked_sub(prior_burn_block_height) - { - None | Some(0) => return false, // same height or older - Some(d) if d > max_height_delta => return false, // too far apart - Some(d) => d, - }; - - let mut parent_burn_block_info = match db - .get_burn_block_by_ch(&new_burn_block.consensus_hash) - .and_then(|burn_block_info| { - db.get_burn_block_by_hash(&burn_block_info.parent_burn_block_hash) - }) { - Ok(info) => info, - Err(e) => { - warn!( - "Failed to get parent burn block info for {}", - new_burn_block.consensus_hash; - "error" => ?e, - ); - return false; - } - }; - - for _ in 0..height_delta { - if parent_burn_block_info.block_height == prior_burn_block_height { - return parent_burn_block_info.consensus_hash != prior_burn_block_ch; - } - - parent_burn_block_info = - match db.get_burn_block_by_hash(&parent_burn_block_info.parent_burn_block_hash) { - Ok(bi) => bi, - Err(e) => { - warn!( - "Failed to get parent burn block info for {}. Error: {e}", - parent_burn_block_info.parent_burn_block_hash - ); - return false; - } - }; - } - - false - } } From 548d0ddd4e6b78b27f5591f73714e7716c9c980c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 13:55:08 +0200 Subject: [PATCH 06/12] chore: remove tx replay behaviour on node side (postblock_proposal) with backward compatibility --- .../src/tests/nakamoto_integrations.rs | 1 - stacks-signer/src/client/stacks_client.rs | 3 - stackslib/src/net/api/postblock_proposal.rs | 276 +------- .../src/net/api/tests/postblock_proposal.rs | 588 +----------------- 4 files changed, 19 insertions(+), 849 deletions(-) diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index 726cadd2f61..0a8c35640ee 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -3518,7 +3518,6 @@ fn block_proposal_api_endpoint() { let proposal = NakamotoBlockProposal { block, chain_id: chainstate.chain_id, - replay_txs: None, }; const HTTP_ACCEPTED: u16 = 202; diff --git a/stacks-signer/src/client/stacks_client.rs b/stacks-signer/src/client/stacks_client.rs index d519cd0e1ba..790ebdf1cd2 100644 --- a/stacks-signer/src/client/stacks_client.rs +++ b/stacks-signer/src/client/stacks_client.rs @@ -287,9 +287,6 @@ impl StacksClient { let block_proposal = NakamotoBlockProposal { block, chain_id: self.chain_id, - // Always `None`: transaction replay was removed. The field itself is dropped - // from `NakamotoBlockProposal` in a next commit. - replay_txs: None, }; let timer = crate::monitoring::actions::new_rpc_call_timer( &self.block_proposal_path(), diff --git a/stackslib/src/net/api/postblock_proposal.rs b/stackslib/src/net/api/postblock_proposal.rs index 6a43c33ca94..a5e70e8a85b 100644 --- a/stackslib/src/net/api/postblock_proposal.rs +++ b/stackslib/src/net/api/postblock_proposal.rs @@ -14,8 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::collections::VecDeque; -use std::hash::{DefaultHasher, Hash, Hasher}; +use std::hash::Hash; #[cfg(any(test, feature = "testing"))] use std::sync::LazyLock; use std::thread::{self, JoinHandle}; @@ -42,13 +41,11 @@ use crate::chainstate::nakamoto::miner::{MinerTenureInfoCause, NakamotoBlockBuil use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState}; use crate::chainstate::stacks::address::PoxAddress; use crate::chainstate::stacks::boot::PoxVersions; -use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState, StacksHeaderInfo}; +use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksChainState}; use crate::chainstate::stacks::miner::{ - BlockBuilder, BlockLimitFunction, TransactionError, TransactionProblematic, - TransactionResourceBudgets, TransactionResult, TransactionSkipped, + BlockBuilder, BlockLimitFunction, TransactionResourceBudgets, TransactionResult, }; -use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload}; -use crate::clarity_vm::clarity::ClarityError; +use crate::chainstate::stacks::{Error as ChainError, TransactionPayload}; use crate::config::DEFAULT_MAX_TENURE_BYTES; use crate::core::mempool::ProposalCallbackReceiver; use crate::net::connection::ConnectionOptions; @@ -71,16 +68,6 @@ pub static TEST_VALIDATE_STALL: LazyLock>>> = pub static TEST_VALIDATE_DELAY_DURATION_SECS: LazyLock> = LazyLock::new(TestFlag::default); -#[cfg(any(test, feature = "testing"))] -/// Mock for the set of transactions that must be replayed -pub static TEST_REPLAY_TRANSACTIONS: LazyLock< - TestFlag>, -> = LazyLock::new(TestFlag::default); - -#[cfg(any(test, feature = "testing"))] -/// Whether to reject any transaction while we're in a replay set. -pub static TEST_REJECT_REPLAY_TXS: LazyLock> = LazyLock::new(TestFlag::default); - // This enum is used to supply a `reason_code` for validation // rejection responses. This is serialized as an enum with string // type (in jsonschema terminology). @@ -92,6 +79,8 @@ define_u8_enum![ValidateRejectCode { UnknownParent = 4, NonCanonicalTenure = 5, NoSuchTenure = 6, + /// Reserved. Transaction replay was removed; no node emits this code any more, but the + /// variant is retained so a newer signer can still decode a `7` sent by an older node. InvalidTransactionReplay = 7, InvalidParentBlock = 8, InvalidTimestamp = 9, @@ -179,11 +168,15 @@ pub struct BlockValidateOk { pub cost: ExecutionCost, pub size: u64, pub validation_time_ms: u64, - /// If a block was validated by a transaction replay set, - /// then this returns `Some` with the hash of the replay set. + /// Deprecated: transaction replay was removed, so this is always `None`. + /// + /// Retained because `BlockValidateOk` has no `#[serde(default)]`: a signer running an + /// older binary treats these as required fields and would fail to deserialize the whole + /// response without them. Remove only in a release that need not interoperate with + /// pre-removal signers. pub replay_tx_hash: Option, - /// If a block was validated by a transaction replay set, - /// then this is true if this block exhausted the set of transactions. + /// Deprecated: transaction replay was removed, so this is always `false`. + /// See `replay_tx_hash` above. pub replay_tx_exhausted: bool, } @@ -245,25 +238,6 @@ fn fault_injection_validation_delay() { #[cfg(not(any(test, feature = "testing")))] fn fault_injection_validation_delay() {} -#[cfg(any(test, feature = "testing"))] -fn fault_injection_reject_replay_txs() -> Result<(), BlockValidateRejectReason> { - let reject = TEST_REJECT_REPLAY_TXS.get(); - if reject { - Err(BlockValidateRejectReason { - reason_code: ValidateRejectCode::InvalidTransactionReplay, - reason: "Rejected by test flag".into(), - failed_txid: None, - }) - } else { - Ok(()) - } -} - -#[cfg(not(any(test, feature = "testing")))] -fn fault_injection_reject_replay_txs() -> Result<(), BlockValidateRejectReason> { - Ok(()) -} - /// Represents a block proposed to the `v3/block_proposal` endpoint for validation #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NakamotoBlockProposal { @@ -272,8 +246,6 @@ pub struct NakamotoBlockProposal { pub block: NakamotoBlock, /// Identifies which chain block is for (Mainnet, Testnet, etc.) pub chain_id: u32, - /// Optional transaction replay set - pub replay_txs: Option>, } fn match_result_ok(value: &Value) -> Option<&Value> { @@ -533,11 +505,6 @@ impl NakamotoBlockProposal { /// - Miner signature is valid /// - Validation of transactions by executing them agains current chainstate. /// This is resource intensive, and therefore done only if previous checks pass - /// - /// During transaction replay, we also check that the block only contains the unmined - /// transactions that need to be replayed, up until either: - /// - The set of transactions that must be replayed is exhausted - /// - A cost limit is hit pub fn validate( &self, sortdb: &SortitionDB, @@ -701,15 +668,6 @@ impl NakamotoBlockProposal { }) .unwrap_or_else(|| MinerTenureInfoCause::NoTenureChange); - let replay_tx_exhausted = self.validate_replay( - &parent_stacks_header, - tenure_change, - coinbase, - tenure_cause, - chainstate, - &burn_dbconn, - )?; - let mut builder = NakamotoBlockBuilder::new( &parent_stacks_header, &self.block.header.consensus_hash, @@ -902,216 +860,16 @@ impl NakamotoBlockProposal { }) ); - let replay_tx_hash = Self::tx_replay_hash(&self.replay_txs); - Ok(BlockValidateOk { signer_signature_hash: block.header.signer_signature_hash(), cost, size, validation_time_ms, - replay_tx_hash, - replay_tx_exhausted, - }) - } - - pub fn tx_replay_hash(replay_txs: &Option>) -> Option { - replay_txs.as_ref().map(|txs| { - let mut hasher = DefaultHasher::new(); - txs.hash(&mut hasher); - hasher.finish() + // Deprecated; see the field docs on `BlockValidateOk`. + replay_tx_hash: None, + replay_tx_exhausted: false, }) } - - /// Validate the block against the replay set. - /// - /// Returns a boolean indicating whether this block exhausts the replay set. - /// - /// Returns `false` if there is no replay set. - fn validate_replay( - &self, - parent_stacks_header: &StacksHeaderInfo, - tenure_change: Option<&StacksTransaction>, - coinbase: Option<&StacksTransaction>, - tenure_cause: MinerTenureInfoCause, - // not directly used; used as a handle to open other chainstates - chainstate_handle: &StacksChainState, - burn_dbconn: &SortitionHandleConn, - ) -> Result { - let mut replay_txs_maybe: Option> = - self.replay_txs.clone().map(|txs| txs.into()); - - let Some(ref mut replay_txs) = replay_txs_maybe else { - return Ok(false); - }; - - let mut replay_builder = NakamotoBlockBuilder::new( - &parent_stacks_header, - &self.block.header.consensus_hash, - self.block.header.burn_spent, - tenure_change, - coinbase, - self.block.header.pox_treatment.len(), - None, - None, - Some(self.block.header.timestamp), - u64::from(DEFAULT_MAX_TENURE_BYTES), - )?; - let (mut replay_chainstate, _) = chainstate_handle.reopen()?; - let mut replay_miner_tenure_info = - replay_builder.load_tenure_info(&mut replay_chainstate, &burn_dbconn, tenure_cause)?; - let mut replay_tenure_tx = - replay_builder.tenure_begin(&burn_dbconn, &mut replay_miner_tenure_info)?; - - let mut total_receipts = 0; - for (i, tx) in self.block.txs.iter().enumerate() { - let tx_len = tx.tx_len(); - - // If a list of replay transactions is set, this transaction must be the next - // mineable transaction from this list. - loop { - if matches!( - tx.payload, - TransactionPayload::TenureChange(..) | TransactionPayload::Coinbase(..) - ) { - // Allow this to happen, tenure extend checks happen elsewhere. - break; - } - fault_injection_reject_replay_txs()?; - let Some(replay_tx) = replay_txs.pop_front() else { - // During transaction replay, we expect that the block only - // contains transactions from the replay set. Thus, if we're here, - // the block contains a transaction that is not in the replay set, - // and we should reject the block. - warn!("Rejected block proposal. Block contains transactions beyond the replay set."; - "txid" => %tx.txid(), - "tx_index" => i, - ); - return Err(BlockValidateRejectReason { - reason_code: ValidateRejectCode::InvalidTransactionReplay, - reason: "Block contains transactions beyond the replay set".into(), - failed_txid: Some(tx.txid()), - }); - }; - if replay_tx.txid() == tx.txid() { - break; - } - - // The included tx doesn't match the next tx in the - // replay set. Check to see if the tx is skipped because - // it was unmineable. - let tx_result = replay_builder.try_mine_tx_with_len( - &mut replay_tenure_tx, - &replay_tx, - replay_tx.tx_len(), - &BlockLimitFunction::NO_LIMIT_HIT, - &TransactionResourceBudgets::unlimited(), - &mut total_receipts, - ); - match tx_result { - TransactionResult::Skipped(TransactionSkipped { error, .. }) - | TransactionResult::ProcessingError(TransactionError { error, .. }) - | TransactionResult::Problematic(TransactionProblematic { error, .. }) => { - // The tx wasn't able to be mined. Check the underlying error, to - // see if we should reject the block or allow the tx to be - // dropped from the replay set. - - match error { - ChainError::CostOverflowError(..) - | ChainError::BlockTooBigError - | ChainError::BlockCostLimitError - | ChainError::ClarityError(ClarityError::CostError(..)) => { - // block limit reached; add tx back to replay set. - // BUT we know that the block should have ended at this point, so - // return an error. - let txid = replay_tx.txid(); - replay_txs.push_front(replay_tx); - - warn!("Rejecting block proposal. Next replay tx exceeds cost limits, so should have been in the next block."; - "error" => %error, - "txid" => %txid, - ); - - return Err(BlockValidateRejectReason { - reason_code: ValidateRejectCode::InvalidTransactionReplay, - reason: "Next replay tx exceeds cost limits, so should have been in the next block.".into(), - failed_txid: None, - }); - } - _ => { - info!("During replay block validation, allowing problematic tx to be dropped"; - "txid" => %replay_tx.txid(), - "error" => %error, - ); - // it's ok, drop it - continue; - } - } - } - TransactionResult::Success(_) => { - // Tx should have been included - warn!("Rejected block proposal. Block doesn't contain replay transaction that should have been included."; - "block_txid" => %tx.txid(), - "block_tx_index" => i, - "replay_txid" => %replay_tx.txid(), - ); - return Err(BlockValidateRejectReason { - reason_code: ValidateRejectCode::InvalidTransactionReplay, - reason: "Transaction is not in the replay set".into(), - failed_txid: Some(tx.txid()), - }); - } - }; - } - - // Apply the block's transaction to our block builder, but we don't - // actually care about the result - that happens in the main - // validation check. - let _tx_result = replay_builder.try_mine_tx_with_len( - &mut replay_tenure_tx, - tx, - tx_len, - &BlockLimitFunction::NO_LIMIT_HIT, - &TransactionResourceBudgets::unlimited(), - &mut total_receipts, - ); - } - - let no_replay_txs_remaining = replay_txs.is_empty(); - - // Now, we need to check if the remaining replay transactions are unmineable. - let only_unmineable_remaining = !replay_txs.is_empty() - && replay_txs.iter().all(|tx| { - let tx_result = replay_builder.try_mine_tx_with_len( - &mut replay_tenure_tx, - &tx, - tx.tx_len(), - &BlockLimitFunction::NO_LIMIT_HIT, - &TransactionResourceBudgets::unlimited(), - &mut total_receipts, - ); - match tx_result { - TransactionResult::Skipped(TransactionSkipped { error, .. }) - | TransactionResult::ProcessingError(TransactionError { error, .. }) - | TransactionResult::Problematic(TransactionProblematic { error, .. }) => { - // If it's just a cost error, it's not unmineable. - !matches!( - error, - ChainError::CostOverflowError(..) - | ChainError::BlockTooBigError - | ChainError::ClarityError(ClarityError::CostError(..)) - | ChainError::BlockCostLimitError - ) - } - TransactionResult::Success(_) => { - // The tx could have been included, but wasn't. This is ok, but we - // haven't exhausted the replay set. - false - } - } - }); - - Ok(no_replay_txs_remaining || only_unmineable_remaining) - } } #[derive(Clone, Default)] diff --git a/stackslib/src/net/api/tests/postblock_proposal.rs b/stackslib/src/net/api/tests/postblock_proposal.rs index fec09dd0cc6..c5130290a63 100644 --- a/stackslib/src/net/api/tests/postblock_proposal.rs +++ b/stackslib/src/net/api/tests/postblock_proposal.rs @@ -14,8 +14,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::collections::VecDeque; -use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Instant; @@ -44,13 +42,9 @@ use crate::chainstate::stacks::{StacksMicroblock, StacksTransaction}; use crate::config::DEFAULT_MAX_TENURE_BYTES; use crate::core::mempool::{MemPoolDropReason, MemPoolEventDispatcher, ProposalCallbackReceiver}; use crate::core::test_util::{ - make_big_read_count_contract, make_contract_call, make_contract_publish, - make_stacks_transfer_tx, to_addr, -}; -use crate::core::{MemPoolDB, BLOCK_LIMIT_MAINNET_21}; -use crate::net::api::postblock_proposal::{ - BlockValidateOk, BlockValidateReject, TEST_REPLAY_TRANSACTIONS, + make_contract_call, make_contract_publish, make_stacks_transfer_tx, to_addr, }; +use crate::core::MemPoolDB; use crate::net::api::*; use crate::net::connection::ConnectionOptions; use crate::net::http::HttpRequestContents; @@ -68,7 +62,6 @@ fn test_try_parse_request() { let proposal = NakamotoBlockProposal { block: block.clone(), chain_id: 0x80000000, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( addr.into(), @@ -114,7 +107,6 @@ fn test_try_parse_request() { Some(NakamotoBlockProposal { block, chain_id: 0x80000000, - replay_txs: None, }) ); @@ -326,7 +318,6 @@ fn test_try_make_response() { let proposal = NakamotoBlockProposal { block: good_block.clone(), chain_id: 0x80000000, - replay_txs: None, }; // deliberately delay by more than 1 second so that the timestamp of the endpoint differs from @@ -356,7 +347,6 @@ fn test_try_make_response() { let proposal = NakamotoBlockProposal { block: early_time_block, chain_id: 0x80000000, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -382,7 +372,6 @@ fn test_try_make_response() { let proposal = NakamotoBlockProposal { block: late_time_block, chain_id: 0x80000000, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -408,7 +397,6 @@ fn test_try_make_response() { let proposal = NakamotoBlockProposal { block: stale_block, chain_id: 0x80000000, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -640,7 +628,6 @@ fn test_block_proposal_validation_timeout() { let proposal = NakamotoBlockProposal { block: slow_block, chain_id: CHAIN_ID_TESTNET, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -819,7 +806,6 @@ fn test_block_proposal_validation_execution_time_expired_blames_tx() { let proposal = NakamotoBlockProposal { block, chain_id: CHAIN_ID_TESTNET, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -996,7 +982,6 @@ fn test_block_proposal_validation_analysis_time_expired_blames_tx() { let proposal = NakamotoBlockProposal { block, chain_id: CHAIN_ID_TESTNET, - replay_txs: None, }; let mut request = StacksHttpRequest::new_for_peer( @@ -1062,572 +1047,3 @@ fn test_block_proposal_validation_analysis_time_expired_blames_tx() { } } } - -#[warn(unused)] -fn replay_validation_test( - setup_fn: impl FnOnce(&mut TestRPC) -> (VecDeque, Vec), -) -> Result { - let test_observer = TestEventObserver::new(); - let mut rpc_test = TestRPC::setup_nakamoto(function_name!(), &test_observer); - - let (expected_replay_txs, block_txs) = setup_fn(&mut rpc_test); - - let mut requests = vec![]; - - let (stacks_tip_ch, stacks_tip_bhh) = SortitionDB::get_canonical_stacks_chain_tip_hash( - rpc_test.peer_1.chain.sortdb.as_ref().unwrap().conn(), - ) - .unwrap(); - let stacks_tip = StacksBlockId::new(&stacks_tip_ch, &stacks_tip_bhh); - - let mut proposed_block = { - let chainstate = rpc_test.peer_1.chainstate(); - let parent_stacks_header = - NakamotoChainState::get_block_header(chainstate.db(), &stacks_tip) - .unwrap() - .unwrap(); - - let mut builder = NakamotoBlockBuilder::new( - &parent_stacks_header, - &parent_stacks_header.consensus_hash, - 26000, - None, - None, - 8, - None, - None, - None, - u64::from(DEFAULT_MAX_TENURE_BYTES), - ) - .unwrap(); - - rpc_test - .peer_1 - .with_db_state( - |sort_db: &mut SortitionDB, - chainstate: &mut StacksChainState, - _: &mut Relayer, - _: &mut MemPoolDB| { - let burn_dbconn = sort_db.index_handle_at_tip(); - let mut miner_tenure_info = builder - .load_tenure_info( - chainstate, - &burn_dbconn, - MinerTenureInfoCause::NoTenureChange, - ) - .unwrap(); - let burn_chain_height = miner_tenure_info.burn_tip_height; - let mut tenure_tx = builder - .tenure_begin(&burn_dbconn, &mut miner_tenure_info) - .unwrap(); - for tx in block_txs { - builder.try_mine_tx_with_len( - &mut tenure_tx, - &tx, - tx.tx_len(), - &BlockLimitFunction::NO_LIMIT_HIT, - &TransactionResourceBudgets::unlimited(), - &mut 0, - ); - } - let block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); - Ok(block) - }, - ) - .unwrap() - }; - - // Increment the timestamp by 1 to ensure it is different from the previous block - proposed_block.header.timestamp += 1; - rpc_test - .peer_1 - .chain - .miner - .sign_nakamoto_block(&mut proposed_block); - - let proposal = NakamotoBlockProposal { - block: proposed_block.clone(), - chain_id: 0x80000000, - replay_txs: Some(expected_replay_txs.into()), - }; - - let mut request = StacksHttpRequest::new_for_peer( - rpc_test.peer_1.to_peer_host(), - "POST".into(), - "/v3/block_proposal".into(), - HttpRequestContents::new().payload_json(serde_json::to_value(proposal).unwrap()), - ) - .expect("failed to construct request"); - request.add_header("authorization".into(), "password".into()); - requests.push(request); - - // Execute the request - let observer = ProposalTestObserver::new(); - let proposal_observer = Arc::clone(&observer.proposal_observer); - - let wait_for = |peer_1: &mut TestPeer, peer_2: &mut TestPeer| { - !peer_1.network.is_proposal_thread_running() && !peer_2.network.is_proposal_thread_running() - }; - - info!("Run request with observer for validation with replay set test"); - let responses = rpc_test.run_with_observer(requests, Some(&observer), wait_for); - - // Expect 202 Accepted initially - assert_eq!(responses[0].preamble().status_code, 202); - - // Wait for the asynchronous validation result - let start = std::time::Instant::now(); - loop { - info!("Wait for validation result to be non-empty"); - if proposal_observer - .lock() - .unwrap() - .results - .lock() - .unwrap() - .len() - >= 1 - // Expecting one result - { - break; - } - std::thread::sleep(std::time::Duration::from_secs(1)); - assert!( - start.elapsed().as_secs() < 60, - "Timed out waiting for validation result" - ); - } - - let observer_locked = proposal_observer.lock().unwrap(); - let mut results = observer_locked.results.lock().unwrap(); - let result = results.pop().unwrap(); - - TEST_REPLAY_TRANSACTIONS.set(Default::default()); - - result -} - -#[test] -/// Tx replay test with mismatching mineable transactions. -fn replay_validation_test_transaction_mismatch() { - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - // Transaction expected in the replay set (different amount) - let tx_for_replay = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 1234, - ); - - let tx = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - (vec![tx_for_replay].into(), vec![tx]) - }); - - match result { - Ok(_) => panic!("Expected error due to replay transaction mismatch, but got Ok"), - Err(postblock_proposal::BlockValidateReject { reason_code, .. }) => { - assert_eq!( - reason_code, - ValidateRejectCode::InvalidTransactionReplay, - "Expected InvalidTransactionReplay reason code" - ); - } - } -} - -#[test] -/// Replay set has one unmineable tx, and one mineable tx. -/// The block has the one mineable tx. -fn replay_validation_test_transaction_unmineable_match() { - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - // Transaction expected in the replay set (different amount) - let unmineable_tx = make_stacks_transfer_tx( - miner_privk, - 37, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 1234, - ); - - let mineable_tx = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - ( - vec![unmineable_tx, mineable_tx.clone()].into(), - vec![mineable_tx], - ) - }); - - match result { - Ok(_) => {} - Err(rejection) => { - panic!("Expected validation to be OK, but got {:?}", rejection); - } - } -} - -#[test] -/// Replay set has [mineable, unmineable, mineable] -/// The block has [mineable, mineable] -fn replay_validation_test_transaction_unmineable_match_2() { - let mut replay_set = vec![]; - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - // Unmineable tx - let unmineable_tx = make_stacks_transfer_tx( - miner_privk, - 38, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - let mineable_tx = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - let mineable_tx_2 = make_stacks_transfer_tx( - miner_privk, - 37, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - replay_set = vec![unmineable_tx, mineable_tx.clone(), mineable_tx_2.clone()]; - - (replay_set.clone().into(), vec![mineable_tx, mineable_tx_2]) - }); - - match result { - Ok(block_validate_ok) => { - let mut hasher = DefaultHasher::new(); - replay_set.hash(&mut hasher); - let replay_hash = hasher.finish(); - - assert_eq!(block_validate_ok.replay_tx_hash, Some(replay_hash)); - assert!(block_validate_ok.replay_tx_exhausted); - } - Err(rejection) => { - panic!("Expected validation to be OK, but got {:?}", rejection); - } - } -} - -#[test] -/// Replay set has [mineable, mineable, tx_a, mineable] -/// The block has [mineable, mineable, tx_b, mineable] -fn replay_validation_test_transaction_mineable_mismatch_series() { - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - // Mineable tx - let mineable_tx_1 = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - let mineable_tx_2 = make_stacks_transfer_tx( - miner_privk, - 37, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - let tx_a = make_stacks_transfer_tx( - miner_privk, - 38, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - let tx_b = make_stacks_transfer_tx( - miner_privk, - 38, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 1234, // different amount - ); - - let mineable_tx_3 = make_stacks_transfer_tx( - miner_privk, - 39, - 300, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 123, - ); - - ( - vec![ - mineable_tx_1.clone(), - mineable_tx_2.clone(), - tx_a.clone(), - mineable_tx_3.clone(), - ] - .into(), - vec![mineable_tx_1, mineable_tx_2, tx_b, mineable_tx_3], - ) - }); - - match result { - Ok(_) => { - panic!("Expected validation to be rejected, but got Ok"); - } - Err(rejection) => { - assert_eq!( - rejection.reason_code, - ValidateRejectCode::InvalidTransactionReplay - ); - } - } -} - -#[test] -/// Replay set has [mineable, tx_b, tx_a] -/// The block has [mineable, tx_a, tx_b] -fn replay_validation_test_transaction_mineable_mismatch_series_2() { - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - - let recipient_sk = StacksPrivateKey::random(); - let recipient_addr = to_addr(&recipient_sk); - let miner_addr = to_addr(miner_privk); - - let mineable_tx_1 = make_stacks_transfer_tx( - miner_privk, - 36, - 300, - CHAIN_ID_TESTNET, - &recipient_addr.clone().into(), - 1000000, - ); - - let tx_b = make_stacks_transfer_tx( - &recipient_sk, - 0, - 300, - CHAIN_ID_TESTNET, - &miner_addr.into(), - 123, - ); - - let tx_a = make_stacks_transfer_tx( - miner_privk, - 37, - 300, - CHAIN_ID_TESTNET, - &recipient_addr.into(), - 123, - ); - - ( - vec![mineable_tx_1.clone(), tx_b.clone(), tx_a.clone()].into(), - vec![mineable_tx_1, tx_a, tx_b], - ) - }); - - match result { - Ok(_) => { - panic!("Expected validation to be rejected, but got Ok"); - } - Err(rejection) => { - assert_eq!( - rejection.reason_code, - ValidateRejectCode::InvalidTransactionReplay - ); - } - } -} - -#[test] -/// Replay set has [deploy, big_a, big_b, c] -/// The block has [deploy, big_a, c] -/// -/// The block should have ended at big_a, because big_b would -/// have cost too much to include. -fn replay_validation_test_budget_exceeded() { - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - let miner_addr = to_addr(miner_privk); - - let contract_code = make_big_read_count_contract(BLOCK_LIMIT_MAINNET_21, 50); - - let deploy_tx_bytes = make_contract_publish( - miner_privk, - 36, - 1000, - CHAIN_ID_TESTNET, - &"big-contract", - &contract_code, - ); - - let big_a_bytes = make_contract_call( - miner_privk, - 37, - 1000, - CHAIN_ID_TESTNET, - &miner_addr, - &"big-contract", - "big-tx", - &vec![], - ); - - let big_b_bytes = make_contract_call( - miner_privk, - 38, - 1000, - CHAIN_ID_TESTNET, - &miner_addr, - &"big-contract", - "big-tx", - &vec![], - ); - - let deploy_tx = - StacksTransaction::consensus_deserialize(&mut deploy_tx_bytes.as_slice()).unwrap(); - let big_a = StacksTransaction::consensus_deserialize(&mut big_a_bytes.as_slice()).unwrap(); - let big_b = StacksTransaction::consensus_deserialize(&mut big_b_bytes.as_slice()).unwrap(); - - let transfer_tx = make_stacks_transfer_tx( - miner_privk, - 38, - 1000, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 100, - ); - - ( - vec![deploy_tx.clone(), big_a.clone(), big_b.clone()].into(), - vec![deploy_tx, big_a, transfer_tx], - ) - }); - - match result { - Ok(_) => { - panic!("Expected validation to be rejected, but got Ok"); - } - Err(rejection) => { - assert_eq!( - rejection.reason_code, - ValidateRejectCode::InvalidTransactionReplay - ); - } - } -} - -#[test] -/// Replay set has [deploy, big_a, big_b] -/// The block has [deploy, big_a] -/// -/// The block is valid, but the replay set is _not_ exhausted. -fn replay_validation_test_budget_exhausted() { - let mut replay_set = vec![]; - let result = replay_validation_test(|rpc_test| { - let miner_privk = &rpc_test.peer_1.chain.miner.nakamoto_miner_key(); - let miner_addr = to_addr(miner_privk); - - let contract_code = make_big_read_count_contract(BLOCK_LIMIT_MAINNET_21, 50); - - let deploy_tx_bytes = make_contract_publish( - miner_privk, - 36, - 1000, - CHAIN_ID_TESTNET, - &"big-contract", - &contract_code, - ); - - let big_a_bytes = make_contract_call( - miner_privk, - 37, - 1000, - CHAIN_ID_TESTNET, - &miner_addr, - &"big-contract", - "big-tx", - &vec![], - ); - - let big_b_bytes = make_contract_call( - miner_privk, - 38, - 1000, - CHAIN_ID_TESTNET, - &miner_addr, - &"big-contract", - "big-tx", - &vec![], - ); - - let deploy_tx = - StacksTransaction::consensus_deserialize(&mut deploy_tx_bytes.as_slice()).unwrap(); - let big_a = StacksTransaction::consensus_deserialize(&mut big_a_bytes.as_slice()).unwrap(); - let big_b = StacksTransaction::consensus_deserialize(&mut big_b_bytes.as_slice()).unwrap(); - - let transfer_tx = make_stacks_transfer_tx( - miner_privk, - 38, - 1000, - CHAIN_ID_TESTNET, - &StandardPrincipalData::transient().into(), - 100, - ); - - replay_set = vec![deploy_tx.clone(), big_a.clone(), big_b.clone()]; - - (replay_set.clone().into(), vec![deploy_tx, big_a]) - }); - - match result { - Ok(block_validate_ok) => { - let mut hasher = DefaultHasher::new(); - replay_set.hash(&mut hasher); - let replay_hash = hasher.finish(); - - assert_eq!(block_validate_ok.replay_tx_hash, Some(replay_hash)); - assert!(!block_validate_ok.replay_tx_exhausted); - } - Err(rejection) => { - panic!( - "Expected validation to be rejected, but got {:?}", - rejection - ); - } - } -} From 503c27e885ada7cb170f6802ed8771ba9d57ec41 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 13:56:39 +0200 Subject: [PATCH 07/12] chore: clean tx replay behaviour on libsigner side with V1/V2 backward compatibility --- libsigner/src/tests/signer_state.rs | 352 +------------------ libsigner/src/v0/messages.rs | 55 +-- libsigner/src/v0/signer_state.rs | 229 +----------- stacks-node/src/tests/signer/multiversion.rs | 2 +- stacks-signer/src/chainstate/tests/v2.rs | 5 +- stacks-signer/src/tests/signer_state.rs | 11 +- stacks-signer/src/v0/signer_state.rs | 14 +- 7 files changed, 28 insertions(+), 640 deletions(-) diff --git a/libsigner/src/tests/signer_state.rs b/libsigner/src/tests/signer_state.rs index 975809d95de..57c23377387 100644 --- a/libsigner/src/tests/signer_state.rs +++ b/libsigner/src/tests/signer_state.rs @@ -28,7 +28,7 @@ use crate::v0::messages::{ StateMachineUpdate as StateMachineUpdateMessage, StateMachineUpdateContent, StateMachineUpdateMinerState, }; -use crate::v0::signer_state::{GlobalStateEvaluator, ReplayTransactionSet, SignerStateMachine}; +use crate::v0::signer_state::{GlobalStateEvaluator, SignerStateMachine}; /// Test setup helper struct containing common test data struct SignerStateTest { @@ -103,42 +103,6 @@ impl SignerStateTest { tx_d, } } - - /// Create a replay transaction update message - fn create_replay_update( - &self, - transactions: Vec, - ) -> StateMachineUpdateMessage { - StateMachineUpdateMessage::new( - self.active_signer_protocol_version, - self.local_supported_signer_protocol_version, - StateMachineUpdateContent::V1 { - burn_block: self.burn_block.clone(), - burn_block_height: self.burn_block_height, - current_miner: self.current_miner.clone(), - replay_transactions: transactions, - }, - ) - .unwrap() - } - - /// Update multiple signers with the same replay transaction set - fn update_signers(&mut self, signer_indices: &[usize], transactions: Vec) { - let update = self.create_replay_update(transactions); - for &index in signer_indices { - self.global_eval - .insert_update(self.addresses[index].clone(), update.clone()); - } - } - - /// Get the global state replay set - fn get_global_replay_set(&mut self) -> Vec { - self.global_eval - .determine_global_state() - .unwrap() - .tx_replay_set - .unwrap_or_default() - } } fn generate_global_state_evaluator(num_addresses: u32) -> GlobalStateEvaluator { @@ -349,7 +313,6 @@ fn determine_global_states() { burn_block_height, current_miner: current_miner.clone().into(), active_signer_protocol_version: local_supported_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here... - tx_replay_set: ReplayTransactionSet::none(), }; global_eval.insert_update(local_address.clone(), local_update); @@ -388,7 +351,6 @@ fn determine_global_states() { burn_block_height, current_miner: new_miner.into(), active_signer_protocol_version: local_supported_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here... - tx_replay_set: ReplayTransactionSet::none(), }; global_eval.insert_update(local_address, new_update); @@ -396,318 +358,6 @@ fn determine_global_states() { assert_eq!(global_eval.determine_global_state().unwrap(), state_machine) } -#[test] -fn determine_global_states_with_tx_replay_set() { - let mut global_eval = generate_global_state_evaluator(5); - - let addresses: Vec<_> = global_eval.address_weights.keys().cloned().collect(); - let local_address = addresses[0].clone(); - let local_update = global_eval - .address_updates - .get(&local_address) - .unwrap() - .clone(); - let StateMachineUpdateMessage { - content: - StateMachineUpdateContent::V0 { - burn_block, - burn_block_height, - current_miner, - }, - .. - } = local_update.clone() - else { - panic!("Unexpected state machine update message version"); - }; - - let local_supported_signer_protocol_version = 1; - let active_signer_protocol_version = 1; - - let state_machine = SignerStateMachine { - burn_block, - burn_block_height, - current_miner: current_miner.clone().into(), - active_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here... - tx_replay_set: ReplayTransactionSet::none(), - }; - - let burn_block = ConsensusHash([20u8; 20]); - let burn_block_height = burn_block_height + 1; - assert_eq!(global_eval.determine_global_state().unwrap(), state_machine); - - let no_tx_replay_set_update = StateMachineUpdateMessage::new( - active_signer_protocol_version, - local_supported_signer_protocol_version, - StateMachineUpdateContent::V1 { - burn_block: ConsensusHash([20u8; 20]), - burn_block_height, - current_miner: current_miner.clone(), - replay_transactions: vec![], - }, - ) - .unwrap(); - - // Let's update 3 signers to some new tx_replay_set but one that has no txs in it - for address in addresses.iter().skip(1).take(3) { - global_eval.insert_update(address.clone(), no_tx_replay_set_update.clone()); - } - - // we have disagreement about the burn block height - assert!( - global_eval.determine_global_state().is_none(), - "We should have disagreement about the burn view" - ); - - global_eval.insert_update(local_address.clone(), no_tx_replay_set_update.clone()); - - let new_burn_view_state_machine = SignerStateMachine { - burn_block: burn_block.clone(), - burn_block_height, - current_miner: current_miner.clone().into(), - active_signer_protocol_version: local_supported_signer_protocol_version, // a majority of signers are saying they support version the same local_supported_signer_protocol_version, so update it here... - tx_replay_set: ReplayTransactionSet::none(), - }; - - // Let's tip the scales over to the correct burn view - global_eval.insert_update(local_address.clone(), no_tx_replay_set_update); - assert_eq!( - global_eval.determine_global_state().unwrap(), - new_burn_view_state_machine - ); - - let pk = StacksPrivateKey::random(); - let tx = StacksTransaction { - version: TransactionVersion::Testnet, - chain_id: 0x80000000, - auth: TransactionAuth::from_p2pkh(&pk).unwrap(), - anchor_mode: TransactionAnchorMode::Any, - post_condition_mode: TransactionPostConditionMode::Allow, - post_conditions: vec![], - payload: TransactionPayload::TokenTransfer( - local_address.clone().into(), - 123, - TokenTransferMemo([0u8; 34]), - ), - }; - - let tx_replay_set_update = StateMachineUpdateMessage::new( - active_signer_protocol_version, - local_supported_signer_protocol_version, - StateMachineUpdateContent::V1 { - burn_block: burn_block.clone(), - burn_block_height, - current_miner: current_miner.clone(), - replay_transactions: vec![tx.clone()], - }, - ) - .unwrap(); - - // Let's update 3 signers to some new non empty replay set - for address in addresses.into_iter().skip(1).take(3) { - global_eval.insert_update(address, tx_replay_set_update.clone()); - } - - // We still have a valid view but with no global tx set so we aren't blocked entirely but also aren't enforcing the tx replays set - assert_eq!( - global_eval.determine_global_state().unwrap(), - new_burn_view_state_machine - ); - - // Let's tip the scales over to require a tx replay set - global_eval.insert_update(local_address, tx_replay_set_update.clone()); - - let tx_replay_state_machine = SignerStateMachine { - burn_block, - burn_block_height, - current_miner: current_miner.into(), - active_signer_protocol_version, - tx_replay_set: ReplayTransactionSet::new(vec![tx]), - }; - - assert_eq!( - global_eval.determine_global_state().unwrap(), - tx_replay_state_machine - ); -} - -#[test] -/// Case: One signer has [A,B,C], another has [A,B] - should find common prefix [A,B] -fn test_replay_set_common_prefix_coalescing() { - let mut state_test = SignerStateTest::new(5); - - // Signers 0, 1: [A,B,C] (40% weight) - state_test.update_signers( - &[0, 1], - vec![ - state_test.tx_a.clone(), - state_test.tx_b.clone(), - state_test.tx_c.clone(), - ], - ); - - // Signers 2, 3, 4: [A,B] (60% weight - should win) - state_test.update_signers( - &[2, 3, 4], - vec![state_test.tx_a.clone(), state_test.tx_b.clone()], - ); - - let transactions = state_test.get_global_replay_set(); - - // Should find common prefix [A,B] since it's the longest prefix with majority support - assert_eq!(transactions.len(), 2); - assert_eq!(transactions[0], state_test.tx_a); // Order matters! - assert_eq!(transactions[1], state_test.tx_b); - assert!(!transactions.contains(&state_test.tx_c)); -} - -#[test] -/// Case: One sequence has clear majority - should use that sequence -fn test_replay_set_majority_prefix_selection() { - let mut state_test = SignerStateTest::new(5); - - // Signer 0: [A] (20% weight) - state_test.update_signers(&[0], vec![state_test.tx_a.clone()]); - - // Signers 1, 2, 3, 4: [C] (80% weight - above threshold) - state_test.update_signers(&[1, 2, 3, 4], vec![state_test.tx_c.clone()]); - - let transactions = state_test.get_global_replay_set(); - - // Should use [C] since it has majority support (80% > 70%) - assert_eq!(transactions.len(), 1); - assert_eq!(transactions[0], state_test.tx_c); -} - -#[test] -/// Case: Exact agreement should be prioritized over subset coalescing -fn test_replay_set_exact_agreement_prioritized() { - let mut state_test = SignerStateTest::new(5); - - // 4 signers agree on [A,B] exactly (80% - above threshold) - state_test.update_signers( - &[0, 1, 2, 3], - vec![state_test.tx_a.clone(), state_test.tx_b.clone()], - ); - - // 1 signer has just [A] (20%) - state_test.update_signers(&[4], vec![state_test.tx_a.clone()]); - - let transactions = state_test.get_global_replay_set(); - - // Should use exact agreement [A,B] rather than common prefix [A] - assert_eq!(transactions.len(), 2); - assert_eq!(transactions[0], state_test.tx_a); // Order matters! - assert_eq!(transactions[1], state_test.tx_b); -} - -#[test] -/// Case: Complete disagreement - no overlap and no majority -fn test_replay_set_no_agreement_returns_empty() { - let mut state_test = SignerStateTest::new(5); - - // Signer 0: [A] (20% weight) - state_test.update_signers(&[0], vec![state_test.tx_a.clone()]); - - // Signer 1: [B] (20% weight) - state_test.update_signers(&[1], vec![state_test.tx_b.clone()]); - - // Signer 2: [C] (20% weight) - state_test.update_signers(&[2], vec![state_test.tx_c.clone()]); - - // Signers 3, 4: empty sets (40% weight) - state_test.update_signers(&[3, 4], vec![]); - - let transactions = state_test.get_global_replay_set(); - - // Should return empty set to prioritize liveness when no agreement - assert_eq!(transactions.len(), 0); -} - -#[test] -/// Case: Same transactions in different order have no common prefix -fn test_replay_set_order_matters_no_common_prefix() { - let mut state_test = SignerStateTest::new(4); - - // Signers 0, 1: [A,B] (50% weight) - state_test.update_signers( - &[0, 1], - vec![state_test.tx_a.clone(), state_test.tx_b.clone()], - ); - - // Signers 2, 3: [B,A] (50% weight) - state_test.update_signers( - &[2, 3], - vec![state_test.tx_b.clone(), state_test.tx_a.clone()], - ); - - let transactions = state_test.get_global_replay_set(); - - // Should return empty set since [A,B] and [B,A] have no common prefix - // Even though both contain the same transactions, order matters for replay - assert_eq!(transactions.len(), 0); -} - -#[test] -/// Case: [A,B,C] vs [A,B,D] should find common prefix [A,B] -fn test_replay_set_partial_prefix_match() { - let mut state_test = SignerStateTest::new(5); - - // Signer 0, 1: [A,B,C] (40% weight) - state_test.update_signers( - &[0, 1], - vec![ - state_test.tx_a.clone(), - state_test.tx_b.clone(), - state_test.tx_c.clone(), - ], - ); - - // Signers 2, 3, 4: [A,B,D] (60% weight) - state_test.update_signers( - &[2, 3, 4], - vec![ - state_test.tx_a.clone(), - state_test.tx_b.clone(), - state_test.tx_d.clone(), - ], - ); - - let transactions = state_test.get_global_replay_set(); - - // Should find [A,B] as the longest common prefix with majority support - assert_eq!(transactions.len(), 2); - assert_eq!(transactions[0], state_test.tx_a); - assert_eq!(transactions[1], state_test.tx_b); -} - -#[test] -/// Edge case: Equal-weight competing prefixes should find common prefix -fn test_replay_set_equal_weight_competing_prefixes() { - let mut state_test = SignerStateTest::new(6); - - // Signers 0, 1, 2: [A,B] (50% weight - not enough alone) - state_test.update_signers( - &[0, 1, 2], - vec![state_test.tx_a.clone(), state_test.tx_b.clone()], - ); - - // Signers 3, 4, 5: [A,C] (50% weight - not enough alone) - state_test.update_signers( - &[3, 4, 5], - vec![state_test.tx_a.clone(), state_test.tx_c.clone()], - ); - - let transactions = state_test.get_global_replay_set(); - - // Should find common prefix [A] since both [A,B] and [A,C] start with [A] - // and [A] has 100% support (above the 70% threshold) - assert_eq!(transactions.len(), 1, "Should find common prefix [A]"); - assert_eq!( - transactions[0], state_test.tx_a, - "Should contain transaction A" - ); -} - /// The threshold tests below hardcode 70% / 30% boundaries and a specific u32 /// wrap value (170_503_271 for `reached_disagreement_no_u32_overflow`) that are /// only correct when the supermajority constant is 7. If this assert ever diff --git a/libsigner/src/v0/messages.rs b/libsigner/src/v0/messages.rs index cf2f33d97e9..710707199cb 100644 --- a/libsigner/src/v0/messages.rs +++ b/libsigner/src/v0/messages.rs @@ -53,7 +53,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, SignerStateMachine}; +use crate::v0::signer_state::SignerStateMachine; use crate::{ BlockProposal, MessageSlotID as MessageSlotIDTrait, SignerMessage as SignerMessageTrait, VERSION_STRING, @@ -586,7 +586,13 @@ pub enum StateMachineUpdateContent { burn_block_height: u64, /// The signer's view of who the current miner should be (and their tenure building info) current_miner: StateMachineUpdateMinerState, - /// The replay transactions + /// Legacy wire field, always empty. + /// + /// Transaction replay was removed, but `V1`/`V2` are defined wire versions and a signer + /// running an older binary still reads this vector off the wire — omitting it would make + /// it fail to decode the whole update. Retained so the variant matches the format it + /// declares. **Do not carry this field into a future content version**; a `V3` without it + /// is the proper way to retire it. replay_transactions: Vec, }, /// Version 2 is exactly the same as Version 1, but is used to indicate this signer is @@ -598,7 +604,13 @@ pub enum StateMachineUpdateContent { burn_block_height: u64, /// The signer's view of who the current miner should be (and their tenure building info) current_miner: StateMachineUpdateMinerState, - /// The replay transactions + /// Legacy wire field, always empty. + /// + /// Transaction replay was removed, but `V1`/`V2` are defined wire versions and a signer + /// running an older binary still reads this vector off the wire — omitting it would make + /// it fail to decode the whole update. Retained so the variant matches the format it + /// declares. **Do not carry this field into a future content version**; a `V3` without it + /// is the proper way to retire it. replay_transactions: Vec, }, } @@ -796,24 +808,6 @@ impl StacksMessageCodec for StateMachineUpdateMinerState { } impl StateMachineUpdateContent { - /// Get the replay transaction txids - pub fn replay_txids(&self) -> Vec { - match self { - Self::V0 { .. } => Vec::new(), - Self::V1 { - replay_transactions, - .. - } - | Self::V2 { - replay_transactions, - .. - } => replay_transactions - .iter() - .map(|tx| tx.txid().to_string()) - .collect(), - } - } - /// Attempt to create a new state machine update content with the specified version pub fn new( version: u64, @@ -830,13 +824,13 @@ impl StateMachineUpdateContent { 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(), + replay_transactions: vec![], }, 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(), + replay_transactions: vec![], }, other => { return Err(CodecError::DeserializeError(format!( @@ -886,21 +880,6 @@ impl StateMachineUpdateContent { } } - /// Get the tx replay set - pub fn tx_replay_set(&self) -> ReplayTransactionSet { - match self { - Self::V0 { .. } => ReplayTransactionSet::none(), - Self::V1 { - replay_transactions, - .. - } - | Self::V2 { - replay_transactions, - .. - } => ReplayTransactionSet::new(replay_transactions.clone()), - } - } - fn serialize(&self, fd: &mut W) -> Result<(), CodecError> { match self { Self::V0 { diff --git a/libsigner/src/v0/signer_state.rs b/libsigner/src/v0/signer_state.rs index 586af63c961..712c1e36e31 100644 --- a/libsigner/src/v0/signer_state.rs +++ b/libsigner/src/v0/signer_state.rs @@ -13,11 +13,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::cmp::Ordering; use std::collections::HashMap; -use std::hash::{Hash, Hasher}; -use blockstack_lib::chainstate::stacks::StacksTransaction; use blockstack_lib::core::NAKAMOTO_SIGNER_BLOCK_APPROVAL_THRESHOLD; use clarity::types::chainstate::StacksAddress; use serde::{Deserialize, Serialize}; @@ -103,58 +100,29 @@ impl GlobalStateEvaluator { let active_signer_protocol_version = self.determine_latest_supported_signer_protocol_version()?; let mut state_views = HashMap::new(); - let mut tx_replay_sets = HashMap::new(); - let mut found_state_view = None; - let mut found_replay_set = None; for (address, update) in &self.address_updates { let Some(weight) = self.address_weights.get(address) else { continue; }; let (burn_block, burn_block_height) = update.content.burn_block_view(); let current_miner = update.content.current_miner(); - let tx_replay_set = update.content.tx_replay_set(); let state_machine = SignerStateMachine { burn_block: burn_block.clone(), burn_block_height, current_miner: current_miner.clone().into(), active_signer_protocol_version, - // We need to calculate the threshold for the tx_replay_set separately - tx_replay_set: ReplayTransactionSet::none(), }; - let key = SignerStateMachineKey(state_machine.clone()); - let entry = state_views.entry(key).or_insert_with(|| 0); + let entry = state_views + .entry(state_machine.clone()) + .or_insert_with(|| 0); *entry += weight; if self.reached_agreement(*entry) { - found_state_view = Some(state_machine); - } - - let replay_entry = tx_replay_sets - .entry(tx_replay_set.clone()) - .or_insert_with(|| 0); - *replay_entry += weight; - - if self.reached_agreement(*replay_entry) { - found_replay_set = Some(tx_replay_set); + return Some(state_machine); } - if found_replay_set.is_some() && found_state_view.is_some() { - break; - } - } - // Try to find agreed replay set, or find longest common prefix if no exact agreement - let final_replay_set = if let Some(tx_replay_set) = found_replay_set { - tx_replay_set - } else { - // No exact agreement found, try finding longest common prefix with majority support - self.find_majority_prefix_replay_set(&tx_replay_sets) - .unwrap_or_else(ReplayTransactionSet::none) - }; - - if let Some(state_view) = found_state_view.as_mut() { - state_view.tx_replay_set = final_replay_set; } - found_state_view + None } /// Will insert the update for the given address and weight only if the GlobalStateMachineEvaluator already is aware of this address @@ -181,168 +149,12 @@ impl GlobalStateEvaluator { > u64::from(self.total_weight).strict_mul(10 - NAKAMOTO_SIGNER_BLOCK_APPROVAL_THRESHOLD) / 10 } - - /// Get the global transaction replay set. Returns `None` if there - /// is no global state. - pub fn get_global_tx_replay_set(&mut self) -> Option { - let global_state = self.determine_global_state()?; - Some(global_state.tx_replay_set) - } - - /// Find the longest common prefix of replay sets that has majority support. - /// This implements the longest common prefix (LCP) strategy where if one signer's replay set - /// is [A,B,C] and another is [A,B], we should use [A,B] as the replay set. - /// Order matters for transaction replay - [A,B] and [B,A] have no common prefix. - fn find_majority_prefix_replay_set( - &self, - tx_replay_sets: &HashMap, - ) -> Option { - if tx_replay_sets.is_empty() { - return None; - } - - // First, try to find an exact match that reaches agreement - for (replay_set, weight) in tx_replay_sets { - if self.reached_agreement(*weight) { - return Some(replay_set.clone()); - } - } - - // No exact agreement found, find longest common prefix with majority support - - // Sort replay sets by weight (descending), then deterministically by length and content - let mut sorted_sets: Vec<_> = tx_replay_sets.iter().collect(); - sorted_sets.sort_by(|(set_a, weight_a), (set_b, weight_b)| { - // Primary: weight descending - let weight_cmp = weight_b.cmp(weight_a); - if weight_cmp != Ordering::Equal { - return weight_cmp; - } - // Secondary: length descending (longer sequences first) - let len_cmp = set_b.0.len().cmp(&set_a.0.len()); - if len_cmp != Ordering::Equal { - return len_cmp; - } - // Tertiary: compare transaction IDs for determinism - for (lhs, rhs) in set_a.0.iter().zip(&set_b.0) { - let ord = lhs.txid().cmp(&rhs.txid()); - if ord != Ordering::Equal { - return ord; - } - } - Ordering::Equal - }); - - // Start with the most supported replay set as initial candidate - if let Some((initial_set, _)) = sorted_sets.first() { - let mut candidate_prefix = initial_set.0.clone(); - let mut total_supporting_weight = 0u32; - - // Find all sets that support the current candidate prefix - for (replay_set, weight) in tx_replay_sets { - if replay_set.0.starts_with(&candidate_prefix) { - total_supporting_weight = total_supporting_weight.saturating_add(*weight); - } - } - - // If the initial candidate already has majority support, return it - if self.reached_agreement(total_supporting_weight) { - return Some(ReplayTransactionSet::new(candidate_prefix)); - } - - // Otherwise, iteratively truncate the prefix until we find majority support - while !candidate_prefix.is_empty() { - // Remove the last transaction from the prefix - candidate_prefix.pop(); - - // Recalculate supporting weight for the shorter prefix - total_supporting_weight = 0u32; - for (replay_set, weight) in tx_replay_sets { - if replay_set.0.starts_with(&candidate_prefix) { - total_supporting_weight = total_supporting_weight.saturating_add(*weight); - } - } - - // If this prefix has majority support, return it - if self.reached_agreement(total_supporting_weight) { - return Some(ReplayTransactionSet::new(candidate_prefix)); - } - } - } - - // If no common prefix with majority support is found, return None - None - } -} - -/// A "wrapper" struct around Vec that behaves like -/// `None` when the vector is empty. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash)] -pub struct ReplayTransactionSet(Vec); - -impl ReplayTransactionSet { - /// Create a new `ReplayTransactionSet` - pub fn new(tx_replay_set: Vec) -> Self { - Self(tx_replay_set) - } - - /// Check if the `ReplayTransactionSet` is empty - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Map into an optional, returning `None` if the set is empty - pub fn clone_as_optional(&self) -> Option> { - if self.is_empty() { - None - } else { - Some(self.0.clone()) - } - } - - /// Unwrap the `ReplayTransactionSet` or return a default vector if it is empty - pub fn unwrap_or_default(self) -> Vec { - if self.is_empty() { - vec![] - } else { - self.0 - } - } - - /// Map the transactions in the set to a new type, only - /// if the set is not empty - pub fn map(self, f: F) -> Option - where - F: Fn(Vec) -> U, - { - if self.is_empty() { - None - } else { - Some(f(self.0)) - } - } - - /// Create a new `ReplayTransactionSet` with no transactions - pub fn none() -> Self { - Self(vec![]) - } - - /// Check if the `ReplayTransactionSet` isn't empty - pub fn is_some(&self) -> bool { - !self.is_empty() - } -} - -impl Default for ReplayTransactionSet { - fn default() -> Self { - Self::none() - } } /// A signer state machine view. This struct can /// be used to encode the local signer's view or /// the global view. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct SignerStateMachine { /// The tip burn block (i.e., the latest bitcoin block) seen by this signer pub burn_block: ConsensusHash, @@ -352,35 +164,6 @@ pub struct SignerStateMachine { pub current_miner: MinerState, /// The active signing protocol version pub active_signer_protocol_version: u64, - /// Transaction replay set - pub tx_replay_set: ReplayTransactionSet, -} - -#[derive(Debug)] -/// A wrapped SignerStateMachine that implements a very specific hash that enables properly ignoring the -/// tx_replay_set when evaluating the global signer state machine -pub struct SignerStateMachineKey(SignerStateMachine); - -impl PartialEq for SignerStateMachineKey { - fn eq(&self, other: &Self) -> bool { - // NOTE: tx_replay_set is intentionally ignored - self.0.burn_block == other.0.burn_block - && self.0.burn_block_height == other.0.burn_block_height - && self.0.current_miner == other.0.current_miner - && self.0.active_signer_protocol_version == other.0.active_signer_protocol_version - } -} - -impl Eq for SignerStateMachineKey {} - -impl Hash for SignerStateMachineKey { - fn hash(&self, state: &mut H) { - // tx_replay_set is intentionally ignored - self.0.burn_block.hash(state); - self.0.burn_block_height.hash(state); - self.0.current_miner.hash(state); - self.0.active_signer_protocol_version.hash(state); - } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash)] diff --git a/stacks-node/src/tests/signer/multiversion.rs b/stacks-node/src/tests/signer/multiversion.rs index aff6dbc0ee4..ff0b55037d9 100644 --- a/stacks-node/src/tests/signer/multiversion.rs +++ b/stacks-node/src/tests/signer/multiversion.rs @@ -19,7 +19,7 @@ use libsigner::v0::messages::{ BlockAccepted, BlockResponse, BlockResponseData, RejectReason, SignerMessage, SignerMessageMetadata, }; -use libsigner::v0::signer_state::{MinerState, ReplayTransactionSet, SignerStateMachine}; +use libsigner::v0::signer_state::{MinerState, SignerStateMachine}; use libsigner_v3_3_0_0_5; use libsigner_v3_3_0_0_5::v0::messages::SignerMessage as OldSignerMessage; use signer_v3_3_0_0_5_0; diff --git a/stacks-signer/src/chainstate/tests/v2.rs b/stacks-signer/src/chainstate/tests/v2.rs index 62f58f49e27..907c00fa8d2 100644 --- a/stacks-signer/src/chainstate/tests/v2.rs +++ b/stacks-signer/src/chainstate/tests/v2.rs @@ -32,9 +32,7 @@ use clarity::types::chainstate::{BurnchainHeaderHash, SortitionId, StacksAddress use clarity::types::PrivateKey; use clarity::util::vrf::VRFProof; use libsigner::v0::messages::RejectReason; -use libsigner::v0::signer_state::{ - GlobalStateEvaluator, MinerState, ReplayTransactionSet, SignerStateMachine, -}; +use libsigner::v0::signer_state::{GlobalStateEvaluator, MinerState, SignerStateMachine}; use libsigner::{BlockProposal, BlockProposalData}; use stacks_common::bitvec::BitVec; use stacks_common::consts::CHAIN_ID_TESTNET; @@ -147,7 +145,6 @@ fn setup_test_environment( parent_tenure_last_block_height: 1, }, active_signer_protocol_version: 0, - tx_replay_set: ReplayTransactionSet::none(), }; let sortitions_view = GlobalStateView { diff --git a/stacks-signer/src/tests/signer_state.rs b/stacks-signer/src/tests/signer_state.rs index 43158265c57..489d2d02263 100644 --- a/stacks-signer/src/tests/signer_state.rs +++ b/stacks-signer/src/tests/signer_state.rs @@ -32,9 +32,7 @@ use libsigner::v0::messages::{ StateMachineUpdate as StateMachineUpdateMessage, StateMachineUpdateContent, StateMachineUpdateMinerState, }; -use libsigner::v0::signer_state::{ - GlobalStateEvaluator, MinerState, ReplayTransactionSet, SignerStateMachine, -}; +use libsigner::v0::signer_state::{GlobalStateEvaluator, MinerState, SignerStateMachine}; use stacks_common::bitvec::BitVec; use stacks_common::function_name; @@ -180,7 +178,6 @@ fn check_capitulate_miner_view() { burn_block, burn_block_height, current_miner: new_miner.clone().into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }; @@ -442,7 +439,6 @@ fn check_capitulate_with_local_timeout() { burn_block: burn_block.clone(), burn_block_height, current_miner: local_miner.clone().into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }; @@ -633,7 +629,6 @@ fn check_capitulate_split_view_node_at_lower_height() { burn_block: burn_block.clone(), burn_block_height, current_miner: local_miner.clone().into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }; let mut local_state_machine = LocalStateMachine::Initialized(signer_state_machine); @@ -820,7 +815,6 @@ fn check_capitulate_split_view_node_at_higher_height() { burn_block: burn_block.clone(), burn_block_height, current_miner: local_miner.clone().into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }; let mut local_state_machine = LocalStateMachine::Initialized(signer_state_machine); @@ -938,7 +932,6 @@ fn check_capitulate_viewpoint_time_guards() { burn_block: burn_block.clone(), burn_block_height, current_miner: local_miner.clone().into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }; @@ -1009,7 +1002,6 @@ fn check_capitulate_viewpoint_time_guards() { burn_block, burn_block_height, current_miner: local_miner.into(), - tx_replay_set: ReplayTransactionSet::none(), active_signer_protocol_version, }), "Recent globally accepted block should prevent capitulation" @@ -1142,7 +1134,6 @@ fn check_miner_inactivity_timeout() { burn_block_height: 1, current_miner: inactive_miner, active_signer_protocol_version: 0, - tx_replay_set: ReplayTransactionSet::none(), }; local_state_machine = LocalStateMachine::Initialized(signer_state.clone()); local_state_machine diff --git a/stacks-signer/src/v0/signer_state.rs b/stacks-signer/src/v0/signer_state.rs index 414c539533d..db3a422699f 100644 --- a/stacks-signer/src/v0/signer_state.rs +++ b/stacks-signer/src/v0/signer_state.rs @@ -26,9 +26,7 @@ use libsigner::v0::messages::{ MessageSlotID, SignerMessage, StateMachineUpdate as StateMachineUpdateMessage, StateMachineUpdateContent, StateMachineUpdateMinerState, }; -use libsigner::v0::signer_state::{ - GlobalStateEvaluator, MinerState, ReplayTransactionSet, SignerStateMachine, -}; +use libsigner::v0::signer_state::{GlobalStateEvaluator, MinerState, SignerStateMachine}; use serde::{Deserialize, Serialize}; use stacks_common::codec::Error as CodecError; use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId}; @@ -166,7 +164,6 @@ impl LocalStateMachine { burn_block_height: 0, current_miner: MinerState::NoValidMiner, active_signer_protocol_version: version, - tx_replay_set: ReplayTransactionSet::none(), } } @@ -498,7 +495,6 @@ impl LocalStateMachine { let peer_info = client.get_peer_info()?; let next_burn_block_height = peer_info.burn_block_height; let next_burn_block_hash = peer_info.pox_consensus; - let tx_replay_set = prior_state_machine.tx_replay_set.clone(); if let Some(expected_burn_block) = expected_burn_block { // If the next height is less than the expected height, we need to wait. @@ -576,7 +572,6 @@ impl LocalStateMachine { burn_block_height: next_burn_block_height, current_miner: miner_state, active_signer_protocol_version: prior_state_machine.active_signer_protocol_version, - tx_replay_set, }); if prior_state != *self { @@ -633,7 +628,6 @@ impl LocalStateMachine { } // We have either timed out our local view of the parent tenure last block, or the node has a new block we didn't know about let (burn_block, burn_block_height) = local_update.content.burn_block_view(); - let tx_replay_set = local_update.content.tx_replay_set(); *self = Self::Initialized(SignerStateMachine { burn_block: burn_block.clone(), burn_block_height, @@ -646,7 +640,6 @@ impl LocalStateMachine { } .into(), active_signer_protocol_version: local_update.active_signer_protocol_version, - tx_replay_set, }); true } @@ -681,14 +674,12 @@ impl LocalStateMachine { ); let (burn_block, burn_block_height) = local_update.content.burn_block_view(); let current_miner = local_update.content.current_miner(); - let tx_replay_set = local_update.content.tx_replay_set(); *self = Self::Initialized(SignerStateMachine { burn_block: burn_block.clone(), burn_block_height, current_miner: current_miner.clone().into(), active_signer_protocol_version, - tx_replay_set, }); } } @@ -794,7 +785,6 @@ impl LocalStateMachine { let (burn_block, burn_block_height) = local_update.content.burn_block_view(); let current_miner = local_update.content.current_miner(); - let tx_replay_set = local_update.content.tx_replay_set(); if current_miner != &new_miner { info!("Signer State: Capitulating local state machine's current miner viewpoint"; @@ -802,7 +792,6 @@ impl LocalStateMachine { "new_miner" => ?new_miner, "burn_block" => %burn_block, "burn_block_height" => burn_block_height, - "tx_replay_set" => ?tx_replay_set, ); crate::monitoring::actions::increment_signer_agreement_state_change_reason( crate::monitoring::SignerAgreementStateChangeReason::MinerViewUpdate, @@ -814,7 +803,6 @@ impl LocalStateMachine { burn_block_height, current_miner: new_miner.clone().into(), active_signer_protocol_version: local_update.active_signer_protocol_version, - tx_replay_set, }); match new_miner { From 932423fa745f173df35f496cd1e2e754a8272b93 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 14:04:42 +0200 Subject: [PATCH 08/12] chore: remove tx replay references from dead code --- stacks-node/src/tests/signer/multiversion.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/stacks-node/src/tests/signer/multiversion.rs b/stacks-node/src/tests/signer/multiversion.rs index ff0b55037d9..f8a4fcc71ab 100644 --- a/stacks-node/src/tests/signer/multiversion.rs +++ b/stacks-node/src/tests/signer/multiversion.rs @@ -104,15 +104,6 @@ pub fn signer_state_machine_v3_3_0_0_5_to_current( burn_block_height: machine.burn_block_height, current_miner: miner_state_v3_3_0_0_5_to_current(&machine.current_miner), active_signer_protocol_version: machine.active_signer_protocol_version, - tx_replay_set: ReplayTransactionSet::new( - machine - .tx_replay_set - .clone() - .unwrap_or_default() - .iter() - .map(stacks_transaction_v3_3_0_0_5_to_current) - .collect(), - ), } } @@ -179,9 +170,11 @@ impl SpawnedSignerTrait for MultiverSpawnedSigner { reorg_attempts_activity_timeout: c.reorg_attempts_activity_timeout, dry_run: c.dry_run, proposal_wait_for_parent_time: c.proposal_wait_for_parent_time, - validate_with_replay_tx: c.validate_with_replay_tx, + // Transaction replay was removed from the current config; the pinned older + // signer still has these fields, so feed it the values replay-disabled. + validate_with_replay_tx: false, capitulate_miner_view_timeout: c.capitulate_miner_view_timeout, - reset_replay_set_after_fork_blocks: c.reset_replay_set_after_fork_blocks, + reset_replay_set_after_fork_blocks: 2, stackerdb_timeout: c.stackerdb_timeout, supported_signer_protocol_version: c.supported_signer_protocol_version, read_count_idle_timeout: c.read_count_idle_timeout, From 879d915a1e905f30022debd8899d448672db624c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 14:14:24 +0200 Subject: [PATCH 09/12] chore: add changelog entries --- changelog.d/drop-tx-replay.breaking | 1 + changelog.d/drop-tx-replay.changed | 1 + stacks-signer/changelog.d/drop-tx-replay.breaking | 1 + stacks-signer/changelog.d/drop-tx-replay.changed | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog.d/drop-tx-replay.breaking create mode 100644 changelog.d/drop-tx-replay.changed create mode 100644 stacks-signer/changelog.d/drop-tx-replay.breaking create mode 100644 stacks-signer/changelog.d/drop-tx-replay.changed diff --git a/changelog.d/drop-tx-replay.breaking b/changelog.d/drop-tx-replay.breaking new file mode 100644 index 00000000000..4bf21b027a6 --- /dev/null +++ b/changelog.d/drop-tx-replay.breaking @@ -0,0 +1 @@ +removed `replay_transactions` config from miner toml. \ No newline at end of file diff --git a/changelog.d/drop-tx-replay.changed b/changelog.d/drop-tx-replay.changed new file mode 100644 index 00000000000..0d88be68b3b --- /dev/null +++ b/changelog.d/drop-tx-replay.changed @@ -0,0 +1 @@ +Drop tx-replay behaviour from node/miner \ No newline at end of file diff --git a/stacks-signer/changelog.d/drop-tx-replay.breaking b/stacks-signer/changelog.d/drop-tx-replay.breaking new file mode 100644 index 00000000000..4646bb5194c --- /dev/null +++ b/stacks-signer/changelog.d/drop-tx-replay.breaking @@ -0,0 +1 @@ +removed `reset_replay_set_after_fork_blocks` config from signer toml. \ No newline at end of file diff --git a/stacks-signer/changelog.d/drop-tx-replay.changed b/stacks-signer/changelog.d/drop-tx-replay.changed new file mode 100644 index 00000000000..8f3482ecf5d --- /dev/null +++ b/stacks-signer/changelog.d/drop-tx-replay.changed @@ -0,0 +1 @@ +Drop tx-replay behaviour from signer logic. \ No newline at end of file From 639614141682dd9d6ab056fd18cf0674de0f68f5 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 15:13:42 +0200 Subject: [PATCH 10/12] chore: fix clippy --- stacks-signer/src/v0/tests.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/stacks-signer/src/v0/tests.rs b/stacks-signer/src/v0/tests.rs index c976d5e46bf..c39666b5ac7 100644 --- a/stacks-signer/src/v0/tests.rs +++ b/stacks-signer/src/v0/tests.rs @@ -358,9 +358,7 @@ mod async_sibling_validation { use stacks_common::util::secp256k1::MessageSignature; use crate::client::{SignerSlotID, StacksClient}; - use crate::config::{ - SignerConfig, SignerConfigMode, DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, - }; + use crate::config::{SignerConfig, SignerConfigMode}; use crate::signerdb::{BlockInfo, BlockState}; use crate::v0::signer::Signer; use crate::Signer as SignerTrait; @@ -624,8 +622,6 @@ mod async_sibling_validation { reorg_attempts_activity_timeout: Duration::from_secs(3), signer_mode: SignerConfigMode::DryRun, proposal_wait_for_parent_time: Duration::ZERO, - validate_with_replay_tx: false, - reset_replay_set_after_fork_blocks: DEFAULT_RESET_REPLAY_SET_AFTER_FORK_BLOCKS, capitulate_miner_view_timeout: Duration::from_secs(30), stackerdb_timeout: Duration::from_secs(2), }; From b72b32b315c6f78dbc699ce0dbc061ff5ca2b1ce Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 10 Aug 2026 15:43:15 +0200 Subject: [PATCH 11/12] crc: remove unused import --- stackslib/src/net/api/postblock_proposal.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/stackslib/src/net/api/postblock_proposal.rs b/stackslib/src/net/api/postblock_proposal.rs index a5e70e8a85b..f2eb284a099 100644 --- a/stackslib/src/net/api/postblock_proposal.rs +++ b/stackslib/src/net/api/postblock_proposal.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::hash::Hash; #[cfg(any(test, feature = "testing"))] use std::sync::LazyLock; use std::thread::{self, JoinHandle}; From 517c5404ec8a74486e80bbeebfc86948ee65c4bb Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 12 Aug 2026 10:34:00 +0200 Subject: [PATCH 12/12] chore: improve tripwire test --- .../src/tests/tx_replay_removal_compat.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/stacks-signer/src/tests/tx_replay_removal_compat.rs b/stacks-signer/src/tests/tx_replay_removal_compat.rs index 2d4b7cb5229..ac3e92b16dc 100644 --- a/stacks-signer/src/tests/tx_replay_removal_compat.rs +++ b/stacks-signer/src/tests/tx_replay_removal_compat.rs @@ -135,13 +135,7 @@ fn state_machine_update_v2_wire_format_is_frozen() { /// Tripwire 2 — a message from a *pre-removal* signer still decodes. /// /// Until every signer has upgraded, peers keep broadcasting populated replay sets. Those -/// messages must still parse, and their non-replay fields must survive intact. -/// -/// NOTE: this asserts decoding **only**, never a byte-identical round trip. After the -/// removal the transactions are read and discarded, so re-encoding legitimately yields the -/// empty vector. Asserting round-trip equality here would pass today and fail at the end of -/// the removal — and the tempting "fix" would be to weaken the test, which is precisely the -/// mistake these tripwires exist to prevent. +/// messages must still parse. #[test] fn state_machine_update_v2_with_populated_replay_set_still_decodes() { let txs = vec![make_transaction([1u8; 34]), make_transaction([2u8; 34])]; @@ -156,6 +150,16 @@ fn state_machine_update_v2_with_populated_replay_set_still_decodes() { burn_block_height, 100, "non-replay fields must survive a populated replay set" ); + + let mut reencoded = Vec::new(); + decoded + .consensus_serialize(&mut reencoded) + .expect("re-encoding must succeed"); + assert_eq!( + reencoded, bytes, + "V1/V2 keep their declared replay_transactions field and the codec is untouched, \ + so a populated set must survive a decode/encode round trip" + ); } /// Tripwire 3 — `/v3/block_proposal` responses still carry the replay fields.