Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE

## 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 sigher weight.

### Fixed

- Fixed a typo in the metrics_identifier route from `/v2/stackedb/:principal/:contract_name/replicas` to `/v2/stackerdb/:principal/:contract_name/replicas`. Note: This may be a breaking change for systems relying on the incorrect route. Please update any metrics tools accordingly.
Expand Down
334 changes: 334 additions & 0 deletions libsigner/src/tests/signer_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,137 @@ 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<StacksAddress>,
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,
}

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];

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 tx_a = StacksTransaction {
version: TransactionVersion::Testnet,
chain_id: 0x80000000,
auth: TransactionAuth::from_p2pkh(&pk1).unwrap(),
anchor_mode: TransactionAnchorMode::Any,
post_condition_mode: TransactionPostConditionMode::Allow,
post_conditions: vec![],
payload: TransactionPayload::TokenTransfer(
local_address.into(),
100,
TokenTransferMemo([1u8; 34]),
),
};

let tx_b = StacksTransaction {
version: TransactionVersion::Testnet,
chain_id: 0x80000000,
auth: TransactionAuth::from_p2pkh(&pk2).unwrap(),
anchor_mode: TransactionAnchorMode::Any,
post_condition_mode: TransactionPostConditionMode::Allow,
post_conditions: vec![],
payload: TransactionPayload::TokenTransfer(
local_address.into(),
200,
TokenTransferMemo([2u8; 34]),
),
};

let tx_c = StacksTransaction {
version: TransactionVersion::Testnet,
chain_id: 0x80000000,
auth: TransactionAuth::from_p2pkh(&pk3).unwrap(),
anchor_mode: TransactionAnchorMode::Any,
post_condition_mode: TransactionPostConditionMode::Allow,
post_conditions: vec![],
payload: TransactionPayload::TokenTransfer(
local_address.into(),
300,
TokenTransferMemo([3u8; 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,
}
}

/// Create a replay transaction update message
fn create_replay_update(
&self,
transactions: Vec<StacksTransaction>,
) -> 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<StacksTransaction>) {
let update = self.create_replay_update(transactions);
for &index in signer_indices {
self.global_eval
.insert_update(self.addresses[index], update.clone());
}
}

/// Get the global state replay set
fn get_global_replay_set(&mut self) -> Vec<StacksTransaction> {
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;
Expand Down Expand Up @@ -417,3 +548,206 @@ 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_demo() {
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]
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(4);

// Signer 0: [A,B,C] (25% weight - not enough alone)
state_test.update_signers(
&[0],
vec![
state_test.tx_a.clone(),
state_test.tx_b.clone(),
state_test.tx_c.clone(),
],
);

// Signers 1, 2, 3: [A,B] only (75% weight - above threshold)
state_test.update_signers(
&[1, 2, 3],
vec![state_test.tx_a.clone(), state_test.tx_b.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"
);
}
Loading
Loading