Skip to content

Commit e314a94

Browse files
AIQnetLabclaude
andcommitted
consensus: deterministic reputation, uniform-VRF selection, equivocation slashing
reputation = pure chain fold {70 gate | 0 if slashed}, decoupled from the per-node live engine (fixes the epoch_commitment divergence that halted the network); live engine is now display/telemetry only. validator selection = uniform VRF sortition among eligible, replacing reputation-rank top-N, which removes validator-set entrenchment at scale. on-chain slashing: block double-sign + same-round checkpoint-vote double-sign, deterministically verified in the fold then permanent ban; fail-safe (a forged or different-round proof never bans); cumulative ban-set anchored in the macroblock body (O(window), pruning-safe). tests: 8 proof-verification tests (valid detected; forged/identical/different-round never ban) + identity-test serialization (parallel flake fix). Fresh genesis required: TX / macroblock body / consensus formats changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 811b709 commit e314a94

9 files changed

Lines changed: 903 additions & 291 deletions

File tree

core/qnet-state/src/block.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,20 @@ pub struct ConsensusData {
154154
/// Format: bincode serialized Vec<SlashingEventData>
155155
#[serde(default)]
156156
pub slashing_events_data: Option<Vec<u8>>,
157-
157+
158+
/// v2 SCALE ANCHOR: cumulative equivocation ban-set as of THIS macroblock.
159+
/// Format: bincode serialized Vec<String> (sorted node_ids).
160+
///
161+
/// Lets the next epoch's reputation fold derive the ban-set in O(window) — prev
162+
/// macroblock's set ∪ this window's verified proofs — instead of re-scanning every
163+
/// microblock from genesis (pruning-safe; scales to 100k+ nodes). NOT included in
164+
/// MacroBlock::hash(): each node self-computes it deterministically, and the ban
165+
/// EFFECT is independently re-verified every epoch via epoch_commitment (eligible
166+
/// excludes banned), so a stale/forged copy self-heals through content_ok fail-stop
167+
/// instead of forking the chain.
168+
#[serde(default)]
169+
pub banned_validators: Option<Vec<u8>>,
170+
158171
/// Serialized automatic jails (computed deterministically)
159172
/// Format: bincode serialized Vec<AutomaticJailData>
160173
#[serde(default)]

core/qnet-state/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ mod python_bindings;
2121

2222
pub use account::{Account, AccountState, NodeType};
2323
pub use block::{Block, BlockHeader, ConsensusProof, BlockType, MicroBlock, MacroBlock, ConsensusData, LightMicroBlock, BlockHash, EfficientMicroBlock, StoredMicroBlock, PoHState, storage_version, SlashingEventData, AutomaticJailData, EligibleProducer, RewardHeartbeat, HeartbeatSummary, ExcludedProducerEntry};
24-
pub use transaction::{Transaction, TransactionReceipt, TransactionType, gas_limits, PingSampleData, HeartbeatSampleData, ShardHeartbeatSummary, GAS_METERING_ACTIVATION_HEIGHT, MAX_CONTRACT_STORAGE_ENTRIES, DynamicGasPricing, init_dynamic_gas_pricing, update_dynamic_gas_pricing, get_dynamic_gas_pricing};
24+
pub use transaction::{Transaction, TransactionReceipt, TransactionType, EquivocationHeader, gas_limits, PingSampleData, HeartbeatSampleData, ShardHeartbeatSummary, GAS_METERING_ACTIVATION_HEIGHT, MAX_CONTRACT_STORAGE_ENTRIES, DynamicGasPricing, init_dynamic_gas_pricing, update_dynamic_gas_pricing, get_dynamic_gas_pricing};
2525
pub use state_db::StateDB;
2626
pub use state_manager::StateManager;
2727
pub use errors::{StateError, StateResult};

core/qnet-state/src/transaction.rs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const RESERVED_PROTOCOL_IDENTIFIERS: &[&str] = &[
6969
"system_emission",
7070
"system_rewards_pool",
7171
"system_ping_commitment",
72+
"system_slashing", // EquivocationProof sender — block-construction only, never gossiped
7273
];
7374

7475
/// Validate whether the `tx.from` value matches one of the three accepted
@@ -242,6 +243,21 @@ pub mod gas_limits {
242243
/// Transaction hash type
243244
pub type TxHash = String;
244245

246+
/// One side of an equivocation proof: the per-block signable header fields of a
247+
/// microblock (height + producer are shared across both sides, kept on the TX).
248+
/// Carries enough to reconstruct the exact `Block_Sig_v23.1` signing digest and
249+
/// re-verify the producer's Dilithium3 signature on-chain — no trust in the reporter.
250+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251+
pub struct EquivocationHeader {
252+
pub timestamp: u64,
253+
pub merkle_root: [u8; 32],
254+
pub previous_hash: [u8; 32],
255+
pub state_root: [u8; 32],
256+
pub vrf_output: Option<[u8; 32]>,
257+
pub timeout_round: u64,
258+
pub signature: Vec<u8>,
259+
}
260+
245261
/// Transaction types
246262
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247263
pub enum TransactionType {
@@ -251,7 +267,41 @@ pub enum TransactionType {
251267
to: String,
252268
amount: u64,
253269
},
254-
270+
271+
/// Cryptographic proof that `offender` signed two DIFFERENT microblocks at the
272+
/// same `height` — provable equivocation. Both `EquivocationHeader.signature`s are
273+
/// the offender's Dilithium3 sigs over the `Block_Sig_v23.1` digest of their fields;
274+
/// unforgeable. Verified on-chain against the offender's registry PK and applied
275+
/// deterministically in the reputation fold (offender → reputation 0 + ban). No
276+
/// balance effect. Fail-safe: an invalid/forged proof simply fails verification.
277+
EquivocationProof {
278+
offender: String,
279+
height: u64,
280+
block_a: EquivocationHeader,
281+
block_b: EquivocationHeader,
282+
},
283+
284+
/// Cryptographic proof that `offender` signed two DIFFERENT checkpoint votes at the SAME
285+
/// consensus round `index` — provable BFT vote equivocation (accountable safety: a
286+
/// committee member double-voting is what an attacker would do to violate finality
287+
/// safety). Both signatures are the offender's consensus-key sigs over the canonical
288+
/// `QNET_BFT2_VOTE:<hex(checkpoint_hash)>` message; unforgeable. Verified on-chain against
289+
/// the offender's registry PK and applied in the reputation fold (offender → ban). No
290+
/// balance effect. Fail-safe: an invalid/forged proof simply fails verification.
291+
VoteEquivocationProof {
292+
offender: String,
293+
/// bincode of BOTH conflicting checkpoints (qnet_consensus Checkpoint). SOUNDNESS:
294+
/// the vote signature covers ONLY the checkpoint hash, NOT the round, so the full
295+
/// preimages are REQUIRED — the fold re-derives each hash and reads each round `index`,
296+
/// then bans ONLY if `index_a == index_b` (a same-round double-vote) and the hashes
297+
/// differ and both sigs verify. Carrying only hashes would let a forger pair two honest
298+
/// votes from DIFFERENT rounds and falsely slash an honest node.
299+
checkpoint_a: Vec<u8>,
300+
signature_a: Vec<u8>,
301+
checkpoint_b: Vec<u8>,
302+
signature_b: Vec<u8>,
303+
},
304+
255305
/// Token swap via DEX smart contract
256306
/// Fee: standard gas fee goes directly to block producer (v3.18: Pool 2 removed)
257307
Swap {
@@ -810,6 +860,8 @@ impl Transaction {
810860
| TransactionType::LightNodeEligibilityBitmap { .. }
811861
| TransactionType::RewardDistribution
812862
| TransactionType::KeyRotation { .. }
863+
| TransactionType::EquivocationProof { .. }
864+
| TransactionType::VoteEquivocationProof { .. }
813865
)
814866
}
815867

@@ -936,6 +988,9 @@ impl Transaction {
936988
// gas costs to deter quantum-readiness adoption. Rate-limited via
937989
// per-account nonce monotonicity (one upgrade per account, ever).
938990
TransactionType::SetPQRequirement {} => 0,
991+
// Equivocation slashing proofs: system TX, free (no gas, no balance effect).
992+
TransactionType::EquivocationProof { .. } => 0,
993+
TransactionType::VoteEquivocationProof { .. } => 0,
939994
}
940995
}
941996

@@ -1521,6 +1576,35 @@ impl Transaction {
15211576
// because it inspects fields on the parent Transaction struct.
15221577
// Empty sender is already caught at the top of `validate()`.
15231578
}
1579+
TransactionType::EquivocationProof { offender, block_a, block_b, .. } => {
1580+
// Structural check only — the cryptographic verification (Dilithium3 over
1581+
// the Block_Sig_v23.1 digest against the offender's registry PK) runs at the
1582+
// integration layer, which holds the consensus PK registry.
1583+
if offender.is_empty() {
1584+
return Err("[REJECT][TX] equivocation_proof empty_offender".to_string());
1585+
}
1586+
if block_a == block_b {
1587+
return Err("[REJECT][TX] equivocation_proof identical_blocks".to_string());
1588+
}
1589+
if block_a.signature.is_empty() || block_b.signature.is_empty() {
1590+
return Err("[REJECT][TX] equivocation_proof missing_signature".to_string());
1591+
}
1592+
}
1593+
TransactionType::VoteEquivocationProof { offender, checkpoint_a, signature_a, checkpoint_b, signature_b } => {
1594+
// Structural check only — the cryptographic + same-round verification (deserialize
1595+
// both checkpoints, index_a == index_b, hashes differ, both consensus-key sigs over
1596+
// QNET_BFT2_VOTE:<hex(hash)> valid vs the offender's registry PK) runs at the
1597+
// integration layer, which holds the consensus PK registry + the Checkpoint type.
1598+
if offender.is_empty() {
1599+
return Err("[REJECT][TX] vote_equivocation_proof empty_offender".to_string());
1600+
}
1601+
if checkpoint_a == checkpoint_b {
1602+
return Err("[REJECT][TX] vote_equivocation_proof identical_checkpoints".to_string());
1603+
}
1604+
if signature_a.is_empty() || signature_b.is_empty() {
1605+
return Err("[REJECT][TX] vote_equivocation_proof missing_signature".to_string());
1606+
}
1607+
}
15241608
}
15251609

15261610
Ok(())
@@ -2713,11 +2797,16 @@ impl Transaction {
27132797
}
27142798
}
27152799
}
2800+
// Equivocation slashing proofs: no account-state effect. The penalty
2801+
// (offender → reputation 0 + ban) is applied deterministically in the
2802+
// reputation fold from the committed proof, not in account state.
2803+
TransactionType::EquivocationProof { .. } => {}
2804+
TransactionType::VoteEquivocationProof { .. } => {}
27162805
}
27172806

