PoW Epoch Hot-Swap and HostRoutingRecord Publisher
File: kinetic-host/src/heartbeat.rs
Crate: kinetic-host | Stage: 13
What Is This?
The most unique code in kinetic-host. Two background tasks run here:
start_dynamic_routing_publisher()— Every 30 seconds, signs and publishes aHostRoutingRecordto the DHT, telling clients “I am still alive and my current ephemeral Peer ID is X.”start_drand_heartbeat()— Every 3 seconds, fetches the latest Drand beacon, checks if the current PoW key is still valid for this epoch, and if not: aborts the old network loop, mines a new keypair, and restarts the loop with zero downtime.
There is nothing equivalent to this in kinetic-daemon or kinetic-node. This is the host’s core Sybil-resistance mechanism.
How start_dynamic_routing_publisher Works
-> See: kinetic-host/src/heartbeat.rs — Lines 10–53
Why this is needed
The host’s active P2P identity (its libp2p Peer ID) changes every Drand epoch. Clients looking up saif.kin find a PeerId in the DNS zone record — but that Peer ID is the static host key, not the ephemeral one. Without a routing record, clients have no way to find the current ephemeral peer to connect to.
HostRoutingRecord bridges this gap: it maps (static host PeerId) → (current ephemeral PeerId, drand_kyn, signature).
Key conversion chain (Ed25519 format bridging)
-> See: kinetic-host/src/heartbeat.rs — Lines 18–26
libp2p uses its own Keypair type. ed25519_dalek is needed to produce the actual signature. The conversion chain:
publisher_host_key.try_into_ed25519()→ libp2p’s internaled25519::Keypair..to_bytes()→ raw 64-byte representation.ed25519_dalek::SigningKey::try_from(&ed_bytes[0..32])→ dalek key using only the 32-byte private key portion.
The dalek signing key is constructed once outside the loop and reused on every interval tick — no redundant key parsing overhead.
The record and signature
-> See: kinetic-host/src/heartbeat.rs — Lines 33–51
#![allow(unused)]
fn main() {
let mut record = HostRoutingRecord {
host_id: host_peer_id_str.clone(), // permanent static Peer ID
current_peer_id: local_peer_id_str.read()...clone(), // current ephemeral Peer ID
drand_kyn, // current beacon round number
signature: vec![], // filled after signing
};
let signature = dalek_kp.sign(&record.signable_bytes(NETWORK_ID));
record.signature = signature.to_bytes().to_vec();
}
The current_peer_id is read from the Arc<RwLock<String>> shared with the hot-swap heartbeat. After each hot-swap, the heartbeat writes the new Peer ID into this shared string. The next tick of the publisher will automatically pick up the updated ID.
How start_drand_heartbeat Works (The Hot-Swap)
-> See: kinetic-host/src/heartbeat.rs — Lines 61–162
Tick interval and data flow
Runs every 3 seconds. Calls hb_drand.fetch_latest(). If the result is a fresh (non-cached, non-unavailable) kyn, sends it through drand_kyn_tx (the watch channel that all tasks share).
Staggered epoch check
-> See: kinetic-host/src/heartbeat.rs — Lines 86–93
#![allow(unused)]
fn main() {
let current_epoch = kinetic_network::pow::get_staggered_epoch(
&hb_local_peer_id.to_bytes(),
kyn.kyn,
);
let needs_validation = match last_verified_epoch {
Some(epoch) => epoch != current_epoch,
None => true,
};
}
get_staggered_epoch() computes a per-peer-id staggered epoch from the kyn. “Staggered” means different peers rotate their keys at different kyn offsets — not all simultaneously. This prevents a network-wide reconnection storm when a Drand epoch boundary passes.
last_verified_epoch caches the last epoch we validated against. On epoch change, needs_validation becomes true and we re-check PoW validity.
PoW validity check
-> See: kinetic-host/src/heartbeat.rs — Lines 97–108
#![allow(unused)]
fn main() {
let pow_valid = tokio::task::spawn_blocking(move || {
kinetic_network::pow::is_valid_sybil_pow(
&peer_id_clone, kyn_round, POW_DIFFICULTY_BITS,
)
}).await.unwrap_or(false);
}
PoW validation is also CPU-bound. Offloaded to spawn_blocking. Returns false if the current Peer ID no longer satisfies the PoW constraint for the new epoch.
The hot-swap sequence (zero-downtime restart)
-> See: kinetic-host/src/heartbeat.rs — Lines 109–154
When pow_valid is false:
1. Mine a new keypair (offloaded, CPU-bound):
#![allow(unused)]
fn main() {
let current_local_key = tokio::task::spawn_blocking(move || {
kinetic_network::pow::mine_sybil_keypair(kyn_round, POW_DIFFICULTY_BITS)
}).await.unwrap_or_else(|_| Keypair::generate_ed25519());
}
If mining panics (extremely unlikely), falls back to a random Ed25519 key so the process keeps running.
2. Update shared Peer ID:
#![allow(unused)]
fn main() {
if let Ok(mut lock) = shared_peer_id.write() {
*lock = hb_local_peer_id.to_string();
}
}
The routing publisher will pick up the new ID on its next 30-second tick.
3. Abort the old network loop and replace it:
#![allow(unused)]
fn main() {
let mut handle = loop_handle_ref.lock().await;
handle.abort(); // Kill the old libp2p swarm
// Create new NetworkEventLoop with the new key
if let Ok((new_client, new_loop)) = NetworkEventLoop::new(..., current_local_key, ...) {
hc_client.update_backend(new_client.get_sender(), new_client.stream_control());
*handle = tokio::spawn(async move { new_loop.run().await; });
}
}
Important
hc_client.update_backend()is the critical step: it replaces the internal sender and stream control inside theNetworkClientthat all other tasks (proxy handler, routing publisher) hold references to. This means those tasks don’t need to be restarted — they continue using the sameNetworkClientstruct, which now routes through the new swarm.
Quick Reference
| Function | Interval | What It Does |
|---|---|---|
start_dynamic_routing_publisher | 30 seconds | Signs + publishes HostRoutingRecord to DHT |
start_drand_heartbeat | 3 seconds | Fetches Drand kyn, checks PoW validity, hot-swaps if expired |
Note
Key concept: The hot-swap is zero-downtime because
NetworkClient::update_backend()swaps the internal channel sender in-place. No other task needs to be told about the change.
Tip
Staggered epochs: Peers rotate at different kyn offsets (derived from their Peer ID bytes) to avoid a network-wide reconnection storm.