A lightweight Rust library for making binary decisions in peer-to-peer or gossipsub networks. Perfect for group governance, voting systems, or any scenario where you need distributed agreement.
- Fast - Reaches consensus in O(log n) rounds
- Byzantine fault tolerant - Correct even if up to 1/3 of peers are malicious
- Pluggable storage - In-memory by default; implement
ConsensusStoragefor persistence - Pluggable signing - Default ECDSA-secp256k1 via
EthereumConsensusSigner; implementConsensusSignatureSchemefor Ed25519, HSMs, or any custom scheme - Network-agnostic - Works with both Gossipsub (fixed 2-round) and P2P (dynamic rounds) topologies
- Event-driven - Subscribe to consensus outcomes via a broadcast event bus
- Cryptographic integrity - Votes are signed and chained in a hashgraph structure
Based on the Hashgraph-like Consensus Protocol RFC.
Add to your Cargo.toml:
[dependencies]
hashgraph-like-consensus = { git = "https://github.com/vacp2p/hashgraph-like-consensus" }use hashgraph_like_consensus::{
scope::ScopeID,
service::DefaultConsensusService,
signing::{ConsensusSignatureScheme, EthereumConsensusSigner},
types::CreateProposalRequest,
};
use alloy::signers::local::PrivateKeySigner;
#[tokio::main]
async 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");
// Create a proposal
let proposal = service
.create_proposal(
&scope,
CreateProposalRequest::new(
"Upgrade contract".into(), // name
b"Switch to v2".to_vec(), // payload (bytes)
signer.identity().to_vec(), // owner
3, // expected voters
60, // expiration (seconds from now)
true, // liveness: silent peers count as YES at timeout
)?,
)
.await?;
// Cast a vote — the service uses its held signer.
let vote = service.cast_vote(&scope, proposal.proposal_id, true).await?;
println!("Recorded vote {}", vote.vote_id);
Ok(())
}A scope groups related proposals together and carries default configuration (network type, threshold, timeout). Proposals inherit scope defaults unless overridden individually.
Scope (group / channel)
+-- ScopeConfig (defaults for all proposals)
+-- Proposals
+-- Proposal 1 -> Session (inherits scope config)
+-- Proposal 2 -> Session (inherits scope config)
+-- Proposal 3 -> Session (overrides scope config)
| Type | Rounds | Behavior |
|---|---|---|
| Gossipsub (default) | Fixed 2 rounds | Round 1 = proposal broadcast, Round 2 = all votes |
| P2P | Dynamic ceil(2n/3) |
Each vote advances the round by one |
ConsensusService is the single entry point. All consensus business logic
lives there. It is generic over three pluggable backends:
ConsensusStorage— where sessions and votes are persisted (in-memory, database, etc.)ConsensusEventBus— how consensus events are delivered (broadcast channel, message queue, etc.)ConsensusSignatureScheme— how votes are signed and verified (ECDSA-secp256k1 by default, or your own scheme)
Use service.storage() for reads, queries, and cleanup.
Use service.event_bus() for event subscription.
The default pattern is one ConsensusService, many scopes — the service
multiplexes proposals across independent decision streams. All scopes share
one storage backend and one event bus; subscribers see (Scope, ConsensusEvent)
pairs from every scope.
For per-scope concerns (per-conversation in de-mls, per-channel in chat apps,
per-room in collaboration tools) you can construct one service per scope.
Storage backends are Clone and Arc-backed internally, so multiple services
can share the same backing data while each owns an independent event bus:
use hashgraph_like_consensus::{
events::BroadcastEventBus,
scope::ScopeID,
service::ConsensusService,
signing::EthereumConsensusSigner,
storage::InMemoryConsensusStorage,
};
use alloy::signers::local::PrivateKeySigner;
// Shared storage across all conversations.
let storage = InMemoryConsensusStorage::<ScopeID>::new();
// This peer's signer — built once and cloned into each conversation's service.
let signer = EthereumConsensusSigner::new(PrivateKeySigner::random());
// One service per conversation, each with its own event bus.
let conv_a: ConsensusService<_, _, _, EthereumConsensusSigner> =
ConsensusService::new_with_components(
storage.clone(), // shared backing data
BroadcastEventBus::default(), // private bus for this conversation
signer.clone(), // same identity in every conversation
10,
);
let conv_b: ConsensusService<_, _, _, EthereumConsensusSigner> =
ConsensusService::new_with_components(
storage.clone(),
BroadcastEventBus::default(),
signer.clone(),
10,
);
// Each conversation's subscribers only see its own events — no filtering needed.
let mut events_a = conv_a.event_bus().subscribe();
let mut events_b = conv_b.event_bus().subscribe();The same shape works for DB-backed storage: build one Pool/connection-bearing
storage value, clone it for each per-scope service.
Cost of scope indexing. Scope is the partition key in storage; the overhead is negligible.
- In-memory: the outer
HashMap<Scope, _>adds roughly 50 bytes per scope plus a small inner allocation. At 1000 scopes that's tens of kilobytes total. - Database: the
scope_idcolumn you'd want anyway for any multi-tenant table becomes a partition key. Point lookups (get_session) are identical speed to a flat namespace;list_scope_sessionsanddelete_scopebecome indexed range scans instead of full-table filters.
This library handles consensus calculation — vote validation, hashgraph chain verification, threshold math, and liveness rules. It does not handle orchestration. Your application is responsible for:
| Responsibility | Why |
|---|---|
| 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. |
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. |
| 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. |
use hashgraph_like_consensus::{
service::{ConsensusService, DefaultConsensusService},
signing::EthereumConsensusSigner,
};
use alloy::signers::local::PrivateKeySigner;
let signer = EthereumConsensusSigner::new(PrivateKeySigner::random());
// In-memory storage + broadcast events + this peer's signer. 10 sessions per scope.
let service = DefaultConsensusService::new(signer.clone());
// Custom session limit (still the Ethereum default scheme).
let service = DefaultConsensusService::new_with_max_sessions(signer.clone(), 20);
// Fully custom: plug in your own storage, event bus, signer, and signature scheme.
let service: ConsensusService<MyScope, MyStorage, MyEvents, MyScheme> =
ConsensusService::new_with_components(my_storage, my_event_bus, my_signer, 10);signer is held inside the service and used for every outgoing vote — see
Casting and Processing Votes. Access via
service.signer() if you need its identity bytes (e.g. for proposal owner
fields).
use hashgraph_like_consensus::{
scope::ScopeID,
scope_config::NetworkType,
service::DefaultConsensusService,
};
use std::time::Duration;
let service = DefaultConsensusService::default();
let scope = ScopeID::from("team_votes");
// Initialize with the builder
service
.scope(&scope)
.await?
.with_network_type(NetworkType::P2P)
.with_threshold(0.75)
.with_timeout(Duration::from_secs(120))
.with_liveness_criteria(false)
.initialize()
.await?;
// Update later (single field)
service
.scope(&scope)
.await?
.with_threshold(0.8)
.update()
.await?;Built-in presets are also available:
// High confidence (threshold = 0.9)
service.scope(&scope).await?.strict_consensus().initialize().await?;
// Low latency (threshold = 0.6, timeout = 30 s)
service.scope(&scope).await?.fast_consensus().initialize().await?;// Create a proposal
let proposal = service
.create_proposal(&scope, CreateProposalRequest::new(
"Upgrade contract".into(),
b"Switch to v2".to_vec(),
owner_address,
3, // expected voters
60, // expiration (seconds from now)
true, // liveness: silent peers count as YES at timeout
)?)
.await?;
// Process a proposal received from the network
service.process_incoming_proposal(&scope, proposal).await?;// Cast your vote (yes = true, no = false) using the service's held signer.
let vote = service.cast_vote(&scope, proposal_id, true).await?;
// Cast a vote and get the updated proposal (useful for gossiping).
let proposal = service
.cast_vote_and_get_proposal(&scope, proposal_id, true)
.await?;
// Process a vote received from the network (uses the service's scheme to verify).
service.process_incoming_vote(&scope, vote).await?;All reads go through service.storage():
use hashgraph_like_consensus::storage::ConsensusStorage;
// Get the consensus result for a proposal (Ok(true) = YES, Ok(false) = NO)
let result: bool = service.storage().get_consensus_result(&scope, proposal_id).await?;
// Get a proposal by ID
let proposal = service.storage().get_proposal(&scope, proposal_id).await?;
// List active proposals (empty Vec if none)
let active: Vec<Proposal> = service.storage().get_active_proposals(&scope).await?;
// List finalized proposals (proposal_id -> result)
let reached: HashMap<u32, bool> = service.storage().get_reached_proposals(&scope).await?;
// Delete all state for a scope (e.g. when a user leaves a group)
service.storage().delete_scope(&scope).await?;The library does not schedule timeouts automatically. Your application must set up a timer for each proposal and call
handle_consensus_timeoutwhen it fires. Without this, proposals with offline voters will stayActiveforever and the silent-peer liveness logic will never run.
When handle_consensus_timeout is called, silent peers (those who never voted)
are counted toward quorum so that the liveness_criteria_yes flag can take effect:
liveness_criteria_yes = true— silent peers are counted as YES votes. A proposal passes unless there are enough explicit NO votes to block it.liveness_criteria_yes = false— silent peers are counted as NO votes. A proposal fails unless there are enough explicit YES votes to carry it.
The only case where timeout produces no result is a tie (equal YES and NO weight after counting silent peers), which marks the session as failed.
// Schedule a timeout (typically via tokio::time::sleep)
tokio::time::sleep(config.consensus_timeout()).await;
match service.handle_consensus_timeout(&scope, proposal_id).await {
Ok(true) => println!("Consensus: YES"),
Ok(false) => println!("Consensus: NO"),
Err(ConsensusError::InsufficientVotesAtTimeout) => {
println!("Tied — no consensus");
}
Err(e) => eprintln!("Error: {e}"),
}During normal voting (before timeout), the quorum gate still requires ceil(2n/3)
actual votes — silent peers are not counted until timeout.
use hashgraph_like_consensus::events::ConsensusEventBus;
use hashgraph_like_consensus::types::ConsensusEvent;
let mut rx = service.event_bus().subscribe();
tokio::spawn(async move {
while let Ok((scope, event)) = rx.recv().await {
match event {
ConsensusEvent::ConsensusReached { proposal_id, result, timestamp } => {
println!("Proposal {} -> {}", proposal_id, if result { "YES" } else { "NO" });
}
ConsensusEvent::ConsensusFailed { proposal_id, timestamp } => {
println!("Proposal {} failed to reach consensus", proposal_id);
}
}
}
});let stats = service.get_scope_stats(&scope).await;
println!(
"Active: {}, Reached: {}, Failed: {}",
stats.active_sessions, stats.consensus_reached, stats.failed_sessions
);Implement the ConsensusStorage trait to persist proposals to a database.
You only need to implement the primitive methods — query helpers like
get_consensus_result, get_active_proposals, and get_reached_proposals
are provided as default implementations for free.
use hashgraph_like_consensus::storage::ConsensusStorage;
// Required primitives (you implement these):
// save_session, get_session, remove_session,
// list_scope_sessions, replace_scope_sessions,
// update_session, update_scope_sessions,
// stream_scope_sessions, list_scopes,
// get_scope_config, set_scope_config, update_scope_config,
// delete_scope
//
// Free query helpers (default implementations):
// get_consensus_result, get_proposal, get_proposal_config,
// get_active_proposals, get_reached_proposalsImplement ConsensusEventBus for alternative event delivery:
use hashgraph_like_consensus::events::ConsensusEventBus;
pub trait ConsensusEventBus<Scope> {
type Receiver;
fn subscribe(&self) -> Self::Receiver;
fn publish(&self, scope: Scope, event: ConsensusEvent);
}The default EthereumConsensusSigner uses ECDSA-secp256k1 with 20-byte
Ethereum addresses, matching the historical behavior of the crate. To
integrate Ed25519, an HSM, or any other scheme, implement
ConsensusSignatureScheme:
use hashgraph_like_consensus::signing::{
ConsensusSchemeError, ConsensusSignatureScheme,
};
pub trait ConsensusSignatureScheme: Send + Sync {
/// Identity bytes written into `Vote::vote_owner` (address, public key, etc.).
fn identity(&self) -> &[u8];
/// Sign a payload. Returns raw signature bytes (length scheme-specific).
fn sign(&self, payload: &[u8])
-> impl Future<Output = Result<Vec<u8>, ConsensusSchemeError>> + Send;
/// Static verification — no instance needed. The service calls this for
/// every incoming vote.
fn verify(identity: &[u8], payload: &[u8], signature: &[u8])
-> Result<bool, ConsensusSchemeError>;
}The same type plays both roles: a value carries private state for signing, and the type itself is used statically by the service for verification. All peers on a network must agree on the scheme type. Pick it at service construction:
use hashgraph_like_consensus::{
events::BroadcastEventBus,
scope::ScopeID,
service::ConsensusService,
storage::InMemoryConsensusStorage,
};
type MyService = ConsensusService<
ScopeID,
InMemoryConsensusStorage<ScopeID>,
BroadcastEventBus<ScopeID>,
MyScheme, // <- your ConsensusSignatureScheme impl
>;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) |
The generic Signer parameter on build_vote / validate_proposal /
validate_vote selects which ConsensusSignatureScheme to use; pick it via
turbofish or inference at the call site.
# Build
cargo build
# Run tests
cargo test
# Generate docs
cargo doc --openNote: Requires a working
protoc(Protocol Buffers compiler) since the library generates code from.protofiles at build time.