Skip to content

Commit 9695844

Browse files
AIQnetLabclaude
andcommitted
super-node onboarding: NAT block delivery, keep-up, heartbeat liveness
- transport: reuse the live inbound QUIC conn for NAT/client-dialed peers (INBOUND_CONN_BY_LISTEN_ADDR consulted in connect) so genesis shred/serve pushes reach a NAT super instead of re-dialing a blocked listen port - p2p: genesis shred fan-out now includes connected Super peers (was genesis- only) so a caught-up super gets per-slot shreds, not a gossip sawtooth - keep-up: NAT-durable signed-head reply over the live conn; committee-anchored head-overclaim clamp (committee + genesis median, scales past 5 nodes) - heartbeat: dedup on on-chain inclusion (own heartbeat_slots bit) + bounded re-anchor, so a missed subwindow is retried not lost -> eligibility holds - registration: boot fallback gated on synced (single sync-gated source) - cold-join: reset chain_height on post-promote rehydrate failure - cleanup: drop dead height_cache + unreachable genesis peer-list branch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e985ba commit 9695844

5 files changed

Lines changed: 345 additions & 148 deletions

File tree

development/qnet-integration/src/bin/qnet-node.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2888,6 +2888,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
28882888
let reg_onchain = node.get_storage().is_node_registration_onchain(&node.get_node_id());
28892889
if already_persisted && reg_onchain {
28902890
if is_info() { println!("[INFO][NODE] activation_persisted reg_onchain=true skip=fallback_call"); }
2891+
} else if !qnet_integration::node::coordinator_is_synchronized() {
2892+
// Defer to the sync-gated activation path (single source of truth). A registration whose
2893+
// burn-attestors are collected pre-sync (local height ~0 → committee_for_height None →
2894+
// genesis-set fallback) is rejected post-genesis; sending only once synced binds it to the
2895+
// true N-2 committee. The sync-gated path re-sends until the registration lands on-chain.
2896+
if is_info() { println!("[INFO][NODE] activation_send_deferred reason=not_synced path=sync_gated"); }
28912897
} else if let Err(e) = node.save_activation_code(&activation_code, node_type).await {
28922898
if is_warn() { println!("[WARN][NODE] activation_code_save_failed err={}", e); }
28932899
}

