iuna

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

commit 67b9c988caf56bbfbfdbc4f46f0116272650f73d
parent 6dfdff5f8a027e1abfd71fc397a680b82e8c2473
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 28 Jul 2026 09:15:30 +0200

Fix stale outbound peer cleanup

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Msrc/adapters/p2p.rs | 193++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/app.rs | 22+++++++++++++++++++++-
Mtests/iuna.rs | 14++++++++++++++
5 files changed, 183 insertions(+), 50 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -502,7 +502,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.1.7" +version = "0.1.8" edition = "2024" license = "Apache-2.0" diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -588,6 +588,12 @@ async fn outbound_session( ) { let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { + if !peer_is_configured_outbound(&network, &peer).await + || is_self_peer_address(&peer, network.inner.listen_addr) + { + network.inner.sessions.lock().await.remove(&peer); + return; + } if network.inner.peers.lock().await.is_banned(&peer) { sleep(MAX_RECONNECT_DELAY).await; continue; @@ -664,6 +670,10 @@ async fn outbound_session( let (sender, next_receiver) = mpsc::channel(PEER_QUEUE_SIZE); receiver = next_receiver; + if !peer_is_configured_outbound(&network, &peer).await { + network.inner.sessions.lock().await.remove(&peer); + return; + } network .inner .sessions @@ -701,6 +711,7 @@ async fn session_loop( ); let mut outbound_closed = false; let mut peer_status: Option<PeerStatus> = None; + let is_outbound_session = stable_peer.is_some(); let mut known_peer = stable_peer; if known_peer.is_some() { @@ -713,6 +724,9 @@ async fn session_loop( if let GossipEnvelope::Hello(hello) = envelope { peer_status = Some(process_hello(&network, remote_addr, &mut known_peer, hello).await?); + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; } else if let GossipEnvelope::PeerStatus { height, tip_hash } = envelope { peer_status = Some(PeerStatus::new(height, tip_hash.clone())); @@ -727,6 +741,9 @@ async fn session_loop( envelope, ) .await?; + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } } } } @@ -774,6 +791,9 @@ async fn session_loop( }; if let GossipEnvelope::Hello(hello) = envelope { peer_status = Some(process_hello(&network, remote_addr, &mut known_peer, hello).await?); + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; continue; } @@ -791,11 +811,23 @@ async fn session_loop( &mut known_peer, envelope, ).await?; + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } } } } } +async fn peer_is_configured_outbound(network: &GossipNetwork, peer: &str) -> bool { + network + .inner + .peers + .lock() + .await + .is_configured_outbound(peer) +} + async fn process_envelope( network: &GossipNetwork, writer: &mut OwnedWriteHalf, @@ -850,6 +882,7 @@ async fn process_envelope( let peer = normalize_advertised_peer(&address, remote_addr)?; if is_self_peer_address(&peer, network.inner.listen_addr) { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); + forget_stale_self_peer(network, known_peer).await; } else if remember_discoverable_advertised_peer( network, remote_addr, @@ -989,14 +1022,20 @@ async fn process_transactions( } for rejection in rejected { - let peer = known_peer - .clone() - .unwrap_or_else(|| remote_addr.to_string()); 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); + match known_peer.as_deref() { + Some(peer) if transaction_rejection_counts_as_misbehavior(&rejection.reason) => { + peers.record_misbehavior(peer, rejection.reason); + } + Some(peer) => { + peers.record_inbound_error(peer, rejection.reason); + } + None if transaction_rejection_counts_as_misbehavior(&rejection.reason) => { + peers.record_inbound_misbehavior(&remote_addr.to_string(), rejection.reason); + } + None => { + peers.record_inbound_error(&remote_addr.to_string(), rejection.reason); + } } } network.forward_outbox().await; @@ -1651,23 +1690,6 @@ 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, @@ -1711,16 +1733,10 @@ 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}"); + forget_stale_self_peer(network, known_peer).await; + } else { + remember_discoverable_advertised_peer(network, remote_addr, known_peer, peer).await?; } - remember_discoverable_advertised_peer(network, remote_addr, known_peer, peer).await?; } record_peer_status( network, @@ -1769,6 +1785,12 @@ async fn remember_discoverable_advertised_peer( Ok(true) } +async fn forget_stale_self_peer(network: &GossipNetwork, known_peer: &mut Option<String>) { + if let Some(previous_peer) = known_peer.take() { + network.inner.peers.lock().await.remove_peer(&previous_peer); + } +} + async fn record_inbound_result( network: &GossipNetwork, known_peer: &Option<String>, @@ -2914,6 +2936,32 @@ mod tests { } #[tokio::test] + async fn peer_announcement_removes_outbound_peer_that_announces_self_address() { + let alice = Wallet::from_seed("px-self-announcement-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "10.42.1.1:30508".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9444".parse().unwrap(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let mut known_peer = Some("10.42.1.1:30508".to_string()); + + super::forget_stale_self_peer(&network, &mut known_peer).await; + + assert_eq!(known_peer, None); + assert!(peers.lock().await.addresses().is_empty()); + } + + #[tokio::test] async fn peer_list_ignores_loopback_alias_for_unspecified_self() { let alice = Wallet::from_seed("px-self-alias-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); @@ -2945,14 +2993,15 @@ mod tests { } #[tokio::test] - async fn hello_rejects_loopback_alias_for_unspecified_self() { + async fn hello_ignores_loopback_alias_for_unspecified_self() { let alice = Wallet::from_seed("hello-self-alias-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); let network = super::GossipNetwork { inner: Arc::new(super::GossipNetworkInner { node, - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), + peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9545".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -2975,19 +3024,69 @@ mod tests { tip_hash: "tip".to_string(), }; - assert!( - super::process_hello( - &network, - "127.0.0.1:52144".parse().unwrap(), - &mut None, - hello, - ) - .await - .unwrap_err() - .to_string() - .contains("our own p2p address") - ); + super::process_hello( + &network, + "127.0.0.1:52144".parse().unwrap(), + &mut None, + hello, + ) + .await + .unwrap(); + assert_eq!(network.metrics().self_peer_rejections, 1); + assert!(peers.lock().await.addresses().is_empty()); + let listed = peers.lock().await.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].direction, PeerDirection::Inbound); + } + + #[tokio::test] + async fn hello_removes_outbound_peer_that_announces_self_address() { + let alice = Wallet::from_seed("hello-self-outbound-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "10.42.1.1:16987".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9444".parse().unwrap(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + 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:9444".to_string()), + height: 0, + tip_hash: "tip".to_string(), + }; + let mut known_peer = Some("10.42.1.1:16987".to_string()); + + super::process_hello( + &network, + "10.42.1.1:16987".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert_eq!(network.metrics().self_peer_rejections, 1); + assert!(known_peer.is_none()); + assert!(peers.lock().await.addresses().is_empty()); } fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { diff --git a/src/app.rs b/src/app.rs @@ -1147,6 +1147,12 @@ impl PeerBook { } } + pub fn is_configured_outbound(&self, address: &str) -> bool { + self.peers + .get(address) + .is_some_and(|peer| peer.direction != PeerDirection::Inbound) + } + pub fn addresses(&self) -> Vec<String> { self.peers .values() @@ -1233,8 +1239,22 @@ impl PeerBook { } pub fn record_misbehavior_at(&mut self, address: &str, reason: impl Into<String>, now_ms: u64) { + self.record_misbehavior_with_direction(address, reason, now_ms, PeerDirection::Outbound); + } + + pub fn record_inbound_misbehavior(&mut self, address: &str, reason: impl Into<String>) { + self.record_misbehavior_with_direction(address, reason, now_ms(), PeerDirection::Inbound); + } + + fn record_misbehavior_with_direction( + &mut self, + address: &str, + reason: impl Into<String>, + now_ms: u64, + direction: PeerDirection, + ) { let reason = reason.into(); - let peer = self.ensure(address, PeerDirection::Outbound); + let peer = self.ensure(address, direction); peer.last_contact_ms = Some(now_ms); peer.last_error_ms = Some(now_ms); peer.last_error = Some(reason.clone()); diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1875,6 +1875,20 @@ fn peer_book_tracks_multiple_peers_without_networking() { } #[test] +fn peer_book_reports_only_configured_outbound_peers_as_outbound() { + let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); + peers.record_received("127.0.0.1:56666", 1); + peers.record_inbound_misbehavior("127.0.0.1:57777", "invalid transaction"); + + assert!(peers.is_configured_outbound("127.0.0.1:9444")); + assert!(!peers.is_configured_outbound("127.0.0.1:56666")); + assert!(!peers.is_configured_outbound("127.0.0.1:57777")); + + assert!(peers.remove_peer("127.0.0.1:9444")); + assert!(!peers.is_configured_outbound("127.0.0.1:9444")); +} + +#[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()]);