diff --git a/bin/sentrix/src/main.rs b/bin/sentrix/src/main.rs index 98a15570..2597227c 100644 --- a/bin/sentrix/src/main.rs +++ b/bin/sentrix/src/main.rs @@ -6,7 +6,6 @@ use libp2p::Multiaddr; use sentrix::api::events::EventBus; use sentrix::api::routes::{SharedState, create_router_with_bus}; use sentrix::core::blockchain::{BLOCK_TIME_SECS, Blockchain}; -use sentrix::core::transaction::PROTOCOL_TREASURY; use sentrix::network::libp2p_node::{LibP2pNode, make_multiaddr}; use sentrix::network::node::{DEFAULT_PORT, NodeEvent}; use sentrix::storage::db::Storage; @@ -2347,74 +2346,7 @@ async fn cmd_start( validator_fee, ); - if sentrix::core::epoch::EpochManager::is_epoch_boundary(height) { - tracing::info!("Epoch boundary at height {} — transitioning", height); - let released = bc.stake_registry.process_unbonding(height); - for (delegator, amount) in &released { - // V4 Step 3: post-reward-v2 fork, unbonded - // stake returns from treasury (where it - // was escrowed on Delegate), not a fresh - // mint. Pre-fork path keeps legacy credit - // behaviour for existing chain.db state. - let r = if Blockchain::is_reward_v2_height(height) { - bc.accounts.transfer( - PROTOCOL_TREASURY, - delegator, - *amount, - 0, - ) - } else { - bc.accounts.credit(delegator, *amount) - }; - r.unwrap_or_else(|e| tracing::warn!("unbonding release failed: {}", e)); - } - if !released.is_empty() { - tracing::info!("Released {} unbonding entries", released.len()); - } - - bc.stake_registry.update_active_set(); - let active_set = bc.stake_registry.active_set.clone(); - let total_staked: u64 = active_set.iter() - .filter_map(|a| bc.stake_registry.get_validator(a)) - .map(|v| v.total_stake()) - .sum(); - bc.epoch_manager.record_block(0); - let finished = bc.epoch_manager.current_epoch.clone(); - bc.epoch_manager.history.push(finished); - if bc.epoch_manager.history.len() > bc.epoch_manager.max_history { - bc.epoch_manager.history.remove(0); - } - let next_num = bc.epoch_manager.current_epoch.epoch_number + 1; - let next_start = next_num * sentrix::core::epoch::EPOCH_LENGTH; - bc.epoch_manager.current_epoch = sentrix::core::epoch::EpochInfo { - epoch_number: next_num, - start_height: next_start, - end_height: next_start + sentrix::core::epoch::EPOCH_LENGTH - 1, - validator_set: active_set.clone(), - total_staked, - total_rewards: 0, - total_blocks_produced: 0, - }; - // Phase 3 WS: notify sentrix_subscribe(validatorSet) - // — full epoch-advance wire-up. Subscribers see - // the new active set on every epoch boundary, - // not just AddSelfStake re-entry. - if let Some(emitter) = &bc.event_emitter { - emitter.emit_validator_set(next_num, &active_set); - } - tracing::info!("Epoch {} started — {} validators, {} staked", - next_num, active_set.len(), total_staked); - - let mut slashing = std::mem::take(&mut bc.slashing); - let slashed = slashing.check_liveness( - &mut bc.stake_registry, &active_set, height, - ); - bc.slashing = slashing; - for (val, amt) in &slashed { - tracing::warn!("Slashed {} for {} sentri (downtime)", val, amt); - bc.accounts.burn(*amt); - } - } + bc.run_epoch_bookkeeping(height); tracing::info!( "BFT finalized height={} round={}", @@ -2963,73 +2895,7 @@ async fn cmd_start( validator_fee, ); - if sentrix::core::epoch::EpochManager::is_epoch_boundary(height) { - tracing::info!("Epoch boundary at height {} — transitioning", height); - let released = bc.stake_registry.process_unbonding(height); - for (delegator, amount) in &released { - // V4 Step 3 — mirror of the self-produced - // finalize handler above; unbonded stake - // returns from treasury post-fork. - let r = if Blockchain::is_reward_v2_height(height) { - bc.accounts.transfer( - PROTOCOL_TREASURY, - delegator, - *amount, - 0, - ) - } else { - bc.accounts.credit(delegator, *amount) - }; - r.unwrap_or_else(|e| tracing::warn!("unbonding release failed: {}", e)); - } - if !released.is_empty() { - tracing::info!("Released {} unbonding entries", released.len()); - } - - bc.stake_registry.update_active_set(); - let active_set = bc.stake_registry.active_set.clone(); - let total_staked: u64 = active_set.iter() - .filter_map(|a| bc.stake_registry.get_validator(a)) - .map(|v| v.total_stake()) - .sum(); - bc.epoch_manager.record_block(0); - let finished = bc.epoch_manager.current_epoch.clone(); - bc.epoch_manager.history.push(finished); - if bc.epoch_manager.history.len() > bc.epoch_manager.max_history { - bc.epoch_manager.history.remove(0); - } - let next_num = bc.epoch_manager.current_epoch.epoch_number + 1; - let next_start = next_num * sentrix::core::epoch::EPOCH_LENGTH; - bc.epoch_manager.current_epoch = sentrix::core::epoch::EpochInfo { - epoch_number: next_num, - start_height: next_start, - end_height: next_start + sentrix::core::epoch::EPOCH_LENGTH - 1, - validator_set: active_set.clone(), - total_staked, - total_rewards: 0, - total_blocks_produced: 0, - }; - // Phase 3 WS: notify sentrix_subscribe(validatorSet) — - // libp2p-applied path mirror of the validator-finalize - // emit above. Both call sites must emit so subscribers - // see the rotation regardless of which path applied - // the boundary block. - if let Some(emitter) = &bc.event_emitter { - emitter.emit_validator_set(next_num, &active_set); - } - tracing::info!("Epoch {} started — {} validators, {} staked", - next_num, active_set.len(), total_staked); - - let mut slashing = std::mem::take(&mut bc.slashing); - let slashed = slashing.check_liveness( - &mut bc.stake_registry, &active_set, height, - ); - bc.slashing = slashing; - for (val, amt) in &slashed { - tracing::warn!("Slashed {} for {} sentri (downtime)", val, amt); - bc.accounts.burn(*amt); - } - } + bc.run_epoch_bookkeeping(height); tracing::info!( "BFT finalized height={} round={}", diff --git a/crates/sentrix-core/src/blockchain.rs b/crates/sentrix-core/src/blockchain.rs index 1381b435..90f0a6fb 100644 --- a/crates/sentrix-core/src/blockchain.rs +++ b/crates/sentrix-core/src/blockchain.rs @@ -519,6 +519,82 @@ impl Blockchain { self.total_minted } + /// Run epoch-boundary bookkeeping for the block at `height` if it is + /// an epoch boundary. Encapsulates process_unbonding → unbond releases + /// → update_active_set → epoch rotation → slashing.check_liveness so + /// both the BFT-finalize path (main.rs) and the libp2p sync paths + /// (gossip + GetBlocks catch-up) run identical logic. + /// + /// Call AFTER record_block + distribute_reward have already run for + /// this block. Returns without doing anything if the height is not + /// an epoch boundary. + pub fn run_epoch_bookkeeping(&mut self, height: u64) { + use sentrix_primitives::transaction::PROTOCOL_TREASURY; + use sentrix_staking::epoch::{EpochInfo, EpochManager, EPOCH_LENGTH}; + + if !EpochManager::is_epoch_boundary(height) { + return; + } + + tracing::info!("Epoch boundary at height {} — transitioning", height); + let released = self.stake_registry.process_unbonding(height); + for (delegator, amount) in &released { + let r = if Self::is_reward_v2_height(height) { + self.accounts.transfer(PROTOCOL_TREASURY, delegator, *amount, 0) + } else { + self.accounts.credit(delegator, *amount) + }; + r.unwrap_or_else(|e| tracing::warn!("unbonding release failed: {}", e)); + } + if !released.is_empty() { + tracing::info!("Released {} unbonding entries", released.len()); + } + + self.stake_registry.update_active_set(); + let active_set = self.stake_registry.active_set.clone(); + let total_staked: u64 = active_set + .iter() + .filter_map(|a| self.stake_registry.get_validator(a)) + .map(|v| v.total_stake()) + .sum(); + + self.epoch_manager.record_block(0); + let finished = self.epoch_manager.current_epoch.clone(); + self.epoch_manager.history.push(finished); + if self.epoch_manager.history.len() > self.epoch_manager.max_history { + self.epoch_manager.history.remove(0); + } + let next_num = self.epoch_manager.current_epoch.epoch_number + 1; + let next_start = next_num * EPOCH_LENGTH; + self.epoch_manager.current_epoch = EpochInfo { + epoch_number: next_num, + start_height: next_start, + end_height: next_start + EPOCH_LENGTH - 1, + validator_set: active_set.clone(), + total_staked, + total_rewards: 0, + total_blocks_produced: 0, + }; + + if let Some(emitter) = &self.event_emitter { + emitter.emit_validator_set(next_num, &active_set); + } + tracing::info!( + "Epoch {} started — {} validators, {} staked", + next_num, + active_set.len(), + total_staked + ); + + let mut slashing = std::mem::take(&mut self.slashing); + let slashed = slashing.check_liveness(&mut self.stake_registry, &active_set, height); + self.slashing = slashing; + for (val, amt) in &slashed { + tracing::warn!("Slashed {} for {} sentri (downtime)", val, amt); + self.accounts.burn(*amt); + } + } + // Memory estimate reflects the in-memory window, not the full historical chain pub fn get_memory_estimate(&self) -> String { let window_blocks = self.chain.len(); diff --git a/crates/sentrix-network/src/libp2p_node.rs b/crates/sentrix-network/src/libp2p_node.rs index e6cb72b4..d3cb7cfd 100644 --- a/crates/sentrix-network/src/libp2p_node.rs +++ b/crates/sentrix-network/src/libp2p_node.rs @@ -1132,6 +1132,11 @@ async fn on_swarm_event( ); // Epoch tracking — mirror main.rs validator-finalize. chain.epoch_manager.record_block(reward); + // Epoch boundary transition — rotate active set, + // release unbonding, run liveness slashing. + // Previously missing here; libp2p-synced nodes + // diverged from BFT-finalize path at boundaries. + chain.run_epoch_bookkeeping(gossip.block.index); } let updated = chain.latest_block().ok().cloned().unwrap_or(gossip.block); @@ -2018,6 +2023,11 @@ async fn on_inbound_response( 0, ); chain.epoch_manager.record_block(reward); + // Epoch boundary transition — rotate active set, + // release unbonding, run liveness slashing. + // Previously missing here; batch-syncing nodes + // diverged from BFT-finalize path at boundaries. + chain.run_epoch_bookkeeping(block.index); } // Use H2 (post-add_block state_root hash) — not the raw peer block (PR #78). let updated = chain diff --git a/crates/sentrix-trie/src/address.rs b/crates/sentrix-trie/src/address.rs index a4750dbd..b2eedde0 100644 --- a/crates/sentrix-trie/src/address.rs +++ b/crates/sentrix-trie/src/address.rs @@ -188,10 +188,13 @@ pub fn epoch_state_value_bytes( v[32..40].copy_from_slice(&total_rewards.to_be_bytes()); v[40..48].copy_from_slice(&total_blocks_produced.to_be_bytes()); - let mut sorted: Vec<&String> = validator_set.iter().collect(); + let mut sorted: Vec = validator_set + .iter() + .map(|a| a.trim_start_matches("0x").to_lowercase()) + .collect(); sorted.sort(); let mut h = Sha256::new(); - for addr in sorted { + for addr in &sorted { h.update(addr.as_bytes()); h.update(b"\n"); } @@ -470,6 +473,31 @@ mod tests { ); } + /// validator_set hash must be case-insensitive and strip 0x prefix. + /// If one node stores addresses as "0xABCD" and another as "0xabcd", + /// both must hash to the same bytes — otherwise state_root forks at + /// every post-SIP-6 block. + #[test] + fn test_epoch_state_value_validator_set_case_insensitive() { + let lower = vec![ + "0xaaaa".to_string(), + "0xbbbb".to_string(), + ]; + let upper = vec![ + "0xAAAA".to_string(), + "0xBBBB".to_string(), + ]; + let no_prefix = vec![ + "aaaa".to_string(), + "bbbb".to_string(), + ]; + let v_lower = epoch_state_value_bytes(1, 0, 0, 0, 0, 0, &lower); + let v_upper = epoch_state_value_bytes(1, 0, 0, 0, 0, 0, &upper); + let v_noprefix = epoch_state_value_bytes(1, 0, 0, 0, 0, 0, &no_prefix); + assert_eq!(v_lower, v_upper, "validator_set hash must be case-insensitive"); + assert_eq!(v_lower, v_noprefix, "validator_set hash must strip 0x prefix"); + } + #[test] fn test_epoch_state_value_decode_short_returns_none() { assert!(epoch_state_value_decode(&[0u8; 79]).is_none());