@@ -1248,9 +1248,33 @@ pub fn try_advance_finality(round: u64, context: &str) -> bool {
12481248 }
12491249 LAST_FINALIZED_CONSENSUS_ROUND.store(round, std::sync::atomic::Ordering::SeqCst);
12501250 LAST_FINALIZED_HEIGHT.store(round, std::sync::atomic::Ordering::SeqCst);
1251+ // Release the finality mutex BEFORE the post-advance refresh so the invariant above (no storage I/O
1252+ // under the lock) holds. On real advance: refresh the committee clamp anchor (once/epoch) + re-sign head.
1253+ drop(_finality_guard);
1254+ if let Some(storage) = try_get_storage() {
1255+ refresh_current_committee(&storage, round);
1256+ }
1257+ if let Some(p2p) = try_get_p2p() {
1258+ p2p.refresh_signed_head_throttled(round);
1259+ }
12511260 true
12521261}
12531262
1263+ /// Refresh the cached committee membership (anti-forgery head-clamp anchor) once per epoch. members =
1264+ /// committee node_ids (empty in the genesis era); genesis_ids always present so the 5 genesis stay an
1265+ /// anchor at any scale. O(R<=1000) recompute under the finality mutex, no-op when the epoch is unchanged.
1266+ fn refresh_current_committee(storage: &Storage, h: u64) {
1267+ let epoch = h.saturating_sub(1) / 90 + 1;
1268+ if epoch == crate::unified_p2p::CURRENT_COMMITTEE.read().epoch { return; }
1269+ let members: std::collections::HashSet<String> = BlockchainNode::committee_for_height(storage, h)
1270+ .map(|v| v.into_iter().collect())
1271+ .unwrap_or_default();
1272+ let genesis_ids: std::collections::HashSet<String> = (1..=crate::genesis_constants::genesis_node_count())
1273+ .map(|i| format!("genesis_node_{:03}", i)).collect();
1274+ *crate::unified_p2p::CURRENT_COMMITTEE.write() =
1275+ std::sync::Arc::new(crate::unified_p2p::CommitteeSnapshot { epoch, members, genesis_ids });
1276+ }
1277+
12541278/// v10.2: Atomic height update helper — DISK FIRST, RAM SECOND.
12551279/// Updates all 3 height sources in one call to prevent inconsistency.
12561280///
@@ -10904,8 +10928,13 @@ impl BlockchainNode {
1090410928 println!("[INFO][HEARTBEAT-LOOP] heartbeat emission loop started (spread, unforgeable)");
1090510929 }
1090610930
10907- // v34: last (epoch, subwindow) for which this node emitted a Heartbeat TX (dedup).
10908- let mut last_hb_subwindow: Option<(u64, u64)> = None;
10931+ // v36: (epoch, subwindow, emit_height) of this node's last Heartbeat emission. Dedup keys on
10932+ // ON-CHAIN INCLUSION (own heartbeat_slots bit), not emission — a heartbeat that missed
10933+ // inclusion is re-anchored and retried until the subwindow is recorded, so a transient
10934+ // desync can never permanently drop a liveness subwindow (producer/reward eligibility).
10935+ let mut last_hb_emit: Option<(u64, u64, u64)> = None;
10936+ // Re-anchor cadence: well under HB_ANCHOR_MAX_LAG (90) so a retried heartbeat never goes stale.
10937+ const HB_REEMIT_INTERVAL: u64 = 60;
1090910938
1091010939 while *is_running.read().await {
1091110940 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@@ -10914,30 +10943,40 @@ impl BlockchainNode {
1091410943
1091510944 // v34: emit ONE unforgeable Heartbeat TX per ~1440-block subwindow (10/epoch) for
1091610945 // super/genesis nodes. Anchored to a recent block hash so it cannot be pre-signed or
10917- // backfilled (verified in validate_received_microblock). Replaces self-attested
10918- // liveness with on-chain liveness tallied in Account.heartbeat_slots (popcount ≥ 9).
10919- // Skip while syncing/behind: a heartbeat anchored to our lagging local height would
10920- // be stale at the network tip, and a producer including it would reject the block.
10946+ // backfilled (verified in validate_received_microblock). Liveness is tallied on-chain
10947+ // in Account.heartbeat_slots (popcount ≥ 9). Skip while syncing: a heartbeat anchored
10948+ // to our lagging height would be stale at the tip and the producer would reject it.
1092110949 if !matches!(node_type, NodeType::Light) && current_height >= 2 && !coordinator_is_syncing() {
10922- let hb_epoch = current_height / EMISSION_BLOCK_INTERVAL;
10923- let pos = current_height % EMISSION_BLOCK_INTERVAL;
10950+ // Derive epoch/subwindow from the ANCHOR (current_height-2) the heartbeat commits and
10951+ // the apply tallies, so the inclusion check tests exactly the bit the emit will set.
10952+ let anchor_h = current_height - 2;
10953+ let hb_epoch = anchor_h / EMISSION_BLOCK_INTERVAL;
10954+ let pos = anchor_h % EMISSION_BLOCK_INTERVAL;
1092410955 let hb_subwindow = pos / 1440;
10925- // Emit at a per-node offset inside the subwindow, not at its boundary, so
10926- // 100k+ nodes spread (~70/block) instead of bursting in one block. Dedup
10927- // makes a late wake (pos already past offset) still emit once in-window.
10956+ // Emit at a per-node offset inside the subwindow (not its boundary) so 100k+ nodes
10957+ // spread (~70/block) instead of bursting in one block.
1092810958 let hb_offset = Self::heartbeat_offset(&node_id, hb_subwindow);
10929- if last_hb_subwindow != Some((hb_epoch, hb_subwindow)) && (pos % 1440) >= hb_offset {
10930- if let Some(hb_tx) = Self::create_heartbeat_tx_static(&storage, &node_id, current_height).await {
10931- if let Ok(hb_bytes) = bincode::serialize(&hb_tx) {
10932- let hb_gp = hb_tx.gas_price;
10933- if mempool.add_binary_transaction(hb_bytes.clone(), hb_tx.hash.clone(), hb_gp) {
10934- if let Some(ref p2p) = unified_p2p {
10935- let _ = p2p.broadcast_transaction(hb_bytes);
10936- }
10937- last_hb_subwindow = Some((hb_epoch, hb_subwindow));
10938- if is_info() {
10939- println!("[INFO][HEARTBEAT] emitted node={} epoch={} subwindow={} at_h={}",
10940- node_id, hb_epoch, hb_subwindow, current_height);
10959+ if (pos % 1440) >= hb_offset {
10960+ // Done once our own heartbeat for this subwindow is recorded on-chain.
10961+ let included = storage.load_account(&node_id).ok().flatten()
10962+ .map(|a| a.heartbeat_epoch == hb_epoch
10963+ && (a.heartbeat_slots & (1u16 << hb_subwindow.min(9))) != 0)
10964+ .unwrap_or(false);
10965+ let first = !matches!(last_hb_emit, Some((e, s, _)) if e == hb_epoch && s == hb_subwindow);
10966+ let reanchor = matches!(last_hb_emit, Some((_, _, h)) if current_height.saturating_sub(h) >= HB_REEMIT_INTERVAL);
10967+ if !included && (first || reanchor) {
10968+ if let Some(hb_tx) = Self::create_heartbeat_tx_static(&storage, &node_id, current_height).await {
10969+ if let Ok(hb_bytes) = bincode::serialize(&hb_tx) {
10970+ let hb_gp = hb_tx.gas_price;
10971+ if mempool.add_binary_transaction(hb_bytes.clone(), hb_tx.hash.clone(), hb_gp) {
10972+ if let Some(ref p2p) = unified_p2p {
10973+ let _ = p2p.broadcast_transaction(hb_bytes);
10974+ }
10975+ last_hb_emit = Some((hb_epoch, hb_subwindow, current_height));
10976+ if is_info() {
10977+ println!("[INFO][HEARTBEAT] emitted node={} epoch={} subwindow={} at_h={}",
10978+ node_id, hb_epoch, hb_subwindow, current_height);
10979+ }
1094110980 }
1094210981 }
1094310982 }
@@ -13897,8 +13936,9 @@ impl BlockchainNode {
1389713936 let mut stall_count = 0u32; // Track consecutive no-progress iterations
1389813937
1389913938 loop {
13900- // Re-read current network height (may have advanced)
13901- let target = crate::unified_p2p::BEST_PEER_HEIGHT.load(Ordering::Relaxed);
13939+ // Re-read current network height (may have advanced) via the clamped
13940+ // chokepoint so a forged head can't inflate the bulk target.
13941+ let target = p2p_clone.get_best_peer_height();
1390213942 let target = std::cmp::max(target, network_height); // At least initial target
1390313943
1390413944 // Sync cursor SLAVED to the real applied chain height: always request
@@ -14012,7 +14052,7 @@ impl BlockchainNode {
1401214052 // v32.1: re-verify network tip via authoritative peer-quorum height
1401314053 // before declaring sync complete. BEST_PEER_HEIGHT alone can lag
1401414054 // attestation TTL; pull live quorum max so we don't exit early.
14015- let best_atomic = crate::unified_p2p::BEST_PEER_HEIGHT.load(Ordering::Relaxed );
14055+ let best_atomic = p2p_clone.get_best_peer_height( );
1401614056 let quorum_max = p2p_clone.get_max_peer_height();
1401714057 let new_target = best_atomic.max(quorum_max);
1401814058 if current_from + 3 >= new_target {
0 commit comments