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
//! Consensus configuration
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Configuration for the consensus engine
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsensusConfig {
/// Target block time in milliseconds (default: 400ms)
pub block_time_ms: u64,
/// Maximum block size in bytes (default: 2MB)
pub max_block_size: usize,
/// Maximum transactions per block (default: 10000)
pub max_transactions_per_block: usize,
/// Maximum gas per block (default: 30M)
pub max_gas_per_block: u64,
/// View timeout in milliseconds — **bootstrap seed only**.
///
/// This value seeds `ViewChangeTimer::base_timeout` at engine
/// construction. After the first successful view, the adaptive
/// tuner (`ViewChangeTimer::record_observed_view_latency`)
/// retunes the base timeout to track the cluster's actually-
/// observed quorum-formation latency.
///
/// **Why this is a seed, not a default**: the validator set is
/// open. A validator on residential WiFi joining a cluster of
/// datacenter peers cannot share a static default with a
/// single-region testnet. The adaptive algorithm tracks an EWMA
/// of observed view-to-QC latency and sets `base_timeout =
/// safety_multiplier × ewma`, clamped to `[base_floor,
/// base_ceiling]`. The seed only matters until the cluster has
/// produced a few successful views — after that, this value is
/// overwritten.
///
/// **Seed choice**: 1000ms is a reasonable midpoint. Lower seeds
/// (e.g. 200ms) cause noisy view-change storms during bootstrap
/// when no peer has spoken yet; higher seeds (e.g. 5000ms) waste
/// wall-clock on the first few views before adaptation kicks in.
/// Operators rarely need to tune this.
pub view_timeout_ms: u64,
/// Minimum validator count (default: 4)
pub min_validators: usize,
/// Byzantine fault tolerance threshold (default: 2f+1 where f = (n-1)/3)
pub bft_threshold: BftThreshold,
/// Epoch duration in blocks (default: 10000)
pub epoch_duration: u64,
/// Mempool size limit in bytes (default: 100MB)
pub mempool_size_limit: usize,
/// Maximum number of transactions in mempool (default: 10000)
pub mempool_max_transactions: usize,
/// Minimum gas price (in wei) accepted by the mempool, before lane
/// fee-floor multipliers are applied. Set to 0 to disable the static
/// floor entirely (mainnet should drive this off live EIP-1559 base fee).
/// Default: 1 Gwei (1e9 wei). Spec 2 lane fee-floor multipliers
/// (`fee_floor_mult(lane)`) scale this value at admission time so the
/// effective floor for a Verified-lane controller is `1.0 × mempool_min_gas_price`,
/// Delegated `1.5×`, and Open `4.0×` (per `AdmissionConfig::Default`).
pub mempool_min_gas_price: u64,
/// Maximum number of pending transactions a single sender address may
/// hold in the mempool at once (default: 64). Bounds the damage one
/// account can do to shared mempool capacity and doubles as the
/// admissible nonce look-ahead: a tx whose nonce exceeds
/// `account_nonce + mempool_max_per_sender` is rejected as
/// unexecutable-for-now spam.
pub mempool_max_per_sender: usize,
/// Transaction TTL in seconds (default: 600)
pub transaction_ttl_seconds: u64,
/// Enable optimistic responsiveness (default: true)
pub optimistic_responsiveness: bool,
/// Proposer election strategy.
///
/// Default is [`ProposerElectionKind::Reputation`] (reputation-weighted proposer election),
/// which prevents the chain from stalling when a single validator becomes
/// unresponsive. [`ProposerElectionKind::RoundRobin`] is retained for
/// tests and replay benchmarks.
pub proposer_election: ProposerElectionKind,
/// Heartbeat interval for empty-block suppression, in milliseconds.
///
/// When the mempool is empty the leader does NOT mint a block on every
/// pacemaker beat — doing so accreted ~216k empty headers/day of
/// monotonic, never-pruned SST on an idle chain. Instead the leader
/// proposes only when it has transactions to commit OR when this
/// interval has elapsed since the last finalized block, whichever comes
/// first. The heartbeat block keeps the chain tip fresh (timestamps,
/// state-root continuity) and gives non-leaders a positive liveness
/// signal so they can distinguish "idle" from "dead leader" without
/// waiting on the view-change timer.
///
/// This follows the standard `create_empty_blocks = false` +
/// `create_empty_blocks_interval` pattern. Set to 0 to disable suppression and
/// restore always-on block production (every beat mints a block).
///
/// Default: 600000ms (10 min) — activity-driven production with only a
/// rare liveness heartbeat. Real (transaction-bearing) blocks are always
/// produced immediately; a fully idle chain mints at most ~144 keepalive
/// headers/day (vs ~2,880 at 30s / ~17,280 at 5s). The heartbeat is
/// decoupled from liveness — followers detect a dead leader via the
/// view-change timer, not this interval — so a long interval only trades a
/// staler idle-tip timestamp for far fewer empty headers, which is the
/// intended policy: don't accumulate millions of empty blocks on an idle
/// network. Set to 0 to disable suppression (mint every beat — dev only).
pub empty_block_heartbeat_ms: u64,
/// Floor on the adaptive view-change base timeout, in milliseconds.
///
/// The pacemaker self-tunes its base timeout to `safety_multiplier ×
/// EWMA(observed quorum latency)`, clamped to this floor. The floor
/// exists because the EWMA measures only *successful* rounds, so on a
/// wide-area fleet it converges to the median quorum latency and gives
/// no headroom for the tail — a single round slower than `2 × median`
/// (WAN jitter, GC pause, a momentarily slow peer) then trips a
/// spurious timeout. Each spurious timeout advances the view without
/// finalizing, which on this chain produced ~2 views per height and a
/// stream of recovery blocks that defeated empty-block suppression.
///
/// 200ms (the previous hard-coded value) is far below realistic
/// cross-region quorum latency: inter-continent RTT alone is
/// 150–250ms, and a quorum needs a full proposal→vote round trip. For
/// a multi-continent validator set the floor must exceed the tail of
/// that round trip, not its median.
///
/// Default: 1000ms — comfortably above observed cross-region quorum
/// latency (~100–400ms tail) so views stop timing out spuriously,
/// while still letting the adaptive tuner raise the timeout further
/// under genuine load. A single-region or LAN cluster can lower this.
pub adaptive_timeout_floor_ms: u64,
}
impl Default for ConsensusConfig {
fn default() -> Self {
Self {
block_time_ms: 400,
max_block_size: 2 * 1024 * 1024, // 2MB
max_transactions_per_block: 10_000,
max_gas_per_block: 30_000_000,
view_timeout_ms: 1000,
min_validators: 4,
bft_threshold: BftThreshold::TwoThirdsPlusOne,
epoch_duration: 10_000,
mempool_size_limit: 100 * 1024 * 1024, // 100MB
mempool_max_transactions: 10_000,
mempool_min_gas_price: 1_000_000_000, // 1 Gwei
mempool_max_per_sender: 64,
transaction_ttl_seconds: 600,
optimistic_responsiveness: true,
proposer_election: ProposerElectionKind::Reputation,
empty_block_heartbeat_ms: 600_000,
adaptive_timeout_floor_ms: 1000,
}
}
}
impl ConsensusConfig {
/// Creates a new consensus configuration with default values
pub fn new() -> Self {
Self::default()
}
/// Sets the block time
pub fn with_block_time(mut self, block_time_ms: u64) -> Self {
self.block_time_ms = block_time_ms;
self
}
/// Sets the max block size
pub fn with_max_block_size(mut self, max_block_size: usize) -> Self {
self.max_block_size = max_block_size;
self
}
/// Sets the view timeout
pub fn with_view_timeout(mut self, view_timeout_ms: u64) -> Self {
self.view_timeout_ms = view_timeout_ms;
self
}
/// Sets the proposer election strategy.
pub fn with_proposer_election(mut self, kind: ProposerElectionKind) -> Self {
self.proposer_election = kind;
self
}
/// Sets the empty-block heartbeat interval (0 disables suppression).
pub fn with_empty_block_heartbeat(mut self, heartbeat_ms: u64) -> Self {
self.empty_block_heartbeat_ms = heartbeat_ms;
self
}
/// Returns the empty-block heartbeat interval as a Duration.
pub fn empty_block_heartbeat(&self) -> Duration {
Duration::from_millis(self.empty_block_heartbeat_ms)
}
/// Whether empty-block suppression is active (heartbeat > 0).
pub fn suppress_empty_blocks(&self) -> bool {
self.empty_block_heartbeat_ms > 0
}
/// Sets the adaptive view-timeout floor.
pub fn with_adaptive_timeout_floor(mut self, floor_ms: u64) -> Self {
self.adaptive_timeout_floor_ms = floor_ms;
self
}
/// Returns the adaptive view-timeout floor as a Duration.
pub fn adaptive_timeout_floor(&self) -> Duration {
Duration::from_millis(self.adaptive_timeout_floor_ms)
}
/// Returns the block time as a Duration
pub fn block_time(&self) -> Duration {
Duration::from_millis(self.block_time_ms)
}
/// Returns the view timeout as a Duration
pub fn view_timeout(&self) -> Duration {
Duration::from_millis(self.view_timeout_ms)
}
/// Returns the transaction TTL as a Duration
pub fn transaction_ttl(&self) -> Duration {
Duration::from_secs(self.transaction_ttl_seconds)
}
/// Calculates the quorum threshold for the given validator count
pub fn quorum_threshold(&self, validator_count: usize) -> usize {
self.bft_threshold.calculate(validator_count)
}
}
/// BFT threshold calculation strategy
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BftThreshold {
/// 2f+1 where f = (n-1)/3 (classic BFT)
TwoThirdsPlusOne,
/// Simple majority (n/2 + 1)
SimpleMajority,
}
impl BftThreshold {
/// Calculate the threshold for the given validator count
pub fn calculate(&self, validator_count: usize) -> usize {
match self {
BftThreshold::TwoThirdsPlusOne => {
// 2f+1 where f = (n-1)/3
// This tolerates f Byzantine faults
let f = (validator_count.saturating_sub(1)) / 3;
2 * f + 1
}
BftThreshold::SimpleMajority => validator_count / 2 + 1,
}
}
}
/// Proposer election strategy.
///
/// Renamed from `LeaderRotation` to avoid colliding with the
/// `ProposerElection` *trait* in `validator.rs`. The "Kind" suffix marks
/// this as the configuration discriminator: the engine resolves it to a
/// concrete `Box<dyn ProposerElection>` at startup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProposerElectionKind {
/// Naïve `view % N` round-robin. Tests and very small validator sets only.
RoundRobin,
/// Reputation-weighted proposer election: stake-weighted seeded draw with
/// observed-behaviour multipliers. Default and recommended for production.
Reputation,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ConsensusConfig::default();
assert_eq!(config.block_time_ms, 400);
assert_eq!(config.max_block_size, 2 * 1024 * 1024);
assert_eq!(config.view_timeout_ms, 1000);
}
#[test]
fn test_bft_threshold() {
let threshold = BftThreshold::TwoThirdsPlusOne;
// With 4 validators: f=1, need 3 votes
assert_eq!(threshold.calculate(4), 3);
// With 7 validators: f=2, need 5 votes
assert_eq!(threshold.calculate(7), 5);
// With 10 validators: f=3, need 7 votes
assert_eq!(threshold.calculate(10), 7);
}
#[test]
fn test_config_builder() {
let config = ConsensusConfig::new()
.with_block_time(500)
.with_max_block_size(3 * 1024 * 1024)
.with_view_timeout(3000);
assert_eq!(config.block_time_ms, 500);
assert_eq!(config.max_block_size, 3 * 1024 * 1024);
assert_eq!(config.view_timeout_ms, 3000);
}
}