27182807
Ok(())
27192808
}
2720-
2809+
27212810
/// Check if transaction qualifies for instant local finalization
27222811
pub fn can_be_locally_finalized(&self, config: &LocalFinalizationConfig) -> bool {
27232812
// Small amount transactions get instant finalization

development/qnet-integration/src/consensus_v2_node.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ pub async fn execute(effects: Vec<Effect>, node_id: &str, p2p: &Arc<SimplifiedP2
174174
.and_then(|m| bincode::serialize(&m).ok());
175175
(hb, lt)
176176
} else { (None, None) };
177+
// v2 SCALE ANCHOR: cumulative equivocation ban-set as of this window (prev
178+
// macroblock's set ∪ this window's verified proofs), sorted for byte-stable
179+
// bincode. Lets the next epoch's reputation fold derive bans in O(window)
180+
// instead of re-scanning from genesis (pruning-safe, scales to 100k). Pure
181+
// function of the committed chain ⇒ every sealer produces the same bytes.
182+
let banned_validators = {
183+
let mut v: Vec<String> =
184+
crate::node::BlockchainNode::compute_cumulative_ban_set(&storage, window)
185+
.await
186+
.into_iter().collect();
187+
v.sort();
188+
Some(bincode::serialize(&v).unwrap_or_default())
189+
};
177190
let mb = qnet_state::MacroBlock {
178191
height: window,
179192
timestamp: checkpoint.timestamp,
@@ -185,6 +198,7 @@ pub async fn execute(effects: Vec<Effect>, node_id: &str, p2p: &Arc<SimplifiedP2
185198
randomness_beacon: Some(checkpoint.beacon),
186199
excluded_producers_for_next_epoch: excluded,
187200
consensus_committee: Some(committee),
201+
banned_validators,
188202
reward_heartbeats,
189203
reward_light_nodes,
190204
..Default::default()
@@ -394,6 +408,18 @@ pub async fn run(
394408
// epoch_commitment (eligible+committee). Honest 2f+1 reject any forged
395409
// checkpoint ⇒ a malicious leader cannot finalize fake state. No local
396410
// content ⇒ can't check here (re-verified on macroblock sync).
411+
// ACCOUNTABLE SAFETY (pure side effect — never alters handling below):
412+
// cache authentic checkpoints + detect a committee member signing two
413+
// DIFFERENT checkpoints at the SAME round → records sound on-chain
414+
// vote-equivocation evidence (drained into a VoteEquivocationProof TX,
415+
// verified + banned in the deterministic reputation fold).
416+
match &msg {
417+
ConsensusMsg::Proposal(cp) => crate::node::observe_checkpoint_proposal(
418+
cp.index, cp.hash(), bincode::serialize(cp).unwrap_or_default()),
419+
ConsensusMsg::Vote(v) => crate::node::observe_checkpoint_vote(
420+
v.index, &v.voter, v.checkpoint_hash, v.signature.clone()),
421+
_ => {}
422+
}
397423
let content_ok = match &msg {
398424
ConsensusMsg::Proposal(cp) => window_buf.get(&(cp.window_head_height / 90))
399425
.map(|c| cp.state_root == c.state_root

development/qnet-integration/src/crypto/key_manager.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ fn keypair_init_locks() -> &'static DashMap<PathBuf, Arc<Mutex<()>>> {
4545
GLOBAL_KEYPAIR_INIT_LOCKS.get_or_init(DashMap::new)
4646
}
4747

48+
/// Serializes identity/keypair tests across modules. They all mutate process-wide global
49+
/// state — the keypair cache, the `CACHED_KEY_DIR` OnceLock, and `canonicalize()` over
50+
/// transient `tempdir()`s — which races under parallel `cargo test` (one test's temp dir
51+
/// is cleaned while another canonicalizes the cached path → key mismatch → spurious
52+
/// `identity_not_installed`). Production installs identity once at boot, so this guard is
53+
/// strictly test-only. Poison-tolerant: a panicking test must not wedge the rest.
54+
#[cfg(test)]
55+
pub(crate) static IDENTITY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
56+
4857
/// Compute the canonical cache key for a given key directory.
4958
/// Uses canonicalized parent dir to ensure two managers pointing to the same on-disk
5059
/// file (even via different relative paths) share the same global cache entry.
@@ -901,6 +910,7 @@ mod tests {
901910

902911
#[test]
903912
fn test_singleton_same_dir_returns_same_keypair() {
913+
let _identity_guard = IDENTITY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
904914
let temp = tempdir().expect("tempdir");
905915
let key_dir = temp.path().join("keys_singleton_a");
906916

@@ -940,6 +950,7 @@ mod tests {
940950
/// producing two different on-disk and in-memory identities.
941951
#[test]
942952
fn test_singleton_concurrent_init_no_divergence() {
953+
let _identity_guard = IDENTITY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
943954
use std::thread;
944955
use std::sync::Arc as StdArc;
945956
use std::sync::Barrier;
@@ -998,6 +1009,7 @@ mod tests {
9981009
/// paths (e.g., trailing slash, current-dir prefix) still share state.
9991010
#[test]
10001011
fn test_singleton_canonical_path_keying() {
1012+
let _identity_guard = IDENTITY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
10011013
let temp = tempdir().expect("tempdir");
10021014
let key_dir = temp.path().join("keys_canon_c");
10031015
fs::create_dir_all(&key_dir).expect("mkdir");
@@ -1024,6 +1036,7 @@ mod tests {
10241036
/// accidental cross-contamination via the global cache).
10251037
#[test]
10261038
fn test_singleton_distinct_dirs_distinct_keypairs() {
1039+
let _identity_guard = IDENTITY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
10271040
let temp = tempdir().expect("tempdir");
10281041
let dir_a = temp.path().join("keys_distinct_a_d");
10291042
let dir_b = temp.path().join("keys_distinct_b_d");

development/qnet-integration/src/crypto/quantum_crypto.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,13 @@ mod tests {
13761376
async fn test_dilithium_sign_and_verify() {
13771377
println!("[TEST][QUANTUM_CRYPTO] test_dilithium_sign_and_verify start");
13781378

1379+
// Serialize against the key_manager identity tests: all share the process-wide
1380+
// keypair cache + CACHED_KEY_DIR OnceLock + canonicalize() over transient temp
1381+
// dirs. Without this, a parallel identity test cleans a dir mid-run and our
1382+
// install/sign resolve to different canonical keys → spurious identity_not_installed.
1383+
let _identity_guard = crate::crypto::key_manager::IDENTITY_TEST_LOCK
1384+
.lock().unwrap_or_else(|e| e.into_inner());
1385+
13791386
// 1. Initialize crypto
13801387
let mut crypto = QNetQuantumCrypto::new();
13811388
let init_result = crypto.initialize().await;

0 commit comments

Comments
 (0)