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:
Ekaterina Broslavskaia
2026-07-06 22:04:12 +03:00
committed by GitHub
parent 48da541313
commit eefc2414aa
21 changed files with 735 additions and 711 deletions
Generated
+276 -319
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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"
+25 -15
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+67
View File
@@ -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)
}
+9 -6
View File
@@ -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);
}
+55 -70
View File
@@ -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");
+10 -5
View File
@@ -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!(
+36 -58
View File
@@ -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");
+40 -55
View File
@@ -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");
+5 -8
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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");
}
+32 -62
View File
@@ -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));
}