diff --git a/CHANGELOG.md b/CHANGELOG.md index ada3a8dac0..f03af4911e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to the versioning scheme outlined in the [README.md](README.md). +## Unreleased + +### Added + +- When determining a global transaction replay set, the state evaluator now uses a longest-common-prefix algorithm to find a replay set in the case where a single replay set has less than 70% of signer weight. + ## [3.2.0.0.1] ### Added diff --git a/libsigner/src/tests/signer_state.rs b/libsigner/src/tests/signer_state.rs index 027f7604b0..02d06da076 100644 --- a/libsigner/src/tests/signer_state.rs +++ b/libsigner/src/tests/signer_state.rs @@ -29,6 +29,117 @@ use crate::v0::messages::{ }; use crate::v0::signer_state::{GlobalStateEvaluator, ReplayTransactionSet, SignerStateMachine}; +/// Test setup helper struct containing common test data +struct SignerStateTest { + global_eval: GlobalStateEvaluator, + addresses: Vec, + burn_block: ConsensusHash, + burn_block_height: u64, + current_miner: StateMachineUpdateMinerState, + local_supported_signer_protocol_version: u64, + active_signer_protocol_version: u64, + tx_a: StacksTransaction, + tx_b: StacksTransaction, + tx_c: StacksTransaction, + tx_d: StacksTransaction, +} + +impl SignerStateTest { + fn new(num_signers: u32) -> Self { + let global_eval = generate_global_state_evaluator(num_signers); + let addresses: Vec<_> = global_eval.address_weights.keys().cloned().collect(); + let local_address = addresses[0].clone(); + + let burn_block = ConsensusHash([20u8; 20]); + let burn_block_height = 100; + let current_miner = StateMachineUpdateMinerState::ActiveMiner { + current_miner_pkh: Hash160([0xab; 20]), + tenure_id: ConsensusHash([0x44; 20]), + parent_tenure_id: ConsensusHash([0x22; 20]), + parent_tenure_last_block: StacksBlockId([0x33; 32]), + parent_tenure_last_block_height: 1, + }; + + let local_supported_signer_protocol_version = 1; + let active_signer_protocol_version = 1; + + // Create test transactions with different memos for uniqueness + let pk1 = StacksPrivateKey::random(); + let pk2 = StacksPrivateKey::random(); + let pk3 = StacksPrivateKey::random(); + let pk4 = StacksPrivateKey::random(); + + let make_tx = |pk: &StacksPrivateKey, memo: [u8; 34]| 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(), + 100, + TokenTransferMemo(memo), + ), + }; + + let tx_a = make_tx(&pk1, [1u8; 34]); + let tx_b = make_tx(&pk2, [2u8; 34]); + let tx_c = make_tx(&pk3, [3u8; 34]); + let tx_d = make_tx(&pk4, [4u8; 34]); + + Self { + global_eval, + addresses, + burn_block, + burn_block_height, + current_miner, + local_supported_signer_protocol_version, + active_signer_protocol_version, + tx_a, + tx_b, + tx_c, + 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, + 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 { let address_weights = generate_random_address_with_equal_weights(num_addresses); let active_protocol_version = 0; @@ -417,3 +528,181 @@ fn determine_global_states_with_tx_replay_set() { 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" + ); +} diff --git a/libsigner/src/v0/signer_state.rs b/libsigner/src/v0/signer_state.rs index bac487ced5..845b458250 100644 --- a/libsigner/src/v0/signer_state.rs +++ b/libsigner/src/v0/signer_state.rs @@ -13,6 +13,7 @@ // 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}; @@ -140,10 +141,17 @@ impl GlobalStateEvaluator { break; } } - if let Some(tx_replay_set) = found_replay_set { - if let Some(state_view) = found_state_view.as_mut() { - state_view.tx_replay_set = tx_replay_set; - } + // 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 } @@ -169,6 +177,91 @@ impl GlobalStateEvaluator { 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 diff --git a/stacks-signer/CHANGELOG.md b/stacks-signer/CHANGELOG.md index 4a3f341afa..236ac17162 100644 --- a/stacks-signer/CHANGELOG.md +++ b/stacks-signer/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to the versioning scheme outlined in the [README.md](README.md). +## Unreleased + +### Added + +- When determining a global transaction replay set, the state evaluator now uses a longest-common-prefix algorithm to find a replay set in the case where a single replay set has less than 70% of signer weight. + ## [3.2.0.0.1.0] ### Changed