Skip to content

Commit 399700b

Browse files
committed
feat: use longest common prefix for determining tx replay set
1 parent 3ece8b5 commit 399700b

File tree

2 files changed

+431
-4
lines changed

2 files changed

+431
-4
lines changed

libsigner/src/tests/signer_state.rs

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,137 @@ 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+
}
45+
46+
impl SignerStateTest {
47+
fn new(num_signers: u32) -> Self {
48+
let global_eval = generate_global_state_evaluator(num_signers);
49+
let addresses: Vec<_> = global_eval.address_weights.keys().cloned().collect();
50+
let local_address = addresses[0];
51+
52+
let burn_block = ConsensusHash([20u8; 20]);
53+
let burn_block_height = 100;
54+
let current_miner = StateMachineUpdateMinerState::ActiveMiner {
55+
current_miner_pkh: Hash160([0xab; 20]),
56+
tenure_id: ConsensusHash([0x44; 20]),
57+
parent_tenure_id: ConsensusHash([0x22; 20]),
58+
parent_tenure_last_block: StacksBlockId([0x33; 32]),
59+
parent_tenure_last_block_height: 1,
60+
};
61+
62+
let local_supported_signer_protocol_version = 1;
63+
let active_signer_protocol_version = 1;
64+
65+
// Create test transactions with different memos for uniqueness
66+
let pk1 = StacksPrivateKey::random();
67+
let pk2 = StacksPrivateKey::random();
68+
let pk3 = StacksPrivateKey::random();
69+
70+
let tx_a = StacksTransaction {
71+
version: TransactionVersion::Testnet,
72+
chain_id: 0x80000000,
73+
auth: TransactionAuth::from_p2pkh(&pk1).unwrap(),
74+
anchor_mode: TransactionAnchorMode::Any,
75+
post_condition_mode: TransactionPostConditionMode::Allow,
76+
post_conditions: vec![],
77+
payload: TransactionPayload::TokenTransfer(
78+
local_address.into(),
79+
100,
80+
TokenTransferMemo([1u8; 34]),
81+
),
82+
};
83+
84+
let tx_b = StacksTransaction {
85+
version: TransactionVersion::Testnet,
86+
chain_id: 0x80000000,
87+
auth: TransactionAuth::from_p2pkh(&pk2).unwrap(),
88+
anchor_mode: TransactionAnchorMode::Any,
89+
post_condition_mode: TransactionPostConditionMode::Allow,
90+
post_conditions: vec![],
91+
payload: TransactionPayload::TokenTransfer(
92+
local_address.into(),
93+
200,
94+
TokenTransferMemo([2u8; 34]),
95+
),
96+
};
97+
98+
let tx_c = StacksTransaction {
99+
version: TransactionVersion::Testnet,
100+
chain_id: 0x80000000,
101+
auth: TransactionAuth::from_p2pkh(&pk3).unwrap(),
102+
anchor_mode: TransactionAnchorMode::Any,
103+
post_condition_mode: TransactionPostConditionMode::Allow,
104+
post_conditions: vec![],
105+
payload: TransactionPayload::TokenTransfer(
106+
local_address.into(),
107+
300,
108+
TokenTransferMemo([3u8; 34]),
109+
),
110+
};
111+
112+
Self {
113+
global_eval,
114+
addresses,
115+
burn_block,
116+
burn_block_height,
117+
current_miner,
118+
local_supported_signer_protocol_version,
119+
active_signer_protocol_version,
120+
tx_a,
121+
tx_b,
122+
tx_c,
123+
}
124+
}
125+
126+
/// Create a replay transaction update message
127+
fn create_replay_update(
128+
&self,
129+
transactions: Vec<StacksTransaction>,
130+
) -> StateMachineUpdateMessage {
131+
StateMachineUpdateMessage::new(
132+
self.active_signer_protocol_version,
133+
self.local_supported_signer_protocol_version,
134+
StateMachineUpdateContent::V1 {
135+
burn_block: self.burn_block,
136+
burn_block_height: self.burn_block_height,
137+
current_miner: self.current_miner.clone(),
138+
replay_transactions: transactions,
139+
},
140+
)
141+
.unwrap()
142+
}
143+
144+
/// Update multiple signers with the same replay transaction set
145+
fn update_signers(&mut self, signer_indices: &[usize], transactions: Vec<StacksTransaction>) {
146+
let update = self.create_replay_update(transactions);
147+
for &index in signer_indices {
148+
self.global_eval
149+
.insert_update(self.addresses[index], update.clone());
150+
}
151+
}
152+
153+
/// Get the global state replay set
154+
fn get_global_replay_set(&mut self) -> Vec<StacksTransaction> {
155+
self.global_eval
156+
.determine_global_state()
157+
.unwrap()
158+
.tx_replay_set
159+
.unwrap_or_default()
160+
}
161+
}
162+
32163
fn generate_global_state_evaluator(num_addresses: u32) -> GlobalStateEvaluator {
33164
let address_weights = generate_random_address_with_equal_weights(num_addresses);
34165
let active_protocol_version = 0;
@@ -417,3 +548,206 @@ fn determine_global_states_with_tx_replay_set() {
417548
tx_replay_state_machine
418549
);
419550
}
551+
552+
#[test]
553+
/// Case: One signer has [A,B,C], another has [A,B] - should find common prefix [A,B]
554+
fn test_replay_set_common_prefix_coalescing_demo() {
555+
let mut state_test = SignerStateTest::new(5);
556+
557+
// Signers 0, 1: [A,B,C] (40% weight)
558+
state_test.update_signers(
559+
&[0, 1],
560+
vec![
561+
state_test.tx_a.clone(),
562+
state_test.tx_b.clone(),
563+
state_test.tx_c.clone(),
564+
],
565+
);
566+
567+
// Signers 2, 3, 4: [A,B] (60% weight - should win)
568+
state_test.update_signers(
569+
&[2, 3, 4],
570+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
571+
);
572+
573+
let transactions = state_test.get_global_replay_set();
574+
575+
// Should find common prefix [A,B] since it's the longest prefix with majority support
576+
assert_eq!(transactions.len(), 2);
577+
assert_eq!(transactions[0], state_test.tx_a); // Order matters!
578+
assert_eq!(transactions[1], state_test.tx_b);
579+
assert!(!transactions.contains(&state_test.tx_c));
580+
}
581+
582+
#[test]
583+
fn test_replay_set_common_prefix_coalescing() {
584+
let mut state_test = SignerStateTest::new(5);
585+
586+
// Signers 0, 1: [A,B,C] (40% weight)
587+
state_test.update_signers(
588+
&[0, 1],
589+
vec![
590+
state_test.tx_a.clone(),
591+
state_test.tx_b.clone(),
592+
state_test.tx_c.clone(),
593+
],
594+
);
595+
596+
// Signers 2, 3, 4: [A,B] (60% weight - should win)
597+
state_test.update_signers(
598+
&[2, 3, 4],
599+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
600+
);
601+
602+
let transactions = state_test.get_global_replay_set();
603+
604+
// Should find common prefix [A,B] since it's the longest prefix with majority support
605+
assert_eq!(transactions.len(), 2);
606+
assert_eq!(transactions[0], state_test.tx_a); // Order matters!
607+
assert_eq!(transactions[1], state_test.tx_b);
608+
assert!(!transactions.contains(&state_test.tx_c));
609+
}
610+
611+
#[test]
612+
/// Case: One sequence has clear majority - should use that sequence
613+
fn test_replay_set_majority_prefix_selection() {
614+
let mut state_test = SignerStateTest::new(5);
615+
616+
// Signer 0: [A] (20% weight)
617+
state_test.update_signers(&[0], vec![state_test.tx_a.clone()]);
618+
619+
// Signers 1, 2, 3, 4: [C] (80% weight - above threshold)
620+
state_test.update_signers(&[1, 2, 3, 4], vec![state_test.tx_c.clone()]);
621+
622+
let transactions = state_test.get_global_replay_set();
623+
624+
// Should use [C] since it has majority support (80% > 70%)
625+
assert_eq!(transactions.len(), 1);
626+
assert_eq!(transactions[0], state_test.tx_c);
627+
}
628+
629+
#[test]
630+
/// Case: Exact agreement should be prioritized over subset coalescing
631+
fn test_replay_set_exact_agreement_prioritized() {
632+
let mut state_test = SignerStateTest::new(5);
633+
634+
// 4 signers agree on [A,B] exactly (80% - above threshold)
635+
state_test.update_signers(
636+
&[0, 1, 2, 3],
637+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
638+
);
639+
640+
// 1 signer has just [A] (20%)
641+
state_test.update_signers(&[4], vec![state_test.tx_a.clone()]);
642+
643+
let transactions = state_test.get_global_replay_set();
644+
645+
// Should use exact agreement [A,B] rather than common prefix [A]
646+
assert_eq!(transactions.len(), 2);
647+
assert_eq!(transactions[0], state_test.tx_a); // Order matters!
648+
assert_eq!(transactions[1], state_test.tx_b);
649+
}
650+
651+
#[test]
652+
/// Case: Complete disagreement - no overlap and no majority
653+
fn test_replay_set_no_agreement_returns_empty() {
654+
let mut state_test = SignerStateTest::new(5);
655+
656+
// Signer 0: [A] (20% weight)
657+
state_test.update_signers(&[0], vec![state_test.tx_a.clone()]);
658+
659+
// Signer 1: [B] (20% weight)
660+
state_test.update_signers(&[1], vec![state_test.tx_b.clone()]);
661+
662+
// Signer 2: [C] (20% weight)
663+
state_test.update_signers(&[2], vec![state_test.tx_c.clone()]);
664+
665+
// Signers 3, 4: empty sets (40% weight)
666+
state_test.update_signers(&[3, 4], vec![]);
667+
668+
let transactions = state_test.get_global_replay_set();
669+
670+
// Should return empty set to prioritize liveness when no agreement
671+
assert_eq!(transactions.len(), 0);
672+
}
673+
674+
#[test]
675+
/// Case: Same transactions in different order have no common prefix
676+
fn test_replay_set_order_matters_no_common_prefix() {
677+
let mut state_test = SignerStateTest::new(4);
678+
679+
// Signers 0, 1: [A,B] (50% weight)
680+
state_test.update_signers(
681+
&[0, 1],
682+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
683+
);
684+
685+
// Signers 2, 3: [B,A] (50% weight)
686+
state_test.update_signers(
687+
&[2, 3],
688+
vec![state_test.tx_b.clone(), state_test.tx_a.clone()],
689+
);
690+
691+
let transactions = state_test.get_global_replay_set();
692+
693+
// Should return empty set since [A,B] and [B,A] have no common prefix
694+
// Even though both contain the same transactions, order matters for replay
695+
assert_eq!(transactions.len(), 0);
696+
}
697+
698+
#[test]
699+
/// Case: [A,B,C] vs [A,B,D] should find common prefix [A,B]
700+
fn test_replay_set_partial_prefix_match() {
701+
let mut state_test = SignerStateTest::new(4);
702+
703+
// Signer 0: [A,B,C] (25% weight - not enough alone)
704+
state_test.update_signers(
705+
&[0],
706+
vec![
707+
state_test.tx_a.clone(),
708+
state_test.tx_b.clone(),
709+
state_test.tx_c.clone(),
710+
],
711+
);
712+
713+
// Signers 1, 2, 3: [A,B] only (75% weight - above threshold)
714+
state_test.update_signers(
715+
&[1, 2, 3],
716+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
717+
);
718+
719+
let transactions = state_test.get_global_replay_set();
720+
721+
// Should find [A,B] as the longest common prefix with majority support
722+
assert_eq!(transactions.len(), 2);
723+
assert_eq!(transactions[0], state_test.tx_a);
724+
assert_eq!(transactions[1], state_test.tx_b);
725+
}
726+
727+
#[test]
728+
/// Edge case: Equal-weight competing prefixes should find common prefix
729+
fn test_replay_set_equal_weight_competing_prefixes() {
730+
let mut state_test = SignerStateTest::new(6);
731+
732+
// Signers 0, 1, 2: [A,B] (50% weight - not enough alone)
733+
state_test.update_signers(
734+
&[0, 1, 2],
735+
vec![state_test.tx_a.clone(), state_test.tx_b.clone()],
736+
);
737+
738+
// Signers 3, 4, 5: [A,C] (50% weight - not enough alone)
739+
state_test.update_signers(
740+
&[3, 4, 5],
741+
vec![state_test.tx_a.clone(), state_test.tx_c.clone()],
742+
);
743+
744+
let transactions = state_test.get_global_replay_set();
745+
746+
// Should find common prefix [A] since both [A,B] and [A,C] start with [A]
747+
// and [A] has 100% support (above the 70% threshold)
748+
assert_eq!(transactions.len(), 1, "Should find common prefix [A]");
749+
assert_eq!(
750+
transactions[0], state_test.tx_a,
751+
"Should contain transaction A"
752+
);
753+
}

0 commit comments

Comments
 (0)