development/qnet-integration/src/node.rs

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {

development/qnet-integration/src/quic_transport.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,16 @@ static HANDSHAKE_FAIL_TRACKER: once_cell::sync::Lazy<
261261
static IP_GATE_REJECTS: AtomicU64 = AtomicU64::new(0);
262262
pub fn ip_gate_reject_count() -> u64 { IP_GATE_REJECTS.load(Ordering::Relaxed) }
263263

264+
/// Live inbound (client-dialed) connections indexed by the peer's advertised QUIC listen addr
265+
/// (ip : API_PORT + QUIC_PORT_OFFSET). A NAT/client-dialed peer's connection lives under its
266+
/// EPHEMERAL source addr in the per-transport pool, so a send targeting its listen port misses and
267+
/// re-dials a port NAT cannot accept inbound. connect() consults this to REUSE the live inbound conn
268+
/// — the only way to push shreds/blocks to such a peer. Keyed by addr like all existing addressing,
269+
/// so it inherits (does not worsen) the one-peer-per-advertised-addr assumption.
270+
static INBOUND_CONN_BY_LISTEN_ADDR: once_cell::sync::Lazy<
271+
DashMap<SocketAddr, Arc<QuicConnection>>
272+
> = once_cell::sync::Lazy::new(DashMap::new);
273+
264274
#[inline]
265275
fn unix_secs_now() -> u64 {
266276
std::time::SystemTime::now()
@@ -1270,6 +1280,13 @@ impl QuicTransport {
12701280
});
12711281

12721282
connections_clone.insert(peer_addr, quic_conn.clone());
1283+
// Index the inbound conn by the peer's advertised QUIC listen addr so a send that
1284+
// targets the listen port (which a NAT/client-dialed peer cannot accept inbound)
1285+
// reuses THIS live conn in connect() instead of re-dialing an unreachable port.
1286+
INBOUND_CONN_BY_LISTEN_ADDR.insert(
1287+
SocketAddr::new(peer_addr.ip(), 8001u16.saturating_add(crate::p2p_transport::QUIC_PORT_OFFSET)),
1288+
quic_conn.clone(),
1289+
);
12731290
// v2.96: Promote IP to known tier after successful handshake
12741291
known_ips_clone.insert(peer_ip_clone, ());
12751292
if is_info() { println!("[INFO][QUIC] conn_stored peer={} node={} type={} ip_tier=known", get_privacy_id_for_addr(&peer_addr.to_string()), remote_node_id, remote_node_type); }
@@ -1287,7 +1304,14 @@ impl QuicTransport {
12871304
pid_map.insert(remote_node_id.clone(), peer_api_addr.clone());
12881305
if is_info() { println!("[INFO][QUIC] peer_mapped node_id={} addr={}", remote_node_id, peer_api_addr); }
12891306
}
1290-
1307+
1308+
// Register the inbound (client-dialed) peer for signed-head relay reachability:
1309+
// is_outbound=false keeps eclipse/reputation/subnet caps; height 0 until its first
1310+
// signed HealthPing attests the tip (so it is not quorum-counted early).
1311+
if let Some(p2p) = crate::node::try_get_p2p() {
1312+
p2p.attest_connected_peer(&remote_node_id, &peer_addr.ip().to_string(), &remote_node_type, 0, false, false);
1313+
}
1314+
12911315
{
12921316
let mut s = stats_clone.write().await;
12931317
s.connections_established += 1;
@@ -1543,6 +1567,10 @@ impl QuicTransport {
15431567
if connections.remove(&peer_addr).is_some() {
15441568
if is_info() { println!("[INFO][QUIC] conn_removed_on_close peer={}", get_privacy_id_for_addr(&peer_addr.to_string())); }
15451569
}
1570+
// Drop the listen-addr index entry only if it still points at THIS closing conn (a sibling
1571+
// conn from the same IP must keep its own live entry).
1572+
let listen_key = SocketAddr::new(peer_addr.ip(), 8001u16.saturating_add(crate::p2p_transport::QUIC_PORT_OFFSET));
1573+
INBOUND_CONN_BY_LISTEN_ADDR.remove_if(&listen_key, |_, v| Arc::ptr_eq(v, &quic_conn));
15461574
}
15471575

15481576
/// Handle bidirectional stream (v2.95: length-prefixed protocol for ACK support)
@@ -1644,6 +1672,31 @@ impl QuicTransport {
16441672
let _ = send.finish();
16451673
}
16461674

1675+
/// Reply our cached signed head over the LIVE inbound conn (no dial) to a behind pinger — the
1676+
/// NAT-durable tip feed: it writes on the same accepted QUIC connection the follower's pull uses,
1677+
/// so it traverses the NAT mapping the follower created (cosend/relay to its listen port cannot).
1678+
async fn maybe_reply_signed_head(conn: &Arc<QuicConnection>, peer_height: u64) {
1679+
// The inbound conn already passed the QUIC handshake ML-DSA identity gate and the reply is OUR
1680+
// OWN signed head, so no per-ping verify here (the follower re-verifies on ingest — I1). Gate only
1681+
// on the lead: reply iff we are >= HEAD_REPLY_MIN_GAP ahead — suppresses at-tip chatter, O(1)/conn.
1682+
let head = crate::unified_p2p::LATEST_SIGNED_HEAD.read().clone();
1683+
let (h_from, h_ts, h_height, h_sig, h_pk) = match head { Some(h) => h, None => return };
1684+
if h_height < peer_height.saturating_add(crate::unified_p2p::HEAD_REPLY_MIN_GAP) { return; }
1685+
// Re-emit OUR signed head over THIS live conn (same framing as try_broadcast_once, no dial).
1686+
let reply = crate::unified_p2p::NetworkMessage::HealthPing { from: h_from, timestamp: h_ts, height: h_height, signature: h_sig, public_key: h_pk };
1687+
if let Ok(wire) = Self::serialize_message(&reply) {
1688+
// Bounded like try_broadcast_once so a stalled stream never pins this per-stream task's permit.
1689+
let _ = tokio::time::timeout(Duration::from_secs(MESSAGE_TIMEOUT_SECS), async {
1690+
if let Ok(mut send) = conn.connection.open_uni().await {
1691+
let len = (wire.len() as u32).to_be_bytes();
1692+
let _ = send.write_all(&len).await;
1693+
let _ = send.write_all(&wire).await;
1694+
let _ = send.finish();
1695+
}
1696+
}).await;
1697+
}
1698+
}
1699+
16471700
/// Handle unidirectional stream (broadcast/fire-and-forget messages)
16481701
/// v2.95.2: Updated to read length-prefixed messages (matches try_send_once)
16491702
async fn handle_uni_stream(
@@ -1738,12 +1791,18 @@ impl QuicTransport {
17381791
}
17391792
};
17401793

1794+
// Live-conn signed-head reply: serve our cached head back over THIS inbound conn to a behind
1795+
// pinger (NAT-durable). Capture the pinger height before the handler moves `msg`.
1796+
let hp_peer_height = if let NetworkMessage::HealthPing { height, .. } = msg { Some(height) } else { None };
17411797
// Call handler
17421798
if let Some(ref h) = handler {
17431799
h(peer_addr, msg);
17441800
}
1801+
if let Some(ph) = hp_peer_height {
1802+
Self::maybe_reply_signed_head(&conn, ph).await;
1803+
}
17451804
}
1746-
1805+
17471806
/// Parse binary message with per-type size enforcement
17481807
fn parse_message(data: &[u8]) -> Result<NetworkMessage, String> {
17491808
if data.len() < 6 {
@@ -1826,6 +1885,16 @@ impl QuicTransport {
18261885
self.connections.remove(&peer_addr);
18271886
}
18281887

1888+
// NAT reuse: no outbound conn to this listen addr. Before dialing (which a NAT/client-dialed
1889+
// peer cannot accept), reuse a live conn the peer opened TO us, indexed by its advertised
1890+
// listen addr — the only durable channel to push shreds/blocks to such a peer.
1891+
if let Some(conn) = INBOUND_CONN_BY_LISTEN_ADDR.get(&peer_addr).map(|r| r.value().clone()) {
1892+
if crate::quic_transport::is_connection_alive(&conn) {
1893+
return Ok(conn);
1894+
}
1895+
INBOUND_CONN_BY_LISTEN_ADDR.remove(&peer_addr);
1896+
}
1897+
18291898
// v2.96: Per-peer reconnect cooldown — reject if last attempt was < COOLDOWN ago.
18301899
// This is the primary defense against reconnect storms: 5 subsystems calling connect()
18311900
// simultaneously all get instant rejection except the first one within the window.
@@ -1999,7 +2068,7 @@ impl QuicTransport {
19992068
if remote_node_id != self.node_id {
20002069
if let Some(p2p) = crate::node::try_get_p2p() {
20012070
let ip = connection.remote_address().ip().to_string();
2002-
p2p.attest_connected_peer(&remote_node_id, &ip, &remote_node_type, remote_block_height, remote_verified);
2071+
p2p.attest_connected_peer(&remote_node_id, &ip, &remote_node_type, remote_block_height, remote_verified, true);
20032072
}
20042073
}
20052074
}

0 commit comments

Comments
 (0)