iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
Msrc/adapters/http.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/p2p.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/app.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/domain.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/iuna.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mwww/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));