Skip to content

Commit 79ee284

Browse files
AIQnetLabclaude
andcommitted
consensus: slot-anchored block_ts, 2f+1-certified reward_root, 100k-scale reward+sigverify
block timestamps - block_ts = genesis_ts + height*SLOT (deterministic, clock-independent); drop median-past/future/monotonic checks; slot exact-match on the live verify path - stall pacemaker on a local wall-clock liveness timer (STALL_PROGRESS_HEIGHT/WALL), not slot ts, so a healthy leader is never view-changed on cumulative deficit reward integrity (off state_root, lazy merkle-claim) - bind per-epoch reward_root into the Checkpoint hash so the 2f+1 QC certifies it; committee re-derives + compares in content_ok before signing (no single-producer trust) - thread reward_root through driver/node/window-end; finalization adopts the QC-certified root on every node so a catch-up node never serves a divergent local root reward recompute scalability (100k super-nodes / <=1000 committee) - light-bitmap index at apply (save/load_light_bitmaps): O(5) keys vs an O(14400)-block scan - super-eligible index at apply (save/load_super_eligible): O(eligible) prefix read vs O(registered) per-account scan; set-equal to the old scan, survives body pruning signature verification scalability - shared process-wide tokio runtime for sig verify (no per-signature runtime build) - rayon-parallel QC signature verification (committee up to ~1000 PQ sigs, no aggregation) determinism & logging - deterministic parallel-executor batch order (HashSet -> BTreeSet) - sig-reject ERR->WARN, shred pacing tag ->INFO, ip-gate sampled log + always-counted metric tests: reward_root hash-binding + light-bitmap/super-eligible index round-trip; qnet-consensus 92 + qnet-integration 162 lib tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a154aee commit 79ee284

13 files changed

Lines changed: 418 additions & 284 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/qnet-consensus/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ dashmap = "5.5"
5959
parking_lot = "0.12"
6060
crossbeam = "0.8"
6161

62+
# Parallel signature verification (QC committee can be ≤1000 post-quantum sigs)
63+
rayon = "1.10"
64+
6265
# Metrics
6366
prometheus = "0.13"
6467
lazy_static = "1.4"

