iuna

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

commit 81221c7a43784310cb6219653e573363cca8f5be
parent 67e32a8fc0b5ecf0b5b8e28000cc58d8aeb2f6ee
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 31 Jul 2026 09:47:45 +0200

Add configurable P2P announce address

Diffstat:
Msrc/adapters/config_store.rs | 12++++++++++++
Msrc/adapters/http.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/p2p.rs | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/main.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mwww/assets/iuna-ui.js | 20++++++++++++++++++++
5 files changed, 391 insertions(+), 32 deletions(-)

diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -27,6 +27,7 @@ pub struct UiConfig { pub burn_fee: Amount, pub pow_mine_fee: Amount, pub keep_track_of_metrics: bool, + pub p2p_announce_addr: Option<String>, pub peers: Vec<String>, } @@ -41,6 +42,7 @@ impl Default for UiConfig { burn_fee: DEFAULT_BURN_FEE, pow_mine_fee: MINE_FINALIZER_FEE, keep_track_of_metrics: false, + p2p_announce_addr: None, peers: Vec::new(), } } @@ -67,6 +69,8 @@ struct ConfigFile { #[serde(default)] keep_track_of_metrics: bool, #[serde(default)] + p2p_announce_addr: Option<String>, + #[serde(default)] peers: Vec<String>, } @@ -97,6 +101,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { burn_fee: Some(config.burn_fee), pow_mine_fee: Some(config.pow_mine_fee), keep_track_of_metrics: config.keep_track_of_metrics, + p2p_announce_addr: config.p2p_announce_addr.clone(), peers: config.peers.clone(), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; @@ -146,6 +151,7 @@ fn load(path: &Path) -> Result<UiConfig> { .map(|fee| fee.saturating_mul(scale)) .unwrap_or(MINE_FINALIZER_FEE), keep_track_of_metrics: stored.keep_track_of_metrics, + p2p_announce_addr: stored.p2p_announce_addr, peers: stored.peers, }) } @@ -189,6 +195,7 @@ mod tests { assert!(stored.contains("\"burn_fee\": 1")); assert!(stored.contains("\"pow_mine_fee\": 1000000")); assert!(stored.contains("\"keep_track_of_metrics\": false")); + assert!(stored.contains("\"p2p_announce_addr\": null")); assert!(stored.contains("\"peers\": []")); } @@ -208,6 +215,7 @@ mod tests { burn_fee: 3 * MICRO_IUNA, pow_mine_fee: 2 * MICRO_IUNA, keep_track_of_metrics: true, + p2p_announce_addr: Some("203.0.113.10:9444".to_string()), peers: vec!["127.0.0.1:9444".to_string()], }, ) @@ -222,6 +230,10 @@ mod tests { assert_eq!(config.burn_fee, 3 * MICRO_IUNA); assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); assert!(config.keep_track_of_metrics); + assert_eq!( + config.p2p_announce_addr.as_deref(), + Some("203.0.113.10:9444") + ); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -144,6 +144,11 @@ struct MetricsSettingsForm { } #[derive(Debug, Deserialize)] +struct P2pAnnounceForm { + addr: String, +} + +#[derive(Debug, Deserialize)] struct TransferForm { to: String, amount: Amount, @@ -409,6 +414,7 @@ pub async fn serve( ) .route("/api/settings/pow-mining", post(api_pow_mining_form)) .route("/api/settings/metrics", post(api_metrics_settings_form)) + .route("/api/settings/p2p-announce", post(api_p2p_announce_form)) .route("/api/transfer", post(api_transfer_form)) .route("/settings/burn-per-block", post(burn_per_block_form)) .route("/transfer", post(transfer_form)) @@ -872,6 +878,13 @@ async fn api_metrics_settings_form( action_json(set_keep_track_of_metrics(&state, form.enabled).await) } +async fn api_p2p_announce_form( + State(state): State<HttpState>, + Form(form): Form<P2pAnnounceForm>, +) -> Json<ActionResponse> { + action_json(set_p2p_announce_addr(&state, form.addr).await) +} + async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, @@ -1010,6 +1023,28 @@ async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<( config_store::save(&state.config_path, &config) } +async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> { + let trimmed = addr.trim(); + let parsed = if trimmed.is_empty() { + None + } else { + Some( + trimmed + .parse::<SocketAddr>() + .with_context(|| format!("invalid P2P announce address {trimmed}"))?, + ) + }; + + let mut config = state.ui_config.lock().await; + let mut next_config = config.clone(); + next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string()); + config_store::save(&state.config_path, &next_config)?; + *config = next_config; + drop(config); + state.gossip.set_p2p_announce_addr(parsed).await; + Ok(()) +} + async fn replace_metrics_for_snapshot( store: &SqliteChainStore, snapshot: crate::domain::ChainSnapshot, @@ -3157,6 +3192,13 @@ const INDEX_HTML: &str = r#"<!doctype html> </label> </div> </div> + <div class="panel" x-show="advancedMode()"> + <h3>Node Networking</h3> + <form class="settings-form" @submit.prevent="saveP2pAnnounce"> + <label>Public P2P address<input x-model="p2pAnnounceAddr" @input="p2pAnnounceDirty = true" placeholder="203.0.113.10:9444"></label> + <div class="setup-actions"><button class="primary" type="submit">Save</button></div> + </form> + </div> <div class="panel"> <h3>Change Password</h3> <div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div> @@ -3407,7 +3449,7 @@ mod tests { chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store, }, - app::{NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, + app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, domain::{ Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, Wallet, @@ -3795,6 +3837,54 @@ mod tests { } #[tokio::test] + async fn p2p_announce_setting_persists_config_and_updates_gossip() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + + super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string()) + .await + .unwrap(); + + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.p2p_announce_addr.as_deref(), + Some("203.0.113.10:9444") + ); + match state.gossip.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(peers.contains(&"203.0.113.10:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + + super::set_p2p_announce_addr(&state, " ".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.p2p_announce_addr.is_none()); + } + + #[tokio::test] + async fn p2p_announce_setting_rejects_invalid_address() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + + let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string()) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("invalid P2P announce address")); + } + + #[tokio::test] async fn setup_config_form_can_add_bootstrap_peer() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -139,6 +139,7 @@ struct GossipNetworkInner { node: SharedNode, peers: SharedPeerBook, listen_addr: SocketAddr, + p2p_announce_addr: Mutex<Option<SocketAddr>>, node_id: String, sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>, tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>, @@ -426,6 +427,7 @@ impl GossipNetwork { node, peers, listen_addr: "127.0.0.1:0".parse().unwrap(), + p2p_announce_addr: Mutex::new(None), node_id: new_node_id(), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), @@ -435,7 +437,12 @@ impl GossipNetwork { } } - pub async fn start(node: SharedNode, peers: SharedPeerBook, addr: SocketAddr) -> Result<Self> { + pub async fn start( + node: SharedNode, + peers: SharedPeerBook, + addr: SocketAddr, + p2p_announce_addr: Option<SocketAddr>, + ) -> Result<Self> { let listener = TcpListener::bind(addr) .await .with_context(|| format!("binding p2p listener on {addr}"))?; @@ -444,6 +451,7 @@ impl GossipNetwork { node, peers, listen_addr: addr, + p2p_announce_addr: Mutex::new(p2p_announce_addr), node_id: new_node_id(), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), @@ -458,6 +466,22 @@ impl GossipNetwork { Ok(network) } + pub async fn set_p2p_announce_addr(&self, addr: Option<SocketAddr>) { + *self.inner.p2p_announce_addr.lock().await = addr; + } + + async fn advertised_addr(&self) -> SocketAddr { + (*self.inner.p2p_announce_addr.lock().await).unwrap_or(self.inner.listen_addr) + } + + async fn is_self_peer(&self, address: &str) -> bool { + is_self_peer_address_for( + address, + self.inner.listen_addr, + self.advertised_addr().await, + ) + } + pub fn metrics(&self) -> P2pMetrics { let mut metrics = self.inner.metrics.snapshot(); metrics.transaction_ack_pending = self @@ -675,7 +699,8 @@ impl GossipNetwork { } pub async fn peer_exchange(&self) -> GossipEnvelope { - let self_addr = self.inner.listen_addr.to_string(); + let advertised_addr = self.advertised_addr().await; + let self_addr = advertised_addr.to_string(); let peers = self .inner .peers @@ -683,7 +708,7 @@ impl GossipNetwork { .await .addresses_except(&self_addr) .into_iter() - .filter(|peer| !is_self_peer_address(peer, self.inner.listen_addr)) + .filter(|peer| !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr)) .collect::<Vec<_>>(); GossipEnvelope::PeerList { peers: std::iter::once(self_addr) @@ -700,17 +725,18 @@ impl GossipNetwork { .await .connectable_addresses_at(crate::app::now_ms()); let address_set = addresses.iter().cloned().collect::<BTreeSet<_>>(); + let advertised_addr = self.advertised_addr().await; let mut sessions = self.inner.sessions.lock().await; sessions.retain(|peer, _| { - let keep = - address_set.contains(peer) && !is_self_peer_address(peer, self.inner.listen_addr); + let keep = address_set.contains(peer) + && !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr); if !keep { P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); } keep }); for peer in addresses { - if is_self_peer_address(&peer, self.inner.listen_addr) { + if is_self_peer_address_for(&peer, self.inner.listen_addr, advertised_addr) { P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); continue; } @@ -811,8 +837,9 @@ async fn outbound_session( ) { let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { + let advertised_addr = network.advertised_addr().await; if !peer_is_configured_outbound(&network, &peer).await - || is_self_peer_address(&peer, network.inner.listen_addr) + || is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr) { network.inner.sessions.lock().await.remove(&peer); return; @@ -922,8 +949,9 @@ async fn session_loop( .as_ref() .map(|peer| format!("outbound {peer}")) .unwrap_or_else(|| format!("inbound {remote_addr}")); + let advertised_addr = network.advertised_addr().await; let hello = network.inner.node.lock().await.hello( - Some(network.inner.listen_addr.to_string()), + Some(advertised_addr.to_string()), Some(network.inner.node_id.clone()), ); write_envelope(&mut writer, &hello).await?; @@ -1152,7 +1180,7 @@ async fn process_envelope( } GossipEnvelope::PeerAnnouncement { address } => { let peer = normalize_advertised_peer(&address, remote_addr)?; - if is_self_peer_address(&peer, network.inner.listen_addr) { + if network.is_self_peer(&peer).await { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); forget_stale_self_peer(network, known_peer).await; } else if remember_discoverable_advertised_peer( @@ -1497,10 +1525,11 @@ async fn apply_peer_list( remote_addr: SocketAddr, peers: Vec<String>, ) -> Result<()> { + let advertised_addr = network.advertised_addr().await; let mut peerbook = network.inner.peers.lock().await; for address in peers { let peer = normalize_advertised_peer(&address, remote_addr)?; - if is_self_peer_address(&peer, network.inner.listen_addr) { + if is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr) { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); } else if peer_list_address_is_discoverable(&peer, remote_addr)? { peerbook.add_peer(peer); @@ -2200,7 +2229,7 @@ async fn process_hello( if let Some(listen_addr) = &hello.listen_addr { let peer = normalize_advertised_peer(listen_addr, remote_addr)?; - if is_self_peer_address(&peer, network.inner.listen_addr) { + if network.is_self_peer(&peer).await { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); forget_stale_self_peer(network, known_peer).await; } else { @@ -2403,10 +2432,15 @@ fn ip_is_publicly_discoverable(ip: IpAddr) -> bool { } } -fn is_self_peer_address(address: &str, listen_addr: SocketAddr) -> bool { - address - .parse::<SocketAddr>() - .is_ok_and(|candidate| is_self_socket_addr(candidate, listen_addr)) +fn is_self_peer_address_for( + address: &str, + listen_addr: SocketAddr, + advertised_addr: SocketAddr, +) -> bool { + address.parse::<SocketAddr>().is_ok_and(|candidate| { + is_self_socket_addr(candidate, listen_addr) + || is_self_socket_addr(candidate, advertised_addr) + }) } fn is_self_socket_addr(candidate: SocketAddr, listen_addr: SocketAddr) -> bool { @@ -2499,11 +2533,24 @@ mod tests { fn loopback_peer_on_unspecified_listen_port_is_self() { let listen_addr: SocketAddr = "0.0.0.0:9545".parse().unwrap(); - assert!(super::is_self_peer_address("127.0.0.1:9545", listen_addr)); - assert!(super::is_self_peer_address("0.0.0.0:9545", listen_addr)); - assert!(!super::is_self_peer_address("127.0.0.1:9546", listen_addr)); - assert!(!super::is_self_peer_address( + assert!(super::is_self_peer_address_for( + "127.0.0.1:9545", + listen_addr, + listen_addr + )); + assert!(super::is_self_peer_address_for( + "0.0.0.0:9545", + listen_addr, + listen_addr + )); + assert!(!super::is_self_peer_address_for( + "127.0.0.1:9546", + listen_addr, + listen_addr + )); + assert!(!super::is_self_peer_address_for( "203.0.113.10:9545", + listen_addr, listen_addr )); } @@ -2826,6 +2873,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -2888,6 +2936,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -2934,6 +2983,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -2985,6 +3035,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3029,6 +3080,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3243,6 +3295,7 @@ mod tests { node, peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3349,6 +3402,7 @@ mod tests { node, peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3412,6 +3466,7 @@ mod tests { "iuna.jhx.app:9444".to_string(), ]))), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3495,6 +3550,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3545,6 +3601,7 @@ mod tests { node: Arc::clone(&node), peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3591,6 +3648,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3663,6 +3721,7 @@ mod tests { node, peers, listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3683,6 +3742,43 @@ mod tests { } #[tokio::test] + async fn peer_exchange_filters_announced_self_from_known_peers() { + let alice = Wallet::from_seed("px-announced-self-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![ + "8.8.8.8:9444".to_string(), + "8.8.4.4:9444".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers, + listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), + node_id: super::new_node_id(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), + metrics: super::P2pMetricsCounters::default(), + }), + }; + + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert_eq!( + peers.iter().filter(|peer| *peer == "8.8.8.8:9444").count(), + 1 + ); + assert!(peers.contains(&"8.8.4.4:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + } + + #[tokio::test] async fn peer_list_adds_stable_outbound_peers() { let alice = Wallet::from_seed("px-recv-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); @@ -3693,6 +3789,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3717,6 +3814,41 @@ mod tests { } #[tokio::test] + async fn peer_list_ignores_announced_self_address() { + let alice = Wallet::from_seed("px-list-announced-self-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::clone(&peers), + listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), + node_id: super::new_node_id(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), + metrics: super::P2pMetricsCounters::default(), + }), + }; + + super::apply_peer_list( + &network, + "8.8.4.4:9444".parse().unwrap(), + vec!["8.8.8.8:9444".to_string(), "8.8.4.4:9445".to_string()], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"8.8.8.8:9444".to_string())); + assert!(addresses.contains(&"8.8.4.4:9445".to_string())); + } + + #[tokio::test] async fn peer_list_ignores_private_ephemeral_addresses() { let alice = Wallet::from_seed("px-private-ephemeral-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); @@ -3727,6 +3859,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3764,6 +3897,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3802,6 +3936,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3830,6 +3965,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9545".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3865,6 +4001,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9545".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3921,6 +4058,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), @@ -3976,6 +4114,7 @@ mod tests { node, peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9444".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), diff --git a/src/main.rs b/src/main.rs @@ -48,6 +48,8 @@ async fn main() -> Result<()> { ); } let mut ui_config = config_store::load_or_create(&config_path)?; + let p2p_announce_addr = configured_p2p_announce_addr(&opts, &ui_config)?; + let advertised_p2p_addr = p2p_announce_addr.unwrap_or(opts.p2p_addr); let wallet_load = load_startup_wallet(&wallet_path)?; let wallet_address = wallet_load.address().to_string(); if opts.chain_mode == ChainMode::Genesis { @@ -58,7 +60,8 @@ async fn main() -> Result<()> { ui_config.burn_fee = GENESIS_INITIAL_BURN_FEE; config_store::save(&config_path, &ui_config)?; } - let ledger = initialize_ledger(&opts, &wallet_address, &chain_store).await?; + let ledger = + initialize_ledger(&opts, &wallet_address, &chain_store, advertised_p2p_addr).await?; let has_chain = opts.has_chain() || persisted_chain_exists; let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config); let initial_burn_fee = initial_burn_fee(&opts, &ui_config); @@ -104,14 +107,22 @@ async fn main() -> Result<()> { println!("chain database: {}", chain_store.path().display()); println!("management UI: http://{}", opts.http_addr); println!("p2p listener: {}", opts.p2p_addr); + if let Some(addr) = p2p_announce_addr { + println!("p2p announce address: {addr}"); + } println!( "automatic finalization: VDF-driven, burning {} IUNA per block with {} IUNA per byte fee rate", format_iuna(initial_burn_per_block), format_iuna(initial_burn_fee) ); - let gossip = - p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?; + let gossip = p2p::GossipNetwork::start( + Arc::clone(&node), + Arc::clone(&peers), + opts.p2p_addr, + p2p_announce_addr, + ) + .await?; let mut stratum_status = StratumStatus { enabled: false, listen_addr: None, @@ -215,6 +226,7 @@ async fn initialize_ledger( opts: &CliOptions, wallet_address: &str, chain_store: &SqliteChainStore, + advertised_p2p_addr: SocketAddr, ) -> Result<Ledger> { if let Some(snapshot) = chain_store.load()? { if opts.chain_mode == ChainMode::Genesis { @@ -239,11 +251,28 @@ async fn initialize_ledger( match opts.chain_mode { ChainMode::Setup => Ok(setup_ledger()), ChainMode::Genesis => start_genesis_ledger(wallet_address), - ChainMode::Join => join_chain_ledger(&opts.join_peers, opts.p2p_addr).await, + ChainMode::Join => join_chain_ledger(&opts.join_peers, advertised_p2p_addr).await, } } } +fn configured_p2p_announce_addr( + opts: &CliOptions, + ui_config: &config_store::UiConfig, +) -> Result<Option<SocketAddr>> { + if let Some(addr) = opts.p2p_announce_addr { + return Ok(Some(addr)); + } + ui_config + .p2p_announce_addr + .as_deref() + .map(|addr| { + addr.parse() + .with_context(|| format!("invalid configured P2P announce address {addr}")) + }) + .transpose() +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ChainMode { Setup, @@ -257,6 +286,7 @@ struct CliOptions { chain_db_path: Option<PathBuf>, http_addr: SocketAddr, p2p_addr: SocketAddr, + p2p_announce_addr: Option<SocketAddr>, stratum_addr: Option<SocketAddr>, peers: Vec<String>, join_peers: Vec<String>, @@ -276,6 +306,7 @@ impl CliOptions { chain_db_path: None, http_addr: SocketAddr::from_str("127.0.0.1:18661")?, p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?, + p2p_announce_addr: None, stratum_addr: None, peers: Vec::new(), join_peers: Vec::new(), @@ -315,6 +346,13 @@ impl CliOptions { .parse() .context("invalid --p2p address")?; } + "--p2p-announce" => { + opts.p2p_announce_addr = Some( + next_value(&mut args, "--p2p-announce")? + .parse() + .context("invalid --p2p-announce address")?, + ); + } "--stratum" => { opts.stratum_addr = Some( next_value(&mut args, "--stratum")? @@ -418,6 +456,7 @@ fn help_text() -> &'static str { --chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\ --http <addr:port> HTTP management UI address (default 127.0.0.1:18661)\n\ --p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\ + --p2p-announce <addr:port> Public P2P address to gossip instead of --p2p\n\ --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ --data-dir <path> Local wallet directory (default ~/.iuna)\n\ @@ -718,9 +757,10 @@ mod tests { use super::{ ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, - StartupWallet, extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, - initialize_ledger, load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, - run_chain_persistence_with_interval, validate_wallet_for_mode, + StartupWallet, configured_p2p_announce_addr, extrapolate_vdf_rounds, help_text, + initial_burn_fee, initial_burn_per_block, initialize_ledger, load_startup_wallet, + measure_vdf_rounds, persist_chain_snapshot, run_chain_persistence_with_interval, + validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -930,6 +970,64 @@ mod tests { } #[test] + fn p2p_announce_port_can_be_configured() { + let opts = parse(&["--p2p-announce", "203.0.113.10:9444"]) + .unwrap() + .unwrap(); + + assert_eq!( + opts.p2p_announce_addr, + Some("203.0.113.10:9444".parse().unwrap()) + ); + } + + #[test] + fn configured_p2p_announce_addr_uses_cli_before_config() { + let opts = parse(&["--p2p-announce", "203.0.113.20:9444"]) + .unwrap() + .unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("203.0.113.10:9444".to_string()), + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_announce_addr(&opts, &config).unwrap(), + Some("203.0.113.20:9444".parse().unwrap()) + ); + } + + #[test] + fn configured_p2p_announce_addr_reads_config_without_cli() { + let opts = parse(&[]).unwrap().unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("203.0.113.10:9444".to_string()), + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_announce_addr(&opts, &config).unwrap(), + Some("203.0.113.10:9444".parse().unwrap()) + ); + } + + #[test] + fn configured_p2p_announce_addr_rejects_invalid_config() { + let opts = parse(&[]).unwrap().unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("not-an-address".to_string()), + ..UiConfig::default() + }; + + let error = configured_p2p_announce_addr(&opts, &config).unwrap_err(); + assert!( + error + .to_string() + .contains("invalid configured P2P announce address") + ); + } + + #[test] fn http_management_port_defaults_to_iuna_port() { let opts = parse(&[]).unwrap().unwrap(); assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); @@ -1055,7 +1153,7 @@ mod tests { .unwrap() .unwrap(); - let error = initialize_ledger(&opts, fresh_wallet.address(), &store) + let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) .await .unwrap_err(); @@ -1078,7 +1176,7 @@ mod tests { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store) + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) .await .unwrap(); @@ -1115,7 +1213,7 @@ mod tests { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store) + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) .await .unwrap(); @@ -1144,7 +1242,7 @@ mod tests { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, bob.address(), &store) + let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr) .await .unwrap(); @@ -1172,7 +1270,7 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) .unwrap() .unwrap(); - let error = initialize_ledger(&opts, wallet.address(), &store) + let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr) .await .unwrap_err(); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -37,6 +37,8 @@ window.iunaApp = function iunaApp() { settingsPasswordConfirm: "", settingsFeedback: null, keepTrackOfMetrics: false, + p2pAnnounceAddr: "", + p2pAnnounceDirty: false, setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, setupWalletMode: "create", setupSeedStep: "write", @@ -499,6 +501,9 @@ window.iunaApp = function iunaApp() { ]); this.config = config; this.keepTrackOfMetrics = config.keep_track_of_metrics === true; + if (!this.p2pAnnounceDirty) { + this.p2pAnnounceAddr = config.p2p_announce_addr || ""; + } if (!this.allowedTabs().includes(this.tab)) { this.setTab("wallet"); } @@ -920,6 +925,21 @@ window.iunaApp = function iunaApp() { } }, + async saveP2pAnnounce() { + const addr = this.p2pAnnounceAddr.trim(); + try { + await this.postForm( + "/api/settings/p2p-announce", + { addr }, + addr ? "P2P announce address saved" : "P2P announce address cleared" + ); + this.p2pAnnounceAddr = addr; + this.p2pAnnounceDirty = false; + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + automaticBurnFeeDraft() { return this.parseiunaAmount(this.burnFeeDraft); },