iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 24c11779101ea36bc5d82717e67fefd71ff98d38
parent 95a004a668edce897f5e4194a04f1937e3631f4c
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 13 Sep 2026 20:01:29 +0200

feat(p2p): expose peer handshake details

Diffstat:
MCHANGELOG.md | 4++++
Msrc/adapters/http/index_html.rs | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/p2p/handshake.rs | 19+++++++++++++++++++
Msrc/adapters/p2p/tests.rs | 2++
Msrc/app/peer_book.rs | 12+++++++++++-
Msrc/main_tests.rs | 14++++++++++++++
Mwww/assets/iuna-ui.js | 24++++++++++++++++++++++++
7 files changed, 156 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -5,6 +5,10 @@ from the Git history and Conventional Commit titles by `deployment.sh`. ## [Unreleased] +### Added + +- expose complete peer handshake details in the P2P interface + ## [0.4.32] - 2026-09-13 ### Fixed diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -382,8 +382,16 @@ pub(super) const INDEX_HTML: &str = concat!( .peer-status.banned { border-color: #713434; color: #ffb1a8; background: #2a1717; } .peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; } .peer-actions { display: flex; gap: 6px; align-items: center; } + .peer-details { padding: 4px 7px; font-size: 12px; } .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } + .peer-modal-section { display: grid; gap: 8px; margin-top: 14px; } + .peer-modal-section h3 { margin: 0; color: #8d989f; font-size: 11px; text-transform: uppercase; } + .peer-modal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; } + .peer-modal-field { min-width: 0; display: grid; gap: 5px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .peer-modal-field.wide { grid-column: 1 / -1; } + .peer-modal-value { min-width: 0; overflow-wrap: anywhere; } + .peer-capabilities { display: flex; flex-wrap: wrap; gap: 6px; } .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; } @@ -1040,7 +1048,7 @@ pub(super) const INDEX_HTML: &str = concat!( <td data-label="Sent" x-show="developmentMode()" x-text="peer.messages_sent"></td> <td data-label="Received" x-show="developmentMode()" x-text="peer.messages_received"></td> <td data-label="Last error" x-show="developmentMode()" x-text="peer.last_error || ''"></td> - <td data-label="Actions"><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> + <td data-label="Actions"><div class="peer-actions"><button class="peer-details" type="button" @click="openPeerModal(peer)">Details</button><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 class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> @@ -1538,6 +1546,79 @@ pub(super) const INDEX_HTML: &str = concat!( <p class="muted">This recovery option removes only the local blockchain. Your wallet and settings stay on this device.</p> </section> </div> + <div class="setup-overlay transaction-overlay" x-show="selectedPeerAddress" x-transition.opacity @click.self="closePeerModal()" role="dialog" aria-modal="true" aria-labelledby="peer-details-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="peer-status" :class="peerDetail() ? peerStatus(peerDetail()) : 'pending'" x-text="peerDetail() ? peerStatusLabel(peerDetail()) : 'Unavailable'"></span> + <h2 id="peer-details-title">Peer details</h2> + <code x-text="peerDetail()?.address || selectedPeerAddress || '-'"></code> + </div> + <button type="button" @click="closePeerModal">Close</button> + </div> + + <div class="tx-modal-empty" x-show="!peerDetail()">This peer is no longer present in the current peer list.</div> + <template x-if="peerDetail()"> + <div> + <section class="peer-modal-section"> + <h3>Connection</h3> + <div class="peer-modal-grid"> + <div class="peer-modal-field"><span class="tx-label">Direction</span><span class="peer-modal-value" x-text="peerDetail().direction"></span></div> + <div class="peer-modal-field"><span class="tx-label">Last contact</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_contact_ms)"></span></div> + <div class="peer-modal-field"><span class="tx-label">Last success</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_success_ms)"></span></div> + <div class="peer-modal-field"><span class="tx-label">Messages sent</span><span class="peer-modal-value" x-text="peerDetail().messages_sent ?? 0"></span></div> + <div class="peer-modal-field"><span class="tx-label">Messages received</span><span class="peer-modal-value" x-text="peerDetail().messages_received ?? 0"></span></div> + </div> + </section> + + <section class="peer-modal-section"> + <h3>Current chain view</h3> + <div class="peer-modal-grid"> + <div class="peer-modal-field"><span class="tx-label">Height</span><span class="peer-modal-value" x-text="peerDetail().last_known_height ?? '-'"></span></div> + <div class="peer-modal-field wide"><span class="tx-label">Tip hash</span><code class="peer-modal-value" x-text="peerDetail().last_known_tip_hash || '-'"></code></div> + </div> + </section> + + <section class="peer-modal-section"> + <h3>Last protocol hello</h3> + <div class="muted">These values are advertised by the remote peer during its handshake.</div> + <div class="peer-modal-grid"> + <div class="peer-modal-field"><span class="tx-label">Protocol version</span><span class="peer-modal-value" x-text="peerDetail().last_hello?.protocol_version ?? '-'"></span></div> + <div class="peer-modal-field"><span class="tx-label">Network</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.network_id || '-'"></code></div> + <div class="peer-modal-field"><span class="tx-label">Advertised address</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.listen_addr || '-'"></code></div> + <div class="peer-modal-field"><span class="tx-label">Node ID</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.node_id || '-'"></code></div> + <div class="peer-modal-field"><span class="tx-label">Hello height</span><span class="peer-modal-value" x-text="peerDetail().last_hello?.height ?? '-'"></span></div> + <div class="peer-modal-field"><span class="tx-label">Peer time</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_hello?.time_ms)"></span></div> + <div class="peer-modal-field wide"><span class="tx-label">Genesis hash</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.genesis_hash || '-'"></code></div> + <div class="peer-modal-field wide"><span class="tx-label">Hello tip hash</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.tip_hash || '-'"></code></div> + <div class="peer-modal-field wide"> + <span class="tx-label">Capabilities</span> + <div class="peer-capabilities" x-show="peerCapabilities(peerDetail()).length"> + <template x-for="capability in peerCapabilities(peerDetail())" :key="capability"><code class="pill" x-text="capability"></code></template> + </div> + <span class="muted" x-show="peerCapabilities(peerDetail()).length === 0">None advertised</span> + </div> + </div> + </section> + + <section class="peer-modal-section"> + <h3>Health and enforcement</h3> + <div class="peer-modal-grid"> + <div class="peer-modal-field"><span class="tx-label">Clock offset</span><span class="peer-modal-value" x-text="peerClockLabel(peerDetail())"></span></div> + <div class="peer-modal-field"><span class="tx-label">Clock accepted</span><span class="peer-modal-value" x-text="peerDetail().last_clock_offset_accepted == null ? '-' : (peerDetail().last_clock_offset_accepted ? 'Yes' : 'No')"></span></div> + <div class="peer-modal-field"><span class="tx-label">Clock observed</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_clock_observed_ms)"></span></div> + <div class="peer-modal-field"><span class="tx-label">Misbehavior score</span><span class="peer-modal-value" x-text="peerDetail().misbehavior_score ?? 0"></span></div> + <div class="peer-modal-field"><span class="tx-label">Banned for</span><span class="peer-modal-value" x-text="peerBanLabel(peerDetail())"></span></div> + <div class="peer-modal-field"><span class="tx-label">Banned until</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().banned_until_ms)"></span></div> + <div class="peer-modal-field"><span class="tx-label">Last error time</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_error_ms)"></span></div> + <div class="peer-modal-field wide"><span class="tx-label">Last error</span><span class="peer-modal-value" x-text="peerDetail().last_error || '-'"></span></div> + <div class="peer-modal-field wide"><span class="tx-label">Ban reason</span><span class="peer-modal-value" x-text="peerDetail().ban_reason || '-'"></span></div> + </div> + </section> + </div> + </template> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="sendConfirmModalOpen" x-transition.opacity @click.self="closeSendConfirmModal()" role="dialog" aria-modal="true" aria-labelledby="send-confirm-title"> <section class="tx-modal address-book-modal"> <div class="tx-modal-head"> diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs @@ -59,6 +59,24 @@ pub(super) async fn record_peer_status( } } +async fn record_peer_hello( + network: &GossipNetwork, + known_peer: &Option<String>, + remote_addr: SocketAddr, + hello: ProtocolHello, +) { + let (peer, direction) = match known_peer { + Some(peer) => (peer.clone(), PeerDirection::Outbound), + None => (remote_addr.to_string(), PeerDirection::Inbound), + }; + network + .inner + .peers + .lock() + .await + .record_hello(&peer, direction, hello); +} + pub(super) async fn process_hello( network: &GossipNetwork, remote_addr: SocketAddr, @@ -182,6 +200,7 @@ async fn process_hello_inner( &PeerStatus::with_time(hello.height, hello.tip_hash.clone(), hello.time_ms), ) .await; + record_peer_hello(network, known_peer, remote_addr, hello.clone()).await; let mut status = if request_bootstrap { PeerStatus::with_bootstrap_request(hello.height, hello.tip_hash, hello.time_ms) } else { diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -1114,6 +1114,7 @@ async fn hello_records_remote_clock_observation() { tip_hash: "tip".to_string(), time_ms: remote_time_ms, }; + let expected_hello = hello.clone(); let mut known_peer = None; super::process_hello( @@ -1132,6 +1133,7 @@ async fn hello_records_remote_clock_observation() { .unwrap(); assert!(peer.last_clock_offset_ms.unwrap() > 30_000); assert_eq!(peer.last_clock_offset_accepted, Some(true)); + assert_eq!(peer.last_hello.as_ref(), Some(&expected_hello)); } #[tokio::test] diff --git a/src/app/peer_book.rs b/src/app/peer_book.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use super::{ PEER_CLOCK_OFFSET_ACCEPTANCE_MS, PEER_CLOCK_OFFSET_STALE_MS, PEER_MISBEHAVIOR_BAN_MS, - PEER_MISBEHAVIOR_BAN_SCORE, now_ms, + PEER_MISBEHAVIOR_BAN_SCORE, ProtocolHello, now_ms, }; pub const MAX_DISCOVERED_PEERS: usize = 256; @@ -212,6 +212,9 @@ impl PeerBook { .last_known_tip_hash .clone() .or(from_peer.last_known_tip_hash); + if to_peer.last_hello.is_none() { + to_peer.last_hello = from_peer.last_hello; + } 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; @@ -357,6 +360,10 @@ impl PeerBook { } } + pub fn record_hello(&mut self, address: &str, direction: PeerDirection, hello: ProtocolHello) { + self.ensure(address, direction).last_hello = Some(hello); + } + pub fn record_clock_observation( &mut self, address: &str, @@ -498,6 +505,8 @@ pub struct PeerInfo { pub messages_received: u64, pub last_known_height: Option<u64>, pub last_known_tip_hash: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_hello: Option<ProtocolHello>, #[serde(default)] pub last_clock_offset_ms: Option<i64>, #[serde(default)] @@ -522,6 +531,7 @@ impl PeerInfo { messages_received: 0, last_known_height: None, last_known_tip_hash: None, + last_hello: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -126,6 +126,20 @@ fn management_ui_opens_on_the_dashboard_and_exposes_chain_health_thresholds() { } #[test] +fn management_ui_exposes_complete_peer_details() { + let html = include_str!("adapters/http/index_html.rs"); + let javascript = include_str!("../www/assets/iuna-ui.js"); + assert!(html.contains("@click=\"openPeerModal(peer)\"")); + assert!(html.contains("Last protocol hello")); + assert!(html.contains("peerDetail().last_hello?.protocol_version")); + assert!(html.contains("peerCapabilities(peerDetail())")); + assert!(html.contains("Health and enforcement")); + assert!(javascript.contains("selectedPeerAddress: null")); + assert!(javascript.contains("peerDetail()")); + assert!(javascript.contains("closePeerModal()")); +} + +#[test] fn management_ui_uses_an_in_app_transfer_confirmation_and_marks_fee_errors() { let html = include_str!("adapters/http/index_html.rs"); let javascript = include_str!("../www/assets/iuna-ui.js"); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -19,6 +19,7 @@ window.iunaApp = function iunaApp() { walletUtxos: [], mempool: [], peers: [], + selectedPeerAddress: null, p2pMetrics: {}, blockchainMetrics: { enabled: false, latest: null, charts: [] }, loadingMetrics: false, @@ -1415,6 +1416,7 @@ window.iunaApp = function iunaApp() { this.closeBurnLeaderRanksModal(); this.closeBurnBundleModal(); this.closeChainResetModal(); + this.closePeerModal(); }, async loadOlderBlocks() { @@ -2809,11 +2811,33 @@ window.iunaApp = function iunaApp() { async removePeer(peer) { try { await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE"); + if (this.selectedPeerAddress === peer.address) this.closePeerModal(); } catch (error) { this.showFlash(error.message, "error"); } }, + openPeerModal(peer) { + this.selectedPeerAddress = peer?.address || null; + }, + + closePeerModal() { + this.selectedPeerAddress = null; + }, + + peerDetail() { + return this.peers.find((peer) => peer.address === this.selectedPeerAddress) || null; + }, + + peerCapabilities(peer) { + return Array.isArray(peer?.last_hello?.capabilities) ? peer.last_hello.capabilities : []; + }, + + peerTimestampLabel(timestampMs) { + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || timestampMs <= 0) return "-"; + return `${new Date(timestampMs).toLocaleString()} (${this.relativeTimeLabel(timestampMs)})`; + }, + addressBookEntries() { return Object.entries(this.addressBook || {}) .map(([address, name]) => ({ address, name }))