iuna

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

commit 6b7e2a49349d55e4b19f686f781afbf148024f0e
parent 5185ae498b0f0138fcf6cbd3b4b71d59210908bb
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 27 Jul 2026 19:50:33 +0200

Add network health overview

Diffstat:
Massets/iuna-ui.js | 19++++++++++++++++++-
Msrc/adapters/http.rs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/iuna.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 259 insertions(+), 5 deletions(-)

diff --git a/assets/iuna-ui.js b/assets/iuna-ui.js @@ -12,6 +12,7 @@ window.iunaApp = function iunaApp() { mempool: [], peers: [], p2pMetrics: {}, + networkHealth: {}, config: { setup_complete: false }, auth: { configured: false, authenticated: false }, authLoaded: false, @@ -350,7 +351,7 @@ window.iunaApp = function iunaApp() { async refresh() { try { - const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics] = await Promise.all([ + const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics, networkHealth] = await Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), this.fetchJson("/api/blocks?limit=30"), @@ -359,6 +360,7 @@ window.iunaApp = function iunaApp() { this.fetchJson("/api/mempool"), this.fetchJson("/api/peers"), this.fetchJson("/api/p2p/metrics"), + this.fetchJson("/api/network/health"), ]); this.config = config; if (!this.config.setup_complete) { @@ -371,6 +373,7 @@ window.iunaApp = function iunaApp() { this.mempool = mempool; this.peers = peers; this.p2pMetrics = p2pMetrics; + this.networkHealth = networkHealth; this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; @@ -1059,6 +1062,20 @@ window.iunaApp = function iunaApp() { return Math.min(local, ...peerHeights); }, + networkHealthClass() { + if (this.networkHealth.ok) return "healthy"; + if (this.networkHealth.state === "syncing") return "syncing"; + if (this.networkHealth.state === "isolated") return "isolated"; + return "error"; + }, + + networkLagLabel() { + const lag = this.networkHealth.lag_blocks; + if (typeof lag !== "number") return "-"; + if (lag === 0) return "even"; + return `${lag} behind`; + }, + outboundPeers() { return this.peers.filter((peer) => peer.direction !== "inbound"); }, diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -28,7 +28,9 @@ use crate::{ p2p::{GossipNetwork, P2pMetrics}, wallet_store, }, - app::{FeeEstimate, NodeStatus, PeerInfo, SharedNode, SharedPeerBook, StratumStatus}, + app::{ + FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, + }, domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash}, }; @@ -75,6 +77,22 @@ struct AuthStatusResponse { authenticated: bool, } +#[derive(Debug, Serialize)] +struct NetworkHealthResponse { + ok: bool, + state: String, + local_height: u64, + best_known_height: u64, + shared_height: u64, + lag_blocks: u64, + outbound_peers: usize, + inbound_peers: usize, + healthy_peers: usize, + failed_peers: usize, + pending_transactions: usize, + last_error: Option<String>, +} + #[derive(Debug, Deserialize)] struct BurnSettingsForm { enabled: Option<bool>, @@ -253,6 +271,7 @@ pub async fn serve( .route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form)) .route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form)) .route("/api/mempool", get(api_mempool)) + .route("/api/network/health", get(api_network_health)) .route( "/api/peers", get(api_peers) @@ -511,6 +530,12 @@ async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { Json(state.gossip.metrics()) } +async fn api_network_health(State(state): State<HttpState>) -> Json<NetworkHealthResponse> { + let status = state.node.lock().await.status(); + let peers = state.peers.lock().await.list(); + Json(network_health(&status, &peers)) +} + async fn api_config_form( State(state): State<HttpState>, Form(form): Form<ConfigForm>, @@ -729,6 +754,73 @@ fn validate_peer_address(peer: String) -> Result<String> { Ok(peer) } +fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthResponse { + let local_height = status.chain.height; + let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max(); + let best_known_height = remote_best_height.unwrap_or(local_height).max(local_height); + let healthy_heights = peers + .iter() + .filter(|peer| peer.last_error.is_none()) + .filter_map(|peer| peer.last_known_height) + .collect::<Vec<_>>(); + let shared_height = healthy_heights + .iter() + .copied() + .min() + .unwrap_or(local_height) + .min(local_height); + let outbound_peers = peers + .iter() + .filter(|peer| peer.direction != PeerDirection::Inbound) + .count(); + let inbound_peers = peers + .iter() + .filter(|peer| peer.direction == PeerDirection::Inbound) + .count(); + let healthy_peers = peers + .iter() + .filter(|peer| peer.last_error.is_none() && peer.last_known_height.is_some()) + .count(); + let failed_peers = peers + .iter() + .filter(|peer| peer.last_error.is_some()) + .count(); + let lag_blocks = best_known_height.saturating_sub(local_height); + let last_error = peers.iter().rev().find_map(|peer| { + peer.last_error + .as_ref() + .map(|error| format!("{}: {error}", peer.address)) + }); + + let state = if peers.is_empty() { + "isolated" + } else if lag_blocks > 0 { + "syncing" + } else if failed_peers > 0 && healthy_peers == 0 { + "peer errors" + } else if remote_best_height.is_some_and(|height| local_height > height) { + "ahead of peers" + } else { + "healthy" + } + .to_string(); + + NetworkHealthResponse { + ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > 0, + state, + local_height, + best_known_height, + shared_height, + lag_blocks, + outbound_peers, + inbound_peers, + healthy_peers, + failed_peers, + pending_transactions: status.chain.pending_transactions, + last_error, + } +} + async fn wallet_setup_response( state: &HttpState, headers: &HeaderMap, @@ -1676,6 +1768,15 @@ const INDEX_HTML: &str = r#"<!doctype html> .peer-actions { display: flex; gap: 6px; align-items: center; } .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } + .network-health { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 1.2fr); gap: 12px; align-items: stretch; margin-bottom: 12px; } + .network-health-state { display: grid; align-content: center; gap: 5px; border: 1px solid #3a4248; border-radius: 8px; padding: 12px; background: #111316; } + .network-health-state.healthy { border-color: #566d25; background: #182112; } + .network-health-state.syncing { border-color: #5f5125; background: #211d12; } + .network-health-state.isolated, .network-health-state.error { border-color: #713434; background: #241716; } + .network-health-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .network-health-value { color: #e8edf0; font-size: 20px; font-weight: 900; text-transform: capitalize; } + .network-health-detail { color: #a8b2b8; font-size: 12px; } + .network-health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(116px, 1fr)); gap: 8px; } .panel .grid + form { margin-top: 12px; } .explorer-shell { width: 100%; display: grid; gap: 12px; } .block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; } @@ -1727,7 +1828,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .utxo-list { display: grid; gap: 8px; } .wallet-utxo-row { display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } @media (max-width: 760px) { .utxo-flow, .mine-action-row, .mine-stats { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } - @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid { grid-template-columns: 1fr; } } + @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .network-health { grid-template-columns: 1fr; } } @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar { position: sticky; z-index: 5; bottom: 0; top: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; } @@ -1744,7 +1845,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=53"></script> + <script defer src="/assets/iuna-ui.js?v=54"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -1982,6 +2083,19 @@ const INDEX_HTML: &str = r#"<!doctype html> <button class="primary" type="submit">Add</button> </form> </div> + <div class="network-health"> + <div class="network-health-state" :class="networkHealthClass()"> + <div class="network-health-label">Network Health</div> + <div class="network-health-value" x-text="networkHealth.state || '-'"></div> + <div class="network-health-detail" x-text="networkHealth.last_error || 'No peer errors reported'"></div> + </div> + <div class="network-health-grid"> + <div class="peer-summary-item"><div class="peer-summary-label">Local Height</div><div class="peer-summary-value" x-text="networkHealth.local_height ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Best Known</div><div class="peer-summary-value" x-text="networkHealth.best_known_height ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Lag</div><div class="peer-summary-value" x-text="networkLagLabel()"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div> + </div> + </div> <div class="peer-summary"> <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div> @@ -2370,7 +2484,7 @@ mod tests { use crate::{ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store}, - app::{NodeCore, PeerBook, StratumStatus}, + app::{NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, domain::{Block, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, Wallet}, }; @@ -2565,6 +2679,55 @@ mod tests { assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); } + #[tokio::test] + async fn network_health_summarizes_sync_and_peer_errors() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + let status = state.node.lock().await.status(); + + let isolated = super::network_health(&status, &[]); + assert!(!isolated.ok); + assert_eq!(isolated.state, "isolated"); + assert_eq!(isolated.local_height, 0); + assert_eq!(isolated.best_known_height, 0); + + let syncing = super::network_health( + &status, + &[PeerInfo { + address: "127.0.0.1:9445".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 1, + messages_received: 1, + last_known_height: Some(3), + last_known_tip_hash: Some("remote-tip".to_string()), + last_error: None, + }], + ); + assert!(!syncing.ok); + assert_eq!(syncing.state, "syncing"); + assert_eq!(syncing.best_known_height, 3); + assert_eq!(syncing.lag_blocks, 3); + + let peer_errors = super::network_health( + &status, + &[PeerInfo { + address: "127.0.0.1:9446".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 0, + messages_received: 0, + last_known_height: None, + last_known_tip_hash: None, + last_error: Some("connection refused".to_string()), + }], + ); + assert!(!peer_errors.ok); + assert_eq!(peer_errors.state, "peer errors"); + assert_eq!( + peer_errors.last_error.as_deref(), + Some("127.0.0.1:9446: connection refused") + ); + } + #[test] fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { let alice = Wallet::from_seed("wallet-history-alice"); diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -955,6 +955,65 @@ fn fallback_finalizer_with_primary_vdf_rounds_is_rejected() { } #[test] +fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() { + let alice = Wallet::from_seed("fallback-network-alice"); + let bob = Wallet::from_seed("fallback-network-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let ledger = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 25, + ) + .unwrap(); + let primary = ledger.expected_leader_for_next_block().unwrap(); + let fallback = wallets + .into_iter() + .find(|wallet| wallet.address() != primary) + .unwrap(); + let primary_wallet = wallets + .into_iter() + .find(|wallet| wallet.address() == primary) + .unwrap(); + + let mut network = InMemoryNetwork::default(); + network.insert( + "primary", + NodeCore::from_ledger( + primary_wallet.clone(), + ledger.clone(), + DEFAULT_BURN_PER_BLOCK, + ), + ); + network.insert( + "fallback", + NodeCore::from_ledger(fallback.clone(), ledger, DEFAULT_BURN_PER_BLOCK), + ); + + network.node_mut("fallback").unwrap().burn(1).unwrap(); + let block = network + .node_mut("fallback") + .unwrap() + .mine_one_at(1) + .unwrap(); + assert_eq!(block.finalizer_rank, 1); + assert_eq!(block.miner, fallback.address()); + network.deliver_until_idle().unwrap(); + + let tip = network.node("fallback").unwrap().ledger().status().tip_hash; + for id in ["primary", "fallback"] { + let status = network.node(id).unwrap().ledger().status(); + assert_eq!(status.height, 1, "{id} did not accept fallback block"); + assert_eq!(status.tip_hash, tip, "{id} has a different tip"); + } +} + +#[test] fn fork_choice_prefers_primary_finalizer_over_fallback_rank() { let alice = Wallet::from_seed("fallback-fork-alice"); let bob = Wallet::from_seed("fallback-fork-bob"); @@ -1173,6 +1232,21 @@ fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { let bob_status = network.node("bob").unwrap().ledger().status(); assert_eq!(bob_status.height, 5); assert_eq!(bob_status.tip_hash, alice_tip); + + network.deliver_until_idle().unwrap(); + network.node_mut("alice").unwrap().burn(1).unwrap(); + network.deliver_until_idle().unwrap(); + network + .node_mut("alice") + .unwrap() + .mine_one_at(6 * VDF_TARGET_BLOCK_MS) + .unwrap(); + network.deliver_until_idle().unwrap(); + + let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash; + let bob_status = network.node("bob").unwrap().ledger().status(); + assert_eq!(bob_status.height, 6); + assert_eq!(bob_status.tip_hash, alice_tip); } #[test]