iuna

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

commit 0b0c67de04e04b7349c2eab60774d0369eaed1ce
parent 74f6c8255ab04b8aa21bb882151a3eabb0528067
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 27 Jul 2026 22:19:45 +0200

Add peer bans and version notice

Diffstat:
Massets/iuna-ui.js | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/adapters/p2p.rs | 223++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/app.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/iuna.rs | 24++++++++++++++++++++++++
5 files changed, 475 insertions(+), 16 deletions(-)

diff --git a/assets/iuna-ui.js b/assets/iuna-ui.js @@ -13,6 +13,9 @@ window.iunaApp = function iunaApp() { peers: [], p2pMetrics: {}, networkHealth: {}, + latestRelease: null, + releaseCheckState: "idle", + releaseCheckError: null, config: { setup_complete: false }, auth: { configured: false, authenticated: false }, authLoaded: false, @@ -81,6 +84,7 @@ window.iunaApp = function iunaApp() { this.hashListenerInstalled = true; } await this.refresh(); + this.checkLatestRelease(); if (!this.pollHandle) { this.pollHandle = setInterval(() => this.refresh(), 5000); } @@ -108,6 +112,33 @@ window.iunaApp = function iunaApp() { }[this.tab] || "iuna"; }, + appVersionLabel() { + return `v${this.normalizeVersion(this.status.app_version || "0.0.0")}`; + }, + + latestReleaseLabel() { + return this.latestRelease?.tag || ""; + }, + + updateAvailable() { + const current = this.status.app_version; + const latest = this.latestRelease?.tag; + if (!current || !latest) return false; + return this.compareVersions(latest, current) > 0; + }, + + versionPanelTitle() { + if (this.updateAvailable()) return `Update available: ${this.latestReleaseLabel()}`; + if (this.releaseCheckState === "failed") return this.releaseCheckError || "Could not check latest release"; + if (this.releaseCheckState === "checking") return "Checking latest release"; + return "iuna is up to date"; + }, + + openLatestRelease() { + const url = this.latestRelease?.url || "https://github.com/iuna-labs/iuna/releases"; + window.open(url, "_blank", "noopener,noreferrer"); + }, + showingSetup() { return this.authLoaded && !this.showingAuth() && !this.config.setup_complete; }, @@ -405,6 +436,27 @@ window.iunaApp = function iunaApp() { return response.json(); }, + async checkLatestRelease() { + if (this.releaseCheckState === "checking") return; + this.releaseCheckState = "checking"; + this.releaseCheckError = null; + try { + const response = await fetch("https://api.github.com/repos/iuna-labs/iuna/releases/latest", { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!response.ok) throw new Error(`release check returned ${response.status}`); + const release = await response.json(); + this.latestRelease = { + tag: release.tag_name || "", + url: release.html_url || "https://github.com/iuna-labs/iuna/releases", + }; + this.releaseCheckState = "done"; + } catch (error) { + this.releaseCheckError = error.message || "Release check failed"; + this.releaseCheckState = "failed"; + } + }, + mergeFreshBlocks(freshBlocks, options = {}) { const previousHeights = new Set(this.blocks.map((block) => block.height)); const previousHead = this.blocks[0]?.height; @@ -1067,6 +1119,7 @@ window.iunaApp = function iunaApp() { if (this.networkHealth.state === "syncing") return "syncing"; if (this.networkHealth.state === "isolated") return "isolated"; if (this.networkHealth.state === "stale") return "stale"; + if (this.networkHealth.state === "banned") return "banned"; return "error"; }, @@ -1099,7 +1152,13 @@ window.iunaApp = function iunaApp() { return Date.now() - lastSuccess > 20 * 60 * 1000; }, + bannedPeer(peer) { + const bannedUntil = peer.banned_until_ms; + return typeof bannedUntil === "number" && bannedUntil > Date.now(); + }, + peerStatus(peer) { + if (this.bannedPeer(peer)) return "banned"; if (peer.last_error) return "error"; if (this.stalePeer(peer)) return "stale"; if (typeof peer.last_known_height === "number") return "synced"; @@ -1110,6 +1169,7 @@ window.iunaApp = function iunaApp() { peerStatusLabel(peer) { return { error: "Error", + banned: "Banned", stale: "Stale", synced: "Synced", active: "Active", @@ -1133,6 +1193,35 @@ window.iunaApp = function iunaApp() { return this.relativeTimeLabel(peer.last_contact_ms); }, + peerBanLabel(peer) { + if (!this.bannedPeer(peer)) return "-"; + const remainingSeconds = Math.max(0, Math.round((peer.banned_until_ms - Date.now()) / 1000)); + if (remainingSeconds < 60) return `${remainingSeconds}s`; + const remainingMinutes = Math.round(remainingSeconds / 60); + if (remainingMinutes < 60) return `${remainingMinutes}m`; + return `${Math.round(remainingMinutes / 60)}h`; + }, + + normalizeVersion(version) { + return String(version || "").trim().replace(/^v/i, ""); + }, + + versionParts(version) { + const [core] = this.normalizeVersion(version).split("-"); + return core.split(".").map((part) => Number.parseInt(part, 10) || 0); + }, + + compareVersions(left, right) { + const leftParts = this.versionParts(left); + const rightParts = this.versionParts(right); + const length = Math.max(leftParts.length, rightParts.length, 3); + for (let index = 0; index < length; index += 1) { + const diff = (leftParts[index] || 0) - (rightParts[index] || 0); + if (diff !== 0) return diff; + } + return 0; + }, + peerHeightDelta(peer) { const local = this.status.chain?.height; const remote = peer.last_known_height; diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -91,6 +91,7 @@ struct NetworkHealthResponse { healthy_peers: usize, failed_peers: usize, stale_peers: usize, + banned_peers: usize, pending_transactions: usize, last_error: Option<String>, } @@ -803,6 +804,10 @@ fn network_health_at( }) }) .count(); + let banned_peers = peers + .iter() + .filter(|peer| peer.is_banned_at(now_ms)) + .count(); let lag_blocks = best_known_height.saturating_sub(local_height); let last_error = peers.iter().rev().find_map(|peer| { peer.last_error @@ -812,6 +817,8 @@ fn network_health_at( let state = if peers.is_empty() { "isolated" + } else if banned_peers > 0 && healthy_peers == 0 { + "banned" } else if lag_blocks > 0 { "syncing" } else if failed_peers > 0 && healthy_peers == 0 { @@ -837,6 +844,7 @@ fn network_health_at( healthy_peers, failed_peers, stale_peers, + banned_peers, pending_transactions: status.chain.pending_transactions, last_error, } @@ -1628,6 +1636,15 @@ const INDEX_HTML: &str = r#"<!doctype html> .nav-button svg.chain-icon { stroke-width: 1.35; } .nav-button span { font-size: 11px; font-weight: 800; } .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } + .version-panel { margin-top: auto; width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } + .version-panel.update { border-color: #566d25; color: #d5f55f; background: #1c2516; cursor: pointer; } + .version-panel.checking { color: #a8b2b8; } + .version-panel.failed { color: #ffb1a8; } + .version-dot { width: 6px; height: 6px; border-radius: 999px; background: #3a4248; } + .version-panel.update .version-dot { background: #d5f55f; box-shadow: 0 0 0 3px rgba(213, 245, 95, .12); } + .version-panel.failed .version-dot { background: #ff8f82; } + .version-label { line-height: 1; } + .version-update { color: #d5f55f; font-size: 9px; line-height: 1; text-transform: uppercase; } .content { width: 100%; min-width: 0; padding: 22px 24px 48px; } main { width: 100%; } main > section { width: 100%; } @@ -1786,6 +1803,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .peer-status { display: inline-flex; align-items: center; border: 1px solid #3a4248; border-radius: 999px; padding: 3px 8px; color: #a8b2b8; font-size: 11px; font-weight: 850; } .peer-status.synced, .peer-status.active { border-color: #566d25; color: #d5f55f; background: #1c2516; } .peer-status.stale { border-color: #5f5125; color: #ffe08a; background: #211d12; } + .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-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } @@ -1794,7 +1812,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .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, .network-health-state.stale { border-color: #5f5125; background: #211d12; } - .network-health-state.isolated, .network-health-state.error { border-color: #713434; background: #241716; } + .network-health-state.isolated, .network-health-state.error, .network-health-state.banned { 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; } @@ -1858,6 +1876,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .side-nav { display: flex; width: auto; gap: 8px; } .nav-button { width: 52px; min-height: 48px; } .nav-button span { font-size: 10px; } + .version-panel { margin-top: 0; width: 48px; padding: 6px 3px; font-size: 9px; } .content { padding: 16px 12px 36px; } header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } header { display: grid; } @@ -1867,7 +1886,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=55"></script> + <script defer src="/assets/iuna-ui.js?v=57"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -1892,6 +1911,11 @@ const INDEX_HTML: &str = r#"<!doctype html> <span>Chain</span> </button> </nav> + <button class="version-panel" type="button" :class="{ update: updateAvailable(), checking: releaseCheckState === 'checking', failed: releaseCheckState === 'failed' }" :title="versionPanelTitle()" @click="openLatestRelease"> + <span class="version-dot" aria-hidden="true"></span> + <span class="version-label" x-text="appVersionLabel()"></span> + <span class="version-update" x-show="updateAvailable()">Update</span> + </button> </aside> <main class="content"> @@ -2116,6 +2140,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <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">Stale</div><div class="peer-summary-value" x-text="networkHealth.stale_peers ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Banned</div><div class="peer-summary-value" x-text="networkHealth.banned_peers ?? '-'"></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> @@ -2128,7 +2153,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>Height</th><th>Delta</th><th>Tip</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>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</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> @@ -2136,6 +2161,8 @@ 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="peerBanLabel(peer)"></td> + <td x-text="peer.misbehavior_score ?? 0"></td> <td x-text="peer.last_known_height ?? '-'"></td> <td x-text="peerHeightDelta(peer)"></td> <td><code x-text="short(peer.last_known_tip_hash)"></code></td> @@ -2145,7 +2172,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="11">No peers</td></tr> + <tr x-show="peers.length === 0"><td colspan="13">No peers</td></tr> </tbody> </table> </div> @@ -2728,6 +2755,9 @@ mod tests { last_contact_ms: Some(10_000), last_success_ms: Some(10_000), last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, }], ); assert!(!syncing.ok); @@ -2748,6 +2778,9 @@ mod tests { last_contact_ms: Some(10_000), last_success_ms: None, last_error_ms: Some(10_000), + misbehavior_score: 1, + banned_until_ms: None, + ban_reason: Some("connection refused".to_string()), }], ); assert!(!peer_errors.ok); @@ -2770,12 +2803,38 @@ mod tests { last_contact_ms: Some(1), last_success_ms: Some(1), last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, }], PEER_STALE_AFTER_MS + 2, ); assert!(!stale.ok); assert_eq!(stale.state, "stale"); assert_eq!(stale.stale_peers, 1); + + let banned = super::network_health_at( + &status, + &[PeerInfo { + address: "127.0.0.1:9448".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 0, + messages_received: 0, + last_known_height: None, + last_known_tip_hash: None, + last_error: Some("invalid block".to_string()), + last_contact_ms: Some(10), + last_success_ms: None, + last_error_ms: Some(10), + misbehavior_score: 3, + banned_until_ms: Some(1_000), + ban_reason: Some("invalid block".to_string()), + }], + 20, + ); + assert!(!banned.ok); + assert_eq!(banned.state, "banned"); + assert_eq!(banned.banned_peers, 1); } #[test] diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -275,6 +275,9 @@ impl GossipNetwork { let sessions = self.inner.sessions.lock().await.clone(); for (peer, sender) in sessions { + if self.inner.peers.lock().await.is_banned(&peer) { + continue; + } match sender.try_send(envelopes.clone()) { Ok(()) => {} Err(mpsc::error::TrySendError::Full(_)) => { @@ -455,7 +458,12 @@ impl GossipNetwork { } async fn ensure_outbound_sessions(&self) { - let addresses = self.inner.peers.lock().await.addresses(); + let addresses = self + .inner + .peers + .lock() + .await + .connectable_addresses_at(crate::app::now_ms()); let mut sessions = self.inner.sessions.lock().await; sessions.retain(|peer, _| { let keep = !is_self_peer_address(peer, self.inner.listen_addr); @@ -542,6 +550,10 @@ async fn outbound_session( ) { let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { + if network.inner.peers.lock().await.is_banned(&peer) { + sleep(MAX_RECONNECT_DELAY).await; + continue; + } P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_attempts); let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&peer)).await { Ok(Ok(stream)) => { @@ -933,12 +945,12 @@ async fn process_transactions( let peer = known_peer .clone() .unwrap_or_else(|| remote_addr.to_string()); - network - .inner - .peers - .lock() - .await - .record_inbound_error(&peer, rejection.reason); + let mut peers = network.inner.peers.lock().await; + if transaction_rejection_counts_as_misbehavior(&rejection.reason) { + peers.record_misbehavior(&peer, rejection.reason); + } else { + peers.record_inbound_error(&peer, rejection.reason); + } } network.forward_outbox().await; record_inbound_result(network, known_peer, remote_addr, Ok(())).await; @@ -1059,6 +1071,49 @@ fn transactions_in_envelopes(envelopes: &[GossipEnvelope]) -> Vec<(String, Trans transactions } +fn transaction_rejection_counts_as_misbehavior(reason: &str) -> bool { + let reason = reason.to_ascii_lowercase(); + if transaction_rejection_is_state_dependent(&reason) { + return false; + } + transaction_rejection_is_structurally_invalid(&reason) +} + +fn transaction_rejection_is_state_dependent(reason: &str) -> bool { + [ + "mempool is full", + "anchor is not on this chain", + "anchor is too old", + "missing output", + "not spendable", + "insufficient funds", + "does not cover", + ] + .iter() + .any(|needle| reason.contains(needle)) +} + +fn transaction_rejection_is_structurally_invalid(reason: &str) -> bool { + [ + "signature", + "proof header is invalid", + "proof hash is invalid", + "proof does not meet difficulty", + "reward is invalid", + "difficulty is invalid", + "inputs do not balance", + "duplicate input", + "input owner does not match", + "has no inputs", + "inputs must have one owner", + "overflow", + "invalid public key", + "invalid transaction public key", + ] + .iter() + .any(|needle| reason.contains(needle)) +} + async fn write_envelope(writer: &mut OwnedWriteHalf, envelope: &GossipEnvelope) -> Result<()> { let line = serde_json::to_string(envelope)?; if line.len() > MAX_GOSSIP_LINE_BYTES { @@ -1527,6 +1582,23 @@ async fn record_peer_status( } } +async fn record_peer_misbehavior( + network: &GossipNetwork, + known_peer: &Option<String>, + remote_addr: SocketAddr, + reason: impl Into<String>, +) { + let peer = known_peer + .clone() + .unwrap_or_else(|| remote_addr.to_string()); + network + .inner + .peers + .lock() + .await + .record_misbehavior(&peer, reason); +} + async fn process_hello( network: &GossipNetwork, remote_addr: SocketAddr, @@ -1541,6 +1613,16 @@ async fn process_hello( ); } if hello.network_id != NETWORK_ID { + record_peer_misbehavior( + network, + known_peer, + remote_addr, + format!( + "wrong network {}; expected {}", + hello.network_id, NETWORK_ID + ), + ) + .await; anyhow::bail!( "wrong network {}; expected {}", hello.network_id, @@ -1556,6 +1638,16 @@ async fn process_hello( .genesis_hash() .to_string(); if hello.genesis_hash != local_genesis { + record_peer_misbehavior( + network, + known_peer, + remote_addr, + format!( + "wrong genesis {}; expected {local_genesis}", + hello.genesis_hash + ), + ) + .await; anyhow::bail!( "wrong genesis {}; expected {local_genesis}", hello.genesis_hash @@ -1566,6 +1658,13 @@ async fn process_hello( let peer = normalize_advertised_peer(listen_addr, remote_addr)?; if is_self_peer_address(&peer, network.inner.listen_addr) { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); + record_peer_misbehavior( + network, + known_peer, + remote_addr, + format!("peer announced our own p2p address {peer}"), + ) + .await; anyhow::bail!("peer announced our own p2p address {peer}"); } *known_peer = Some(peer.clone()); @@ -1605,7 +1704,7 @@ async fn record_inbound_result( .peers .lock() .await - .record_inbound_error(&peer, message.clone()); + .record_misbehavior(&peer, message.clone()); } eprintln!("p2p envelope from {peer} ignored: {message}"); } @@ -2093,6 +2192,41 @@ mod tests { assert_eq!(metrics.transactions_accepted_received, 1); } + #[test] + fn transaction_rejection_classifier_only_scores_structural_invalidity() { + for reason in [ + "transaction signature is invalid", + "mine transaction proof hash is invalid", + "mine transaction proof does not meet difficulty", + "mine transaction reward is invalid", + "mine transaction difficulty is invalid", + "transaction inputs do not balance outputs, burn, and fee", + "duplicate input in transaction", + "transaction input owner does not match spent output", + "transaction has no inputs", + "transaction inputs must have one owner", + ] { + assert!( + super::transaction_rejection_counts_as_misbehavior(reason), + "{reason} should count as misbehavior" + ); + } + + for reason in [ + "mempool is full", + "mine transaction anchor is not on this chain", + "mine transaction anchor is too old", + "transaction spends missing output abc:0", + "selected UTXOs do not cover transfer amount plus fee", + "insufficient funds for address", + ] { + assert!( + !super::transaction_rejection_counts_as_misbehavior(reason), + "{reason} should be treated as state-dependent" + ); + } + } + #[tokio::test] async fn inventory_requests_only_missing_objects() { let alice = Wallet::from_seed("missing-inv-alice"); @@ -2255,6 +2389,79 @@ mod tests { .to_string() .contains("wrong genesis") ); + + let wrong_protocol = ProtocolHello { + protocol_version: PROTOCOL_VERSION + 1, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + height: 0, + tip_hash: "tip".to_string(), + }; + assert!( + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut None, + wrong_protocol, + ) + .await + .unwrap_err() + .to_string() + .contains("unsupported protocol version") + ); + + let peer_after_protocol_mismatch = network + .inner + .peers + .lock() + .await + .list() + .into_iter() + .find(|peer| peer.address == "127.0.0.1:9545") + .unwrap(); + assert_eq!(peer_after_protocol_mismatch.misbehavior_score, 2); + assert!(!peer_after_protocol_mismatch.is_banned_at(crate::app::now_ms())); + + let repeated_wrong_genesis = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: "still-not-local-genesis".to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + height: 0, + tip_hash: "tip".to_string(), + }; + assert!( + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut None, + repeated_wrong_genesis, + ) + .await + .unwrap_err() + .to_string() + .contains("wrong genesis") + ); + + let banned_peer = network + .inner + .peers + .lock() + .await + .list() + .into_iter() + .find(|peer| peer.address == "127.0.0.1:9545") + .unwrap(); + assert_eq!(banned_peer.misbehavior_score, 3); + assert!(banned_peer.is_banned_at(crate::app::now_ms())); } #[tokio::test] diff --git a/src/app.rs b/src/app.rs @@ -23,6 +23,8 @@ pub const PROTOCOL_VERSION: u32 = 1; pub const NETWORK_ID: &str = "iuna-devnet-v2"; pub const BLOCK_REQUEST_LIMIT: usize = 128; 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; #[derive(Clone, Debug)] pub struct NodeConfig { @@ -138,6 +140,7 @@ pub struct TransactionRejection { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeStatus { + pub app_version: String, pub wallet_address: String, pub wallet_balance: Amount, pub wallet_locked: bool, @@ -437,6 +440,7 @@ impl NodeCore { .is_none_or(|leader| leader == self.wallet.address()); NodeStatus { + app_version: env!("CARGO_PKG_VERSION").to_string(), wallet_address: self.wallet.address().to_string(), wallet_balance: self.ledger.balance_of(self.wallet.address()), wallet_locked: self.wallet.is_locked(), @@ -1104,8 +1108,17 @@ impl PeerBook { .collect() } + pub fn connectable_addresses_at(&self, now_ms: u64) -> Vec<String> { + self.peers + .values() + .filter(|peer| peer.direction != PeerDirection::Inbound) + .filter(|peer| !peer.is_banned_at(now_ms)) + .map(|peer| peer.address.clone()) + .collect() + } + pub fn addresses_except(&self, excluded: &str) -> Vec<String> { - self.addresses() + self.connectable_addresses_at(now_ms()) .into_iter() .filter(|address| address != excluded) .collect() @@ -1121,7 +1134,10 @@ impl PeerBook { peer.messages_sent += count; peer.last_contact_ms = Some(now); peer.last_success_ms = Some(now); - peer.last_error = None; + if !peer.is_banned_at(now) { + peer.last_error = None; + peer.clear_misbehavior(); + } } pub fn record_status(&mut self, address: &str, height: u64, tip_hash: String) { @@ -1131,7 +1147,10 @@ impl PeerBook { peer.last_known_tip_hash = Some(tip_hash); peer.last_contact_ms = Some(now); peer.last_success_ms = Some(now); - peer.last_error = None; + if !peer.is_banned_at(now) { + peer.last_error = None; + peer.clear_misbehavior(); + } } pub fn record_error(&mut self, address: &str, error: impl Into<String>) { @@ -1156,7 +1175,37 @@ impl PeerBook { peer.messages_received += count; peer.last_contact_ms = Some(now); peer.last_success_ms = Some(now); - peer.last_error = None; + if !peer.is_banned_at(now) { + peer.last_error = None; + peer.clear_misbehavior(); + } + } + + pub fn record_misbehavior(&mut self, address: &str, reason: impl Into<String>) { + self.record_misbehavior_at(address, reason, now_ms()); + } + + pub fn record_misbehavior_at(&mut self, address: &str, reason: impl Into<String>, now_ms: u64) { + let reason = reason.into(); + let peer = self.ensure(address, PeerDirection::Outbound); + peer.last_contact_ms = Some(now_ms); + peer.last_error_ms = Some(now_ms); + peer.last_error = Some(reason.clone()); + peer.misbehavior_score = peer.misbehavior_score.saturating_add(1); + peer.ban_reason = Some(reason); + if peer.misbehavior_score >= PEER_MISBEHAVIOR_BAN_SCORE { + peer.banned_until_ms = Some(now_ms.saturating_add(PEER_MISBEHAVIOR_BAN_MS)); + } + } + + pub fn is_banned(&self, address: &str) -> bool { + self.is_banned_at(address, now_ms()) + } + + pub fn is_banned_at(&self, address: &str, now_ms: u64) -> bool { + self.peers + .get(address) + .is_some_and(|peer| peer.is_banned_at(now_ms)) } fn ensure(&mut self, address: &str, direction: PeerDirection) -> &mut PeerInfo { @@ -1178,6 +1227,9 @@ pub struct PeerInfo { pub last_contact_ms: Option<u64>, pub last_success_ms: Option<u64>, pub last_error_ms: Option<u64>, + pub misbehavior_score: u32, + pub banned_until_ms: Option<u64>, + pub ban_reason: Option<String>, } impl PeerInfo { @@ -1193,8 +1245,22 @@ impl PeerInfo { last_contact_ms: None, last_success_ms: None, last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, } } + + pub fn is_banned_at(&self, now_ms: u64) -> bool { + self.banned_until_ms + .is_some_and(|banned_until| banned_until > now_ms) + } + + fn clear_misbehavior(&mut self) { + self.misbehavior_score = 0; + self.banned_until_ms = None; + self.ban_reason = None; + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -1386,6 +1452,20 @@ mod tests { } #[test] + fn status_reports_package_version() { + let wallet = Wallet::from_seed("status-version-wallet"); + let node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 1, + burn_per_block: 0, + burn_fee: 0, + }); + + assert_eq!(node.status().app_version, env!("CARGO_PKG_VERSION")); + } + + #[test] fn automatic_pow_mining_reports_fee_rate_above_reward() { let wallet = Wallet::from_seed("automatic-pow-mining-too-high-fee-wallet"); let mut node = NodeCore::new(NodeConfig { diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1875,6 +1875,30 @@ fn peer_book_tracks_multiple_peers_without_networking() { } #[test] +fn peer_book_bans_misbehaving_peer_temporarily_and_recovers_on_success() { + let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); + + peers.record_misbehavior_at("127.0.0.1:9444", "invalid transaction", 100); + peers.record_misbehavior_at("127.0.0.1:9444", "invalid block", 200); + assert!(!peers.is_banned_at("127.0.0.1:9444", 200)); + assert_eq!(peers.connectable_addresses_at(200), vec!["127.0.0.1:9444"]); + + peers.record_misbehavior_at("127.0.0.1:9444", "wrong genesis", 300); + assert!(peers.is_banned_at("127.0.0.1:9444", 300)); + assert!(peers.connectable_addresses_at(300).is_empty()); + assert_eq!(peers.addresses(), vec!["127.0.0.1:9444"]); + let banned = peers.list().pop().unwrap(); + assert_eq!(banned.misbehavior_score, 3); + assert_eq!(banned.ban_reason.as_deref(), Some("wrong genesis")); + + assert!(!peers.is_banned_at("127.0.0.1:9444", 11 * 60 * 1_000)); + peers.record_status("127.0.0.1:9444", 1, "tip".to_string()); + let recovered = peers.list().pop().unwrap(); + assert_eq!(recovered.misbehavior_score, 0); + assert_eq!(recovered.banned_until_ms, None); +} + +#[test] fn chain_snapshot_round_trips_ledger_state() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new();