core/qnet-consensus/src/checkpoint_bft.rs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ pub struct Checkpoint {
223223
/// eligible producers + committee). In the QC-signed hash ⇒ 2f+1 certify the
224224
/// validator set, so syncing (non-committee) nodes trust it without re-deriving.
225225
pub epoch_commitment: Hash,
226+
/// Per-epoch reward merkle root for the emission-boundary window ([0;32] otherwise);
227+
/// QC-signed ⇒ 2f+1 certify it ⇒ nodes adopt this root for claims, never a single
228+
/// producer's unverified value (no Byzantine/lag reward divergence).
229+
pub reward_root: Hash,
226230
/// Proposer's wall-clock for this window (the head microblock's timestamp).
227231
/// In the QC-signed hash ⇒ agreed by the committee ⇒ every node seals an
228232
/// identical MacroBlock from the checkpoint (no producer dependency, no fork).
@@ -246,6 +250,7 @@ impl Checkpoint {
246250
h.update(self.state_root);
247251
h.update(self.beacon);
248252
h.update(self.epoch_commitment);
253+
h.update(self.reward_root);
249254
h.update(self.timestamp.to_le_bytes());
250255
h.update(self.proposer.as_bytes());
251256
h.finalize().into()
@@ -275,7 +280,7 @@ pub fn sig_merkle_root(sigs: &[Vec<u8>]) -> Hash {
275280
impl QuorumCertificate {
276281
/// Structural + cryptographic validity. `verify_sig(voter, msg, sig)` is
277282
/// injected so this crate stays crypto-agnostic. `committee` = sorted epoch set.
278-
pub fn verify<F: Fn(&str, &[u8], &[u8]) -> bool>(
283+
pub fn verify<F: Fn(&str, &[u8], &[u8]) -> bool + Sync>(
279284
&self,
280285
committee: &[NodeId],
281286
verify_sig: F,
@@ -289,9 +294,13 @@ impl QuorumCertificate {
289294
if !committee.iter().any(|c| c == s) { return Err("qc_non_member"); }
290295
}
291296
if sig_merkle_root(&self.sigs) != self.sig_merkle_root { return Err("qc_merkle_mismatch"); }
292-
for (voter, sig) in self.signers.iter().zip(self.sigs.iter()) {
293-
if !verify_sig(voter, &self.checkpoint_hash, sig) { return Err("qc_bad_sig"); }
294-
}
297+
// Verify signatures in parallel: a committee is ≤1000 post-quantum sigs and ML-DSA has no
298+
// BLS-style aggregation, so par_iter spreads the per-sig Dilithium open across cores.
299+
// all() ≡ the serial AND (short-circuits on the first invalid); order is irrelevant.
300+
use rayon::prelude::*;
301+
let all_ok = self.signers.par_iter().zip(self.sigs.par_iter())
302+
.all(|(voter, sig)| verify_sig(voter, &self.checkpoint_hash, sig));
303+
if !all_ok { return Err("qc_bad_sig"); }
295304
Ok(())
296305
}
297306
}
@@ -411,13 +420,21 @@ mod tests {
411420
let mut c = Checkpoint {
412421
index: 1, parent_qc: None, window_head_height: 90,
413422
window_mb_hashes: vec![h(1), h(2)], state_root: h(3),
414-
beacon: h(4), epoch_commitment: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
423+
beacon: h(4), epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
415424
};
416425
let x = c.hash();
417426
c.proposer_sig = vec![9, 9, 9]; // sig change must NOT change hash
418427
assert_eq!(c.hash(), x);
419428
c.state_root = h(7); // field change MUST change hash
420429
assert_ne!(c.hash(), x);
430+
// reward_root MUST be bound into the hash: the QC signs cp.hash(), so a checkpoint
431+
// differing only in reward_root must produce a different hash (else 2f+1 could not
432+
// certify the reward distribution and a proposer-chosen root would ride uncertified).
433+
let mut a = c.clone();
434+
a.reward_root = h(0);
435+
let mut b = a.clone();
436+
b.reward_root = h(5);
437+
assert_ne!(a.hash(), b.hash());
421438
}
422439

423440
#[test]
@@ -585,7 +602,7 @@ mod tests {
585602
let child = Checkpoint {
586603
index: 5, parent_qc: Some(parent_qc), window_head_height: 450,
587604
window_mb_hashes: vec![h(1)], state_root: h(2), beacon: h(3),
588-
epoch_commitment: h(0), timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
605+
epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n0".into(), proposer_sig: vec![],
589606
};
590607
let child_qc = mk_qc(&committee, child.hash(), 5, 3);
591608
assert_eq!(commits_parent(&child, &child_qc), Some(4)); // C4 final
@@ -601,7 +618,7 @@ mod tests {
601618
let c = Checkpoint {
602619
index: 7, parent_qc: Some(qc.clone()), window_head_height: 630,
603620
window_mb_hashes: vec![h(1), h(2)], state_root: h(3), beacon: h(4),
604-
epoch_commitment: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
621+
epoch_commitment: h(0), reward_root: h(0), timestamp: 0, proposer: "n1".into(), proposer_sig: vec![1,2,3],
605622
};
606623
let bytes = bincode::serialize(&c).unwrap();
607624
let back: Checkpoint = bincode::deserialize(&bytes).unwrap();

core/qnet-consensus/src/checkpoint_consensus.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ mod tests {
229229
Checkpoint {
230230
index, parent_qc, window_head_height: index * 10,
231231
window_mb_hashes: vec![hh(index as u8)], state_root: hh(index as u8),
232-
beacon: hh(0), epoch_commitment: hh(0), timestamp: 0, proposer: c[li].clone(), proposer_sig: Vec::new(),
232+
beacon: hh(0), epoch_commitment: hh(0), reward_root: hh(0), timestamp: 0, proposer: c[li].clone(), proposer_sig: Vec::new(),
233233
}
234234
}
235235

core/qnet-consensus/src/consensus_crypto.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,8 +2081,9 @@ async fn verify_with_real_dilithium(
20812081
node_id, hex::encode(&public_key_bytes[..8]));
20822082
return true;
20832083
} else {
2084-
eprintln!("[ERR][CONSENSUS] msg_mismatch node={} expected_len={} recovered_len={}",
2085-
node_id, expected_msg.len(), recovered_message.len());
2084+
// Recovered != expected: stale/cross-round signature or forgery. Rejection
2085+
// is unconditional (security boundary); expected at low rate ⇒ WARN, not ERR.
2086+
eprintln!("[WARN][CONSENSUS] sig_reject node={} reason=msg_mismatch", node_id);
20862087
return false;
20872088
}
20882089
}

development/qnet-integration/src/block_pipeline.rs

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,44 +1795,19 @@ impl BlockPipeline {
17951795
}
17961796
}
17971797

1798-
// 2. Timestamp validation (only in live mode — sync mode skips)
1799-
// v14.8.11: three-check canonical timestamp model.
1800-
// (a) FUTURE: block.ts ≤ wall_clock + TIMESTAMP_FUTURE_TOLERANCE
1801-
// (b) MEDIAN-PAST: block.ts > median(last 11 on-chain timestamps)
1802-
// (c) MONOTONICITY: block.ts > parent.ts (enforced in step 1 hash chain)
1803-
// Same ruleset is applied in `validate_received_microblock`; this
1804-
// pipeline check catches bad blocks before they reach the apply
1805-
// stage.
1798+
// 2. Slot-anchored timestamp validation (LIVE only; SYNC skips — block_ts is
1799+
// already bound by the block hash + producer Dilithium sig + hash-chain).
1800+
// block_ts must equal genesis_ts + height*SLOT exactly: deterministic,
1801+
// clock-independent, non-gameable. The single source of truth on the live path.
18061802
let snap = coordinator.snapshot();
18071803
if !snap.is_syncing() && mb.height > 0 {
1808-
let now = crate::node::get_timestamp_safe();
1809-
// (a) FUTURE check against raw wall clock: canonical design, so
1810-
// an attacker cannot game the window via network-adjusted time.
1811-
// TIMESTAMP_FUTURE_TOLERANCE = 7200 s (2 h) comfortably covers
1812-
// realistic hypervisor / NTP events without letting Byzantine
1813-
// inflation go unchecked.
1814-
if mb.timestamp > now + crate::node::TIMESTAMP_FUTURE_TOLERANCE {
1815-
if is_warn() {
1816-
println!("[WARN][PIPELINE] future_block h={} delta=+{}s from={}",
1817-
mb.height, mb.timestamp.saturating_sub(now), decoded.from_peer);
1818-
}
1819-
metrics.verify_failed.fetch_add(1, Ordering::Relaxed);
1820-
continue;
1821-
}
1822-
// (b) MEDIAN-PAST rule. Silently skipped during the first ~11
1823-
// blocks after boot when the ring is undersized.
1824-
// v15.15: height-ordered (BIP-113 canonical) — median is taken
1825-
// over the parent-chain segment [mb.height - WINDOW, mb.height),
1826-
// not over the last WINDOW insertions. Fixes false positives
1827-
// during catch-up sync where blocks legitimately re-arrive in
1828-
// non-height order.
1829-
if let Some(median_past) = crate::node::median_past_timestamp_at_height(mb.height) {
1830-
if mb.timestamp <= median_past {
1804+
let g = crate::GLOBAL_GENESIS_TIMESTAMP.load(Ordering::Relaxed);
1805+
if g != 0 {
1806+
let expected = crate::node::expected_block_timestamp(g, mb.height);
1807+
if mb.timestamp != expected {
18311808
if is_warn() {
1832-
println!(
1833-
"[WARN][PIPELINE] median_past_violation h={} ts={} median_past={} from={}",
1834-
mb.height, mb.timestamp, median_past, decoded.from_peer
1835-
);
1809+
println!("[WARN][PIPELINE] slot_mismatch h={} ts={} expected={} from={}",
1810+
mb.height, mb.timestamp, expected, decoded.from_peer);
18361811
}
18371812
metrics.verify_failed.fetch_add(1, Ordering::Relaxed);
18381813
continue;

development/qnet-integration/src/consensus_v2_driver.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ impl ConsensusDriver {
121121
pub fn build_proposal(
122122
&mut self, window: u64, mb_hashes: Vec<Hash>,
123123
state_root: Hash, beacon: Hash, head_ts: u64,
124-
committee: Vec<NodeId>, eligible_producers: Vec<u8>, banned: Vec<NodeId>,
124+
committee: Vec<NodeId>, eligible_producers: Vec<u8>, banned: Vec<NodeId>, reward_root: Hash,
125125
) -> Vec<Effect> {
126126
self.set_committee(committee.clone());
127127
let round = self.eng.current_index;
@@ -139,7 +139,7 @@ impl ConsensusDriver {
139139
let head_height = window.saturating_mul(self.cp_interval);
140140
let cp = Checkpoint {
141141
index: round, parent_qc: self.eng.high_qc.clone(), window_head_height: head_height,
142-
window_mb_hashes: mb_hashes, state_root, beacon, epoch_commitment: epoch_c, timestamp: head_ts,
142+
window_mb_hashes: mb_hashes, state_root, beacon, epoch_commitment: epoch_c, reward_root, timestamp: head_ts,
143143
proposer: self.node_id.clone(), proposer_sig: Vec::new(),
144144
};
145145
self.last_proposed_round = round;
@@ -312,7 +312,7 @@ mod tests {
312312
for index in 1..=8u64 {
313313
let mut seed = Vec::new();
314314
for k in 0..nodes.len() {
315-
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new());
315+
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32]);
316316
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
317317
}
318318
deliver(&mut nodes, &c, seed);
@@ -340,7 +340,7 @@ mod tests {
340340
for index in 1..=6u64 {
341341
let mut seed = Vec::new();
342342
for k in 0..nodes.len() {
343-
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new());
343+
let effs = nodes[k].d.build_proposal(index, vec![[index as u8; 32]], [index as u8; 32], [0u8; 32], index * 1000, c.clone(), Vec::new(), Vec::new(), [0u8; 32]);
344344
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
345345
}
346346
deliver(&mut nodes, &c, seed);
@@ -359,7 +359,7 @@ mod tests {
359359
let c: Vec<NodeId> = (0..4).map(|i| format!("n{}", i)).collect();
360360
let cp = Checkpoint {
361361
index: 1, parent_qc: None, window_head_height: 10, window_mb_hashes: vec![[1u8; 32]],
362-
state_root: [1u8; 32], beacon: [0u8; 32], epoch_commitment: [0u8; 32], timestamp: 0, proposer: "n1".into(), proposer_sig: vec![9, 9],
362+
state_root: [1u8; 32], beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], timestamp: 0, proposer: "n1".into(), proposer_sig: vec![9, 9],
363363
};
364364
// forged proposer_sig fails node verify ⇒ never reaches the driver
365365
assert!(!verify_msg(&c, &ConsensusMsg::Proposal(cp)));
@@ -382,7 +382,7 @@ mod tests {
382382
fn step(nodes: &mut Vec<Node>, c: &[NodeId], w: u64) {
383383
let mut seed = Vec::new();
384384
for k in 0..nodes.len() {
385-
let effs = nodes[k].d.build_proposal(w, vec![[w as u8; 32]], [w as u8; 32], [0u8; 32], w * 1000, c.to_vec(), Vec::new(), Vec::new());
385+
let effs = nodes[k].d.build_proposal(w, vec![[w as u8; 32]], [w as u8; 32], [0u8; 32], w * 1000, c.to_vec(), Vec::new(), Vec::new(), [0u8; 32]);
386386
for e in effs { seed.extend(exec(&mut nodes[k], e)); }
387387
}
388388
deliver(nodes, c, seed);
@@ -426,7 +426,7 @@ mod tests {
426426
let cp = Checkpoint {
427427
index: i, parent_qc: prev_qc.clone(), window_head_height: i * 90,
428428
window_mb_hashes: vec![[i as u8; 32]], state_root: [i as u8; 32],
429-
beacon: [0u8; 32], epoch_commitment: [0u8; 32], timestamp: 0,
429+
beacon: [0u8; 32], epoch_commitment: [0u8; 32], reward_root: [0u8; 32], timestamp: 0,
430430
proposer: c[leader_index(i, &parent_hash, c.len())].clone(), proposer_sig: Vec::new(),
431431
};
432432
let signers: Vec<NodeId> = c.iter().take(3).cloned().collect();

0 commit comments

Comments
 (0)