Skip to content

Commit 10f96e0

Browse files
authored
Merge pull request #6353 from hstove/feat/tx-replay-lcp
feat: use longest common prefix for determining tx replay set
2 parents a2ff29c + 3361418 commit 10f96e0

File tree

4 files changed

+398
-4
lines changed

4 files changed

+398
-4
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to the versioning scheme outlined in the [README.md](README.md).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- 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.
13+
814
## [3.2.0.0.1]
915
### Added
1016

libsigner/src/tests/signer_state.rs

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,117 @@ use crate::v0::messages::{
2929
};
3030
use crate::v0::signer_state::{GlobalStateEvaluator, ReplayTransactionSet, SignerStateMachine};
3131

32+
/// Test setup helper struct containing common test data
33+
struct SignerStateTest {
34+
global_eval: GlobalStateEvaluator,
35+
addresses: Vec<StacksAddress>,
36+
burn_block: ConsensusHash,
37+
burn_block_height: u64,
38+
current_miner: StateMachineUpdateMinerState,
39+
local_supported_signer_protocol_version: u64,
40+
active_signer_protocol_version: u64,
41+
tx_a: StacksTransaction,
42+
tx_b: StacksTransaction,
43+
tx_c: StacksTransaction,
44+
tx_d: StacksTransaction,
45+
}
46+
47+
impl SignerStateTest {
48+
fn new(num_signers: u32) -> Self {
49+
let global_eval = generate_global_state_evaluator(num_signers);
50+
let addresses: Vec<_> = global_eval.address_weights.keys().cloned().collect();
51+
let local_address = addresses[0].clone();
52+
53+
let burn_block = ConsensusHash([20u8; 20]);
54+
let burn_block_height = 100;
55+
let current_miner = StateMachineUpdateMinerState::ActiveMiner {
56+
current_miner_pkh: Hash160([0xab; 20]),
57+
tenure_id: ConsensusHash([0x44; 20]),
58+
parent_tenure_id: ConsensusHash([0x22; 20]),
59+
parent_tenure_last_block: StacksBlockId([0x33; 32]),
60+
parent_tenure_last_block_height: 1,
61+
};
62+
63+
let local_supported_signer_protocol_version = 1;
64+
let active_signer_protocol_version = 1;
65+
66+
// Create test transactions with different memos for uniqueness
67+
let pk1 = StacksPrivateKey::random();
68+
let pk2 = StacksPrivateKey::random();
69+
let pk3 = StacksPrivateKey::random();
70+
let pk4 = StacksPrivateKey::random();
71+
72+
let make_tx = |pk: &StacksPrivateKey, memo: [u8; 34]| StacksTransaction {
73+
version: TransactionVersion::Testnet,
74+
chain_id: 0x80000000,
75+
auth: TransactionAuth::from_p2pkh(pk).unwrap(),
76+
anchor_mode: TransactionAnchorMode::Any,
77+
post_condition_mode: TransactionPostConditionMode::Allow,
78+
post_conditions: vec![],
79+
payload: TransactionPayload::TokenTransfer(
80+
local_address.clone().into(),
81+
100,
82+
TokenTransferMemo(memo),
83+
),
84+
};
85+
86+
let tx_a = make_tx(&pk1, [1u8; 34]);
87+
let tx_b = make_tx(&pk2, [2u8; 34]);
88+
let tx_c = make_tx(&pk3, [3u8; 34]);
89+
let tx_d = make_tx(&pk4, [4u8; 34]);
90+
91+
Self {
92+
global_eval,
93+
addresses,
94+
burn_block,
95+
burn_block_height,
96+
current_miner,
97+
local_supported_signer_protocol_version,
98+
active_signer_protocol_version,
99+
tx_a,
100+
tx_b,
101+
tx_c,
102+
tx_d,
103+
}
104+
}
105+
106+
/// Create a replay transaction update message
107+
fn create_replay_update(
108+
&self,
109+
transactions: Vec<StacksTransaction>,
110+
) -> StateMachineUpdateMessage {
111+
StateMachineUpdateMessage::new(
112+
self.active_signer_protocol_version,
113+
self.local_supported_signer_protocol_version,
114+
StateMachineUpdateContent::V1 {
115+
burn_block: self.burn_block,
116+
burn_block_height: self.burn_block_height,
117+
current_miner: self.current_miner.clone(),
118+
replay_transactions: transactions,
119+
},
120+
)
121+
.unwrap()
122+
}
123+
124+
/// Update multiple signers with the same replay transaction set
125+
fn update_signers(&mut self, signer_indices: &[usize], transactions: Vec<StacksTransaction>) {
126+
let update = self.create_replay_update(transactions);
127+
for &index in signer_indices {
128+
self.global_eval
129+
.insert_update(self.addresses[index].clone(), update.clone());
130+
}
131+
}
132+
133+
/// Get the global state replay set
134+
fn get_global_replay_set(&mut self) -> Vec<StacksTransaction> {
135+
self.global_eval
136+
.determine_global_state()
137+
.unwrap()
138+
.tx_replay_set
139+
.unwrap_or_default()
140+
}
141+
}
142+
32143
fn generate_global_state_evaluator(num_addresses: u32) -> GlobalStateEvaluator {
33144
let address_weights = generate_random_address_with_equal_weights(num_addresses);
34145
let active_protocol_version = 0;
@@ -417,3 +528,181 @@ fn determine_global_states_with_tx_replay_set() {
417528
tx_replay_state_machine
418529
);
419530
}
531+
532+
#[test]
533+
/// Case: One signer has [A,B,C], another has [A,B] - should find common prefix [A,B]
534+
fn test_replay_set_common_prefix_coalescing() {
535+
let mut state_test = SignerStateTest::new(5);
536+
537+
// Signers 0, 1: [A,B,C] (40% weight)
538+
state_test.update_signers(
539+
&[0, 1],
540+
vec![
541+
state_test.tx_a.clone(),
542+
state_test.tx_b.clone(),
543+
state_test.tx_c.clone(),
544+
],
545+
);
546+
547+
// Signers 2, 3, 4: [A,B] (60% weight - should win)
548+
state_test.update_signers(
549+
&[2, 3, 4],
550+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
551+
);
552+
553+
let transactions = state_test.get_global_replay_set();
554+
555+
// Should find common prefix [A,B] since it's the longest prefix with majority support
556+
assert_eq!(transactions.len(), 2);
557+
assert_eq!(transactions[0], state_test.tx_a); // Order matters!
558+
assert_eq!(transactions[1], state_test.tx_b);
559+
assert!(!transactions.contains(&state_test.tx_c));
560+
}
561+
562+
#[test]
563+
/// Case: One sequence has clear majority - should use that sequence
564+
fn test_replay_set_majority_prefix_selection() {
565+
let mut state_test = SignerStateTest::new(5);
566+
567+
// Signer 0: [A] (20% weight)
568+
state_test.update_signers(&[0], vec![state_test.tx_a.clone()]);
569+
570+
// Signers 1, 2, 3, 4: [C] (80% weight - above threshold)
571+
state_test.update_signers(&[1, 2, 3, 4], vec![state_test.tx_c.clone()]);
572+
573+
let transactions = state_test.get_global_replay_set();
574+
575+
// Should use [C] since it has majority support (80% > 70%)
576+
assert_eq!(transactions.len(), 1);
577+
assert_eq!(transactions[0], state_test.tx_c);
578+
}
579+
580+
#[test]
581+
/// Case: Exact agreement should be prioritized over subset coalescing
582+
fn test_replay_set_exact_agreement_prioritized() {
583+
let mut state_test = SignerStateTest::new(5);
584+
585+
// 4 signers agree on [A,B] exactly (80% - above threshold)
586+
state_test.update_signers(
587+
&[0, 1, 2, 3],
588+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
589+
);
590+
591+
// 1 signer has just [A] (20%)
592+
state_test.update_signers(&[4], vec![state_test.tx_a.clone()]);
593+
594+
let transactions = state_test.get_global_replay_set();
595+
596+
// Should use exact agreement [A,B] rather than common prefix [A]
597+
assert_eq!(transactions.len(), 2);
598+
assert_eq!(transactions[0], state_test.tx_a); // Order matters!
599+
assert_eq!(transactions[1], state_test.tx_b);
600+
}
601+
602+
#[test]
603+
/// Case: Complete disagreement - no overlap and no majority
604+
fn test_replay_set_no_agreement_returns_empty() {
605+
let mut state_test = SignerStateTest::new(5);
606+
607+
// Signer 0: [A] (20% weight)
608+
state_test.update_signers(&[0], vec![state_test.tx_a.clone()]);
609+
610+
// Signer 1: [B] (20% weight)
611+
state_test.update_signers(&[1], vec![state_test.tx_b.clone()]);
612+
613+
// Signer 2: [C] (20% weight)
614+
state_test.update_signers(&[2], vec![state_test.tx_c.clone()]);
615+
616+
// Signers 3, 4: empty sets (40% weight)
617+
state_test.update_signers(&[3, 4], vec![]);
618+
619+
let transactions = state_test.get_global_replay_set();
620+
621+
// Should return empty set to prioritize liveness when no agreement
622+
assert_eq!(transactions.len(), 0);
623+
}
624+
625+
#[test]
626+
/// Case: Same transactions in different order have no common prefix
627+
fn test_replay_set_order_matters_no_common_prefix() {
628+
let mut state_test = SignerStateTest::new(4);
629+
630+
// Signers 0, 1: [A,B] (50% weight)
631+
state_test.update_signers(
632+
&[0, 1],
633+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
634+
);
635+
636+
// Signers 2, 3: [B,A] (50% weight)
637+
state_test.update_signers(
638+
&[2, 3],
639+
vec![state_test.tx_b.clone(), state_test.tx_a.clone()],
640+
);
641+
642+
let transactions = state_test.get_global_replay_set();
643+
644+
// Should return empty set since [A,B] and [B,A] have no common prefix
645+
// Even though both contain the same transactions, order matters for replay
646+
assert_eq!(transactions.len(), 0);
647+
}
648+
649+
#[test]
650+
/// Case: [A,B,C] vs [A,B,D] should find common prefix [A,B]
651+
fn test_replay_set_partial_prefix_match() {
652+
let mut state_test = SignerStateTest::new(5);
653+
654+
// Signer 0, 1: [A,B,C] (40% weight)
655+
state_test.update_signers(
656+
&[0, 1],
657+
vec![
658+
state_test.tx_a.clone(),
659+
state_test.tx_b.clone(),
660+
state_test.tx_c.clone(),
661+
],
662+
);
663+
664+
// Signers 2, 3, 4: [A,B,D] (60% weight)
665+
state_test.update_signers(
666+
&[2, 3, 4],
667+
vec![
668+
state_test.tx_a.clone(),
669+
state_test.tx_b.clone(),
670+
state_test.tx_d.clone(),
671+
],
672+
);
673+
674+
let transactions = state_test.get_global_replay_set();
675+
676+
// Should find [A,B] as the longest common prefix with majority support
677+
assert_eq!(transactions.len(), 2);
678+
assert_eq!(transactions[0], state_test.tx_a);
679+
assert_eq!(transactions[1], state_test.tx_b);
680+
}
681+
682+
#[test]
683+
/// Edge case: Equal-weight competing prefixes should find common prefix
684+
fn test_replay_set_equal_weight_competing_prefixes() {
685+
let mut state_test = SignerStateTest::new(6);
686+
687+
// Signers 0, 1, 2: [A,B] (50% weight - not enough alone)
688+
state_test.update_signers(
689+
&[0, 1, 2],
690+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
691+
);
692+
693+
// Signers 3, 4, 5: [A,C] (50% weight - not enough alone)
694+
state_test.update_signers(
695+
&[3, 4, 5],
696+
vec![state_test.tx_a.clone(), state_test.tx_c.clone()],
697+
);
698+
699+
let transactions = state_test.get_global_replay_set();
700+
701+
// Should find common prefix [A] since both [A,B] and [A,C] start with [A]
702+
// and [A] has 100% support (above the 70% threshold)
703+
assert_eq!(transactions.len(), 1, "Should find common prefix [A]");
704+
assert_eq!(
705+
transactions[0], state_test.tx_a,
706+
"Should contain transaction A"
707+
);
708+
}

0 commit comments

Comments
 (0)