commit 2dc67914bae6139620fad0940cdd0aa203ffe52c
parent ccc94c25c33fa2ec182edaa8156d2e7331884700
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 29 Jul 2026 10:35:25 +0200
Harden block timing with network-adjusted time
Diffstat:
| M | src/adapters/http.rs | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/adapters/p2p.rs | | | 167 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
| M | src/app.rs | | | 101 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | src/domain.rs | | | 146 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- |
| M | tests/iuna.rs | | | 105 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | www/assets/iuna-ui.js | | | 21 | +++++++++++++++++++++ |
6 files changed, 548 insertions(+), 70 deletions(-)
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -107,6 +107,8 @@ struct NetworkHealthResponse {
mempool_known_peers: usize,
mempool_divergent_peers: usize,
mempool_missing_transactions: usize,
+ network_time_offset_ms: Option<i64>,
+ bad_clock_peers: usize,
last_error: Option<String>,
}
@@ -839,6 +841,16 @@ fn network_health_at(
.iter()
.map(|peer| peer.last_known_mempool_missing.unwrap_or(0))
.sum();
+ let network_time_offset_ms = median_peer_clock_offset(peers, now_ms);
+ let bad_clock_peers = peers
+ .iter()
+ .filter(|peer| {
+ peer.last_clock_observed_ms.is_some_and(|observed_ms| {
+ now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS
+ })
+ })
+ .filter(|peer| peer.last_clock_offset_accepted == Some(false))
+ .count();
let lag_blocks = best_known_height.saturating_sub(local_height);
let last_error = peers.iter().rev().find_map(|peer| {
peer.last_error
@@ -886,10 +898,32 @@ fn network_health_at(
mempool_known_peers,
mempool_divergent_peers,
mempool_missing_transactions,
+ network_time_offset_ms,
+ bad_clock_peers,
last_error,
}
}
+fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> {
+ let mut offsets = peers
+ .iter()
+ .filter(|peer| peer.last_error.is_none())
+ .filter(|peer| !peer.is_banned_at(now_ms))
+ .filter(|peer| peer.last_clock_offset_accepted == Some(true))
+ .filter(|peer| {
+ peer.last_clock_observed_ms.is_some_and(|observed_ms| {
+ now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS
+ })
+ })
+ .filter_map(|peer| peer.last_clock_offset_ms)
+ .collect::<Vec<_>>();
+ if offsets.is_empty() {
+ return None;
+ }
+ offsets.sort_unstable();
+ Some(offsets[offsets.len() / 2])
+}
+
async fn wallet_setup_response(
state: &HttpState,
headers: &HeaderMap,
@@ -2217,6 +2251,8 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="peer-summary-item"><div class="peer-summary-label">Peer Mempools</div><div class="peer-summary-value" x-text="networkHealth.mempool_known_peers ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Divergent</div><div class="peer-summary-value" x-text="networkHealth.mempool_divergent_peers ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Missing Tx</div><div class="peer-summary-value" x-text="networkHealth.mempool_missing_transactions ?? '-'"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Time Offset</div><div class="peer-summary-value" x-text="networkTimeOffsetLabel()"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Clock Warnings</div><div class="peer-summary-value" x-text="networkHealth.bad_clock_peers ?? '-'"></div></div>
</div>
</div>
<div class="peer-summary">
@@ -2228,7 +2264,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="table-wrap">
<table>
- <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Mempool</th><th>Shared</th><th>Missing</th><th>Root</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead>
+ <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Clock</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Mempool</th><th>Shared</th><th>Missing</th><th>Root</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead>
<tbody>
<template x-for="peer in peers" :key="peer.address">
<tr>
@@ -2236,6 +2272,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<td><code x-text="peer.address"></code></td>
<td x-text="peer.direction"></td>
<td x-text="peerLastContactLabel(peer)"></td>
+ <td x-text="peerClockLabel(peer)"></td>
<td x-text="peerBanLabel(peer)"></td>
<td x-text="peer.misbehavior_score ?? 0"></td>
<td x-text="peer.last_known_height ?? '-'"></td>
@@ -2251,7 +2288,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td>
</tr>
</template>
- <tr x-show="peers.length === 0"><td colspan="17">No peers</td></tr>
+ <tr x-show="peers.length === 0"><td colspan="18">No peers</td></tr>
</tbody>
</table>
</div>
@@ -2890,6 +2927,28 @@ mod tests {
assert_eq!(mempool_syncing.mempool_divergent_peers, 1);
assert_eq!(mempool_syncing.mempool_missing_transactions, 1);
+ let mut clock_peers = PeerBook::from_addresses(vec![
+ "127.0.0.1:9450".to_string(),
+ "127.0.0.1:9451".to_string(),
+ ]);
+ clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string());
+ clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string());
+ clock_peers.record_clock_observation(
+ "127.0.0.1:9450",
+ PeerDirection::Outbound,
+ 10_500,
+ 10_000,
+ );
+ clock_peers.record_clock_observation(
+ "127.0.0.1:9451",
+ PeerDirection::Outbound,
+ 11 * 60 * 1_000,
+ 10_000,
+ );
+ let clock_health = super::network_health_at(&status, &clock_peers.list(), 10_000);
+ assert_eq!(clock_health.network_time_offset_ms, Some(500));
+ assert_eq!(clock_health.bad_clock_peers, 1);
+
let syncing = super::network_health(
&status,
&[PeerInfo {
@@ -2904,6 +2963,9 @@ mod tests {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: None,
last_transaction_rejection: None,
last_contact_ms: Some(10_000),
@@ -2934,6 +2996,9 @@ mod tests {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: Some("connection refused".to_string()),
last_transaction_rejection: None,
last_contact_ms: Some(10_000),
@@ -2966,6 +3031,9 @@ mod tests {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: None,
last_transaction_rejection: Some(
"peer rejected transaction abc: conflict".to_string(),
@@ -2998,6 +3066,9 @@ mod tests {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: None,
last_transaction_rejection: None,
last_contact_ms: Some(1),
@@ -3028,6 +3099,9 @@ mod tests {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: Some("invalid block".to_string()),
last_transaction_rejection: None,
last_contact_ms: Some(10),
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -24,7 +24,8 @@ use tokio::{
use crate::{
app::{
BlockInventory, GossipEnvelope, MEMPOOL_STATUS_LIMIT, NETWORK_ID, NodeCore,
- PROTOCOL_VERSION, ProtocolHello, SharedNode, SharedPeerBook, TransactionRejection,
+ PROTOCOL_VERSION, PeerDirection, ProtocolHello, SharedNode, SharedPeerBook,
+ TransactionRejection, now_ms,
},
domain::{Block, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, verify_vdf},
};
@@ -50,6 +51,7 @@ static NEXT_NODE_ID: AtomicU64 = AtomicU64::new(1);
struct PeerStatus {
height: u64,
tip_hash: String,
+ time_ms: u64,
mempool_count: usize,
mempool_root: String,
mempool_txs: Vec<String>,
@@ -59,9 +61,14 @@ struct PeerStatus {
impl PeerStatus {
fn new(height: u64, tip_hash: String) -> Self {
+ Self::with_time(height, tip_hash, now_ms())
+ }
+
+ fn with_time(height: u64, tip_hash: String, time_ms: u64) -> Self {
Self {
height,
tip_hash,
+ time_ms,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -76,10 +83,12 @@ impl PeerStatus {
mempool_count: usize,
mempool_root: String,
mempool_txs: Vec<String>,
+ time_ms: u64,
) -> Self {
Self {
height,
tip_hash,
+ time_ms,
mempool_count,
mempool_root,
mempool_txs,
@@ -88,10 +97,11 @@ impl PeerStatus {
}
}
- fn with_snapshot_request(height: u64, tip_hash: String) -> Self {
+ fn with_snapshot_request(height: u64, tip_hash: String, time_ms: u64) -> Self {
Self {
height,
tip_hash,
+ time_ms,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -100,10 +110,11 @@ impl PeerStatus {
}
}
- fn with_snapshot_push(height: u64, tip_hash: String) -> Self {
+ fn with_snapshot_push(height: u64, tip_hash: String, time_ms: u64) -> Self {
Self {
height,
tip_hash,
+ time_ms,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -799,6 +810,7 @@ async fn session_loop(
} else if let GossipEnvelope::PeerStatus {
height,
tip_hash,
+ time_ms,
mempool_count,
mempool_root,
mempool_txs,
@@ -810,6 +822,7 @@ async fn session_loop(
mempool_count,
mempool_root,
mempool_txs,
+ time_ms,
);
record_peer_status(&network, &known_peer, remote_addr, &status).await;
maybe_request_mempool_catchup(
@@ -890,6 +903,7 @@ async fn session_loop(
if let GossipEnvelope::PeerStatus {
height,
tip_hash,
+ time_ms,
mempool_count,
mempool_root,
mempool_txs,
@@ -901,6 +915,7 @@ async fn session_loop(
*mempool_count,
mempool_root.clone(),
mempool_txs.clone(),
+ *time_ms,
);
record_peer_status(&network, &known_peer, remote_addr, &status).await;
maybe_request_mempool_catchup(&network, &mut writer, &known_peer, remote_addr, &status)
@@ -1024,9 +1039,10 @@ async fn process_envelope(
record_inbound_result(network, known_peer, remote_addr, Ok(())).await;
}
GossipEnvelope::Block(block) => {
+ let adjusted_time_ms = network_adjusted_time_ms(network).await;
let needs_vdf = {
let node = network.inner.node.lock().await;
- node.block_requires_vdf_verification(&block)
+ node.block_requires_vdf_verification_at(&block, adjusted_time_ms)
};
let result = match needs_vdf {
Ok(false) => Ok(()),
@@ -1036,7 +1052,7 @@ async fn process_envelope(
.node
.lock()
.await
- .receive_preverified_block(block),
+ .receive_preverified_block_at(block, adjusted_time_ms),
Err(error) => Err(error),
},
Err(error) => Err(error),
@@ -1045,17 +1061,19 @@ async fn process_envelope(
network.forward_outbox().await;
}
GossipEnvelope::Blocks { blocks } => {
+ let adjusted_time_ms = network_adjusted_time_ms(network).await;
let local_ledger = network.inner.node.lock().await.clone_ledger();
- let result = match validate_blocks_extension(local_ledger, blocks).await {
- Ok(ledger) => network
- .inner
- .node
- .lock()
- .await
- .import_verified_ledger(ledger)
- .map(|_| ()),
- Err(error) => Err(error),
- };
+ let result =
+ match validate_blocks_extension(local_ledger, blocks, adjusted_time_ms).await {
+ Ok(ledger) => network
+ .inner
+ .node
+ .lock()
+ .await
+ .import_verified_ledger(ledger)
+ .map(|_| ()),
+ Err(error) => Err(error),
+ };
let request_snapshot = result.as_ref().err().is_some_and(is_possible_fork_error);
record_inbound_result(network, known_peer, remote_addr, result).await;
if request_snapshot {
@@ -1064,17 +1082,19 @@ async fn process_envelope(
network.forward_outbox().await;
}
GossipEnvelope::ChainSnapshot(snapshot) => {
+ let adjusted_time_ms = network_adjusted_time_ms(network).await;
let local_ledger = network.inner.node.lock().await.clone_ledger();
- let result = match validate_snapshot_extension(local_ledger, snapshot).await {
- Ok(ledger) => network
- .inner
- .node
- .lock()
- .await
- .import_verified_ledger(ledger)
- .map(|_| ()),
- Err(error) => Err(error),
- };
+ let result =
+ match validate_snapshot_extension(local_ledger, snapshot, adjusted_time_ms).await {
+ Ok(ledger) => network
+ .inner
+ .node
+ .lock()
+ .await
+ .import_verified_ledger(ledger)
+ .map(|_| ()),
+ Err(error) => Err(error),
+ };
record_inbound_result(network, known_peer, remote_addr, result).await;
network.forward_outbox().await;
}
@@ -1711,11 +1731,16 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> {
NETWORK_ID
);
}
- Ok(PeerStatus::new(hello.height, hello.tip_hash))
+ Ok(PeerStatus::with_time(
+ hello.height,
+ hello.tip_hash,
+ hello.time_ms,
+ ))
}
GossipEnvelope::PeerStatus {
height,
tip_hash,
+ time_ms,
mempool_count,
mempool_root,
mempool_txs,
@@ -1725,6 +1750,7 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> {
mempool_count,
mempool_root,
mempool_txs,
+ time_ms,
)),
other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"),
}
@@ -1819,9 +1845,10 @@ fn join_snapshot_response(peer: &str, envelope: GossipEnvelope) -> Result<Option
async fn validate_snapshot_extension(
mut ledger: Ledger,
snapshot: ChainSnapshot,
+ now_ms: u64,
) -> Result<Ledger> {
if ledger.is_setup_placeholder() {
- return tokio::task::spawn_blocking(move || Ledger::from_snapshot(snapshot))
+ return tokio::task::spawn_blocking(move || Ledger::from_snapshot_at(snapshot, now_ms))
.await
.context("chain snapshot adoption worker failed")?;
}
@@ -1829,14 +1856,18 @@ async fn validate_snapshot_extension(
verify_blocks_vdf(missing_blocks).await?;
tokio::task::spawn_blocking(move || {
- ledger.extend_from_preverified_snapshot(snapshot)?;
+ ledger.extend_from_preverified_snapshot_at(snapshot, now_ms)?;
Ok(ledger)
})
.await
.context("chain snapshot extension worker failed")?
}
-async fn validate_blocks_extension(mut ledger: Ledger, blocks: Vec<Block>) -> Result<Ledger> {
+async fn validate_blocks_extension(
+ mut ledger: Ledger,
+ blocks: Vec<Block>,
+ now_ms: u64,
+) -> Result<Ledger> {
if blocks.is_empty() {
return Ok(ledger);
}
@@ -1844,7 +1875,7 @@ async fn validate_blocks_extension(mut ledger: Ledger, blocks: Vec<Block>) -> Re
tokio::task::spawn_blocking(move || {
for block in blocks {
- ledger.apply_preverified_block(block)?;
+ ledger.apply_preverified_block_at(block, now_ms)?;
}
Ok(ledger)
})
@@ -1852,6 +1883,16 @@ async fn validate_blocks_extension(mut ledger: Ledger, blocks: Vec<Block>) -> Re
.context("block batch extension worker failed")?
}
+async fn network_adjusted_time_ms(network: &GossipNetwork) -> u64 {
+ let local_time_ms = now_ms();
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .adjusted_time_ms_at(local_time_ms)
+}
+
async fn verify_block_vdf(block: Block) -> Result<Block> {
let seed = block.vdf_seed();
let rounds = block.vdf_rounds;
@@ -1890,19 +1931,26 @@ async fn record_peer_status(
remote_addr: SocketAddr,
peer_status: &PeerStatus,
) {
+ let local_receive_time_ms = now_ms();
if let Some(peer) = known_peer {
- network.inner.peers.lock().await.record_status(
+ let mut peers = network.inner.peers.lock().await;
+ peers.record_status(peer, peer_status.height, peer_status.tip_hash.clone());
+ peers.record_clock_observation(
peer,
- peer_status.height,
- peer_status.tip_hash.clone(),
+ PeerDirection::Outbound,
+ peer_status.time_ms,
+ local_receive_time_ms,
);
} else {
- network
- .inner
- .peers
- .lock()
- .await
- .record_received(&remote_addr.to_string(), 1);
+ let peer = remote_addr.to_string();
+ let mut peers = network.inner.peers.lock().await;
+ peers.record_clock_observation(
+ &peer,
+ PeerDirection::Inbound,
+ peer_status.time_ms,
+ local_receive_time_ms,
+ );
+ peers.record_received(&peer, 1);
}
}
@@ -1961,7 +2009,11 @@ async fn process_hello(
{
P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections);
forget_stale_self_peer(network, known_peer).await;
- return Ok(PeerStatus::new(hello.height, hello.tip_hash));
+ return Ok(PeerStatus::with_time(
+ hello.height,
+ hello.tip_hash,
+ hello.time_ms,
+ ));
}
let (local_genesis, local_accepts_remote_genesis) = {
let node = network.inner.node.lock().await;
@@ -2002,11 +2054,20 @@ async fn process_hello(
Ok(PeerStatus::with_snapshot_request(
hello.height,
hello.tip_hash,
+ hello.time_ms,
))
} else if push_snapshot {
- Ok(PeerStatus::with_snapshot_push(hello.height, hello.tip_hash))
+ Ok(PeerStatus::with_snapshot_push(
+ hello.height,
+ hello.tip_hash,
+ hello.time_ms,
+ ))
} else {
- Ok(PeerStatus::new(hello.height, hello.tip_hash))
+ Ok(PeerStatus::with_time(
+ hello.height,
+ hello.tip_hash,
+ hello.time_ms,
+ ))
}
}
@@ -2340,6 +2401,7 @@ mod tests {
GossipEnvelope::PeerStatus {
height: 7,
tip_hash: "tip".to_string(),
+ time_ms: 0,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -2352,6 +2414,7 @@ mod tests {
let envelope = GossipEnvelope::PeerStatus {
height: 7,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
mempool_count: super::MEMPOOL_STATUS_LIMIT + 1,
mempool_root: "root".to_string(),
mempool_txs: vec!["sig".to_string(); super::MEMPOOL_STATUS_LIMIT + 1],
@@ -2367,6 +2430,7 @@ mod tests {
let envelope = GossipEnvelope::PeerStatus {
height: 7,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
mempool_count: MAX_INVENTORY_ITEMS + 1,
mempool_root: "root".to_string(),
mempool_txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1],
@@ -2384,6 +2448,7 @@ mod tests {
&GossipEnvelope::PeerStatus {
height: 7,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -2423,6 +2488,7 @@ mod tests {
let line = serde_json::to_string(&GossipEnvelope::PeerStatus {
height: 7,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -2872,6 +2938,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
assert!(
super::process_hello(
@@ -2894,6 +2961,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
assert!(
super::process_hello(
@@ -2923,6 +2991,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
assert!(
super::process_hello(
@@ -2979,6 +3048,7 @@ mod tests {
node_id: None,
height: 5,
tip_hash: "remote-tip".to_string(),
+ time_ms: 1_000,
};
let mut known_peer = Some("iuna.jhx.app:9444".to_string());
let peer_status = super::process_hello(
@@ -3002,9 +3072,10 @@ mod tests {
assert_eq!(peer.misbehavior_score, 0);
assert!(!peer.is_banned_at(crate::app::now_ms()));
- let adopted = super::validate_snapshot_extension(local_ledger, remote_snapshot)
- .await
- .unwrap();
+ let adopted =
+ super::validate_snapshot_extension(local_ledger, remote_snapshot, crate::app::now_ms())
+ .await
+ .unwrap();
assert_eq!(adopted.genesis_hash(), remote_genesis);
assert!(
network
@@ -3049,6 +3120,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: setup_ledger.status().tip_hash,
+ time_ms: 1_000,
};
let peer_status = super::process_hello(
@@ -3095,6 +3167,7 @@ mod tests {
node_id: None,
height: status.height,
tip_hash: status.tip_hash,
+ time_ms: 1_000,
};
let mut known_peer = None;
@@ -3152,6 +3225,7 @@ mod tests {
GossipEnvelope::PeerStatus {
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
mempool_count: 0,
mempool_root: String::new(),
mempool_txs: Vec::new(),
@@ -3395,6 +3469,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
super::process_hello(
@@ -3447,6 +3522,7 @@ mod tests {
node_id: None,
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
let mut known_peer = Some("10.42.1.1:16987".to_string());
@@ -3498,6 +3574,7 @@ mod tests {
node_id: Some(network.inner.node_id.clone()),
height: 0,
tip_hash: "tip".to_string(),
+ time_ms: 1_000,
};
let mut known_peer = Some("142.132.164.59:9444".to_string());
diff --git a/src/app.rs b/src/app.rs
@@ -28,6 +28,8 @@ pub const MEMPOOL_STATUS_LIMIT: usize = MAX_PENDING_TRANSACTIONS;
const IMPORT_REBROADCAST_LIMIT: usize = 128;
pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3;
pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000;
+pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000;
+const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000;
const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8;
#[derive(Clone, Debug)]
@@ -84,6 +86,8 @@ pub enum GossipEnvelope {
height: u64,
tip_hash: String,
#[serde(default)]
+ time_ms: u64,
+ #[serde(default)]
mempool_count: usize,
#[serde(default)]
mempool_root: String,
@@ -136,6 +140,8 @@ pub struct ProtocolHello {
pub node_id: Option<String>,
pub height: u64,
pub tip_hash: String,
+ #[serde(default)]
+ pub time_ms: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -421,6 +427,7 @@ impl NodeCore {
node_id,
height: status.height,
tip_hash: status.tip_hash,
+ time_ms: now_ms(),
})
}
@@ -430,6 +437,7 @@ impl NodeCore {
GossipEnvelope::PeerStatus {
height: status.height,
tip_hash: status.tip_hash,
+ time_ms: now_ms(),
mempool_count: self.mempool_count(),
mempool_root: self.mempool_root(),
mempool_txs,
@@ -1122,17 +1130,23 @@ impl NodeCore {
}
}
- pub(crate) fn receive_preverified_block(&mut self, block: Block) -> Result<()> {
+ pub(crate) fn receive_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
let previous_height = self.ledger.height();
- self.ledger.apply_preverified_block(block.clone())?;
+ self.ledger
+ .apply_preverified_block_at(block.clone(), now_ms)?;
if self.ledger.height() > previous_height {
self.outbox.push(GossipEnvelope::Block(block));
}
Ok(())
}
- pub(crate) fn block_requires_vdf_verification(&self, block: &Block) -> Result<bool> {
- self.ledger.block_requires_vdf_verification(block)
+ pub(crate) fn block_requires_vdf_verification_at(
+ &self,
+ block: &Block,
+ now_ms: u64,
+ ) -> Result<bool> {
+ self.ledger
+ .block_requires_vdf_verification_at(block, now_ms)
}
pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> {
@@ -1256,6 +1270,11 @@ impl PeerBook {
to_peer.last_mempool_status_ms = to_peer
.last_mempool_status_ms
.max(from_peer.last_mempool_status_ms);
+ if from_peer.last_clock_observed_ms > to_peer.last_clock_observed_ms {
+ to_peer.last_clock_offset_ms = from_peer.last_clock_offset_ms;
+ to_peer.last_clock_offset_accepted = from_peer.last_clock_offset_accepted;
+ to_peer.last_clock_observed_ms = from_peer.last_clock_observed_ms;
+ }
to_peer.last_contact_ms = to_peer.last_contact_ms.max(from_peer.last_contact_ms);
to_peer.last_success_ms = to_peer.last_success_ms.max(from_peer.last_success_ms);
to_peer.last_error_ms = to_peer.last_error_ms.max(from_peer.last_error_ms);
@@ -1349,6 +1368,63 @@ impl PeerBook {
}
}
+ pub fn record_clock_observation(
+ &mut self,
+ address: &str,
+ direction: PeerDirection,
+ remote_time_ms: u64,
+ local_receive_time_ms: u64,
+ ) {
+ if remote_time_ms == 0 {
+ return;
+ }
+ let offset = remote_time_ms as i128 - local_receive_time_ms as i128;
+ let offset = offset.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
+ let accepted = offset.abs() <= PEER_CLOCK_OFFSET_ACCEPTANCE_MS;
+ let peer = self.ensure(address, direction);
+ peer.last_clock_offset_ms = Some(offset);
+ peer.last_clock_offset_accepted = Some(accepted);
+ peer.last_clock_observed_ms = Some(local_receive_time_ms);
+ }
+
+ pub fn network_time_offset_ms_at(&self, now_ms: u64) -> Option<i64> {
+ median_i64(
+ self.peers
+ .values()
+ .filter(|peer| !peer.is_banned_at(now_ms))
+ .filter(|peer| peer.last_error.is_none())
+ .filter(|peer| peer.last_clock_offset_accepted == Some(true))
+ .filter(|peer| {
+ peer.last_clock_observed_ms.is_some_and(|observed_ms| {
+ now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS
+ })
+ })
+ .filter_map(|peer| peer.last_clock_offset_ms)
+ .collect(),
+ )
+ }
+
+ pub fn adjusted_time_ms_at(&self, now_ms: u64) -> u64 {
+ match self.network_time_offset_ms_at(now_ms) {
+ Some(offset) if offset >= 0 => now_ms.saturating_add(offset as u64),
+ Some(offset) => now_ms.saturating_sub(offset.unsigned_abs()),
+ None => now_ms,
+ }
+ }
+
+ pub fn bad_clock_peer_count_at(&self, now_ms: u64) -> usize {
+ self.peers
+ .values()
+ .filter(|peer| !peer.is_banned_at(now_ms))
+ .filter(|peer| {
+ peer.last_clock_observed_ms.is_some_and(|observed_ms| {
+ now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS
+ })
+ })
+ .filter(|peer| peer.last_clock_offset_accepted == Some(false))
+ .count()
+ }
+
pub fn record_mempool_status(
&mut self,
address: &str,
@@ -1523,6 +1599,12 @@ pub struct PeerInfo {
pub last_known_mempool_missing: Option<usize>,
#[serde(default)]
pub last_mempool_status_ms: Option<u64>,
+ #[serde(default)]
+ pub last_clock_offset_ms: Option<i64>,
+ #[serde(default)]
+ pub last_clock_offset_accepted: Option<bool>,
+ #[serde(default)]
+ pub last_clock_observed_ms: Option<u64>,
pub last_error: Option<String>,
pub last_transaction_rejection: Option<String>,
pub last_contact_ms: Option<u64>,
@@ -1548,6 +1630,9 @@ impl PeerInfo {
last_known_mempool_shared: None,
last_known_mempool_missing: None,
last_mempool_status_ms: None,
+ last_clock_offset_ms: None,
+ last_clock_offset_accepted: None,
+ last_clock_observed_ms: None,
last_error: None,
last_transaction_rejection: None,
last_contact_ms: None,
@@ -1572,6 +1657,14 @@ impl PeerInfo {
}
}
+fn median_i64(mut values: Vec<i64>) -> Option<i64> {
+ if values.is_empty() {
+ return None;
+ }
+ values.sort_unstable();
+ Some(values[values.len() / 2])
+}
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerDirection {
diff --git a/src/domain.rs b/src/domain.rs
@@ -1,4 +1,7 @@
-use std::collections::{BTreeMap, BTreeSet};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ time::{SystemTime, UNIX_EPOCH},
+};
use anyhow::{Context, Result, anyhow, bail};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
@@ -30,6 +33,10 @@ const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3;
const MIN_VDF_ROUNDS: u32 = 1;
const VDF_RETARGET_WINDOW_BLOCKS: usize = 10;
const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 10;
+const MIN_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS / 4;
+const MAX_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS * 4;
+const MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS: u64 = 2 * 60 * 1_000;
+const BLOCK_MEDIAN_TIME_PAST_WINDOW: usize = 11;
const FORK_FINALITY_DEPTH: u64 = 6;
const VDF_MODULUS: u128 = 4_611_685_975_477_714_963;
const VDF_CHALLENGE_MIN: u64 = 1_073_741_827;
@@ -1222,10 +1229,18 @@ impl Ledger {
}
pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
- Self::from_snapshot_with_vdf_policy(snapshot, true)
+ Self::from_snapshot_at(snapshot, unix_now_ms())
}
- fn from_snapshot_with_vdf_policy(snapshot: ChainSnapshot, verify_vdf: bool) -> Result<Self> {
+ pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> {
+ Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms)
+ }
+
+ fn from_snapshot_with_vdf_policy(
+ snapshot: ChainSnapshot,
+ verify_vdf: bool,
+ now_ms: u64,
+ ) -> Result<Self> {
let ChainSnapshot {
genesis_allocations,
vdf_rounds,
@@ -1267,23 +1282,24 @@ impl Ledger {
for block in blocks.into_iter().skip(1) {
if verify_vdf {
- ledger.apply_block(block)?;
+ ledger.apply_block_at(block, now_ms)?;
} else {
- ledger.apply_preverified_block(block)?;
+ ledger.apply_preverified_block_at(block, now_ms)?;
}
}
Ok(ledger)
}
pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> {
- self.extend_from_snapshot_with_vdf_policy(snapshot, true)
+ self.extend_from_snapshot_with_vdf_policy(snapshot, true, unix_now_ms())
}
- pub(crate) fn extend_from_preverified_snapshot(
+ pub(crate) fn extend_from_preverified_snapshot_at(
&mut self,
snapshot: ChainSnapshot,
+ now_ms: u64,
) -> Result<bool> {
- self.extend_from_snapshot_with_vdf_policy(snapshot, false)
+ self.extend_from_snapshot_with_vdf_policy(snapshot, false, now_ms)
}
pub(crate) fn missing_snapshot_blocks(&self, snapshot: &ChainSnapshot) -> Result<Vec<Block>> {
@@ -1305,9 +1321,10 @@ impl Ledger {
&mut self,
snapshot: ChainSnapshot,
verify_vdf: bool,
+ now_ms: u64,
) -> Result<bool> {
self.validate_snapshot_identity(&snapshot)?;
- let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf)?;
+ let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, now_ms)?;
let fork_point = self.fork_point_with_candidate(&candidate)?;
if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal {
@@ -1926,11 +1943,19 @@ impl Ledger {
}
pub fn apply_block(&mut self, block: Block) -> Result<()> {
- self.apply_block_with_vdf_policy(block, true)
+ self.apply_block_at(block, unix_now_ms())
+ }
+
+ pub(crate) fn apply_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
+ self.apply_block_with_vdf_policy(block, true, now_ms)
}
- pub(crate) fn block_requires_vdf_verification(&self, block: &Block) -> Result<bool> {
- self.precheck_block_without_vdf(block)
+ pub(crate) fn block_requires_vdf_verification_at(
+ &self,
+ block: &Block,
+ now_ms: u64,
+ ) -> Result<bool> {
+ self.precheck_block_without_vdf_at(block, now_ms)
}
pub fn apply_locally_mined_block(&mut self, block: Block) -> Result<()> {
@@ -1938,11 +1963,20 @@ impl Ledger {
}
pub(crate) fn apply_preverified_block(&mut self, block: Block) -> Result<()> {
- self.apply_block_with_vdf_policy(block, false)
+ self.apply_preverified_block_at(block, unix_now_ms())
}
- fn apply_block_with_vdf_policy(&mut self, block: Block, should_verify_vdf: bool) -> Result<()> {
- if !self.precheck_block_without_vdf(&block)? {
+ pub(crate) fn apply_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
+ self.apply_block_with_vdf_policy(block, false, now_ms)
+ }
+
+ fn apply_block_with_vdf_policy(
+ &mut self,
+ block: Block,
+ should_verify_vdf: bool,
+ now_ms: u64,
+ ) -> Result<()> {
+ if !self.precheck_block_without_vdf_at(&block, now_ms)? {
return Ok(());
}
@@ -1999,7 +2033,7 @@ impl Ledger {
Ok(())
}
- fn precheck_block_without_vdf(&self, block: &Block) -> Result<bool> {
+ fn precheck_block_without_vdf_at(&self, block: &Block, now_ms: u64) -> Result<bool> {
if block.height <= self.tip().height {
let existing = self
.chain
@@ -2037,6 +2071,14 @@ impl Ledger {
if block.timestamp_ms <= self.tip().timestamp_ms {
bail!("block timestamp must increase");
}
+ let median_time_past = self.median_time_past();
+ if block.timestamp_ms <= median_time_past {
+ bail!("block timestamp must exceed median time past");
+ }
+ let max_future_timestamp = now_ms.saturating_add(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS);
+ if block.timestamp_ms > max_future_timestamp {
+ bail!("block timestamp is too far in the future");
+ }
if block.transactions.len() > self.launch_profile.max_block_transactions {
bail!("block has too many transactions");
}
@@ -2066,6 +2108,18 @@ impl Ledger {
Ok(true)
}
+ fn median_time_past(&self) -> u64 {
+ let mut timestamps = self
+ .chain
+ .iter()
+ .rev()
+ .take(BLOCK_MEDIAN_TIME_PAST_WINDOW)
+ .map(|block| block.timestamp_ms)
+ .collect::<Vec<_>>();
+ timestamps.sort_unstable();
+ timestamps[timestamps.len() / 2]
+ }
+
fn next_vdf_rounds_after_tip(&self) -> u32 {
let Some(tip) = self.chain.last() else {
return self.vdf_rounds;
@@ -2076,8 +2130,16 @@ impl Ledger {
let mut total_observed_ms = 0_u128;
let mut observed_blocks = 0_u128;
- for pair in self.chain.windows(2).rev().take(VDF_RETARGET_WINDOW_BLOCKS) {
- total_observed_ms += u128::from(pair[1].timestamp_ms - pair[0].timestamp_ms);
+ for pair in self
+ .chain
+ .windows(2)
+ .rev()
+ .filter(|pair| pair[0].height > 0)
+ .take(VDF_RETARGET_WINDOW_BLOCKS)
+ {
+ total_observed_ms += u128::from(clamped_vdf_retarget_observed_block_ms(
+ pair[1].timestamp_ms - pair[0].timestamp_ms,
+ ));
observed_blocks += 1;
}
if observed_blocks == 0 {
@@ -3363,6 +3425,22 @@ fn retarget_vdf_rounds(current_rounds: u32, observed_block_ms: u64) -> u32 {
raw_adjusted.clamp(min_next, max_next) as u32
}
+fn clamped_vdf_retarget_observed_block_ms(observed_block_ms: u64) -> u64 {
+ observed_block_ms.clamp(
+ MIN_VDF_RETARGET_OBSERVED_BLOCK_MS,
+ MAX_VDF_RETARGET_OBSERVED_BLOCK_MS,
+ )
+}
+
+fn unix_now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis()
+ .try_into()
+ .unwrap_or(u64::MAX)
+}
+
pub fn hex_hash(input: impl AsRef<[u8]>) -> String {
hex_encode(Sha256::digest(input.as_ref()))
}
@@ -3740,6 +3818,38 @@ mod tests {
}
#[test]
+ fn vdf_retarget_observed_block_time_is_clamped() {
+ assert_eq!(
+ clamped_vdf_retarget_observed_block_ms(1),
+ MIN_VDF_RETARGET_OBSERVED_BLOCK_MS
+ );
+ assert_eq!(
+ clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS),
+ VDF_TARGET_BLOCK_MS
+ );
+ assert_eq!(
+ clamped_vdf_retarget_observed_block_ms(u64::MAX),
+ MAX_VDF_RETARGET_OBSERVED_BLOCK_MS
+ );
+ }
+
+ #[test]
+ fn block_timestamp_future_check_uses_supplied_network_time() {
+ let wallet = Wallet::from_seed("adjusted-time-domain");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(allocations, 1);
+
+ let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
+ assert!(ledger.submit_transaction(burn).unwrap());
+ let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap();
+
+ let error = ledger.apply_block_at(block, 1_000).unwrap_err();
+
+ assert!(format!("{error:#}").contains("too far in the future"));
+ }
+
+ #[test]
fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() {
let alice = Wallet::from_seed("oversized-select-alice");
let bob = Wallet::from_seed("oversized-select-bob");
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -1,4 +1,7 @@
-use std::collections::BTreeMap;
+use std::{
+ collections::BTreeMap,
+ time::{SystemTime, UNIX_EPOCH},
+};
use iuna::{
adapters::chain_store::SqliteChainStore,
@@ -38,6 +41,15 @@ fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
.collect()
}
+fn unix_now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis()
+ .try_into()
+ .unwrap_or(u64::MAX)
+}
+
fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String {
let burn = ledger.build_burn(wallet, 1, 0).unwrap();
ledger.submit_transaction(burn).unwrap();
@@ -1069,6 +1081,45 @@ fn vdf_rounds_retarget_toward_target_block_time() {
}
#[test]
+fn block_timestamp_too_far_in_future_is_rejected() {
+ let wallet = Wallet::from_seed("future-timestamp-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(genesis, 25);
+ submit_burn(&mut ledger, &wallet, 1);
+
+ let far_future = unix_now_ms()
+ .saturating_add(VDF_TARGET_BLOCK_MS)
+ .saturating_add(1);
+ let block = ledger.mine_next_block(&wallet, far_future).unwrap();
+
+ let error = ledger.apply_block(block).unwrap_err();
+
+ assert!(format!("{error:#}").contains("too far in the future"));
+}
+
+#[test]
+fn genesis_wall_clock_gap_does_not_lower_vdf_rounds() {
+ let wallet = Wallet::from_seed("genesis-gap-vdf-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new(genesis, 100);
+ let now = unix_now_ms();
+ let first_timestamp = now.saturating_sub(VDF_TARGET_BLOCK_MS);
+
+ submit_burn(&mut ledger, &wallet, 1);
+ let block1 = ledger.mine_next_block(&wallet, first_timestamp).unwrap();
+ ledger.apply_block(block1).unwrap();
+ assert_eq!(ledger.vdf_rounds(), 100);
+
+ submit_burn(&mut ledger, &wallet, 1);
+ let block2 = ledger.mine_next_block(&wallet, now).unwrap();
+ ledger.apply_block(block2).unwrap();
+
+ assert_eq!(ledger.vdf_rounds(), 100);
+}
+
+#[test]
fn conflicting_utxo_spends_are_not_accepted_together() {
let wallet = Wallet::from_seed("alice");
let mut genesis = BTreeMap::new();
@@ -1929,6 +1980,58 @@ fn peer_book_bans_misbehaving_peer_temporarily_and_recovers_on_success() {
}
#[test]
+fn peer_book_uses_median_accepted_clock_offset() {
+ let mut peers = PeerBook::from_addresses(vec![
+ "127.0.0.1:9444".to_string(),
+ "127.0.0.1:9445".to_string(),
+ "127.0.0.1:9446".to_string(),
+ ]);
+ let now = 1_000_000;
+ peers.record_status("127.0.0.1:9444", 1, "tip-a".to_string());
+ peers.record_status("127.0.0.1:9445", 1, "tip-b".to_string());
+ peers.record_status("127.0.0.1:9446", 1, "tip-c".to_string());
+ peers.record_clock_observation("127.0.0.1:9444", PeerDirection::Outbound, now + 1_000, now);
+ peers.record_clock_observation("127.0.0.1:9445", PeerDirection::Outbound, now + 2_000, now);
+ peers.record_clock_observation(
+ "127.0.0.1:9446",
+ PeerDirection::Outbound,
+ now + 20 * 60 * 1_000,
+ now,
+ );
+
+ assert_eq!(peers.network_time_offset_ms_at(now), Some(2_000));
+ assert_eq!(peers.adjusted_time_ms_at(now), now + 2_000);
+ assert_eq!(peers.bad_clock_peer_count_at(now), 1);
+ assert!(!peers.is_banned_at("127.0.0.1:9446", now));
+}
+
+#[test]
+fn peer_book_address_replacement_keeps_newest_clock_observation() {
+ let mut peers = PeerBook::from_addresses(vec![
+ "seed.example:9444".to_string(),
+ "142.132.164.59:9444".to_string(),
+ ]);
+ peers.record_clock_observation(
+ "seed.example:9444",
+ PeerDirection::Outbound,
+ 1_001_000,
+ 1_000_000,
+ );
+ peers.record_clock_observation(
+ "142.132.164.59:9444",
+ PeerDirection::Outbound,
+ 2_002_000,
+ 2_000_000,
+ );
+
+ peers.replace_peer_address("seed.example:9444", "142.132.164.59:9444");
+
+ let peer = peers.list().pop().unwrap();
+ assert_eq!(peer.last_clock_offset_ms, Some(2_000));
+ assert_eq!(peer.last_clock_observed_ms, Some(2_000_000));
+}
+
+#[test]
fn chain_snapshot_round_trips_ledger_state() {
let alice = Wallet::from_seed("alice");
let mut allocations = BTreeMap::new();
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -1134,6 +1134,10 @@ window.iunaApp = function iunaApp() {
return `${lag} behind`;
},
+ networkTimeOffsetLabel() {
+ return this.clockOffsetLabel(this.networkHealth.network_time_offset_ms, true);
+ },
+
outboundPeers() {
return this.peers.filter((peer) => peer.direction !== "inbound");
},
@@ -1197,6 +1201,23 @@ window.iunaApp = function iunaApp() {
return this.relativeTimeLabel(peer.last_contact_ms);
},
+ peerClockLabel(peer) {
+ const label = this.clockOffsetLabel(peer.last_clock_offset_ms, false);
+ if (label === "-") return "-";
+ return peer.last_clock_offset_accepted === false ? `${label} ignored` : label;
+ },
+
+ clockOffsetLabel(offsetMs, zeroAsSynced) {
+ if (typeof offsetMs !== "number") return "-";
+ const sign = offsetMs > 0 ? "+" : offsetMs < 0 ? "-" : "";
+ const absoluteSeconds = Math.round(Math.abs(offsetMs) / 1000);
+ if (absoluteSeconds === 0) return zeroAsSynced ? "even" : "0s";
+ if (absoluteSeconds < 60) return `${sign}${absoluteSeconds}s`;
+ const minutes = Math.round(absoluteSeconds / 60);
+ if (minutes < 60) return `${sign}${minutes}m`;
+ return `${sign}${Math.round(minutes / 60)}h`;
+ },
+
peerBanLabel(peer) {
if (!this.bannedPeer(peer)) return "-";
const remainingSeconds = Math.max(0, Math.round((peer.banned_until_ms - Date.now()) / 1000));