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:
| M | assets/iuna-ui.js | | | 89 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/adapters/http.rs | | | 67 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | src/adapters/p2p.rs | | | 223 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/app.rs | | | 88 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | tests/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();