hashgraph-like-consensus 0.4.0

A lightweight Rust library for making binary decisions in networks using hashgraph-style consensus
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# Hashgraph-like Consensus

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Crates.io](https://img.shields.io/crates/v/hashgraph-like-consensus.svg)](https://crates.io/crates/hashgraph-like-consensus)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vacp2p/hashgraph-like-consensus/ci.yml?branch=main&label=CI)](https://github.com/vacp2p/hashgraph-like-consensus/actions)
[![Rust](https://img.shields.io/badge/rust-edition%202024-orange.svg)](https://doc.rust-lang.org/edition-guide/)

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.

## Features

- **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 `ConsensusStorage` for persistence
- **Pluggable signing** - Default ECDSA-secp256k1 via `EthereumConsensusSigner`; implement `ConsensusSignatureScheme` for 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](https://lip.logos.co/ift-ts/raw/consensus-hashgraphlike.html).

## Installation

Add to your `Cargo.toml`:

```toml
[dependencies]
hashgraph-like-consensus = { git = "https://github.com/vacp2p/hashgraph-like-consensus" }
```

## Quick Start

```rust
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(())
}
```

## Core Concepts

### Scopes and Proposals

A **scope** groups related proposals together and carries default configuration
(network type, threshold, timeout). Proposals inherit scope defaults unless
overridden individually.

``` text
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)
```

### Network Types

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

### Architecture

`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.

### Service shape: one service vs. per-scope services

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:

```rust
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_id` column 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_sessions` and `delete_scope` become
  indexed range scans instead of full-table filters.

## What the Library Does vs. What You Do

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.                                       |

## API Reference

### Creating a Service

```rust
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](#casting-and-processing-votes). Access via
`service.signer()` if you need its identity bytes (e.g. for proposal owner
fields).

### Configuring a Scope

```rust
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:

```rust
// 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?;
```

### Working with Proposals

```rust
// 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?;
```

### 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).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?;
```

### Reading State (via Storage)

All reads go through `service.storage()`:

```rust
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?;
```

### Handling Timeouts

> **The library does not schedule timeouts automatically.** Your application must
> set up a timer for each proposal and call `handle_consensus_timeout` when it
> fires. Without this, proposals with offline voters will stay `Active` forever
> 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.

```rust
// 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.

### Subscribing to Events

```rust
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);
            }
        }
    }
});
```

### Statistics

```rust
let stats = service.get_scope_stats(&scope).await;
println!(
    "Active: {}, Reached: {}, Failed: {}",
    stats.active_sessions, stats.consensus_reached, stats.failed_sessions
);
```

## Advanced Usage

### Custom Storage

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.

```rust
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_proposals
```

### Custom Event Bus

Implement `ConsensusEventBus` for alternative event delivery:

```rust
use hashgraph_like_consensus::events::ConsensusEventBus;

pub trait ConsensusEventBus<Scope> {
    type Receiver;
    fn subscribe(&self) -> Self::Receiver;
    fn publish(&self, scope: Scope, event: ConsensusEvent);
}
```

### Custom Signature Scheme

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`:

```rust
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:

```rust
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.

### Utility Functions

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.

## Building

```bash
# Build
cargo build

# Run tests
cargo test

# Generate docs
cargo doc --open
```

> **Note:** Requires a working `protoc` (Protocol Buffers compiler) since the library generates code from `.proto` files at build time.

## License

[MIT](https://opensource.org/licenses/MIT)