diff --git a/CHANGELOG.md b/CHANGELOG.md index 333221b1..42fff4a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,30 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.2.27] — 2026-06-04 — Deterministic per-block bookkeeping (fork-gated) + +### Consensus — reward/epoch bookkeeping moved to the apply path + +Root cause of the 2026-06-03 testnet 4-way state drift (only +`PROTOCOL_TREASURY` diverged): reward distribution, liveness recording, +epoch accounting, and epoch-boundary rotation/slashing ran in 5 separate +network/finalize receive paths instead of the deterministic block-apply +path. Uneven path coverage applied a block's bookkeeping a per-node-variable +number of times → `pending_rewards`/`delegator_rewards` (hence the treasury +balance) and `epoch_state` drifted → `state_root` divergence (masked because +`state_root` is excluded from the block hash). + +Fix (PR #782): the full bundle (`record_block_signatures` + +`distribute_reward` + `epoch_manager.record_block` + `run_epoch_bookkeeping`) +now runs exactly once inside `apply_block_pass2`, keyed off the committed +block's justification, before `update_trie_for_block`. The 5 receive-path +copies are gated off post-fork. + +Fork-gated by `REWARD_APPLY_PATH_HEIGHT` (default `u64::MAX` on both nets): +pre-fork is bit-identical to today; post-fork the bookkeeping is +deterministic regardless of how a block arrived. Consensus-changing — +activate only via halt-all + state-root-aligned + simul-start. + ## [2.2.26] — 2026-06-03 — BFT block-hash consistency + fork detection ### Consensus — Bug B: block hash now correct before BFT voting diff --git a/Cargo.lock b/Cargo.lock index fdda9f33..646a122f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5172,7 +5172,7 @@ dependencies = [ [[package]] name = "sentrix" -version = "2.2.26" +version = "2.2.27" dependencies = [ "aes-gcm", "alloy-consensus", @@ -5220,7 +5220,7 @@ dependencies = [ [[package]] name = "sentrix-bft" -version = "2.2.26" +version = "2.2.27" dependencies = [ "bincode", "hex", @@ -5248,7 +5248,7 @@ dependencies = [ [[package]] name = "sentrix-core" -version = "2.2.26" +version = "2.2.27" dependencies = [ "alloy-consensus", "alloy-eips", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "sentrix-evm" -version = "2.2.26" +version = "2.2.27" dependencies = [ "alloy-primitives", "hex", @@ -5294,7 +5294,7 @@ dependencies = [ [[package]] name = "sentrix-faucet" -version = "2.2.26" +version = "2.2.27" dependencies = [ "anyhow", "axum", @@ -5317,7 +5317,7 @@ dependencies = [ [[package]] name = "sentrix-grpc" -version = "2.2.26" +version = "2.2.27" dependencies = [ "async-stream", "bincode", @@ -5336,7 +5336,7 @@ dependencies = [ [[package]] name = "sentrix-network" -version = "2.2.26" +version = "2.2.27" dependencies = [ "async-trait", "bincode", @@ -5354,7 +5354,7 @@ dependencies = [ [[package]] name = "sentrix-nft" -version = "2.2.26" +version = "2.2.27" dependencies = [ "hex", "serde", @@ -5365,7 +5365,7 @@ dependencies = [ [[package]] name = "sentrix-node" -version = "2.2.26" +version = "2.2.27" dependencies = [ "anyhow", "axum", @@ -5391,14 +5391,14 @@ dependencies = [ [[package]] name = "sentrix-precompiles" -version = "2.2.26" +version = "2.2.27" dependencies = [ "alloy-primitives", ] [[package]] name = "sentrix-primitives" -version = "2.2.26" +version = "2.2.27" dependencies = [ "hex", "proptest", @@ -5413,7 +5413,7 @@ dependencies = [ [[package]] name = "sentrix-prom-exporter" -version = "2.2.26" +version = "2.2.27" dependencies = [ "http-body-util", "hyper", @@ -5442,7 +5442,7 @@ dependencies = [ [[package]] name = "sentrix-rpc" -version = "2.2.26" +version = "2.2.27" dependencies = [ "alloy-consensus", "alloy-eips", @@ -5475,14 +5475,14 @@ dependencies = [ [[package]] name = "sentrix-rpc-types" -version = "2.2.26" +version = "2.2.27" dependencies = [ "serde_json", ] [[package]] name = "sentrix-staking" -version = "2.2.26" +version = "2.2.27" dependencies = [ "sentrix-primitives", "serde", @@ -5492,7 +5492,7 @@ dependencies = [ [[package]] name = "sentrix-storage" -version = "2.2.26" +version = "2.2.27" dependencies = [ "bincode", "libmdbx", @@ -5507,7 +5507,7 @@ dependencies = [ [[package]] name = "sentrix-trie" -version = "2.2.26" +version = "2.2.27" dependencies = [ "bincode", "blake3", @@ -5524,7 +5524,7 @@ dependencies = [ [[package]] name = "sentrix-wallet" -version = "2.2.26" +version = "2.2.27" dependencies = [ "aes-gcm", "argon2", @@ -5544,7 +5544,7 @@ dependencies = [ [[package]] name = "sentrix-wire" -version = "2.2.26" +version = "2.2.27" dependencies = [ "bincode", "secp256k1 0.31.1", diff --git a/Cargo.toml b/Cargo.toml index c99cf1fc..8db6b5fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [".", "crates/sentrix-primitives", "crates/sentrix-wallet", "crates/se # `version.workspace = true`. Same goes for edition/license/repository so # they can't drift across crates. [workspace.package] -version = "2.2.26" +version = "2.2.27" edition = "2024" license = "BUSL-1.1" repository = "https://github.com/sentrix-labs/sentrix" diff --git a/bin/sentrix/src/main.rs b/bin/sentrix/src/main.rs index a5aa72e1..4f16f809 100644 --- a/bin/sentrix/src/main.rs +++ b/bin/sentrix/src/main.rs @@ -2332,55 +2332,66 @@ async fn cmd_start( bc.latest_block().ok().cloned(); // ── Post-block Voyager bookkeeping ── - let reward = bc.get_block_reward(); - bc.epoch_manager.record_block(reward); - - let active = - bc.stake_registry.active_set.clone(); - // #253: liveness-signers bug — the old - // `signers = vec![proposer]` marked every - // non-proposer as MISSED each block, so on - // a 4-validator BFT chain each validator - // signed only 25% of blocks vs the 30% - // MIN_SIGNED_PER_WINDOW threshold, driving - // deterministic cascade-jail every 14400 - // blocks (~80min). Correct model: every - // precommit signer in the justification - // signed the block, not just the proposer. - let signers: Vec = justification - .precommits - .iter() - .map(|p| p.validator.clone()) - .collect(); - bc.slashing.record_block_signatures( - &active, &signers, height, - ); - - // V4 Step 2: pay every signer pro-rata - // by stake, not just the proposer. Extract - // (validator, stake_weight) tuples from the - // justification's precommit list. - let reward_signers: Vec<(String, u64)> = - justification + // Reward / liveness / epoch-record bundle: + // pre-fork only. Post REWARD_APPLY_PATH_HEIGHT + // this runs once in apply_block_pass2, so the + // validator-finalize copy is skipped. + if !sentrix::core::fork_heights::is_reward_apply_path_height(height) { + let reward = bc.get_block_reward(); + bc.epoch_manager.record_block(reward); + + let active = + bc.stake_registry.active_set.clone(); + // #253: liveness-signers bug — the old + // `signers = vec![proposer]` marked every + // non-proposer as MISSED each block, so on + // a 4-validator BFT chain each validator + // signed only 25% of blocks vs the 30% + // MIN_SIGNED_PER_WINDOW threshold, driving + // deterministic cascade-jail every 14400 + // blocks (~80min). Correct model: every + // precommit signer in the justification + // signed the block, not just the proposer. + let signers: Vec = justification .precommits .iter() - .map(|p| { - ( - p.validator.clone(), - p.stake_weight, - ) - }) + .map(|p| p.validator.clone()) .collect(); - let validator_fee = 0; - let _ = - bc.stake_registry.distribute_reward( - &proposer, - &reward_signers, - reward, - validator_fee, + bc.slashing.record_block_signatures( + &active, &signers, height, ); - bc.run_epoch_bookkeeping(height); + // V4 Step 2: pay every signer pro-rata + // by stake, not just the proposer. Extract + // (validator, stake_weight) tuples from the + // justification's precommit list. + let reward_signers: Vec<(String, u64)> = + justification + .precommits + .iter() + .map(|p| { + ( + p.validator.clone(), + p.stake_weight, + ) + }) + .collect(); + let validator_fee = 0; + let _ = + bc.stake_registry.distribute_reward( + &proposer, + &reward_signers, + reward, + validator_fee, + ); + } + + // Epoch boundary transition — pre-fork + // only; post REWARD_APPLY_PATH_HEIGHT it + // runs in apply_block_pass2. + if !sentrix::core::fork_heights::is_reward_apply_path_height(height) { + bc.run_epoch_bookkeeping(height); + } tracing::info!( "BFT finalized height={} round={}", @@ -2940,39 +2951,48 @@ async fn cmd_start( let updated = bc.latest_block().ok().cloned(); // ── Post-block Voyager bookkeeping ── - let reward = bc.get_block_reward(); - bc.epoch_manager.record_block(reward); - - let active = bc.stake_registry.active_set.clone(); - // #253: see the sibling site above for rationale. - // Peer-finalize branch — same fix, same model. - let signers: Vec = justification - .precommits - .iter() - .map(|p| p.validator.clone()) - .collect(); - bc.slashing.record_block_signatures( - &active, &signers, height, - ); - - // V4 Step 2 — see sibling site above for rationale. - let reward_signers: Vec<(String, u64)> = - justification + // Reward / liveness / epoch-record bundle: pre-fork + // only. Post REWARD_APPLY_PATH_HEIGHT it runs once in + // apply_block_pass2; skip the peer-finalize copy. + if !sentrix::core::fork_heights::is_reward_apply_path_height(height) { + let reward = bc.get_block_reward(); + bc.epoch_manager.record_block(reward); + + let active = bc.stake_registry.active_set.clone(); + // #253: see the sibling site above for rationale. + // Peer-finalize branch — same fix, same model. + let signers: Vec = justification .precommits .iter() - .map(|p| { - (p.validator.clone(), p.stake_weight) - }) + .map(|p| p.validator.clone()) .collect(); - let validator_fee = 0; - let _ = bc.stake_registry.distribute_reward( - &proposer, - &reward_signers, - reward, - validator_fee, - ); + bc.slashing.record_block_signatures( + &active, &signers, height, + ); - bc.run_epoch_bookkeeping(height); + // V4 Step 2 — see sibling site above for rationale. + let reward_signers: Vec<(String, u64)> = + justification + .precommits + .iter() + .map(|p| { + (p.validator.clone(), p.stake_weight) + }) + .collect(); + let validator_fee = 0; + let _ = bc.stake_registry.distribute_reward( + &proposer, + &reward_signers, + reward, + validator_fee, + ); + } + + // Epoch boundary transition — pre-fork only; post + // REWARD_APPLY_PATH_HEIGHT it runs in apply_block_pass2. + if !sentrix::core::fork_heights::is_reward_apply_path_height(height) { + bc.run_epoch_bookkeeping(height); + } tracing::info!( "BFT finalized height={} round={}", diff --git a/crates/sentrix-core/src/block_executor.rs b/crates/sentrix-core/src/block_executor.rs index 3333aadd..db9919dc 100644 --- a/crates/sentrix-core/src/block_executor.rs +++ b/crates/sentrix-core/src/block_executor.rs @@ -1571,6 +1571,31 @@ impl Blockchain { self.chain.drain(..excess); } + // Reward-apply-path fork: run ALL per-block bookkeeping here, exactly + // once per block, instead of in the 5 network/finalize receive paths + // (which applied it a per-node-variable number of times and drifted + // consensus state). Two pieces, both deterministic off the committed + // block + justification, run BEFORE update_trie_for_block so their + // mutations land in THIS block's state_root: + // + // 1. reward / liveness / epoch-record bundle (every block) — drifted + // PROTOCOL_TREASURY = sum(pending_rewards + delegator_rewards). + // 2. run_epoch_bookkeeping (epoch boundaries only) — active-set + // rotation, unbonding release, liveness slashing. NOT idempotent + // (advances epoch_number, pushes history, slashes), so a per-node- + // variable application count would corrupt epoch_state (trie- + // committed) + double-slash. Runs AFTER the bundle so it sees the + // bundle's fresh liveness counts + pre-rotation active_set, matching + // the external ordering. + // + // Pre-fork: skipped here; the external receive-path call sites still do + // it (bit-identical to today). Inside Pass-2 → covered by the snapshot/ + // rollback if a later step fails. + if Self::is_reward_apply_path_height(self.height()) { + self.apply_reward_bookkeeping_for_latest_block(); + self.run_epoch_bookkeeping(self.height()); + } + // Update state trie after block commit, stamp state_root on the block header, // and verify the sender's committed root when receiving from peers. let profile_t1 = profile_t0.map(|_| std::time::Instant::now()); @@ -1746,6 +1771,61 @@ impl Blockchain { /// `accounts[TREASURY] == sum(pending_rewards) + sum(delegator_rewards)` /// load-bearing from block 0 of the post-fork era onward. /// + /// Reward / liveness / epoch bookkeeping for the just-committed latest + /// block, run once inside `apply_block_pass2` post `REWARD_APPLY_PATH_HEIGHT`. + /// + /// This is the deterministic home for the bundle that pre-fork lived in 5 + /// separate network/finalize receive paths (gossip-apply, peer-apply, + /// catch-up-sync, validator-finalize ×2). Because those paths covered + /// blocks unevenly, a block's reward got applied a per-node-variable number + /// of times → `pending_rewards` / `delegator_rewards` (and thus + /// PROTOCOL_TREASURY = their sum) drifted → state_root divergence. Running + /// it here, keyed off the committed block's justification, guarantees + /// exactly-once application on every node regardless of how the block + /// arrived. + /// + /// Mirrors the external call sites exactly: proposer = block.validator, + /// signer stakes = justification precommit `stake_weight`, reward = + /// `get_block_reward()`, fee_share = 0. No-op for Pioneer blocks (no + /// justification). + fn apply_reward_bookkeeping_for_latest_block(&mut self) { + // Pull everything we need out of the committed block first so the + // immutable borrow of `self.chain` ends before the mutable borrows. + let (block_index, proposer, signers, reward_signers) = { + let Some(latest) = self.chain.last() else { + return; + }; + let Some(j) = latest.justification.as_ref() else { + return; // Pioneer / no-justification block — nothing to do. + }; + let signers: Vec = j.precommits.iter().map(|p| p.validator.clone()).collect(); + let reward_signers: Vec<(String, u64)> = j + .precommits + .iter() + .map(|p| (p.validator.clone(), p.stake_weight)) + .collect(); + ( + latest.index, + latest.validator.clone(), + signers, + reward_signers, + ) + }; + + let active = self.stake_registry.active_set.clone(); + let reward = self.get_block_reward(); + + // 1. Liveness (signed/missed per validator). + self.slashing + .record_block_signatures(&active, &signers, block_index); + // 2. Reward accumulators (validator pending_rewards + delegator_rewards). + let _ = self + .stake_registry + .distribute_reward(&proposer, &reward_signers, reward, 0); + // 3. Epoch accounting. + self.epoch_manager.record_block(reward); + } + /// Called exactly once by `apply_block_pass2` on the single /// transition block, gated by /// `is_reward_v2_height(block.index) && !is_reward_v2_height(block.index - 1)`. @@ -2988,4 +3068,74 @@ mod tests { std::env::remove_var("VOYAGER_REWARD_V2_HEIGHT"); } } + + // ── Reward-apply-path determinism (centralized bookkeeping) ────────── + // + // The bundle (liveness + reward + epoch-record) was moved out of the 5 + // network/finalize receive paths into apply_block_pass2, gated by + // REWARD_APPLY_PATH_HEIGHT, to run exactly once per block. These exercise + // the relocated helper directly (the gate itself is tested in fork_heights). + + #[test] + fn reward_apply_path_credits_pending_rewards_once() { + use sentrix_primitives::justification::BlockJustification; + let mut bc = setup(); + let val = format!("0x{}", "77".repeat(20)); + // Register a single staker so it receives the whole signer share. + bc.stake_registry + .register_validator(&val, 15_000 * 100_000_000, 1000, bc.height()) + .unwrap(); + bc.stake_registry.active_set = vec![val.clone()]; + let before = bc + .stake_registry + .validators + .get(&val) + .unwrap() + .pending_rewards; + + // A block proposed by `val` with `val` as the sole precommit signer. + let mut block = bc.create_block("v1").unwrap(); + let mut just = BlockJustification::new(block.index, 0, block.hash.clone()); + just.add_precommit(val.clone(), vec![], 15_000 * 100_000_000); + block.validator = val.clone(); + block.justification = Some(just); + bc.chain.push(block); + + bc.apply_reward_bookkeeping_for_latest_block(); + let after = bc + .stake_registry + .validators + .get(&val) + .unwrap() + .pending_rewards; + assert!( + after > before, + "apply-path bookkeeping must credit pending_rewards (before={before} after={after})" + ); + + // Idempotency-of-inputs check: it is NOT internally idempotent (each + // call distributes another block's worth) — so the gate/caller must + // ensure exactly-once. A second call credits again, proving each + // invocation = one distribution (caller runs it once per block). + bc.apply_reward_bookkeeping_for_latest_block(); + let twice = bc + .stake_registry + .validators + .get(&val) + .unwrap() + .pending_rewards; + assert!( + twice > after, + "second call distributes a second time as expected" + ); + } + + #[test] + fn reward_apply_path_noop_without_justification() { + let mut bc = setup(); + let block = bc.create_block("v1").unwrap(); // Pioneer-style: no justification + bc.chain.push(block); + // Must be a clean no-op (no panic, no state change) when justification absent. + bc.apply_reward_bookkeeping_for_latest_block(); + } } diff --git a/crates/sentrix-core/src/blockchain.rs b/crates/sentrix-core/src/blockchain.rs index 1c71e173..8e8308d6 100644 --- a/crates/sentrix-core/src/blockchain.rs +++ b/crates/sentrix-core/src/blockchain.rs @@ -377,6 +377,10 @@ impl Blockchain { crate::fork_heights::is_native_state_in_trie_height(height) } + pub fn is_reward_apply_path_height(height: u64) -> bool { + crate::fork_heights::is_reward_apply_path_height(height) + } + pub fn is_bft_gate_relax_height(height: u64) -> bool { crate::fork_heights::is_bft_gate_relax_height(height) } diff --git a/crates/sentrix-core/src/fork_heights.rs b/crates/sentrix-core/src/fork_heights.rs index c21f5690..db1c9089 100644 --- a/crates/sentrix-core/src/fork_heights.rs +++ b/crates/sentrix-core/src/fork_heights.rs @@ -186,6 +186,20 @@ const STATE_IN_TRIE_HEIGHT_TESTNET_DEFAULT: u64 = 6_026_000; /// state at the activation block. const NATIVE_STATE_IN_TRIE_HEIGHT_DEFAULT: u64 = u64::MAX; +/// Reward bookkeeping in the deterministic apply path. Fixes the +/// PROTOCOL_TREASURY drift class: pre-fork, reward distribution + +/// liveness + epoch bookkeeping is done in 5 separate network/finalize +/// receive paths (gossip-apply, peer-apply, catch-up-sync, validator- +/// finalize ×2), so a block's reward is applied a per-node-variable +/// number of times → `sum(pending_rewards+delegator_rewards)` (= +/// PROTOCOL_TREASURY) drifts → state_root divergence. Post-fork, the +/// bundle runs exactly once inside `apply_block_pass2` (keyed off the +/// block's justification), and the 5 external call sites skip it. +/// Default `u64::MAX` on both nets — consensus-changing, so it stays off +/// until an activation height is pinned (halt-all + aligned state + +/// simul-start). See `audits/reward-distribution-flow-audit-2026-04-27.md`. +const REWARD_APPLY_PATH_HEIGHT_DEFAULT: u64 = u64::MAX; + // ── Runtime readers (env → u64, default to compile-time default) ────── fn configured_chain_id() -> u64 { @@ -421,6 +435,14 @@ pub fn get_native_state_in_trie_height() -> u64 { ) } +/// Reward-apply-path fork height. Default `u64::MAX` on both nets (see +/// [`REWARD_APPLY_PATH_HEIGHT_DEFAULT`]). Post-fork, reward/liveness/epoch +/// bookkeeping runs once in `apply_block_pass2`; pre-fork it stays in the +/// network/finalize receive paths (current behaviour, bit-identical). +pub fn get_reward_apply_path_height() -> u64 { + read_height("REWARD_APPLY_PATH_HEIGHT", REWARD_APPLY_PATH_HEIGHT_DEFAULT) +} + // ── Height predicates ──────────────────────────────────── // // Every fork-height predicate has the same shape: @@ -563,6 +585,16 @@ pub fn is_native_state_in_trie_height(height: u64) -> bool { fork != u64::MAX && height >= fork } +/// Reward-apply-path — true once `REWARD_APPLY_PATH_HEIGHT` activates. +/// Post-fork: `apply_block_pass2` runs reward/liveness/epoch bookkeeping +/// exactly once per block (off the block's justification), and the 5 +/// network/finalize receive-path call sites skip it — eliminating the +/// per-node-variable application count that drifted PROTOCOL_TREASURY. +pub fn is_reward_apply_path_height(height: u64) -> bool { + let fork = get_reward_apply_path_height(); + fork != u64::MAX && height >= fork +} + /// BFT-gate-relax: is the given height at or after the fork? /// Post-fork: validator-loop's P1 BFT safety gate uses /// `active >= ⌈2/3 × total⌉` instead of `active >= MIN_BFT_VALIDATORS @@ -811,6 +843,43 @@ mod tests { } } + /// REWARD_APPLY_PATH must default disabled (`u64::MAX`) on both nets — it's + /// a consensus-changing move of reward bookkeeping into the apply path, so + /// pre-activation behaviour must be bit-identical (bookkeeping stays in the + /// network/finalize receive paths). + #[test] + fn reward_apply_path_disabled_by_default_both_nets() { + let _guard = env_test_lock(); + unsafe { + std::env::remove_var("REWARD_APPLY_PATH_HEIGHT"); + + std::env::set_var("SENTRIX_CHAIN_ID", MAINNET_CHAIN_ID.to_string()); + assert_eq!(get_reward_apply_path_height(), u64::MAX); + assert!(!is_reward_apply_path_height(0)); + assert!(!is_reward_apply_path_height(u64::MAX - 1)); + + std::env::set_var("SENTRIX_CHAIN_ID", TESTNET_CHAIN_ID.to_string()); + assert_eq!(get_reward_apply_path_height(), u64::MAX); + assert!(!is_reward_apply_path_height(6_026_000)); + + std::env::remove_var("SENTRIX_CHAIN_ID"); + } + } + + /// Once pinned, the reward-apply-path gate flips at the activation height. + #[test] + fn reward_apply_path_activates_at_pinned_height() { + let _guard = env_test_lock(); + unsafe { + std::env::set_var("REWARD_APPLY_PATH_HEIGHT", "2000"); + assert_eq!(get_reward_apply_path_height(), 2000); + assert!(!is_reward_apply_path_height(1999)); + assert!(is_reward_apply_path_height(2000)); + assert!(is_reward_apply_path_height(2001)); + std::env::remove_var("REWARD_APPLY_PATH_HEIGHT"); + } + } + /// Once pinned, the gate flips open exactly at the activation height. #[test] fn native_state_in_trie_activates_at_pinned_height() { diff --git a/crates/sentrix-network/src/libp2p_node.rs b/crates/sentrix-network/src/libp2p_node.rs index 41e7ff0e..9a07547f 100644 --- a/crates/sentrix-network/src/libp2p_node.rs +++ b/crates/sentrix-network/src/libp2p_node.rs @@ -1109,43 +1109,55 @@ async fn on_swarm_event( // Apply same bookkeeping that main.rs validator-finalize paths apply, // so libp2p-applied blocks have identical state mutations. if let Some(j) = &gossip.block.justification { - let active = chain.stake_registry.active_set.clone(); - let signers: Vec = j - .precommits - .iter() - .map(|p| p.validator.clone()) - .collect(); - chain.slashing.record_block_signatures( - &active, - &signers, + // Reward / liveness / epoch-record bundle: pre-fork only. + // Post REWARD_APPLY_PATH_HEIGHT this runs exactly once in + // apply_block_pass2 (off the same justification), so this + // receive-path copy is skipped to avoid double-application. + if !sentrix_core::fork_heights::is_reward_apply_path_height( gossip.block.index, - ); - - // Reward distribution — mirror main.rs validator-finalize. - // Without this, libp2p-synced validators have stale - // pending_rewards / delegator_rewards accumulators - // → ClaimRewards tx credits divergent amounts (HIGH severity - // latent consensus-divergence bug). - let proposer = gossip.block.validator.clone(); - let reward_signers: Vec<(String, u64)> = j - .precommits - .iter() - .map(|p| (p.validator.clone(), p.stake_weight)) - .collect(); - let reward = chain.get_block_reward(); - let _ = chain.stake_registry.distribute_reward( - &proposer, - &reward_signers, - reward, - 0, - ); - // Epoch tracking — mirror main.rs validator-finalize. - chain.epoch_manager.record_block(reward); + ) { + let active = chain.stake_registry.active_set.clone(); + let signers: Vec = j + .precommits + .iter() + .map(|p| p.validator.clone()) + .collect(); + chain.slashing.record_block_signatures( + &active, + &signers, + gossip.block.index, + ); + + // Reward distribution — mirror main.rs validator-finalize. + // Without this, libp2p-synced validators have stale + // pending_rewards / delegator_rewards accumulators + // → ClaimRewards tx credits divergent amounts (HIGH severity + // latent consensus-divergence bug). + let proposer = gossip.block.validator.clone(); + let reward_signers: Vec<(String, u64)> = j + .precommits + .iter() + .map(|p| (p.validator.clone(), p.stake_weight)) + .collect(); + let reward = chain.get_block_reward(); + let _ = chain.stake_registry.distribute_reward( + &proposer, + &reward_signers, + reward, + 0, + ); + // 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); + // Pre-fork only; post REWARD_APPLY_PATH_HEIGHT this + // runs once in apply_block_pass2. + if !sentrix_core::fork_heights::is_reward_apply_path_height( + gossip.block.index, + ) { + chain.run_epoch_bookkeeping(gossip.block.index); + } } let updated = chain.latest_block().ok().cloned().unwrap_or(gossip.block); @@ -1731,27 +1743,35 @@ async fn on_inbound_request( } // Asymmetric-recording fix bundle (see audits/reward-distribution-flow-audit-2026-04-27.md). if let Some(j) = &block_justification { - let active = chain.stake_registry.active_set.clone(); - let signers: Vec = - j.precommits.iter().map(|p| p.validator.clone()).collect(); - chain - .slashing - .record_block_signatures(&active, &signers, block_idx); - // Reward distribution + epoch tracking (mirror main.rs validator-finalize). - let reward_signers: Vec<(String, u64)> = j - .precommits - .iter() - .map(|p| (p.validator.clone(), p.stake_weight)) - .collect(); - let reward = chain.get_block_reward(); - let _ = chain.stake_registry.distribute_reward( - &block_validator, - &reward_signers, - reward, - 0, - ); - chain.epoch_manager.record_block(reward); - chain.run_epoch_bookkeeping(block_idx); + // Reward / liveness / epoch-record bundle: pre-fork only (post + // REWARD_APPLY_PATH_HEIGHT it runs once in apply_block_pass2). + if !sentrix_core::fork_heights::is_reward_apply_path_height(block_idx) { + let active = chain.stake_registry.active_set.clone(); + let signers: Vec = + j.precommits.iter().map(|p| p.validator.clone()).collect(); + chain + .slashing + .record_block_signatures(&active, &signers, block_idx); + // Reward distribution + epoch tracking (mirror main.rs validator-finalize). + let reward_signers: Vec<(String, u64)> = j + .precommits + .iter() + .map(|p| (p.validator.clone(), p.stake_weight)) + .collect(); + let reward = chain.get_block_reward(); + let _ = chain.stake_registry.distribute_reward( + &block_validator, + &reward_signers, + reward, + 0, + ); + chain.epoch_manager.record_block(reward); + } + // Epoch boundary transition — pre-fork only; post + // REWARD_APPLY_PATH_HEIGHT it runs in apply_block_pass2. + if !sentrix_core::fork_heights::is_reward_apply_path_height(block_idx) { + chain.run_epoch_bookkeeping(block_idx); + } } tracing::info!("libp2p: applied block {} from {}", block_idx, peer); // Capture H2 (with state_root + recomputed hash) before releasing @@ -2065,32 +2085,42 @@ async fn on_inbound_response( // state across validators. Fix mirrors all 3 calls. // See `audits/reward-distribution-flow-audit-2026-04-27.md`. if let Some(j) = &block.justification { - let active = chain.stake_registry.active_set.clone(); - let signers: Vec = - j.precommits.iter().map(|p| p.validator.clone()).collect(); - chain - .slashing - .record_block_signatures(&active, &signers, block.index); - // Reward distribution + epoch tracking. - let proposer = block.validator.clone(); - let reward_signers: Vec<(String, u64)> = j - .precommits - .iter() - .map(|p| (p.validator.clone(), p.stake_weight)) - .collect(); - let reward = chain.get_block_reward(); - let _ = chain.stake_registry.distribute_reward( - &proposer, - &reward_signers, - reward, - 0, - ); - chain.epoch_manager.record_block(reward); + // Reward / liveness / epoch-record bundle: pre-fork only (post + // REWARD_APPLY_PATH_HEIGHT it runs once in apply_block_pass2). + if !sentrix_core::fork_heights::is_reward_apply_path_height(block.index) + { + let active = chain.stake_registry.active_set.clone(); + let signers: Vec = + j.precommits.iter().map(|p| p.validator.clone()).collect(); + chain.slashing.record_block_signatures( + &active, + &signers, + block.index, + ); + // Reward distribution + epoch tracking. + let proposer = block.validator.clone(); + let reward_signers: Vec<(String, u64)> = j + .precommits + .iter() + .map(|p| (p.validator.clone(), p.stake_weight)) + .collect(); + let reward = chain.get_block_reward(); + let _ = chain.stake_registry.distribute_reward( + &proposer, + &reward_signers, + reward, + 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); + // Pre-fork only; post REWARD_APPLY_PATH_HEIGHT it runs + // in apply_block_pass2. + if !sentrix_core::fork_heights::is_reward_apply_path_height(block.index) + { + chain.run_epoch_bookkeeping(block.index); + } } // Use H2 (post-add_block state_root hash) — not the raw peer block (PR #78). let updated = chain