mirror of
https://github.com/vacp2p/hashgraph-like-consensus.git
synced 2026-08-27 13:01:09 +00:00
feat!: take current time as a caller-supplied parameter (#22)
* feat!: take current time as a caller-supplied parameter * docs(scope): note any key type works as a scope * chore: release 0.6.0 * refactor(tests): share duplicated helpers via tests/common * chore: bump version to 0.6.0 * refactor(readme): adjust syntax
This commit is contained in:
Generated
+276
-319
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hashgraph-like-consensus"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "A lightweight Rust library for making binary decisions in networks using hashgraph-style consensus"
|
||||
license = "MIT"
|
||||
|
||||
@@ -48,12 +48,17 @@ use hashgraph_like_consensus::{
|
||||
types::CreateProposalRequest,
|
||||
};
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let signer = EthereumConsensusSigner::new(PrivateKeySigner::random());
|
||||
let service = DefaultConsensusService::new(signer.clone());
|
||||
let scope = ScopeID::from("example-scope");
|
||||
|
||||
// The caller supplies the current time (seconds since Unix epoch) to every
|
||||
// time-sensitive call, so the application controls the time source.
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
// Create a proposal
|
||||
let proposal = service.create_proposal(
|
||||
&scope,
|
||||
@@ -65,10 +70,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
60, // expiration (seconds from now)
|
||||
true, // liveness: silent peers count as YES at timeout
|
||||
)?,
|
||||
now,
|
||||
)?;
|
||||
|
||||
// Cast a vote — the service uses its held signer.
|
||||
let vote = service.cast_vote(&scope, proposal.proposal_id, true)?;
|
||||
let vote = service.cast_vote(&scope, proposal.proposal_id, true, now)?;
|
||||
println!("Recorded vote {}", vote.vote_id);
|
||||
|
||||
Ok(())
|
||||
@@ -184,8 +190,9 @@ orchestration. Your application is responsible for:
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Network propagation** | The library performs no I/O. When you create a proposal or cast a vote, you must gossip it to peers yourself. When a message arrives from the network, call `process_incoming_proposal` or `process_incoming_vote`. |
|
||||
| **Timeout scheduling** | The library does not spawn timers. You must schedule a timer for each proposal (using `consensus_timeout()` from the config) and call `handle_consensus_timeout` when it fires. Without this, proposals with offline voters stay `Active` forever. |
|
||||
| **Time source** | Every time-sensitive method takes `now` (seconds since Unix epoch) as a parameter. The application decides where time comes from — system time in production, a controllable clock in tests. |
|
||||
| **`expected_voters_count` accuracy** | This value drives all threshold math (`ceil(2n/3)` quorum, silent peer counting). If it doesn't match the actual group size, consensus results will be wrong. |
|
||||
| **Signer management** | You construct each `ConsensusService` with the peer's `ConsensusSignatureScheme` value (e.g. `EthereumConsensusSigner::new(private_key)`). `cast_vote` uses that held signer. Each identity may vote at most once per proposal. |
|
||||
| **Signer management** | You construct each `ConsensusService` with the peer's `ConsensusSignatureScheme` value (e.g. `EthereumConsensusSigner::new(private_key)`). `cast_vote` uses that held signer. Each identity may vote at most once per proposal. |
|
||||
| **Proposal ID tracking** | The library generates a `proposal_id` on creation. You must store it and pass it to every subsequent call (`cast_vote`, `handle_consensus_timeout`, etc.). |
|
||||
| **Session eviction awareness** | The default service keeps at most 10 sessions per scope (configurable via `new_with_max_sessions`). Older sessions are silently dropped when the limit is exceeded. Archive results before they are evicted. |
|
||||
|
||||
@@ -259,6 +266,9 @@ service.scope(&scope)?.fast_consensus().initialize()?;
|
||||
|
||||
### Working with Proposals
|
||||
|
||||
Every time-sensitive call takes `now` — the current time in seconds since Unix
|
||||
epoch, supplied by the application.
|
||||
|
||||
```rust
|
||||
// Create a proposal
|
||||
let proposal = service.create_proposal(&scope, CreateProposalRequest::new(
|
||||
@@ -268,23 +278,23 @@ let proposal = service.create_proposal(&scope, CreateProposalRequest::new(
|
||||
3, // expected voters
|
||||
60, // expiration (seconds from now)
|
||||
true, // liveness: silent peers count as YES at timeout
|
||||
)?)?;
|
||||
)?, now)?;
|
||||
|
||||
// Process a proposal received from the network
|
||||
service.process_incoming_proposal(&scope, proposal)?;
|
||||
service.process_incoming_proposal(&scope, proposal, now)?;
|
||||
```
|
||||
|
||||
### Casting and Processing Votes
|
||||
|
||||
```rust
|
||||
// Cast your vote (yes = true, no = false) using the service's held signer.
|
||||
let vote = service.cast_vote(&scope, proposal_id, true)?;
|
||||
let vote = service.cast_vote(&scope, proposal_id, true, now)?;
|
||||
|
||||
// Cast a vote and get the updated proposal (useful for gossiping).
|
||||
let proposal = service.cast_vote_and_get_proposal(&scope, proposal_id, true)?;
|
||||
let proposal = service.cast_vote_and_get_proposal(&scope, proposal_id, true, now)?;
|
||||
|
||||
// Process a vote received from the network (uses the service's scheme to verify).
|
||||
service.process_incoming_vote(&scope, vote)?;
|
||||
service.process_incoming_vote(&scope, vote, now)?;
|
||||
```
|
||||
|
||||
### Reading State (via Storage)
|
||||
@@ -332,7 +342,7 @@ after counting silent peers), which marks the session as failed.
|
||||
// Drive the timer however your app prefers, then call the handler when it fires.
|
||||
std::thread::sleep(config.consensus_timeout());
|
||||
|
||||
match service.handle_consensus_timeout(&scope, proposal_id) {
|
||||
match service.handle_consensus_timeout(&scope, proposal_id, now) {
|
||||
Ok(true) => println!("Consensus: YES"),
|
||||
Ok(false) => println!("Consensus: NO"),
|
||||
Err(ConsensusError::InsufficientVotesAtTimeout) => {
|
||||
@@ -469,13 +479,13 @@ See `tests/custom_scheme_tests.rs` for a working non-Ethereum example.
|
||||
|
||||
The `utils` module provides low-level helpers for advanced use cases:
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `build_vote::<Signer>()` | Create a signed vote linked into the hashgraph chain |
|
||||
| `compute_vote_hash()` | Compute the deterministic hash of a vote |
|
||||
| `validate_proposal::<Signer>()` | Validate a proposal and all its votes against a signature scheme |
|
||||
| `calculate_consensus_result()` | Determine result from collected votes using threshold and liveness rules |
|
||||
| `has_sufficient_votes()` | Quick threshold check (count-based) |
|
||||
| Function | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `build_vote::<Signer>()` | Create a signed vote linked into the hashgraph chain |
|
||||
| `compute_vote_hash()` | Compute the deterministic hash of a vote |
|
||||
| `validate_proposal::<Signer>()` | Validate a proposal and all its votes against a signature scheme |
|
||||
| `calculate_consensus_result()` | Determine result from collected votes using threshold and liveness rules |
|
||||
| `has_sufficient_votes()` | Quick threshold check (count-based) |
|
||||
|
||||
The generic `Signer` parameter on `build_vote` / `validate_proposal` /
|
||||
`validate_vote` selects which `ConsensusSignatureScheme` to use; pick it via
|
||||
|
||||
@@ -71,6 +71,4 @@ pub enum ConsensusError {
|
||||
|
||||
#[error("Signature scheme failure: {0}")]
|
||||
SignatureScheme(#[from] ConsensusSchemeError),
|
||||
#[error("Failed to get current time")]
|
||||
FailedToGetCurrentTime(#[from] std::time::SystemTimeError),
|
||||
}
|
||||
|
||||
+11
-1
@@ -25,6 +25,9 @@
|
||||
//! [`handle_consensus_timeout`](service::ConsensusService::handle_consensus_timeout)
|
||||
//! when it fires. Without this, proposals with offline voters stay `Active`
|
||||
//! forever and silent-peer liveness logic never runs.
|
||||
//! - **Time source** — every time-sensitive method takes `now` (seconds since
|
||||
//! Unix epoch) as a parameter, so the application controls where time comes
|
||||
//! from (system time, a test clock, etc.).
|
||||
//! - **`expected_voters_count` accuracy** — this drives all threshold math;
|
||||
//! a wrong value produces wrong results.
|
||||
//! - **Session eviction awareness** — the default service keeps at most 10
|
||||
@@ -59,11 +62,14 @@
|
||||
//! types::CreateProposalRequest,
|
||||
//! };
|
||||
//! use alloy::signers::local::PrivateKeySigner;
|
||||
//! use std::time::{SystemTime, UNIX_EPOCH};
|
||||
//!
|
||||
//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let signer = EthereumConsensusSigner::new(PrivateKeySigner::random());
|
||||
//! let service = DefaultConsensusService::new(signer.clone());
|
||||
//! let scope = ScopeID::from("my-scope");
|
||||
//! // The caller supplies the current time (seconds since Unix epoch).
|
||||
//! let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
//!
|
||||
//! let proposal = service
|
||||
//! .create_proposal(
|
||||
@@ -76,9 +82,10 @@
|
||||
//! 60, // expiration (seconds from now)
|
||||
//! true, // liveness: silent peers count as YES at timeout
|
||||
//! )?,
|
||||
//! now,
|
||||
//! )?;
|
||||
//!
|
||||
//! let vote = service.cast_vote(&scope, proposal.proposal_id, true)?;
|
||||
//! let vote = service.cast_vote(&scope, proposal.proposal_id, true, now)?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
@@ -117,3 +124,6 @@ pub mod signing;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_utils;
|
||||
|
||||
+8
-2
@@ -3,10 +3,16 @@ use std::{fmt::Debug, hash::Hash};
|
||||
/// A scope groups related proposals together.
|
||||
///
|
||||
/// Think of it like a namespace or category. For example, you might use a scope as group of users.
|
||||
/// Any type that can be used as a group of users can be used as a scope.
|
||||
/// The trait is blanket-implemented: any `Clone + Eq + Hash + Send + Sync + Debug + 'static`
|
||||
/// type works as a scope key — `String`, `Vec<u8>`, `[u8; 32]`, integers, or a
|
||||
/// custom key type of your own.
|
||||
pub trait ConsensusScope: Clone + Eq + Hash + Send + Sync + Debug + 'static {}
|
||||
|
||||
impl<T> ConsensusScope for T where T: Clone + Eq + Hash + Send + Sync + Debug + 'static {}
|
||||
|
||||
/// A simple string-based scope identifier.
|
||||
/// A simple string-based scope identifier, used by
|
||||
/// [`DefaultConsensusService`](crate::service::DefaultConsensusService).
|
||||
/// Integrations keyed by raw bytes can use `Vec<u8>` (or any other
|
||||
/// [`ConsensusScope`] type) directly in their `ConsensusService` /
|
||||
/// `ConsensusStorage` type parameters.
|
||||
pub type ScopeID = String;
|
||||
|
||||
+41
-22
@@ -11,10 +11,7 @@ use crate::{
|
||||
signing::ConsensusSignatureScheme,
|
||||
storage::ConsensusStorage,
|
||||
types::{ConsensusEvent, CreateProposalRequest, SessionTransition},
|
||||
utils::{
|
||||
build_vote, calculate_consensus_result, current_timestamp, validate_proposal_timestamp,
|
||||
validate_vote,
|
||||
},
|
||||
utils::{build_vote, calculate_consensus_result, validate_proposal_timestamp, validate_vote},
|
||||
};
|
||||
#[cfg(feature = "ethereum")]
|
||||
use crate::{
|
||||
@@ -181,12 +178,15 @@ where
|
||||
///
|
||||
/// Configuration is resolved from: proposal config > scope config > global default.
|
||||
/// If no config is provided, the scope's default configuration is used.
|
||||
///
|
||||
/// `now` is the current time in seconds since Unix epoch, supplied by the caller.
|
||||
pub fn create_proposal(
|
||||
&self,
|
||||
scope: &Scope,
|
||||
request: CreateProposalRequest,
|
||||
now: u64,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
self.create_proposal_with_config(scope, request, None)
|
||||
self.create_proposal_with_config(scope, request, None, now)
|
||||
}
|
||||
|
||||
/// Create a new proposal with an explicit [`ConsensusConfig`] override.
|
||||
@@ -197,11 +197,12 @@ where
|
||||
scope: &Scope,
|
||||
request: CreateProposalRequest,
|
||||
config: Option<ConsensusConfig>,
|
||||
now: u64,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
let proposal = request.into_proposal()?;
|
||||
let proposal = request.into_proposal(now)?;
|
||||
let config = self.resolve_config(scope, config, Some(&proposal))?;
|
||||
let (session, _) =
|
||||
ConsensusSession::from_proposal::<Signer>(proposal.clone(), config.clone())?;
|
||||
ConsensusSession::from_proposal::<Signer>(proposal.clone(), config.clone(), now)?;
|
||||
self.save_session(scope, session)?;
|
||||
self.trim_scope_sessions(scope)?;
|
||||
Ok(proposal)
|
||||
@@ -217,20 +218,21 @@ where
|
||||
scope: &Scope,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
now: u64,
|
||||
) -> Result<Vote, ConsensusError> {
|
||||
let session = self.get_session(scope, proposal_id)?;
|
||||
validate_proposal_timestamp(session.proposal.expiration_timestamp)?;
|
||||
validate_proposal_timestamp(session.proposal.expiration_timestamp, now)?;
|
||||
|
||||
if session.votes.contains_key(self.signer.identity()) {
|
||||
return Err(ConsensusError::UserAlreadyVoted);
|
||||
}
|
||||
|
||||
let vote = build_vote(&session.proposal, choice, &self.signer)?;
|
||||
let vote = build_vote(&session.proposal, choice, &self.signer, now)?;
|
||||
let vote_clone = vote.clone();
|
||||
let transition = self.update_session(scope, proposal_id, move |session| {
|
||||
session.add_vote(vote_clone)
|
||||
session.add_vote(vote_clone, now)
|
||||
})?;
|
||||
self.handle_transition(scope, proposal_id, transition);
|
||||
self.handle_transition(scope, proposal_id, transition, now);
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
@@ -243,8 +245,9 @@ where
|
||||
scope: &Scope,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
now: u64,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
self.cast_vote(scope, proposal_id, choice)?;
|
||||
self.cast_vote(scope, proposal_id, choice, now)?;
|
||||
let session = self.get_session(scope, proposal_id)?;
|
||||
Ok(session.proposal)
|
||||
}
|
||||
@@ -261,13 +264,15 @@ where
|
||||
&self,
|
||||
scope: &Scope,
|
||||
proposal: Proposal,
|
||||
now: u64,
|
||||
) -> Result<(), ConsensusError> {
|
||||
if self.get_session(scope, proposal.proposal_id).is_ok() {
|
||||
return Err(ConsensusError::ProposalAlreadyExist);
|
||||
}
|
||||
let config = self.resolve_config(scope, None, Some(&proposal))?;
|
||||
let (session, transition) = ConsensusSession::from_proposal::<Signer>(proposal, config)?;
|
||||
self.handle_transition(scope, session.proposal.proposal_id, transition);
|
||||
let (session, transition) =
|
||||
ConsensusSession::from_proposal::<Signer>(proposal, config, now)?;
|
||||
self.handle_transition(scope, session.proposal.proposal_id, transition, now);
|
||||
self.save_session(scope, session)?;
|
||||
self.trim_scope_sessions(scope)?;
|
||||
Ok(())
|
||||
@@ -278,17 +283,24 @@ where
|
||||
/// Call this when your networking layer delivers a vote from another peer.
|
||||
/// Validates the vote (signature, timestamp, chain) and adds it to the
|
||||
/// corresponding proposal session. May trigger consensus.
|
||||
pub fn process_incoming_vote(&self, scope: &Scope, vote: Vote) -> Result<(), ConsensusError> {
|
||||
pub fn process_incoming_vote(
|
||||
&self,
|
||||
scope: &Scope,
|
||||
vote: Vote,
|
||||
now: u64,
|
||||
) -> Result<(), ConsensusError> {
|
||||
let session = self.get_session(scope, vote.proposal_id)?;
|
||||
validate_vote::<Signer>(
|
||||
&vote,
|
||||
session.proposal.expiration_timestamp,
|
||||
session.proposal.timestamp,
|
||||
now,
|
||||
)?;
|
||||
let proposal_id = vote.proposal_id;
|
||||
let transition =
|
||||
self.update_session(scope, proposal_id, move |session| session.add_vote(vote))?;
|
||||
self.handle_transition(scope, proposal_id, transition);
|
||||
let transition = self.update_session(scope, proposal_id, move |session| {
|
||||
session.add_vote(vote, now)
|
||||
})?;
|
||||
self.handle_transition(scope, proposal_id, transition, now);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -312,6 +324,7 @@ where
|
||||
&self,
|
||||
scope: &Scope,
|
||||
proposal_id: u32,
|
||||
now: u64,
|
||||
) -> Result<bool, ConsensusError> {
|
||||
let timeout_result: Result<Option<bool>, ConsensusError> =
|
||||
self.update_session(scope, proposal_id, |session| {
|
||||
@@ -341,7 +354,7 @@ where
|
||||
ConsensusEvent::ConsensusReached {
|
||||
proposal_id,
|
||||
result: consensus_result,
|
||||
timestamp: current_timestamp()?,
|
||||
timestamp: now,
|
||||
},
|
||||
);
|
||||
Ok(consensus_result)
|
||||
@@ -351,7 +364,7 @@ where
|
||||
scope,
|
||||
ConsensusEvent::ConsensusFailed {
|
||||
proposal_id,
|
||||
timestamp: current_timestamp()?,
|
||||
timestamp: now,
|
||||
},
|
||||
);
|
||||
Err(ConsensusError::InsufficientVotesAtTimeout)
|
||||
@@ -517,14 +530,20 @@ where
|
||||
.ok_or(ConsensusError::ScopeNotFound)
|
||||
}
|
||||
|
||||
fn handle_transition(&self, scope: &Scope, proposal_id: u32, transition: SessionTransition) {
|
||||
fn handle_transition(
|
||||
&self,
|
||||
scope: &Scope,
|
||||
proposal_id: u32,
|
||||
transition: SessionTransition,
|
||||
now: u64,
|
||||
) {
|
||||
if let SessionTransition::ConsensusReached(result) = transition {
|
||||
self.emit_event(
|
||||
scope,
|
||||
ConsensusEvent::ConsensusReached {
|
||||
proposal_id,
|
||||
result,
|
||||
timestamp: current_timestamp().unwrap_or(0),
|
||||
timestamp: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+72
-49
@@ -13,7 +13,7 @@ use crate::{
|
||||
signing::ConsensusSignatureScheme,
|
||||
types::SessionTransition,
|
||||
utils::{
|
||||
calculate_consensus_result, calculate_max_rounds, current_timestamp, validate_proposal,
|
||||
calculate_consensus_result, calculate_max_rounds, validate_proposal,
|
||||
validate_proposal_timestamp, validate_vote, validate_vote_chain,
|
||||
},
|
||||
};
|
||||
@@ -180,8 +180,8 @@ pub struct ConsensusSession {
|
||||
impl ConsensusSession {
|
||||
/// Create a new session from a validated proposal (no votes).
|
||||
/// Used when creating proposals locally where we know the proposal is clean.
|
||||
fn new(proposal: Proposal, config: ConsensusConfig) -> Self {
|
||||
let now = current_timestamp().unwrap_or(0);
|
||||
/// `now` (seconds since Unix epoch) becomes `created_at`.
|
||||
fn new(proposal: Proposal, config: ConsensusConfig, now: u64) -> Self {
|
||||
Self {
|
||||
proposal,
|
||||
state: ConsensusState::Active,
|
||||
@@ -194,11 +194,13 @@ impl ConsensusSession {
|
||||
/// Create a session from a proposal, validating the proposal and all votes.
|
||||
/// This validates the proposal structure, vote chain, and individual votes before creating the session.
|
||||
/// The session is created with votes already processed and rounds correctly set.
|
||||
/// `now` (seconds since Unix epoch) drives expiration checks and `created_at`.
|
||||
pub fn from_proposal<Signer: ConsensusSignatureScheme>(
|
||||
proposal: Proposal,
|
||||
config: ConsensusConfig,
|
||||
now: u64,
|
||||
) -> Result<(Self, SessionTransition), ConsensusError> {
|
||||
validate_proposal::<Signer>(&proposal)?;
|
||||
validate_proposal::<Signer>(&proposal, now)?;
|
||||
|
||||
// Create clean proposal for session (votes will be added via initialize_with_votes)
|
||||
let existing_votes = proposal.votes.clone();
|
||||
@@ -207,21 +209,27 @@ impl ConsensusSession {
|
||||
// Always start with round 1 for new proposals as we at least have the proposal owner's vote.
|
||||
clean_proposal.round = 1;
|
||||
|
||||
let mut session = Self::new(clean_proposal, config);
|
||||
let mut session = Self::new(clean_proposal, config, now);
|
||||
let transition = session.initialize_with_votes::<Signer>(
|
||||
existing_votes,
|
||||
proposal.expiration_timestamp,
|
||||
proposal.timestamp,
|
||||
now,
|
||||
)?;
|
||||
|
||||
Ok((session, transition))
|
||||
}
|
||||
|
||||
/// Add a vote to the session.
|
||||
pub(crate) fn add_vote(&mut self, vote: Vote) -> Result<SessionTransition, ConsensusError> {
|
||||
/// Add a vote to the session. Expiration is checked against `now`
|
||||
/// (seconds since Unix epoch).
|
||||
pub(crate) fn add_vote(
|
||||
&mut self,
|
||||
vote: Vote,
|
||||
now: u64,
|
||||
) -> Result<SessionTransition, ConsensusError> {
|
||||
match self.state {
|
||||
ConsensusState::Active => {
|
||||
validate_proposal_timestamp(self.proposal.expiration_timestamp)?;
|
||||
validate_proposal_timestamp(self.proposal.expiration_timestamp, now)?;
|
||||
|
||||
// Check if adding this vote would exceed round limits
|
||||
self.check_round_limit(1)?;
|
||||
@@ -247,12 +255,13 @@ impl ConsensusSession {
|
||||
votes: Vec<Vote>,
|
||||
expiration_timestamp: u64,
|
||||
creation_time: u64,
|
||||
now: u64,
|
||||
) -> Result<SessionTransition, ConsensusError> {
|
||||
if !matches!(self.state, ConsensusState::Active) {
|
||||
return Err(ConsensusError::SessionNotActive);
|
||||
}
|
||||
|
||||
validate_proposal_timestamp(expiration_timestamp)?;
|
||||
validate_proposal_timestamp(expiration_timestamp, now)?;
|
||||
|
||||
if votes.is_empty() {
|
||||
return Ok(SessionTransition::StillActive);
|
||||
@@ -274,7 +283,7 @@ impl ConsensusSession {
|
||||
|
||||
validate_vote_chain(&votes)?;
|
||||
for vote in &votes {
|
||||
validate_vote::<Signer>(vote, expiration_timestamp, creation_time)?;
|
||||
validate_vote::<Signer>(vote, expiration_timestamp, creation_time, now)?;
|
||||
}
|
||||
|
||||
self.check_round_limit(votes.len())?;
|
||||
@@ -405,6 +414,7 @@ mod tests {
|
||||
error::ConsensusError,
|
||||
session::{ConsensusConfig, ConsensusSession, ConsensusState},
|
||||
signing::EthereumConsensusSigner,
|
||||
test_utils::now_ts,
|
||||
types::CreateProposalRequest,
|
||||
utils::build_vote,
|
||||
};
|
||||
@@ -432,28 +442,28 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
let config = ConsensusConfig::gossipsub();
|
||||
let mut session = ConsensusSession::new(proposal, config);
|
||||
let mut session = ConsensusSession::new(proposal, config, now_ts());
|
||||
|
||||
// Round 1 -> Round 2 (first vote)
|
||||
let vote1 = build_vote(&session.proposal, true, &wrap(signer1)).unwrap();
|
||||
session.add_vote(vote1).unwrap();
|
||||
let vote1 = build_vote(&session.proposal, true, &wrap(signer1), now_ts()).unwrap();
|
||||
session.add_vote(vote1, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 2);
|
||||
|
||||
// Stay at round 2 (second vote)
|
||||
let vote2 = build_vote(&session.proposal, false, &wrap(signer2)).unwrap();
|
||||
session.add_vote(vote2).unwrap();
|
||||
let vote2 = build_vote(&session.proposal, false, &wrap(signer2), now_ts()).unwrap();
|
||||
session.add_vote(vote2, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 2);
|
||||
|
||||
// Stay at round 2 (third vote)
|
||||
let vote3 = build_vote(&session.proposal, true, &wrap(signer3)).unwrap();
|
||||
session.add_vote(vote3).unwrap();
|
||||
let vote3 = build_vote(&session.proposal, true, &wrap(signer3), now_ts()).unwrap();
|
||||
session.add_vote(vote3, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 2);
|
||||
|
||||
// Stay at round 2 (fourth vote) - should succeed
|
||||
let vote4 = build_vote(&session.proposal, true, &wrap(signer4)).unwrap();
|
||||
session.add_vote(vote4).unwrap();
|
||||
let vote4 = build_vote(&session.proposal, true, &wrap(signer4), now_ts()).unwrap();
|
||||
session.add_vote(vote4, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 2);
|
||||
assert_eq!(session.votes.len(), 4);
|
||||
}
|
||||
@@ -479,37 +489,37 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
let config = ConsensusConfig::p2p();
|
||||
let mut session = ConsensusSession::new(proposal, config);
|
||||
let mut session = ConsensusSession::new(proposal, config, now_ts());
|
||||
|
||||
// Round 1 -> Round 2 (first vote, 1 vote total)
|
||||
let vote1 = build_vote(&session.proposal, true, &wrap(signer1)).unwrap();
|
||||
session.add_vote(vote1).unwrap();
|
||||
let vote1 = build_vote(&session.proposal, true, &wrap(signer1), now_ts()).unwrap();
|
||||
session.add_vote(vote1, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 2);
|
||||
assert_eq!(session.votes.len(), 1);
|
||||
|
||||
// Round 2 -> Round 3 (second vote, 2 votes total) - should succeed
|
||||
let vote2 = build_vote(&session.proposal, false, &wrap(signer2)).unwrap();
|
||||
session.add_vote(vote2).unwrap();
|
||||
let vote2 = build_vote(&session.proposal, false, &wrap(signer2), now_ts()).unwrap();
|
||||
session.add_vote(vote2, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 3);
|
||||
assert_eq!(session.votes.len(), 2);
|
||||
|
||||
// Round 3 -> Round 4 (third vote, 3 votes total) - should succeed
|
||||
let vote3 = build_vote(&session.proposal, true, &wrap(signer3)).unwrap();
|
||||
session.add_vote(vote3).unwrap();
|
||||
let vote3 = build_vote(&session.proposal, true, &wrap(signer3), now_ts()).unwrap();
|
||||
session.add_vote(vote3, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 4);
|
||||
assert_eq!(session.votes.len(), 3);
|
||||
|
||||
// Round 4 -> Round 5 (fourth vote, 4 votes total) - should succeed (dynamic limit = 4)
|
||||
let vote4 = build_vote(&session.proposal, true, &wrap(signer4)).unwrap();
|
||||
session.add_vote(vote4).unwrap();
|
||||
let vote4 = build_vote(&session.proposal, true, &wrap(signer4), now_ts()).unwrap();
|
||||
session.add_vote(vote4, now_ts()).unwrap();
|
||||
assert_eq!(session.proposal.round, 5);
|
||||
assert_eq!(session.votes.len(), 4);
|
||||
|
||||
// Fifth vote would exceed dynamic max_round_limit (=4 votes)
|
||||
let vote5 = build_vote(&session.proposal, true, &wrap(signer5)).unwrap();
|
||||
let err = session.add_vote(vote5).unwrap_err();
|
||||
let vote5 = build_vote(&session.proposal, true, &wrap(signer5), now_ts()).unwrap();
|
||||
let err = session.add_vote(vote5, now_ts()).unwrap_err();
|
||||
assert!(matches!(err, ConsensusError::MaxRoundsExceeded));
|
||||
}
|
||||
|
||||
@@ -553,21 +563,28 @@ mod tests {
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
|
||||
// Failed sessions reject new votes.
|
||||
let mut failed_session =
|
||||
ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub());
|
||||
ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub(), now_ts());
|
||||
failed_session.state = ConsensusState::Failed;
|
||||
let vote = build_vote(&failed_session.proposal, true, &wrap(signer.clone())).unwrap();
|
||||
let err = failed_session.add_vote(vote).unwrap_err();
|
||||
let vote = build_vote(
|
||||
&failed_session.proposal,
|
||||
true,
|
||||
&wrap(signer.clone()),
|
||||
now_ts(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = failed_session.add_vote(vote, now_ts()).unwrap_err();
|
||||
assert!(matches!(err, ConsensusError::SessionNotActive));
|
||||
|
||||
// Finalized sessions return existing transition/result.
|
||||
let mut finalized_session = ConsensusSession::new(proposal, ConsensusConfig::gossipsub());
|
||||
let mut finalized_session =
|
||||
ConsensusSession::new(proposal, ConsensusConfig::gossipsub(), now_ts());
|
||||
finalized_session.state = ConsensusState::ConsensusReached(true);
|
||||
let vote = build_vote(&finalized_session.proposal, true, &wrap(signer)).unwrap();
|
||||
let transition = finalized_session.add_vote(vote).unwrap();
|
||||
let vote = build_vote(&finalized_session.proposal, true, &wrap(signer), now_ts()).unwrap();
|
||||
let transition = finalized_session.add_vote(vote, now_ts()).unwrap();
|
||||
assert!(matches!(
|
||||
transition,
|
||||
crate::types::SessionTransition::ConsensusReached(true)
|
||||
@@ -586,35 +603,41 @@ mod tests {
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
|
||||
// Non-active sessions reject initialization.
|
||||
let mut inactive = ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub());
|
||||
let mut inactive =
|
||||
ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub(), now_ts());
|
||||
inactive.state = ConsensusState::Failed;
|
||||
let err = inactive
|
||||
.initialize_with_votes::<EthereumConsensusSigner>(
|
||||
vec![],
|
||||
proposal.expiration_timestamp,
|
||||
proposal.timestamp,
|
||||
now_ts(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ConsensusError::SessionNotActive));
|
||||
|
||||
// Duplicate owners are rejected before chain/signature checks.
|
||||
let mut dup_session = ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub());
|
||||
let vote1 = build_vote(&dup_session.proposal, true, &wrap(signer.clone())).unwrap();
|
||||
let vote2 = build_vote(&dup_session.proposal, false, &wrap(signer)).unwrap();
|
||||
let mut dup_session =
|
||||
ConsensusSession::new(proposal.clone(), ConsensusConfig::gossipsub(), now_ts());
|
||||
let vote1 =
|
||||
build_vote(&dup_session.proposal, true, &wrap(signer.clone()), now_ts()).unwrap();
|
||||
let vote2 = build_vote(&dup_session.proposal, false, &wrap(signer), now_ts()).unwrap();
|
||||
let err = dup_session
|
||||
.initialize_with_votes::<EthereumConsensusSigner>(
|
||||
vec![vote1, vote2],
|
||||
proposal.expiration_timestamp,
|
||||
proposal.timestamp,
|
||||
now_ts(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ConsensusError::DuplicateVote));
|
||||
|
||||
// Explicitly exercise gossipsub projected round branch where vote_count == 0.
|
||||
let mut zero_votes = ConsensusSession::new(proposal, ConsensusConfig::gossipsub());
|
||||
let mut zero_votes =
|
||||
ConsensusSession::new(proposal, ConsensusConfig::gossipsub(), now_ts());
|
||||
zero_votes.check_round_limit(0).unwrap();
|
||||
}
|
||||
|
||||
@@ -635,8 +658,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let mut session = ConsensusSession::new(proposal, ConsensusConfig::p2p());
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
let mut session = ConsensusSession::new(proposal, ConsensusConfig::p2p(), now_ts());
|
||||
|
||||
let wrapped_vote_count = (u32::MAX as usize) + 1;
|
||||
|
||||
@@ -661,8 +684,8 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let proposal = request.into_proposal().unwrap();
|
||||
let mut session = ConsensusSession::new(proposal, ConsensusConfig::p2p());
|
||||
let proposal = request.into_proposal(now_ts()).unwrap();
|
||||
let mut session = ConsensusSession::new(proposal, ConsensusConfig::p2p(), now_ts());
|
||||
let starting_round = session.proposal.round;
|
||||
|
||||
// vote_count at the u32 boundary should still advance the round via saturating_add
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Helpers shared by the crate's inline test modules.
|
||||
|
||||
/// Current time in seconds since Unix epoch — the caller-supplied `now`
|
||||
/// the library expects.
|
||||
pub(crate) fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
+7
-6
@@ -8,7 +8,7 @@ use std::time::Duration;
|
||||
use crate::{
|
||||
error::ConsensusError,
|
||||
protos::consensus::v1::Proposal,
|
||||
utils::{current_timestamp, generate_id, validate_expected_voters_count, validate_timeout},
|
||||
utils::{generate_id, validate_expected_voters_count, validate_timeout},
|
||||
};
|
||||
|
||||
/// Events emitted by the consensus service when a proposal reaches a terminal state.
|
||||
@@ -84,11 +84,11 @@ impl CreateProposalRequest {
|
||||
|
||||
/// Convert this request into an actual proposal.
|
||||
///
|
||||
/// Generates a unique proposal ID and sets the creation timestamp. The proposal
|
||||
/// starts with round 1 and no votes.
|
||||
pub fn into_proposal(self) -> Result<Proposal, ConsensusError> {
|
||||
/// Generates a unique proposal ID and stamps `now` (seconds since Unix epoch)
|
||||
/// as the creation timestamp; the absolute expiration is derived from it.
|
||||
/// The proposal starts with round 1 and no votes.
|
||||
pub fn into_proposal(self, now: u64) -> Result<Proposal, ConsensusError> {
|
||||
let proposal_id = generate_id();
|
||||
let now = current_timestamp()?;
|
||||
|
||||
Ok(Proposal {
|
||||
name: self.name,
|
||||
@@ -108,6 +108,7 @@ impl CreateProposalRequest {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CreateProposalRequest;
|
||||
use crate::test_utils::now_ts;
|
||||
|
||||
#[test]
|
||||
fn into_proposal_should_not_overflow_expiration_timestamp() {
|
||||
@@ -124,7 +125,7 @@ mod tests {
|
||||
// Desired behavior: proposal creation should not panic on overflow-prone input,
|
||||
// and expiration should never be earlier than creation timestamp.
|
||||
let proposal = request
|
||||
.into_proposal()
|
||||
.into_proposal(now_ts())
|
||||
.expect("proposal creation should handle large expiration safely");
|
||||
|
||||
assert!(
|
||||
|
||||
+13
-19
@@ -6,10 +6,7 @@
|
||||
|
||||
use prost::Message;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -54,13 +51,13 @@ pub fn compute_vote_hash(vote: &Vote) -> Vec<u8> {
|
||||
/// This builds a vote that links to previous votes in the hashgraph structure.
|
||||
/// The vote is signed with the provided signer and includes all the necessary
|
||||
/// fields for validation (parent_hash, received_hash, vote_hash, signature).
|
||||
/// `now` (seconds since Unix epoch) becomes the vote's timestamp.
|
||||
pub fn build_vote<Signer: ConsensusSignatureScheme>(
|
||||
proposal: &Proposal,
|
||||
user_vote: bool,
|
||||
signer: &Signer,
|
||||
now: u64,
|
||||
) -> Result<Vote, ConsensusError> {
|
||||
let now = current_timestamp()?;
|
||||
|
||||
let voter_identity = signer.identity();
|
||||
// RFC Section 2.2: Define `parent_hash` as hash of previous owner's vote (empty if none).
|
||||
// RFC Section 2.3: Set `received_hash` to hash of immediately previous vote (last vote in list).
|
||||
@@ -102,20 +99,21 @@ pub fn build_vote<Signer: ConsensusSignatureScheme>(
|
||||
|
||||
/// Validate a proposal and all its votes against a signature scheme.
|
||||
///
|
||||
/// Checks that the proposal hasn't expired.
|
||||
/// Checks that the proposal hasn't expired as of `now` (seconds since Unix epoch).
|
||||
/// Also validates that all votes belong to this proposal, vote signatures are valid,
|
||||
/// and the vote chain (parent_hash/received_hash) is correct.
|
||||
/// Should be called when receiving a proposal from the network.
|
||||
pub fn validate_proposal<Signer: ConsensusSignatureScheme>(
|
||||
proposal: &Proposal,
|
||||
now: u64,
|
||||
) -> Result<(), ConsensusError> {
|
||||
validate_proposal_timestamp(proposal.expiration_timestamp)?;
|
||||
validate_proposal_timestamp(proposal.expiration_timestamp, now)?;
|
||||
|
||||
for vote in proposal.votes.iter() {
|
||||
if vote.proposal_id != proposal.proposal_id {
|
||||
return Err(ConsensusError::VoteProposalIdMismatch);
|
||||
}
|
||||
validate_vote::<Signer>(vote, proposal.expiration_timestamp, proposal.timestamp)?;
|
||||
validate_vote::<Signer>(vote, proposal.expiration_timestamp, proposal.timestamp, now)?;
|
||||
}
|
||||
validate_vote_chain(&proposal.votes)?;
|
||||
Ok(())
|
||||
@@ -130,6 +128,7 @@ pub(crate) fn validate_vote<Signer: ConsensusSignatureScheme>(
|
||||
vote: &Vote,
|
||||
expiration_timestamp: u64,
|
||||
creation_time: u64,
|
||||
now: u64,
|
||||
) -> Result<(), ConsensusError> {
|
||||
if vote.vote_owner.is_empty() {
|
||||
return Err(ConsensusError::EmptyVoteOwner);
|
||||
@@ -158,8 +157,6 @@ pub(crate) fn validate_vote<Signer: ConsensusSignatureScheme>(
|
||||
return Err(ConsensusError::InvalidVoteSignature);
|
||||
}
|
||||
|
||||
let now = current_timestamp()?;
|
||||
|
||||
// RFC Section 3.4: Check the `timestamp` against the replay attack.
|
||||
// In particular, the `timestamp` cannot be the old in the determined threshold.
|
||||
if vote.timestamp < creation_time {
|
||||
@@ -315,18 +312,15 @@ fn calculate_threshold_based_value(expected_voters: u32, consensus_threshold: f6
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_timestamp() -> Result<u64, ConsensusError> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
/// Check if a proposal has expired.
|
||||
///
|
||||
/// RFC Section 2.5.4: Verifies that the proposal has not expired by checking that
|
||||
/// the current time is less than the expiration timestamp.
|
||||
/// `now` (seconds since Unix epoch) is less than the expiration timestamp.
|
||||
/// Returns an error if the proposal has expired.
|
||||
pub(crate) fn validate_proposal_timestamp(expiration_timestamp: u64) -> Result<(), ConsensusError> {
|
||||
let now = current_timestamp()?;
|
||||
pub(crate) fn validate_proposal_timestamp(
|
||||
expiration_timestamp: u64,
|
||||
now: u64,
|
||||
) -> Result<(), ConsensusError> {
|
||||
if now >= expiration_timestamp {
|
||||
return Err(ConsensusError::ProposalExpired);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Helpers shared by the integration-test crates.
|
||||
//!
|
||||
//! Each test binary compiles this module independently and uses only a
|
||||
//! subset of the helpers, so unused-code warnings are suppressed.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use hashgraph_like_consensus::{
|
||||
error::ConsensusError,
|
||||
protos::consensus::v1::{Proposal, Vote},
|
||||
scope::ScopeID,
|
||||
service::DefaultConsensusService,
|
||||
signing::EthereumConsensusSigner,
|
||||
storage::ConsensusStorage,
|
||||
utils::build_vote,
|
||||
};
|
||||
|
||||
/// Current time in seconds since Unix epoch — the caller-supplied `now`
|
||||
/// the library expects.
|
||||
pub fn now_ts() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// A service with in-memory storage and a fresh random signer.
|
||||
pub fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
/// Wrap a raw key into the Ethereum signature scheme.
|
||||
pub fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
/// The signer's address bytes, as used for proposal/vote owner fields.
|
||||
pub fn owner_bytes(signer: &PrivateKeySigner) -> Vec<u8> {
|
||||
signer.address().as_slice().to_vec()
|
||||
}
|
||||
|
||||
/// Build and process a vote as if it arrived from a remote peer, returning
|
||||
/// the vote for further gossip.
|
||||
pub fn cast_remote_vote(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<Vote, ConsensusError> {
|
||||
let proposal = service.storage().get_proposal(scope, proposal_id)?;
|
||||
let vote = build_vote(&proposal, choice, signer, now_ts())?;
|
||||
service.process_incoming_vote(scope, vote.clone(), now_ts())?;
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
/// [`cast_remote_vote`], then return the updated proposal snapshot.
|
||||
pub fn cast_remote_vote_and_get_proposal(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<Proposal, ConsensusError> {
|
||||
cast_remote_vote(service, scope, proposal_id, choice, signer)?;
|
||||
service.storage().get_proposal(scope, proposal_id)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
use common::{now_ts, wrap};
|
||||
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use std::{
|
||||
sync::{Arc, Barrier},
|
||||
@@ -16,10 +19,6 @@ use hashgraph_like_consensus::{
|
||||
types::CreateProposalRequest,
|
||||
};
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
fn peer_service(
|
||||
storage: &InMemoryConsensusStorage<ScopeID>,
|
||||
bus: &BroadcastEventBus<ScopeID>,
|
||||
@@ -65,6 +64,7 @@ fn test_concurrent_vote_casting() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -81,7 +81,7 @@ fn test_concurrent_vote_casting() {
|
||||
let handle = thread::spawn(move || {
|
||||
barrier_clone.wait();
|
||||
let peer = peer_service(&storage, &bus, wrap(PrivateKeySigner::random()));
|
||||
peer.cast_vote(&scope_clone, proposal_id, i % 2 == 0)
|
||||
peer.cast_vote(&scope_clone, proposal_id, i % 2 == 0, now_ts())
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ fn test_concurrent_proposal_operations() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
});
|
||||
handles.push(handle);
|
||||
@@ -165,6 +166,7 @@ fn test_concurrent_duplicate_vote_rejection() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -178,7 +180,8 @@ fn test_concurrent_duplicate_vote_rejection() {
|
||||
for _ in 0..EXPECTED_VOTERS_COUNT_5 {
|
||||
let voter = Arc::clone(&voter);
|
||||
let scope_clone = scope.clone();
|
||||
let handle = thread::spawn(move || voter.cast_vote(&scope_clone, proposal_id, true));
|
||||
let handle =
|
||||
thread::spawn(move || voter.cast_vote(&scope_clone, proposal_id, true, now_ts()));
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
use common::{cast_remote_vote, cast_remote_vote_and_get_proposal, make_service, now_ts, wrap};
|
||||
|
||||
use alloy::signers::SignerSync;
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
@@ -16,44 +19,6 @@ use hashgraph_like_consensus::{
|
||||
utils::{build_vote, compute_vote_hash},
|
||||
};
|
||||
|
||||
fn cast_remote_vote(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Vote,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
let proposal = service.storage().get_proposal(scope, proposal_id)?;
|
||||
let vote = build_vote(&proposal, choice, signer)?;
|
||||
service.process_incoming_vote(scope, vote.clone())?;
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
fn cast_remote_vote_and_get_proposal(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Proposal,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
cast_remote_vote(service, scope, proposal_id, choice, signer)?;
|
||||
service.storage().get_proposal(scope, proposal_id)
|
||||
}
|
||||
|
||||
fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
const SCOPE1_NAME: &str = "scope1";
|
||||
const SCOPE2_NAME: &str = "scope2";
|
||||
const PROPOSAL_NAME: &str = "Test Proposal";
|
||||
@@ -92,6 +57,7 @@ fn setup_proposal(
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(consensus_config),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created")
|
||||
}
|
||||
@@ -126,6 +92,7 @@ fn test_basic_consensus_flow() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -209,6 +176,7 @@ fn test_multi_scope_isolation() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("scope1 proposal");
|
||||
|
||||
@@ -234,6 +202,7 @@ fn test_multi_scope_isolation() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("scope2 proposal");
|
||||
|
||||
@@ -284,6 +253,7 @@ fn test_consensus_threshold_emits_event() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -370,7 +340,7 @@ fn test_handle_consensus_timeout_already_reached() {
|
||||
|
||||
// Now call handle_consensus_timeout - should return the already reached consensus
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should return consensus result");
|
||||
|
||||
assert!(result, "should return true (YES consensus)");
|
||||
@@ -415,7 +385,7 @@ fn test_handle_consensus_timeout_reaches_consensus() {
|
||||
|
||||
// Call handle_consensus_timeout - should calculate consensus and reach it
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach consensus");
|
||||
|
||||
assert!(result, "should return true (YES consensus)");
|
||||
@@ -488,7 +458,7 @@ fn test_handle_consensus_timeout_reaches_no_consensus_with_multiple_votes() {
|
||||
);
|
||||
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach consensus at timeout");
|
||||
|
||||
assert!(!result, "should return false (NO consensus)");
|
||||
@@ -542,7 +512,7 @@ fn test_handle_consensus_timeout_resolves_with_liveness_yes() {
|
||||
|
||||
// At timeout with liveness=true, silent peers count as YES → consensus reached
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach consensus with silent peers as YES");
|
||||
|
||||
assert!(result, "should return true (YES consensus)");
|
||||
@@ -614,7 +584,7 @@ fn test_handle_consensus_timeout_insufficient_votes() {
|
||||
|
||||
// Call handle_consensus_timeout - should fail (tied votes, no majority)
|
||||
let err = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect_err("should fail with insufficient votes");
|
||||
|
||||
assert!(
|
||||
@@ -677,7 +647,7 @@ fn test_handle_consensus_timeout_no_votes_liveness_true() {
|
||||
);
|
||||
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach consensus with silent peers as YES");
|
||||
|
||||
assert!(result, "should return true (all silent peers as YES)");
|
||||
@@ -722,7 +692,7 @@ fn test_handle_consensus_timeout_no_votes_liveness_false() {
|
||||
);
|
||||
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach NO consensus with silent peers as NO");
|
||||
|
||||
assert!(!result, "should return false (all silent peers as NO)");
|
||||
@@ -784,7 +754,7 @@ fn test_handle_consensus_timeout_reaches_consensus_p2p() {
|
||||
);
|
||||
|
||||
let result = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect("should reach consensus");
|
||||
|
||||
assert!(result, "should return true (YES consensus)");
|
||||
@@ -846,7 +816,7 @@ fn test_handle_consensus_timeout_insufficient_votes_p2p() {
|
||||
);
|
||||
|
||||
let err = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect_err("should fail with insufficient votes");
|
||||
|
||||
assert!(
|
||||
@@ -894,15 +864,16 @@ fn test_cast_vote_rejects_same_voter_twice() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
service
|
||||
.cast_vote(&scope, proposal.proposal_id, VOTE_YES)
|
||||
.cast_vote(&scope, proposal.proposal_id, VOTE_YES, now_ts())
|
||||
.expect("first vote should succeed");
|
||||
|
||||
let err = service
|
||||
.cast_vote(&scope, proposal.proposal_id, VOTE_YES)
|
||||
.cast_vote(&scope, proposal.proposal_id, VOTE_YES, now_ts())
|
||||
.expect_err("second vote from same voter should fail");
|
||||
|
||||
assert!(
|
||||
@@ -930,11 +901,12 @@ fn test_process_incoming_proposal_rejects_duplicate_proposal() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
let err = service
|
||||
.process_incoming_proposal(&scope, proposal)
|
||||
.process_incoming_proposal(&scope, proposal, now_ts())
|
||||
.expect_err("duplicate proposal should be rejected");
|
||||
|
||||
assert!(
|
||||
@@ -962,6 +934,7 @@ fn test_process_incoming_vote_rejects_unknown_session() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -988,7 +961,7 @@ fn test_process_incoming_vote_rejects_unknown_session() {
|
||||
.to_vec();
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, invalid_vote)
|
||||
.process_incoming_vote(&scope, invalid_vote, now_ts())
|
||||
.expect_err("vote for unknown proposal should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1003,7 +976,7 @@ fn test_handle_consensus_timeout_rejects_unknown_session() {
|
||||
let scope = ScopeID::from(SCOPE1_NAME);
|
||||
|
||||
let err = service
|
||||
.handle_consensus_timeout(&scope, u32::MAX)
|
||||
.handle_consensus_timeout(&scope, u32::MAX, now_ts())
|
||||
.expect_err("timeout handling for unknown proposal should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1027,12 +1000,14 @@ fn test_process_incoming_proposal_rejects_expired_proposal() {
|
||||
true,
|
||||
)
|
||||
.expect("valid proposal request");
|
||||
let proposal = request.into_proposal().expect("proposal should be created");
|
||||
let proposal = request
|
||||
.into_proposal(now_ts())
|
||||
.expect("proposal should be created");
|
||||
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
let err = service
|
||||
.process_incoming_proposal(&scope, proposal)
|
||||
.process_incoming_proposal(&scope, proposal, now_ts())
|
||||
.expect_err("expired incoming proposal should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1060,6 +1035,7 @@ fn test_process_incoming_vote_rejects_invalid_vote_hash() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1073,12 +1049,12 @@ fn test_process_incoming_vote_rejects_invalid_vote_hash() {
|
||||
.expect("first vote should succeed");
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let mut vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter)).expect("valid vote should be built");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter), now_ts())
|
||||
.expect("valid vote should be built");
|
||||
vote.vote_hash = vec![1; 32];
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect_err("tampered vote hash should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1106,6 +1082,7 @@ fn test_process_incoming_vote_rejects_invalid_vote_signature() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1119,8 +1096,8 @@ fn test_process_incoming_vote_rejects_invalid_vote_signature() {
|
||||
.expect("first vote should succeed");
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let mut vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter)).expect("valid vote should be built");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter), now_ts())
|
||||
.expect("valid vote should be built");
|
||||
let wrong_signer = PrivateKeySigner::random();
|
||||
let vote_bytes = vote.encode_to_vec();
|
||||
let wrong_sig = wrong_signer
|
||||
@@ -1129,7 +1106,7 @@ fn test_process_incoming_vote_rejects_invalid_vote_signature() {
|
||||
vote.signature = wrong_sig.as_bytes().to_vec();
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect_err("tampered signature should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1157,6 +1134,7 @@ fn test_process_incoming_vote_rejects_duplicate_vote_owner() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1169,11 +1147,11 @@ fn test_process_incoming_vote_rejects_duplicate_vote_owner() {
|
||||
)
|
||||
.expect("first owner vote should succeed");
|
||||
|
||||
let duplicate_vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner))
|
||||
let duplicate_vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner), now_ts())
|
||||
.expect("duplicate vote should be signable");
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, duplicate_vote)
|
||||
.process_incoming_vote(&scope, duplicate_vote, now_ts())
|
||||
.expect_err("duplicate vote owner should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1201,6 +1179,7 @@ fn test_process_incoming_vote_rejects_expired_vote_timestamp() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1214,8 +1193,8 @@ fn test_process_incoming_vote_rejects_expired_vote_timestamp() {
|
||||
.expect("first vote should succeed");
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let mut vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter.clone())).expect("valid vote should be built");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter.clone()), now_ts())
|
||||
.expect("valid vote should be built");
|
||||
vote.timestamp = proposal.expiration_timestamp.saturating_add(1);
|
||||
vote.vote_hash = compute_vote_hash(&vote);
|
||||
vote.signature.clear();
|
||||
@@ -1227,7 +1206,7 @@ fn test_process_incoming_vote_rejects_expired_vote_timestamp() {
|
||||
.to_vec();
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect_err("expired vote timestamp should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1256,6 +1235,7 @@ fn test_handle_consensus_timeout_is_idempotent_for_failed_session() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1279,7 +1259,7 @@ fn test_handle_consensus_timeout_is_idempotent_for_failed_session() {
|
||||
.expect("second vote should succeed");
|
||||
|
||||
let err_first = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect_err("first timeout should fail consensus");
|
||||
assert!(matches!(
|
||||
err_first,
|
||||
@@ -1287,7 +1267,7 @@ fn test_handle_consensus_timeout_is_idempotent_for_failed_session() {
|
||||
));
|
||||
|
||||
let err_second = service
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id)
|
||||
.handle_consensus_timeout(&scope, proposal.proposal_id, now_ts())
|
||||
.expect_err("second timeout should keep failed consensus");
|
||||
assert!(matches!(
|
||||
err_second,
|
||||
@@ -1306,7 +1286,7 @@ fn test_handle_consensus_timeout_rejects_unknown_scope() {
|
||||
let unknown_scope = ScopeID::from("unknown_scope");
|
||||
|
||||
let err = service
|
||||
.handle_consensus_timeout(&unknown_scope, 1)
|
||||
.handle_consensus_timeout(&unknown_scope, 1, now_ts())
|
||||
.expect_err("unknown scope timeout handling should fail");
|
||||
|
||||
assert!(
|
||||
@@ -1364,7 +1344,7 @@ fn test_process_incoming_proposal_resolve_config_uses_base_timeout_when_expirati
|
||||
)
|
||||
.expect("valid proposal request");
|
||||
|
||||
let mut incoming = request.into_proposal().expect("proposal");
|
||||
let mut incoming = request.into_proposal(now_ts()).expect("proposal");
|
||||
|
||||
// Force expiration_timestamp <= timestamp while keeping both in the future,
|
||||
// so proposal remains non-expired but resolve_config must fall back to base timeout.
|
||||
@@ -1377,7 +1357,7 @@ fn test_process_incoming_proposal_resolve_config_uses_base_timeout_when_expirati
|
||||
incoming.expiration_timestamp = future;
|
||||
|
||||
service
|
||||
.process_incoming_proposal(&scope, incoming.clone())
|
||||
.process_incoming_proposal(&scope, incoming.clone(), now_ts())
|
||||
.expect("incoming proposal should be accepted");
|
||||
|
||||
let resolved = service
|
||||
@@ -1416,6 +1396,7 @@ fn test_get_reached_proposals_with_consensus() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1468,6 +1449,7 @@ fn test_get_reached_proposals_no_consensus() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1504,6 +1486,7 @@ fn test_get_reached_proposals_mixed_states() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1530,6 +1513,7 @@ fn test_get_reached_proposals_mixed_states() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1556,6 +1540,7 @@ fn test_get_reached_proposals_mixed_states() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! signed by one peer and validated by another round-trip cleanly through the
|
||||
//! service without any Ethereum-specific assumptions.
|
||||
|
||||
mod common;
|
||||
use common::now_ts;
|
||||
|
||||
use hashgraph_like_consensus::{
|
||||
events::BroadcastEventBus,
|
||||
scope::ScopeID,
|
||||
@@ -107,17 +110,18 @@ fn stub_scheme_reaches_consensus_without_ethereum_types() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
owner
|
||||
.cast_vote(&scope, proposal.proposal_id, true)
|
||||
.cast_vote(&scope, proposal.proposal_id, true, now_ts())
|
||||
.expect("owner vote");
|
||||
voter_two
|
||||
.cast_vote(&scope, proposal.proposal_id, true)
|
||||
.cast_vote(&scope, proposal.proposal_id, true, now_ts())
|
||||
.expect("voter two");
|
||||
voter_three
|
||||
.cast_vote(&scope, proposal.proposal_id, true)
|
||||
.cast_vote(&scope, proposal.proposal_id, true, now_ts())
|
||||
.expect("voter three");
|
||||
|
||||
let session = owner
|
||||
@@ -153,15 +157,16 @@ fn stub_scheme_rejects_forged_signature() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
let mut vote = build_vote(&proposal, true, &voter).expect("vote");
|
||||
let mut vote = build_vote(&proposal, true, &voter, now_ts()).expect("vote");
|
||||
// Tamper with the signature so verify() returns false.
|
||||
vote.signature.iter_mut().for_each(|b| *b ^= 0xFF);
|
||||
|
||||
let err = owner
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect_err("forged signature must be rejected");
|
||||
assert!(
|
||||
matches!(
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
mod common;
|
||||
use common::{cast_remote_vote, make_service, now_ts, owner_bytes, wrap};
|
||||
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use hashgraph_like_consensus::{
|
||||
error::ConsensusError, scope::ScopeID, service::DefaultConsensusService,
|
||||
session::ConsensusConfig, signing::EthereumConsensusSigner, storage::ConsensusStorage,
|
||||
types::CreateProposalRequest, utils::build_vote,
|
||||
error::ConsensusError, scope::ScopeID, session::ConsensusConfig, storage::ConsensusStorage,
|
||||
types::CreateProposalRequest,
|
||||
};
|
||||
|
||||
fn cast_remote_vote(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Vote,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
let proposal = service.storage().get_proposal(scope, proposal_id)?;
|
||||
let vote = build_vote(&proposal, choice, signer)?;
|
||||
service.process_incoming_vote(scope, vote.clone())?;
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
const SCOPE: &str = "network_gossip_scope";
|
||||
const PROPOSAL_NAME: &str = "Network Gossip Proposal";
|
||||
const PROPOSAL_PAYLOAD: Vec<u8> = vec![];
|
||||
const EXPIRATION: u64 = 120;
|
||||
|
||||
fn owner_bytes(signer: &PrivateKeySigner) -> Vec<u8> {
|
||||
signer.address().as_slice().to_vec()
|
||||
}
|
||||
|
||||
/// Peer A creates a proposal, gossips it to peer B, both vote YES,
|
||||
/// gossip votes back/forth, and both peers converge to Ok(true) consensus result.
|
||||
#[test]
|
||||
@@ -63,19 +37,20 @@ fn test_two_peers_gossip_reaches_unanimous_yes_for_n2() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("peer_a proposal");
|
||||
|
||||
// Gossip proposal to peer_b (decentralized: each peer stores locally).
|
||||
peer_b
|
||||
.process_incoming_proposal(&scope, proposal.clone())
|
||||
.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer_b accepts proposal");
|
||||
|
||||
// Peer A votes YES, gossip vote to peer B.
|
||||
let vote_a = cast_remote_vote(&peer_a, &scope, proposal.proposal_id, true, &wrap(owner_a))
|
||||
.expect("peer_a vote");
|
||||
peer_b
|
||||
.process_incoming_vote(&scope, vote_a)
|
||||
.process_incoming_vote(&scope, vote_a, now_ts())
|
||||
.expect("peer_b accepts peer_a vote");
|
||||
|
||||
// Peer B votes YES, gossip vote to peer A.
|
||||
@@ -83,7 +58,7 @@ fn test_two_peers_gossip_reaches_unanimous_yes_for_n2() {
|
||||
let vote_b = cast_remote_vote(&peer_b, &scope, proposal.proposal_id, true, &wrap(owner_b))
|
||||
.expect("peer_b vote");
|
||||
peer_a
|
||||
.process_incoming_vote(&scope, vote_b)
|
||||
.process_incoming_vote(&scope, vote_b, now_ts())
|
||||
.expect("peer_a accepts peer_b vote");
|
||||
|
||||
// Both peers should converge to the same consensus result.
|
||||
@@ -123,15 +98,16 @@ fn test_three_peers_gossip_converges_with_out_of_order_delivery() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("peer_a proposal");
|
||||
|
||||
// Gossip proposal to other peers
|
||||
peer_b
|
||||
.process_incoming_proposal(&scope, proposal.clone())
|
||||
.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer_b accepts proposal");
|
||||
peer_c
|
||||
.process_incoming_proposal(&scope, proposal.clone())
|
||||
.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer_c accepts proposal");
|
||||
|
||||
// Two YES votes are sufficient for n=3 with threshold 2/3 and majority YES.
|
||||
@@ -143,18 +119,18 @@ fn test_three_peers_gossip_converges_with_out_of_order_delivery() {
|
||||
|
||||
// Deliver to peer_c out-of-order (vote_b then vote_a).
|
||||
peer_c
|
||||
.process_incoming_vote(&scope, vote_b.clone())
|
||||
.process_incoming_vote(&scope, vote_b.clone(), now_ts())
|
||||
.expect("peer_c accepts vote_b");
|
||||
peer_c
|
||||
.process_incoming_vote(&scope, vote_a.clone())
|
||||
.process_incoming_vote(&scope, vote_a.clone(), now_ts())
|
||||
.expect("peer_c accepts vote_a");
|
||||
|
||||
// Deliver to peer_a and peer_b (in-order doesn't matter either).
|
||||
peer_a
|
||||
.process_incoming_vote(&scope, vote_b)
|
||||
.process_incoming_vote(&scope, vote_b, now_ts())
|
||||
.expect("peer_a accepts vote_b");
|
||||
peer_b
|
||||
.process_incoming_vote(&scope, vote_a)
|
||||
.process_incoming_vote(&scope, vote_a, now_ts())
|
||||
.expect("peer_b accepts vote_a");
|
||||
|
||||
let res_a = peer_a
|
||||
@@ -202,33 +178,34 @@ fn test_multi_peer_timeout_task_converges_to_failed() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("peer_a proposal");
|
||||
|
||||
peer_b
|
||||
.process_incoming_proposal(&scope, proposal.clone())
|
||||
.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer_b accepts proposal");
|
||||
peer_c
|
||||
.process_incoming_proposal(&scope, proposal.clone())
|
||||
.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer_c accepts proposal");
|
||||
|
||||
// 2 YES votes total.
|
||||
let vote_a = cast_remote_vote(&peer_a, &scope, proposal.proposal_id, true, &wrap(owner_a))
|
||||
.expect("peer_a vote");
|
||||
peer_b
|
||||
.process_incoming_vote(&scope, vote_a.clone())
|
||||
.process_incoming_vote(&scope, vote_a.clone(), now_ts())
|
||||
.expect("peer_b accepts vote_a");
|
||||
peer_c
|
||||
.process_incoming_vote(&scope, vote_a)
|
||||
.process_incoming_vote(&scope, vote_a, now_ts())
|
||||
.expect("peer_c accepts vote_a");
|
||||
|
||||
let vote_b = cast_remote_vote(&peer_b, &scope, proposal.proposal_id, true, &wrap(voter_b))
|
||||
.expect("peer_b vote");
|
||||
peer_a
|
||||
.process_incoming_vote(&scope, vote_b.clone())
|
||||
.process_incoming_vote(&scope, vote_b.clone(), now_ts())
|
||||
.expect("peer_a accepts vote_b");
|
||||
peer_c
|
||||
.process_incoming_vote(&scope, vote_b)
|
||||
.process_incoming_vote(&scope, vote_b, now_ts())
|
||||
.expect("peer_c accepts vote_b");
|
||||
|
||||
// App-style scheduling: each peer runs its own timeout task.
|
||||
@@ -241,15 +218,15 @@ fn test_multi_peer_timeout_task_converges_to_failed() {
|
||||
let peer_c_task = peer_c.clone();
|
||||
let ha = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
let _ = peer_a_task.handle_consensus_timeout(&scope_a, proposal_id);
|
||||
let _ = peer_a_task.handle_consensus_timeout(&scope_a, proposal_id, now_ts());
|
||||
});
|
||||
let hb = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
let _ = peer_b_task.handle_consensus_timeout(&scope_b, proposal_id);
|
||||
let _ = peer_b_task.handle_consensus_timeout(&scope_b, proposal_id, now_ts());
|
||||
});
|
||||
let hc = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
let _ = peer_c_task.handle_consensus_timeout(&scope_c, proposal_id);
|
||||
let _ = peer_c_task.handle_consensus_timeout(&scope_c, proposal_id, now_ts());
|
||||
});
|
||||
|
||||
ha.join().expect("timeout task A");
|
||||
@@ -301,11 +278,12 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("peer_a proposal");
|
||||
|
||||
for peer in [&peer_b, &peer_c, &peer_d] {
|
||||
peer.process_incoming_proposal(&scope, proposal.clone())
|
||||
peer.process_incoming_proposal(&scope, proposal.clone(), now_ts())
|
||||
.expect("peer accepts proposal");
|
||||
}
|
||||
|
||||
@@ -314,7 +292,7 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
let vote_a = cast_remote_vote(&peer_a, &scope, proposal.proposal_id, true, &wrap(owner_a))
|
||||
.expect("vote_a");
|
||||
for peer in [&peer_b, &peer_c, &peer_d] {
|
||||
peer.process_incoming_vote(&scope, vote_a.clone())
|
||||
peer.process_incoming_vote(&scope, vote_a.clone(), now_ts())
|
||||
.expect("peer accepts vote_a");
|
||||
}
|
||||
|
||||
@@ -322,7 +300,7 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
let vote_b = cast_remote_vote(&peer_b, &scope, proposal.proposal_id, true, &wrap(voter_b))
|
||||
.expect("vote_b");
|
||||
for peer in [&peer_a, &peer_c, &peer_d] {
|
||||
peer.process_incoming_vote(&scope, vote_b.clone())
|
||||
peer.process_incoming_vote(&scope, vote_b.clone(), now_ts())
|
||||
.expect("peer accepts vote_b");
|
||||
}
|
||||
|
||||
@@ -330,7 +308,7 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
let vote_c = cast_remote_vote(&peer_c, &scope, proposal.proposal_id, false, &wrap(voter_c))
|
||||
.expect("vote_c");
|
||||
for peer in [&peer_a, &peer_b, &peer_d] {
|
||||
peer.process_incoming_vote(&scope, vote_c.clone())
|
||||
peer.process_incoming_vote(&scope, vote_c.clone(), now_ts())
|
||||
.expect("peer accepts vote_c");
|
||||
}
|
||||
|
||||
@@ -338,7 +316,7 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
let vote_d = cast_remote_vote(&peer_d, &scope, proposal.proposal_id, false, &wrap(voter_d))
|
||||
.expect("vote_d");
|
||||
for peer in [&peer_a, &peer_b, &peer_c] {
|
||||
peer.process_incoming_vote(&scope, vote_d.clone())
|
||||
peer.process_incoming_vote(&scope, vote_d.clone(), now_ts())
|
||||
.expect("peer accepts vote_d");
|
||||
}
|
||||
|
||||
@@ -355,19 +333,19 @@ fn test_multi_peer_timeout_task_resolves_tie_by_liveness_criteria_yes() {
|
||||
|
||||
let ha = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let _ = peer_a_task.handle_consensus_timeout(&scope_a, proposal_id);
|
||||
let _ = peer_a_task.handle_consensus_timeout(&scope_a, proposal_id, now_ts());
|
||||
});
|
||||
let hb = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let _ = peer_b_task.handle_consensus_timeout(&scope_b, proposal_id);
|
||||
let _ = peer_b_task.handle_consensus_timeout(&scope_b, proposal_id, now_ts());
|
||||
});
|
||||
let hc = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let _ = peer_c_task.handle_consensus_timeout(&scope_c, proposal_id);
|
||||
let _ = peer_c_task.handle_consensus_timeout(&scope_c, proposal_id, now_ts());
|
||||
});
|
||||
let hd = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let _ = peer_d_task.handle_consensus_timeout(&scope_d, proposal_id);
|
||||
let _ = peer_d_task.handle_consensus_timeout(&scope_d, proposal_id, now_ts());
|
||||
});
|
||||
|
||||
ha.join().expect("timeout task A");
|
||||
|
||||
@@ -1,56 +1,21 @@
|
||||
mod common;
|
||||
use common::{
|
||||
cast_remote_vote, cast_remote_vote_and_get_proposal, make_service, now_ts, owner_bytes, wrap,
|
||||
};
|
||||
|
||||
use alloy::signers::{SignerSync, local::PrivateKeySigner};
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
use prost::Message;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use hashgraph_like_consensus::{
|
||||
error::ConsensusError,
|
||||
scope::ScopeID,
|
||||
service::DefaultConsensusService,
|
||||
session::ConsensusConfig,
|
||||
storage::ConsensusStorage,
|
||||
types::CreateProposalRequest,
|
||||
utils::{build_vote, compute_vote_hash},
|
||||
};
|
||||
|
||||
fn cast_remote_vote(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Vote,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
let proposal = service.storage().get_proposal(scope, proposal_id)?;
|
||||
let vote = build_vote(&proposal, choice, signer)?;
|
||||
service.process_incoming_vote(scope, vote.clone())?;
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
fn cast_remote_vote_and_get_proposal(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Proposal,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
cast_remote_vote(service, scope, proposal_id, choice, signer)?;
|
||||
service.storage().get_proposal(scope, proposal_id)
|
||||
}
|
||||
|
||||
fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
const SCOPE: &str = "rfc_compliance_scope";
|
||||
const PROPOSAL_NAME: &str = "RFC Compliance Test";
|
||||
const PROPOSAL_PAYLOAD: Vec<u8> = vec![];
|
||||
@@ -69,10 +34,6 @@ const EXPECTED_VOTERS_COUNT_1: u32 = 1;
|
||||
const VOTE_YES: bool = true;
|
||||
const VOTE_NO: bool = false;
|
||||
|
||||
fn owner_bytes(signer: &PrivateKeySigner) -> Vec<u8> {
|
||||
signer.address().as_slice().to_vec()
|
||||
}
|
||||
|
||||
/// Test that proposal initialization has round = 1
|
||||
#[test]
|
||||
fn test_proposal_initialization_round_is_one() {
|
||||
@@ -93,6 +54,7 @@ fn test_proposal_initialization_round_is_one() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -119,6 +81,7 @@ fn test_round_increments_on_vote_p2p() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::p2p()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -179,6 +142,7 @@ fn test_gossipsub_rounds_stay_at_two() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -266,6 +230,7 @@ fn test_gossipsub_allows_multiple_votes_in_round_two() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -331,6 +296,7 @@ fn test_p2p_dynamic_max_rounds() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::p2p()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -420,6 +386,7 @@ fn test_p2p_ceil_calculation_edge_cases() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::p2p()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -469,26 +436,31 @@ fn test_gossipsub_batch_vote_processing() {
|
||||
)
|
||||
.expect("valid proposal request");
|
||||
|
||||
let mut proposal = request.into_proposal().expect("proposal should be created");
|
||||
let mut proposal = request
|
||||
.into_proposal(now_ts())
|
||||
.expect("proposal should be created");
|
||||
|
||||
// Add votes to the proposal (simulating votes received from network)
|
||||
let voter1 = PrivateKeySigner::random();
|
||||
let vote1 = build_vote(&proposal, VOTE_YES, &wrap(voter1)).expect("vote should be created");
|
||||
let vote1 =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter1), now_ts()).expect("vote should be created");
|
||||
proposal.votes.push(vote1);
|
||||
proposal.round = 2; // Gossipsub: round 2 after first vote
|
||||
|
||||
let voter2 = PrivateKeySigner::random();
|
||||
let vote2 = build_vote(&proposal, VOTE_YES, &wrap(voter2)).expect("vote should be created");
|
||||
let vote2 =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter2), now_ts()).expect("vote should be created");
|
||||
proposal.votes.push(vote2);
|
||||
// Round stays at 2 for gossipsub
|
||||
|
||||
let voter3 = PrivateKeySigner::random();
|
||||
let vote3 = build_vote(&proposal, VOTE_YES, &wrap(voter3)).expect("vote should be created");
|
||||
let vote3 =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter3), now_ts()).expect("vote should be created");
|
||||
proposal.votes.push(vote3);
|
||||
|
||||
// Process the proposal with multiple votes (batch processing)
|
||||
// This should work in gossipsub mode - all votes are in round 2
|
||||
let result = service.process_incoming_proposal(&scope, proposal.clone());
|
||||
let result = service.process_incoming_proposal(&scope, proposal.clone(), now_ts());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Gossipsub: Should accept batch votes in round 2"
|
||||
@@ -533,7 +505,9 @@ fn test_p2p_batch_vote_processing() {
|
||||
)
|
||||
.expect("valid proposal request");
|
||||
|
||||
let mut proposal = request.into_proposal().expect("proposal should be created");
|
||||
let mut proposal = request
|
||||
.into_proposal(now_ts())
|
||||
.expect("proposal should be created");
|
||||
|
||||
// Add votes up to the limit (6 votes)
|
||||
let mut voters = vec![proposal_owner];
|
||||
@@ -542,14 +516,14 @@ fn test_p2p_batch_vote_processing() {
|
||||
}
|
||||
|
||||
for (i, voter) in voters.iter().enumerate() {
|
||||
let vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter.clone())).expect("vote should be created");
|
||||
let vote = build_vote(&proposal, VOTE_YES, &wrap(voter.clone()), now_ts())
|
||||
.expect("vote should be created");
|
||||
proposal.votes.push(vote);
|
||||
proposal.round = (i + 2) as u32; // P2P: round increments per vote
|
||||
}
|
||||
|
||||
// Process the proposal with 6 votes (at the limit)
|
||||
let result = service.process_incoming_proposal(&scope, proposal.clone());
|
||||
let result = service.process_incoming_proposal(&scope, proposal.clone(), now_ts());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"P2P: Should accept batch votes up to ceil(2n/3)"
|
||||
@@ -619,6 +593,7 @@ fn test_consensus_reachable_in_both_modes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -661,6 +636,7 @@ fn test_consensus_reachable_in_both_modes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::p2p()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -708,6 +684,7 @@ fn test_n_le_2_requires_unanimous_yes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -743,6 +720,7 @@ fn test_n_le_2_requires_unanimous_yes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -788,6 +766,7 @@ fn test_n_le_2_requires_unanimous_yes() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -844,6 +823,7 @@ fn test_n_gt_2_consensus_requirements() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -913,6 +893,7 @@ fn test_expired_proposal_rejected() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -960,6 +941,7 @@ fn test_timestamp_replay_attack_protection() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -980,7 +962,8 @@ fn test_timestamp_replay_attack_protection() {
|
||||
.saturating_sub(EXPIRATION * 2); // More than expiration time
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter.clone())).expect("create vote");
|
||||
let mut vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter.clone()), now_ts()).expect("create vote");
|
||||
|
||||
// Manually set old timestamp
|
||||
vote.timestamp = old_timestamp;
|
||||
@@ -994,7 +977,7 @@ fn test_timestamp_replay_attack_protection() {
|
||||
.to_vec();
|
||||
|
||||
let err = service
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect_err("Should reject vote with old timestamp");
|
||||
|
||||
assert!(
|
||||
@@ -1026,6 +1009,7 @@ fn test_equality_of_votes_handling() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
@@ -1100,6 +1084,7 @@ fn test_equality_of_votes_handling() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal should be created");
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
mod common;
|
||||
use common::{make_service, now_ts};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
use hashgraph_like_consensus::{
|
||||
error::ConsensusError, scope::ScopeID, scope_config::NetworkType,
|
||||
service::DefaultConsensusService, session::ConsensusConfig, signing::EthereumConsensusSigner,
|
||||
error::ConsensusError, scope::ScopeID, scope_config::NetworkType, session::ConsensusConfig,
|
||||
storage::ConsensusStorage, types::CreateProposalRequest,
|
||||
};
|
||||
|
||||
fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
const SCOPE_NAME: &str = "test_scope";
|
||||
const PROPOSAL_PAYLOAD: Vec<u8> = vec![];
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
@@ -257,7 +254,7 @@ fn create_proposal_with_config_preserves_override_timeout() {
|
||||
.unwrap();
|
||||
|
||||
let proposal = service
|
||||
.create_proposal_with_config(&scope, request, Some(override_config))
|
||||
.create_proposal_with_config(&scope, request, Some(override_config), now_ts())
|
||||
.unwrap();
|
||||
|
||||
let config = service
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
use common::now_ts;
|
||||
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
|
||||
use hashgraph_like_consensus::{
|
||||
@@ -23,12 +26,13 @@ fn make_session(name: &str) -> ConsensusSession {
|
||||
true,
|
||||
)
|
||||
.expect("valid proposal request")
|
||||
.into_proposal()
|
||||
.into_proposal(now_ts())
|
||||
.expect("proposal");
|
||||
|
||||
let (session, _) = ConsensusSession::from_proposal::<EthereumConsensusSigner>(
|
||||
proposal,
|
||||
ConsensusConfig::gossipsub(),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("session");
|
||||
session
|
||||
|
||||
+12
-10
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
use common::{now_ts, wrap};
|
||||
|
||||
use alloy::signers::local::PrivateKeySigner;
|
||||
|
||||
use hashgraph_like_consensus::{
|
||||
@@ -9,10 +12,6 @@ use hashgraph_like_consensus::{
|
||||
utils::{build_vote, validate_proposal},
|
||||
};
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
const SCOPE: &str = "vote_scope";
|
||||
const PROPOSAL_NAME: &str = "Vote Test Proposal";
|
||||
const PROPOSAL_PAYLOAD: Vec<u8> = vec![];
|
||||
@@ -42,15 +41,16 @@ fn test_received_hash_for_new_voter() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let proposal = service
|
||||
.cast_vote_and_get_proposal(&scope, proposal.proposal_id, VOTE_YES)
|
||||
.cast_vote_and_get_proposal(&scope, proposal.proposal_id, VOTE_YES, now_ts())
|
||||
.expect("proposal_owner vote");
|
||||
|
||||
let other_voter = wrap(PrivateKeySigner::random());
|
||||
let vote = build_vote(&proposal, VOTE_YES, &other_voter).expect("second vote");
|
||||
let vote = build_vote(&proposal, VOTE_YES, &other_voter, now_ts()).expect("second vote");
|
||||
|
||||
assert!(
|
||||
vote.parent_hash.is_empty(),
|
||||
@@ -63,7 +63,7 @@ fn test_received_hash_for_new_voter() {
|
||||
|
||||
let mut proposal_with_vote = proposal.clone();
|
||||
proposal_with_vote.votes.push(vote);
|
||||
validate_proposal::<EthereumConsensusSigner>(&proposal_with_vote)
|
||||
validate_proposal::<EthereumConsensusSigner>(&proposal_with_vote, now_ts())
|
||||
.expect("proposal with second voter should validate");
|
||||
}
|
||||
|
||||
@@ -86,15 +86,17 @@ fn test_parent_hash_for_same_voter() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let proposal = service
|
||||
.cast_vote_and_get_proposal(&scope, proposal.proposal_id, VOTE_YES)
|
||||
.cast_vote_and_get_proposal(&scope, proposal.proposal_id, VOTE_YES, now_ts())
|
||||
.expect("proposal_owner vote");
|
||||
|
||||
// Create a second vote from the same voter to exercise parent_hash logic.
|
||||
let second_vote = build_vote(&proposal, VOTE_NO, &proposal_owner).expect("second vote");
|
||||
let second_vote =
|
||||
build_vote(&proposal, VOTE_NO, &proposal_owner, now_ts()).expect("second vote");
|
||||
|
||||
assert!(
|
||||
second_vote.received_hash == proposal.votes[0].vote_hash,
|
||||
@@ -107,6 +109,6 @@ fn test_parent_hash_for_same_voter() {
|
||||
|
||||
let mut proposal_with_vote = proposal.clone();
|
||||
proposal_with_vote.votes.push(second_vote);
|
||||
validate_proposal::<EthereumConsensusSigner>(&proposal_with_vote)
|
||||
validate_proposal::<EthereumConsensusSigner>(&proposal_with_vote, now_ts())
|
||||
.expect("proposal with parent hash chain should validate");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod common;
|
||||
use common::{cast_remote_vote_and_get_proposal, make_service, now_ts, owner_bytes, wrap};
|
||||
|
||||
use alloy::signers::{SignerSync, local::PrivateKeySigner};
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
|
||||
@@ -6,51 +9,11 @@ use prost::Message;
|
||||
use hashgraph_like_consensus::{
|
||||
error::ConsensusError,
|
||||
scope::ScopeID,
|
||||
service::DefaultConsensusService,
|
||||
session::ConsensusConfig,
|
||||
storage::ConsensusStorage,
|
||||
types::CreateProposalRequest,
|
||||
utils::{build_vote, compute_vote_hash, validate_proposal},
|
||||
};
|
||||
|
||||
fn cast_remote_vote(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Vote,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
let proposal = service.storage().get_proposal(scope, proposal_id)?;
|
||||
let vote = build_vote(&proposal, choice, signer)?;
|
||||
service.process_incoming_vote(scope, vote.clone())?;
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
fn cast_remote_vote_and_get_proposal(
|
||||
service: &DefaultConsensusService,
|
||||
scope: &ScopeID,
|
||||
proposal_id: u32,
|
||||
choice: bool,
|
||||
signer: &EthereumConsensusSigner,
|
||||
) -> Result<
|
||||
hashgraph_like_consensus::protos::consensus::v1::Proposal,
|
||||
hashgraph_like_consensus::error::ConsensusError,
|
||||
> {
|
||||
cast_remote_vote(service, scope, proposal_id, choice, signer)?;
|
||||
service.storage().get_proposal(scope, proposal_id)
|
||||
}
|
||||
|
||||
fn make_service() -> DefaultConsensusService {
|
||||
DefaultConsensusService::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
|
||||
}
|
||||
|
||||
fn wrap(signer: PrivateKeySigner) -> EthereumConsensusSigner {
|
||||
EthereumConsensusSigner::new(signer)
|
||||
}
|
||||
|
||||
const SCOPE: &str = "validation_scope";
|
||||
const PROPOSAL_NAME: &str = "Proposal";
|
||||
const PROPOSAL_PAYLOAD: Vec<u8> = vec![];
|
||||
@@ -63,10 +26,6 @@ const EXPECTED_VOTERS_COUNT_2: u32 = 2;
|
||||
const VOTE_YES: bool = true;
|
||||
const VOTE_NO: bool = false;
|
||||
|
||||
fn owner_bytes(signer: &PrivateKeySigner) -> Vec<u8> {
|
||||
signer.address().as_slice().to_vec()
|
||||
}
|
||||
|
||||
fn resign_vote(
|
||||
vote: &mut hashgraph_like_consensus::protos::consensus::v1::Vote,
|
||||
signer: &PrivateKeySigner,
|
||||
@@ -100,6 +59,7 @@ fn test_vote_created_with_helper_is_valid() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
@@ -113,10 +73,11 @@ fn test_vote_created_with_helper_is_valid() {
|
||||
.expect("proposal_owner vote");
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let vote = build_vote(&proposal, VOTE_YES, &wrap(voter)).expect("vote should be created");
|
||||
let vote =
|
||||
build_vote(&proposal, VOTE_YES, &wrap(voter), now_ts()).expect("vote should be created");
|
||||
|
||||
service
|
||||
.process_incoming_vote(&scope, vote)
|
||||
.process_incoming_vote(&scope, vote, now_ts())
|
||||
.expect("vote should validate");
|
||||
}
|
||||
|
||||
@@ -139,6 +100,7 @@ fn test_invalid_signature_is_rejected() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
@@ -152,7 +114,7 @@ fn test_invalid_signature_is_rejected() {
|
||||
.expect("proposal_owner vote");
|
||||
|
||||
let voter = PrivateKeySigner::random();
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter)).expect("vote");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(voter), now_ts()).expect("vote");
|
||||
|
||||
let wrong_signer = PrivateKeySigner::random();
|
||||
let vote_bytes = vote.encode_to_vec();
|
||||
@@ -164,7 +126,7 @@ fn test_invalid_signature_is_rejected() {
|
||||
let mut invalid_proposal = proposal.clone();
|
||||
invalid_proposal.votes.push(vote);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid_proposal)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid_proposal, now_ts())
|
||||
.expect_err("validation should fail");
|
||||
assert!(
|
||||
matches!(err, ConsensusError::InvalidVoteSignature),
|
||||
@@ -191,6 +153,7 @@ fn test_vote_chain_validation_rejects_bad_received_hash() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
@@ -206,8 +169,9 @@ fn test_vote_chain_validation_rejects_bad_received_hash() {
|
||||
let voter_one = PrivateKeySigner::random();
|
||||
let voter_two = PrivateKeySigner::random();
|
||||
|
||||
let vote_one = build_vote(&proposal, VOTE_YES, &wrap(voter_one)).expect("vote one");
|
||||
let mut vote_two = build_vote(&proposal, VOTE_NO, &wrap(voter_two.clone())).expect("vote two");
|
||||
let vote_one = build_vote(&proposal, VOTE_YES, &wrap(voter_one), now_ts()).expect("vote one");
|
||||
let mut vote_two =
|
||||
build_vote(&proposal, VOTE_NO, &wrap(voter_two.clone()), now_ts()).expect("vote two");
|
||||
|
||||
vote_two.received_hash = vec![0; 32];
|
||||
vote_two.vote_hash = compute_vote_hash(&vote_two);
|
||||
@@ -223,7 +187,7 @@ fn test_vote_chain_validation_rejects_bad_received_hash() {
|
||||
invalid.votes.push(vote_one);
|
||||
invalid.votes.push(vote_two);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("should fail chain validation");
|
||||
assert!(
|
||||
matches!(err, ConsensusError::ReceivedHashMismatch),
|
||||
@@ -250,16 +214,17 @@ fn test_validate_proposal_rejects_empty_vote_owner() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner)).expect("vote");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner), now_ts()).expect("vote");
|
||||
vote.vote_owner.clear();
|
||||
|
||||
let mut invalid = proposal;
|
||||
invalid.votes.push(vote);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("empty vote owner should fail");
|
||||
assert!(matches!(err, ConsensusError::EmptyVoteOwner));
|
||||
}
|
||||
@@ -283,16 +248,17 @@ fn test_validate_proposal_rejects_empty_vote_hash() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner)).expect("vote");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner), now_ts()).expect("vote");
|
||||
vote.vote_hash.clear();
|
||||
|
||||
let mut invalid = proposal;
|
||||
invalid.votes.push(vote);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("empty vote hash should fail");
|
||||
assert!(matches!(err, ConsensusError::EmptyVoteHash));
|
||||
}
|
||||
@@ -316,16 +282,17 @@ fn test_validate_proposal_rejects_empty_signature() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner)).expect("vote");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner), now_ts()).expect("vote");
|
||||
vote.signature.clear();
|
||||
|
||||
let mut invalid = proposal;
|
||||
invalid.votes.push(vote);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("empty signature should fail");
|
||||
assert!(matches!(err, ConsensusError::EmptySignature));
|
||||
}
|
||||
@@ -349,16 +316,17 @@ fn test_validate_proposal_rejects_mismatched_signature_length() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner)).expect("vote");
|
||||
let mut vote = build_vote(&proposal, VOTE_YES, &wrap(proposal_owner), now_ts()).expect("vote");
|
||||
vote.signature = vec![7; 64];
|
||||
|
||||
let mut invalid = proposal;
|
||||
invalid.votes.push(vote);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("invalid signature length should fail");
|
||||
// Signature-length checks now live in the scheme and surface as a
|
||||
// SignatureScheme error rather than a protocol-level MismatchedLength variant.
|
||||
@@ -384,14 +352,16 @@ fn test_vote_chain_validation_rejects_bad_parent_hash_owner_mismatch() {
|
||||
)
|
||||
.expect("valid proposal request"),
|
||||
Some(ConsensusConfig::gossipsub()),
|
||||
now_ts(),
|
||||
)
|
||||
.expect("proposal");
|
||||
|
||||
let voter_one = PrivateKeySigner::random();
|
||||
let voter_two = PrivateKeySigner::random();
|
||||
|
||||
let vote_one = build_vote(&proposal, VOTE_YES, &wrap(voter_one)).expect("vote one");
|
||||
let mut vote_two = build_vote(&proposal, VOTE_NO, &wrap(voter_two.clone())).expect("vote two");
|
||||
let vote_one = build_vote(&proposal, VOTE_YES, &wrap(voter_one), now_ts()).expect("vote one");
|
||||
let mut vote_two =
|
||||
build_vote(&proposal, VOTE_NO, &wrap(voter_two.clone()), now_ts()).expect("vote two");
|
||||
|
||||
// parent_hash points to another owner's vote, which should fail RFC parent-chain checks.
|
||||
vote_two.parent_hash = vote_one.vote_hash.clone();
|
||||
@@ -401,7 +371,7 @@ fn test_vote_chain_validation_rejects_bad_parent_hash_owner_mismatch() {
|
||||
invalid.votes.push(vote_one);
|
||||
invalid.votes.push(vote_two);
|
||||
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid)
|
||||
let err = validate_proposal::<EthereumConsensusSigner>(&invalid, now_ts())
|
||||
.expect_err("parent hash owner mismatch should fail");
|
||||
assert!(matches!(err, ConsensusError::ParentHashMismatch));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user