diff --git a/consensus/src/merkle_integration.rs b/consensus/src/merkle_integration.rs index 1cd70c4..f662071 100644 --- a/consensus/src/merkle_integration.rs +++ b/consensus/src/merkle_integration.rs @@ -133,7 +133,7 @@ pub fn compute_global_state_root(subnet_roots: &HashMap) - return MerkleHash::zero(); } - let entries: Vec = subnet_roots + let mut entries: Vec = subnet_roots .iter() .map(|(subnet_id, root)| { // Convert SubnetId to [u8; 32] @@ -141,6 +141,10 @@ pub fn compute_global_state_root(subnet_roots: &HashMap) - SubnetStateEntry::new(subnet_bytes, *root) }) .collect(); + // Sort by subnet_id bytes for deterministic ordering across all validators. + // HashMap iteration order is non-deterministic, so without sorting different + // validators would compute different global_state_root from identical inputs. + entries.sort_by(|a, b| a.subnet_id.cmp(&b.subnet_id)); let tree = SubnetAggregationTree::build(entries); tree.root() diff --git a/types/src/consensus.rs b/types/src/consensus.rs index fdf62cc..8ca83a4 100644 --- a/types/src/consensus.rs +++ b/types/src/consensus.rs @@ -88,7 +88,7 @@ impl Anchor { event_ids: &[EventId], vlc_snapshot: &VLCSnapshot, state_root: &str, - timestamp: u64, + _timestamp: u64, ) -> AnchorId { let mut hasher = blake3::Hasher::new(); hasher.update(b"SETU_ANCHOR_ID:"); @@ -97,7 +97,9 @@ impl Anchor { } hasher.update(&vlc_snapshot.logical_time.to_le_bytes()); hasher.update(state_root.as_bytes()); - hasher.update(×tamp.to_le_bytes()); + // NOTE: timestamp is intentionally excluded from the ID computation. + // Using SystemTime::now() made anchor IDs non-deterministic across validators, + // breaking consensus. The event_ids + vlc + state_root already ensure uniqueness. hex::encode(hasher.finalize().as_bytes()) } @@ -308,14 +310,16 @@ impl ConsensusFrame { } } - fn compute_id(round: u64, anchor: &Anchor, proposer: &str, timestamp: u64) -> CFId { + fn compute_id(round: u64, anchor: &Anchor, proposer: &str, _timestamp: u64) -> CFId { let mut hasher = blake3::Hasher::new(); // V2 domain separator: includes round to bind proposer<->round (PR-4). hasher.update(b"SETU_CF_ID_V2:"); hasher.update(&round.to_le_bytes()); hasher.update(anchor.id.as_bytes()); hasher.update(proposer.as_bytes()); - hasher.update(×tamp.to_le_bytes()); + // NOTE: timestamp is intentionally excluded from the ID computation. + // Using SystemTime::now() made CF IDs non-deterministic across validators, + // breaking consensus. The round + anchor + proposer already ensure uniqueness. hex::encode(hasher.finalize().as_bytes()) } @@ -332,7 +336,10 @@ impl ConsensusFrame { } pub fn check_quorum(&self, total_validators: usize) -> bool { - let threshold = (total_validators * 2) / 3 + 1; + // BFT quorum: ceil(2n/3). For n=3 → 2, n=4 → 3, n=6 → 4. + // Previous formula `(n * 2) / 3 + 1` required unanimity for n=3 (3/3), + // which is incorrect for BFT consensus. + let threshold = (total_validators * 2 + 2) / 3; self.approve_count() >= threshold } diff --git a/types/src/event.rs b/types/src/event.rs index 132e941..bc6a8d5 100644 --- a/types/src/event.rs +++ b/types/src/event.rs @@ -555,10 +555,10 @@ impl Event { /// /// The validator builds the genesis event deterministically /// (`setu-validator/src/main.rs`): empty `parent_ids`, fixed creator - /// `"genesis"`, `timestamp = 0`, and `logical_time = 0`. `compute_id` hashes - /// exactly those four inputs, so the genesis event id is a fixed, - /// chain-independent constant - /// (`691c8dd61cdc0391ae5414ce9b6ac9be3ef205ba61daa9cb58dc0b3989e0dd6d`). + /// `"genesis", `timestamp = 0`, and `logical_time = 0`. `compute_id` hashes + /// exactly those three inputs (timestamp excluded for determinism), so the + /// genesis event id is a fixed, chain-independent constant + /// (`1efb63d540602ba4b6487dd7da94ff3bdb31fd0c24e21644f9c333e550d5e8cd`). /// /// Task preparation uses this to drop the genesis parent edge from a /// never-moved coin before `compute_id`, preventing `ParentTooOld` once the @@ -750,7 +750,7 @@ impl Event { parent_ids: &[EventId], vlc_snapshot: &VLCSnapshot, creator: &str, - timestamp: u64, + _timestamp: u64, ) -> EventId { let mut hasher = blake3::Hasher::new(); hasher.update(b"SETU_EVENT_ID:"); @@ -759,7 +759,10 @@ impl Event { } hasher.update(&vlc_snapshot.logical_time.to_le_bytes()); hasher.update(creator.as_bytes()); - hasher.update(×tamp.to_le_bytes()); + // NOTE: timestamp is intentionally excluded from the ID computation. + // Using SystemTime::now() made event IDs non-deterministic across validators, + // breaking consensus. The VLC logical_time already provides causal ordering, + // and creator + parent_ids ensure uniqueness. hex::encode(hasher.finalize().as_bytes()) } @@ -1027,7 +1030,7 @@ mod tests { #[test] fn test_genesis_event_id_is_canonical_constant() { const EXPECTED: &str = - "691c8dd61cdc0391ae5414ce9b6ac9be3ef205ba61daa9cb58dc0b3989e0dd6d"; + "1efb63d540602ba4b6487dd7da94ff3bdb31fd0c24e21644f9c333e550d5e8cd"; assert_eq!(Event::genesis_event_id(), EXPECTED); // Mirror setu-validator/src/main.rs genesis construction. diff --git a/types/src/object.rs b/types/src/object.rs index ae680d8..31611c9 100644 --- a/types/src/object.rs +++ b/types/src/object.rs @@ -420,9 +420,9 @@ impl Object { hasher.update(b"SETU_OBJ_DIGEST:"); hasher.update(self.metadata.id.as_bytes()); hasher.update(&self.metadata.version.to_le_bytes()); - if let Ok(data_bytes) = bcs::to_bytes(&self.data) { - hasher.update(&data_bytes); - } + let data_bytes = bcs::to_bytes(&self.data) + .expect("BCS serialization failed for object data — this indicates a type-level bug"); + hasher.update(&data_bytes); self.metadata.digest = ObjectDigest::new(*hasher.finalize().as_bytes()); }