diff --git a/Cargo.lock b/Cargo.lock index 5dc706e4fc..62bab445e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7418,7 +7418,6 @@ version = "1.20.4" dependencies = [ "cargo_metadata 0.19.2", "dotenvy", - "log", "regex", "schemars 0.8.22", "serde", @@ -7463,6 +7462,100 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "nym-network-monitor-agent" +version = "1.0.2" +dependencies = [ + "anyhow", + "arrayref", + "clap", + "futures", + "hkdf", + "humantime", + "lioness", + "nym-bin-common", + "nym-crypto", + "nym-network-monitor-orchestrator-requests", + "nym-noise", + "nym-pemstore", + "nym-sphinx-addressing", + "nym-sphinx-framing", + "nym-sphinx-params", + "nym-sphinx-types", + "nym-task", + "nym-test-utils", + "rand 0.8.5", + "sha2 0.10.9", + "time", + "tokio", + "tokio-util", + "tracing", + "url", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "nym-network-monitor-orchestrator" +version = "1.0.2" +dependencies = [ + "anyhow", + "axum 0.7.9", + "clap", + "futures", + "humantime", + "nym-api-requests", + "nym-bin-common", + "nym-crypto", + "nym-http-api-common", + "nym-metrics", + "nym-network-defaults", + "nym-network-monitor-orchestrator-requests", + "nym-node-requests", + "nym-task", + "nym-test-utils", + "nym-validator-client", + "rand 0.8.5", + "sqlx", + "strum 0.28.0", + "thiserror 2.0.12", + "time", + "tokio", + "tracing", + "url", + "utoipa", + "utoipa-swagger-ui", + "utoipauto", + "zeroize", +] + +[[package]] +name = "nym-network-monitor-orchestrator-requests" +version = "1.20.4" +dependencies = [ + "anyhow", + "humantime-serde", + "nym-crypto", + "nym-http-api-client", + "serde", + "time", + "tracing", + "utoipa", + "zeroize", +] + +[[package]] +name = "nym-network-monitors-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars 0.8.22", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-network-requester" version = "1.1.78" @@ -7584,6 +7677,7 @@ dependencies = [ "nym-verloc", "nym-wireguard", "nym-wireguard-types", + "nyxd-scraper-shared", "opentelemetry", "opentelemetry_sdk", "rand 0.8.5", @@ -7634,6 +7728,7 @@ dependencies = [ "nym-exit-policy", "nym-http-api-client", "nym-kkt-ciphersuite", + "nym-network-defaults", "nym-noise-keys", "nym-test-utils", "nym-upgrade-mode-check", @@ -8572,6 +8667,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-multisig-contract-common", "nym-network-defaults", + "nym-network-monitors-contract-common", "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", diff --git a/Cargo.toml b/Cargo.toml index 14ab79e00a..7025c29062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "common/cosmwasm-smart-contracts/nym-performance-contract", "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", + "common/cosmwasm-smart-contracts/network-monitors-contract", "common/credential-proxy", "common/credential-storage", "common/credential-utils", @@ -176,6 +177,8 @@ members = [ "integration-tests", "common/nym-kkt-ciphersuite", "common/nym-kkt-context", + "nym-network-monitor-v3/nym-network-monitor-orchestrator", + "nym-network-monitor-v3/nym-network-monitor-agent", "nym-network-monitor-v3/nym-network-monitor-orchestrator-requests", ] default-members = [ @@ -192,6 +195,8 @@ default-members = [ "service-providers/network-requester", "tools/nymvisor", "nym-registration-client", + "nym-network-monitor-v3/nym-network-monitor-orchestrator", + "nym-network-monitor-v3/nym-network-monitor-agent", "tools/internal/localnet-orchestrator" ] @@ -402,6 +407,10 @@ zeroize = "1.7.0" prometheus = { version = "0.14.0" } +# recreating lioness +# we don't care about particular versions - just pull whatever is used by sphinx +lioness = "*" +arrayref = "*" # libcrux libcrux-kem = "0.0.7" @@ -507,6 +516,7 @@ nym-types = { version = "1.20.4", path = "common/types" } nym-upgrade-mode-check = { version = "1.20.4", path = "common/upgrade-mode-check" } nym-validator-client = { version = "1.20.4", path = "common/client-libs/validator-client", default-features = false } nym-vesting-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/vesting-contract" } +nym-network-monitors-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/network-monitors-contract" } nym-verloc = { version = "1.20.4", path = "common/verloc" } nym-wireguard = { version = "1.20.4", path = "common/wireguard" } nym-wireguard-types = { version = "1.20.4", path = "common/wireguard-types" } diff --git a/common/client-core/src/client/mix_traffic/transceiver.rs b/common/client-core/src/client/mix_traffic/transceiver.rs index 8edc17dbfb..3ec27c0488 100644 --- a/common/client-core/src/client/mix_traffic/transceiver.rs +++ b/common/client-core/src/client/mix_traffic/transceiver.rs @@ -240,7 +240,7 @@ mod nonwasm_sealed { impl GatewaySender for LocalGateway { async fn send_mix_packet(&mut self, packet: MixPacket) -> Result<(), ErasedGatewayError> { self.packet_forwarder - .forward_packet(packet) + .forward_client_packet_without_delay(packet) .map_err(erase_err) } } diff --git a/common/client-libs/mixnet-client/Cargo.toml b/common/client-libs/mixnet-client/Cargo.toml index 614e4e698e..353da725bd 100644 --- a/common/client-libs/mixnet-client/Cargo.toml +++ b/common/client-libs/mixnet-client/Cargo.toml @@ -34,3 +34,4 @@ client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"] [dev-dependencies] nym-crypto = { workspace = true } rand = { workspace = true } +tokio = { workspace = true, features = ["macros", "io-util", "rt", "rt-multi-thread"] } diff --git a/common/client-libs/mixnet-client/src/client.rs b/common/client-libs/mixnet-client/src/client.rs index 0bdb06a198..9cef37e2d2 100644 --- a/common/client-libs/mixnet-client/src/client.rs +++ b/common/client-libs/mixnet-client/src/client.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use dashmap::DashMap; -use futures::StreamExt; +use futures::{SinkExt, StreamExt}; use nym_noise::config::NoiseConfig; use nym_noise::upgrade_noise_initiator; use nym_sphinx::forwarding::packet::MixPacket; @@ -14,6 +14,7 @@ use std::ops::Deref; use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TrySendError; @@ -90,13 +91,17 @@ impl Deref for ActiveConnections { pub struct ConnectionSender { channel: mpsc::Sender, current_reconnection_attempt: Arc, + // Identifies the `ManagedConnection` task currently owning this entry; used + // to ensure drop-time eviction only fires on the still-owning task. + handle_token: Arc<()>, } impl ConnectionSender { - fn new(channel: mpsc::Sender) -> Self { + fn new(channel: mpsc::Sender, handle_token: Arc<()>) -> Self { ConnectionSender { channel, current_reconnection_attempt: Arc::new(AtomicU32::new(0)), + handle_token, } } } @@ -107,6 +112,31 @@ struct ManagedConnection { message_receiver: ReceiverStream, connection_timeout: Duration, current_reconnection: Arc, + active_connections: ActiveConnections, + handle_token: Arc<()>, +} + +// Evicts the cache entry on task exit (only if still owned by this task). +// Without this, a stale `ConnectionSender` survives after the peer disconnects +// and the next outbound packet is silently swallowed by the dead TCP. +struct EvictOnDrop { + active_connections: ActiveConnections, + address: SocketAddr, + handle_token: Arc<()>, +} + +impl Drop for EvictOnDrop { + fn drop(&mut self) { + let address = self.address; + let handle_token = &self.handle_token; + self.active_connections.remove_if(&address, |_, sender| { + Arc::ptr_eq(&sender.handle_token, handle_token) + }); + trace!( + peer = %address, + "managed connection task exited; evicted owning cache entry" + ); + } } impl ManagedConnection { @@ -116,6 +146,8 @@ impl ManagedConnection { message_receiver: mpsc::Receiver, connection_timeout: Duration, current_reconnection: Arc, + active_connections: ActiveConnections, + handle_token: Arc<()>, ) -> Self { ManagedConnection { address, @@ -123,72 +155,30 @@ impl ManagedConnection { message_receiver: ReceiverStream::new(message_receiver), connection_timeout, current_reconnection, + active_connections, + handle_token, } } async fn run(self) { let address = self.address; + let _evict_guard = EvictOnDrop { + active_connections: self.active_connections, + address, + handle_token: self.handle_token, + }; + let reconnection_attempt = self.current_reconnection.load(Ordering::Acquire); let connect_start = tokio::time::Instant::now(); let connection_fut = TcpStream::connect(address); - let conn = match tokio::time::timeout(self.connection_timeout, connection_fut).await { - Ok(stream_res) => match stream_res { - Ok(stream) => { - let connect_ms = connect_start.elapsed().as_millis() as u64; - debug!( - peer = %address, - connect_ms, - "Managed to establish connection to {}", self.address - ); - - let noise_start = tokio::time::Instant::now(); - let noise_stream = - match upgrade_noise_initiator(stream, &self.noise_config).await { - Ok(noise_stream) => noise_stream, - Err(err) => { - let noise_handshake_ms = noise_start.elapsed().as_millis() as u64; - warn!( - event = "connection.failed.noise", - peer = %address, - error = %err, - connect_ms, - noise_handshake_ms, - reconnection_attempt, - exit_reason = "noise_error", - "Failed to perform Noise initiator handshake with {address}" - ); - self.current_reconnection.fetch_add(1, Ordering::SeqCst); - return; - } - }; - let noise_handshake_ms = noise_start.elapsed().as_millis() as u64; - self.current_reconnection.store(0, Ordering::Release); - debug!( - peer = %address, - connect_ms, - noise_handshake_ms, - "Noise initiator handshake completed for {:?}", address - ); - Framed::new(noise_stream, NymCodec) - } - Err(err) => { - let connect_ms = connect_start.elapsed().as_millis() as u64; - warn!( - event = "connection.failed.connect", - peer = %address, - error = %err, - connect_ms, - reconnection_attempt, - exit_reason = "connect_error", - "failed to establish connection to {address}" - ); - return; - } - }, + // 1. attempt to establish the connection with timeout + let maybe_stream = match tokio::time::timeout(self.connection_timeout, connection_fut).await + { + Ok(stream) => stream, Err(_) => { let connect_ms = connect_start.elapsed().as_millis() as u64; - warn!( + debug!( event = "connection.failed.timeout", peer = %address, timeout_ms = self.connection_timeout.as_millis() as u64, @@ -203,21 +193,133 @@ impl ManagedConnection { } }; - if let Err(err) = self.message_receiver.map(Ok).forward(conn).await { - warn!( - event = "connection.forward_error", - peer = %address, - error = %err, - exit_reason = "forward_error", - "Failed to forward packets to {address}: {err}" - ); - } + // 2. check if it actually succeeded + let stream = match maybe_stream { + Ok(stream) => stream, + Err(err) => { + let connect_ms = connect_start.elapsed().as_millis() as u64; + debug!( + event = "connection.failed.connect", + peer = %address, + error = %err, + connect_ms, + reconnection_attempt, + exit_reason = "connect_error", + "failed to establish connection to {address}" + ); + return; + } + }; + let connect_ms = connect_start.elapsed().as_millis() as u64; debug!( peer = %address, - exit_reason = "sender_dropped", - "connection manager to {address} finished" + connect_ms, + "Managed to establish connection to {}", self.address ); + + // 3. perform noise handshake (if applicable) + let noise_start = tokio::time::Instant::now(); + let noise_stream = match upgrade_noise_initiator(stream, &self.noise_config).await { + Ok(noise_stream) => noise_stream, + Err(err) => { + let noise_handshake_ms = noise_start.elapsed().as_millis() as u64; + debug!( + event = "connection.failed.noise", + peer = %address, + error = %err, + connect_ms, + noise_handshake_ms, + reconnection_attempt, + exit_reason = "noise_error", + "Failed to perform Noise initiator handshake with {address}" + ); + self.current_reconnection.fetch_add(1, Ordering::SeqCst); + return; + } + }; + let noise_handshake_ms = noise_start.elapsed().as_millis() as u64; + self.current_reconnection.store(0, Ordering::Release); + debug!( + peer = %address, + connect_ms, + noise_handshake_ms, + "Noise initiator handshake completed for {:?}", address + ); + let conn = Framed::new(noise_stream, NymCodec); + + // 4. start handling the framed stream + run_io_loop(conn, self.message_receiver, address).await; + } +} + +// The connection is unidirectional (send-only); we read from it solely to +// notice peer FIN/RST while idle so we can evict the cache entry before the +// next outbound send finds it stale. +async fn run_io_loop( + conn: Framed, + mut receiver: ReceiverStream, + address: SocketAddr, +) where + T: AsyncRead + AsyncWrite + Unpin, +{ + let (mut sink, mut stream) = conn.split(); + + loop { + tokio::select! { + msg = stream.next() => { + match msg { + None => { + debug!( + peer = %address, + exit_reason = "peer_closed", + "peer closed mixnet connection to {address}" + ); + break; + } + Some(Err(err)) => { + debug!( + event = "connection.read_error", + peer = %address, + error = %err, + exit_reason = "read_error", + "read error on mixnet connection to {address}: {err}" + ); + break; + } + Some(Ok(_)) => { + trace!( + peer = %address, + "unexpected inbound packet on mixnet connection to {address}; discarding" + ); + } + } + } + outgoing = receiver.next() => { + match outgoing { + None => { + debug!( + peer = %address, + exit_reason = "sender_dropped", + "connection manager to {address} finished" + ); + break; + } + Some(packet) => { + if let Err(err) = sink.send(packet).await { + debug!( + event = "connection.forward_error", + peer = %address, + error = %err, + exit_reason = "forward_error", + "Failed to forward packet to {address}: {err}" + ); + break; + } + } + } + } + } } } @@ -264,13 +366,18 @@ impl Client { sender.try_send(pending_packet).unwrap(); } + // Ownership token for the task we're about to spawn; lets it tell + // on exit whether the cache entry still names it. + let handle_token = Arc::new(()); + // if we already tried to connect to `address` before, grab the current attempt count let current_reconnection_attempt = if let Some(mut existing) = self.active_connections.get_mut(&address) { existing.channel = sender; + existing.handle_token = Arc::clone(&handle_token); Arc::clone(&existing.current_reconnection_attempt) } else { - let new_entry = ConnectionSender::new(sender); + let new_entry = ConnectionSender::new(sender, Arc::clone(&handle_token)); let current_attempt = Arc::clone(&new_entry.current_reconnection_attempt); self.active_connections.insert(address, new_entry); current_attempt @@ -285,6 +392,7 @@ impl Client { let connections_count = self.connections_count.clone(); let noise_config = self.noise_config.clone(); + let active_connections = self.active_connections.clone(); tokio::spawn(async move { // before executing the manager, wait for what was specified, if anything if let Some(backoff) = backoff { @@ -299,6 +407,8 @@ impl Client { receiver, initial_connection_timeout, current_reconnection_attempt, + active_connections, + handle_token, ) .run() .await; @@ -428,4 +538,102 @@ mod tests { client.config.maximum_reconnection_backoff ); } + + fn test_addr() -> SocketAddr { + "127.0.0.1:1".parse().unwrap() + } + + fn insert_with_token( + active: &ActiveConnections, + addr: SocketAddr, + token: Arc<()>, + ) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(1); + active.insert(addr, ConnectionSender::new(tx, token)); + rx + } + + #[test] + fn evict_on_drop_removes_entry_when_token_still_matches() { + let active = ActiveConnections::default(); + let addr = test_addr(); + let token = Arc::new(()); + let _rx = insert_with_token(&active, addr, Arc::clone(&token)); + + assert!(active.get(&addr).is_some()); + + { + let _guard = EvictOnDrop { + active_connections: active.clone(), + address: addr, + handle_token: token, + }; + } + + assert!( + active.get(&addr).is_none(), + "owning task's drop should evict the entry" + ); + } + + #[test] + fn evict_on_drop_preserves_entry_replaced_by_newer_make_connection() { + // Simulates the race: old task's run() has returned, but before its + // drop guard fires, a concurrent `make_connection` replaced the + // entry's channel + handle_token with a fresh task's token. + let active = ActiveConnections::default(); + let addr = test_addr(); + let old_token = Arc::new(()); + let new_token = Arc::new(()); + let _rx_new = insert_with_token(&active, addr, Arc::clone(&new_token)); + + { + let _guard = EvictOnDrop { + active_connections: active.clone(), + address: addr, + handle_token: old_token, + }; + } + + assert!( + active.get(&addr).is_some(), + "old task's drop must not clobber the newer entry" + ); + } + + #[tokio::test] + async fn io_loop_exits_when_peer_closes_idle_connection() { + // The fix's second half: while no packets are flowing, peer FIN/RST + // must still be observed so the cache entry can be evicted before the + // next send finds it stale. + let (a, b) = tokio::io::duplex(64); + let conn = Framed::new(a, NymCodec); + let (_tx, rx) = mpsc::channel(1); + + let task = tokio::spawn(run_io_loop(conn, ReceiverStream::new(rx), test_addr())); + + // Simulate peer closing both directions of the connection. + drop(b); + + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("io_loop must notice peer close while idle") + .expect("io_loop task must not panic"); + } + + #[tokio::test] + async fn io_loop_exits_when_sender_dropped() { + let (a, _b) = tokio::io::duplex(64); + let conn = Framed::new(a, NymCodec); + let (tx, rx) = mpsc::channel(1); + + let task = tokio::spawn(run_io_loop(conn, ReceiverStream::new(rx), test_addr())); + + drop(tx); + + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("io_loop must exit when the upstream sender is dropped") + .expect("io_loop task must not panic"); + } } diff --git a/common/client-libs/mixnet-client/src/forwarder.rs b/common/client-libs/mixnet-client/src/forwarder.rs index d7815be92f..402a3b056a 100644 --- a/common/client-libs/mixnet-client/src/forwarder.rs +++ b/common/client-libs/mixnet-client/src/forwarder.rs @@ -21,12 +21,16 @@ impl From> for MixForwardingSender { } impl MixForwardingSender { - pub fn forward_packet(&self, packet: impl Into) -> Result<(), SendError> { + pub fn forward_packet(&self, packet: PacketToForward) -> Result<(), SendError> { self.0 - .unbounded_send(packet.into()) + .unbounded_send(packet) .map_err(|err| err.into_send_error()) } + pub fn forward_client_packet_without_delay(&self, packet: MixPacket) -> Result<(), SendError> { + self.forward_packet(PacketToForward::client_packet_without_delay(packet)) + } + #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { self.0.len() @@ -38,35 +42,23 @@ pub type MixForwardingReceiver = mpsc::UnboundedReceiver; pub struct PacketToForward { pub packet: MixPacket, pub forward_delay_target: Option, -} - -impl From for PacketToForward { - fn from(packet: MixPacket) -> Self { - PacketToForward::new_no_delay(packet) - } -} - -impl From<(MixPacket, Option)> for PacketToForward { - fn from((packet, delay_until): (MixPacket, Option)) -> Self { - PacketToForward::new(packet, delay_until) - } -} - -impl From<(MixPacket, Instant)> for PacketToForward { - fn from((packet, delay_until): (MixPacket, Instant)) -> Self { - PacketToForward::new(packet, Some(delay_until)) - } + pub network_monitor_packet: bool, } impl PacketToForward { - pub fn new(packet: MixPacket, forward_delay_target: Option) -> Self { + pub fn new( + packet: MixPacket, + forward_delay_target: Option, + network_monitor_packet: bool, + ) -> Self { PacketToForward { packet, forward_delay_target, + network_monitor_packet, } } - pub fn new_no_delay(packet: MixPacket) -> Self { - Self::new(packet, None) + pub fn client_packet_without_delay(packet: MixPacket) -> Self { + Self::new(packet, None, false) } } diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index ea1de63563..61bfc64cf9 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -26,6 +26,7 @@ nym-ecash-contract-common = { workspace = true } nym-multisig-contract-common = { workspace = true } nym-group-contract-common = { workspace = true } nym-performance-contract-common = { workspace = true } +nym-network-monitors-contract-common = { workspace = true } nym-serde-helpers = { workspace = true, features = ["hex", "base64"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 19977987b8..8507b0fcb4 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -104,6 +104,14 @@ impl TryFrom for Config { } impl Config { + pub fn new(nyxd_url: Url, api_url: Url, nyxd_config: nyxd::Config) -> Self { + Config { + api_url, + nyxd_url, + nyxd_config, + } + } + pub fn try_from_nym_network_details( details: &NymNetworkDetails, ) -> Result { @@ -114,6 +122,15 @@ impl Config { .map(|url| Url::parse(url)) .collect::, _>>()?; + if let Some(nym_api_urls) = details.nym_api_urls.as_ref() { + api_url.extend( + nym_api_urls + .iter() + .map(|url| url.url.parse()) + .collect::, _>>()?, + ); + } + if api_url.is_empty() { return Err(ValidatorClientError::NoAPIUrlAvailable); } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index e2dffe496e..dc2739ea48 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -15,11 +15,15 @@ use nym_api_requests::ecash::models::{ VerifyEcashTicketBody, }; use nym_api_requests::ecash::VerificationKeyResponse; +use nym_api_requests::models::network_monitor::{ + KnownNetworkMonitorResponse, StressTestBatchSubmission, +}; use nym_api_requests::models::{ - AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainBlocksStatusResponse, - ChainStatusResponse, KeyRotationInfoResponse, NodePerformanceResponse, NodeRefreshBody, - NymNodeDescriptionV1, NymNodeDescriptionV2, PerformanceHistoryResponse, RewardedSetResponse, - SignerInformationResponse, + AnnotationResponseV1, ApiHealthResponse, BinaryBuildInformationOwned, + ChainBlocksStatusResponse, ChainStatusResponse, KeyRotationInfoResponse, + NodePerformanceResponse, NodeRefreshBody, NymNodeDescriptionV1, NymNodeDescriptionV2, + PerformanceHistoryResponse, RewardedSetResponse, SignerInformationResponse, + StressTestBatchSubmissionResponse, }; use nym_api_requests::pagination::PaginatedResponse; use nym_http_api_client::{ApiClient, NO_PARAMS}; @@ -976,7 +980,7 @@ pub trait NymApiClientExt: ApiClient { async fn get_node_annotation( &self, node_id: NodeId, - ) -> Result { + ) -> Result { self.get_json( &[ routes::V1_API_VERSION, @@ -1359,6 +1363,53 @@ pub trait NymApiClientExt: ApiClient { Ok(SemiSkimmedNodesWithMetadata::new(nodes, metadata)) } + + /// Queries the nym-api for whether a particular ed25519 identity key is currently recognised + /// as an authorised network monitor permitted to submit stress testing results. + /// + /// `identity_key` is expected to be the base58-encoded form of the ed25519 public key. + #[instrument(level = "debug", skip(self))] + async fn get_known_network_monitor( + &self, + identity_key: IdentityKeyRef<'_>, + ) -> Result { + self.get_json( + &[ + routes::V3_API_VERSION, + routes::NYM_NODES_ROUTES, + routes::STRESS_TESTING, + routes::STRESS_TESTING_KNOWN_MONITORS, + identity_key, + ], + NO_PARAMS, + ) + .await + } + + /// Submit a signed batch of stress-testing results to nym-api on behalf of a network monitor + /// orchestrator. + /// + /// The caller is expected to have produced `request` via + /// `StressTestBatchSubmissionContent::new(...)` and signed it with the orchestrator's ed25519 + /// key; nym-api will reject submissions that are stale, replayed, unauthorised, or whose + /// signature fails to verify. + #[instrument(level = "debug", skip(self, request))] + async fn submit_stress_testing_results( + &self, + request: &StressTestBatchSubmission, + ) -> Result { + self.post_json( + &[ + routes::V3_API_VERSION, + routes::NYM_NODES_ROUTES, + routes::STRESS_TESTING, + routes::STRESS_TESTING_BATCH_SUBMIT, + ], + NO_PARAMS, + request, + ) + .await + } } // Client is already nym_http_api_client::Client (re-exported above), so just one impl needed diff --git a/common/client-libs/validator-client/src/nym_api/routes.rs b/common/client-libs/validator-client/src/nym_api/routes.rs index 755b1282ef..8320944f2f 100644 --- a/common/client-libs/validator-client/src/nym_api/routes.rs +++ b/common/client-libs/validator-client/src/nym_api/routes.rs @@ -49,6 +49,9 @@ pub mod nym_nodes { pub const NYM_NODES_REWARDED_SET: &str = "rewarded-set"; pub const NYM_NODES_REFRESH_DESCRIBED: &str = "refresh-described"; pub const BY_ADDRESSES: &str = "by-addresses"; + pub const STRESS_TESTING: &str = "stress-testing"; + pub const STRESS_TESTING_KNOWN_MONITORS: &str = "known-monitors"; + pub const STRESS_TESTING_BATCH_SUBMIT: &str = "batch-submit"; } pub const STATUS_ROUTES: &str = "status"; diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs index ad7db0991b..3dd4bb70fe 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs @@ -13,6 +13,7 @@ pub mod ecash_query_client; pub mod group_query_client; pub mod mixnet_query_client; pub mod multisig_query_client; +pub mod network_monitors_query_client; pub mod performance_query_client; pub mod vesting_query_client; @@ -22,6 +23,7 @@ pub mod ecash_signing_client; pub mod group_signing_client; pub mod mixnet_signing_client; pub mod multisig_signing_client; +pub mod network_monitors_signing_client; pub mod performance_signing_client; pub mod vesting_signing_client; @@ -31,6 +33,9 @@ pub use ecash_query_client::{EcashQueryClient, PagedEcashQueryClient}; pub use group_query_client::{GroupQueryClient, PagedGroupQueryClient}; pub use mixnet_query_client::{MixnetQueryClient, PagedMixnetQueryClient}; pub use multisig_query_client::{MultisigQueryClient, PagedMultisigQueryClient}; +pub use network_monitors_query_client::{ + NetworkMonitorsQueryClient, PagedNetworkMonitorsQueryClient, +}; pub use performance_query_client::{PagedPerformanceQueryClient, PerformanceQueryClient}; pub use vesting_query_client::{PagedVestingQueryClient, VestingQueryClient}; @@ -40,6 +45,7 @@ pub use ecash_signing_client::EcashSigningClient; pub use group_signing_client::GroupSigningClient; pub use mixnet_signing_client::MixnetSigningClient; pub use multisig_signing_client::MultisigSigningClient; +pub use network_monitors_signing_client::NetworkMonitorsSigningClient; pub use performance_signing_client::PerformanceSigningClient; pub use vesting_signing_client::VestingSigningClient; @@ -49,6 +55,7 @@ pub trait NymContractsProvider { fn mixnet_contract_address(&self) -> Option<&AccountId>; fn vesting_contract_address(&self) -> Option<&AccountId>; fn performance_contract_address(&self) -> Option<&AccountId>; + fn network_monitors_contract_address(&self) -> Option<&AccountId>; // coconut-related fn ecash_contract_address(&self) -> Option<&AccountId>; @@ -62,6 +69,7 @@ pub struct TypedNymContracts { pub mixnet_contract_address: Option, pub vesting_contract_address: Option, pub performance_contract_address: Option, + pub network_monitors_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, @@ -86,6 +94,10 @@ impl TryFrom for TypedNymContracts { .performance_contract_address .map(|addr| addr.parse()) .transpose()?, + network_monitors_contract_address: value + .network_monitors_contract_address + .map(|addr| addr.parse()) + .transpose()?, ecash_contract_address: value .ecash_contract_address .map(|addr| addr.parse()) diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_query_client.rs new file mode 100644 index 0000000000..86e7767b84 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_query_client.rs @@ -0,0 +1,107 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::collect_paged; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::error::NyxdError; +use crate::nyxd::CosmWasmClient; +use async_trait::async_trait; +use nym_network_monitors_contract_common::{ + AuthorisedNetworkMonitor, AuthorisedNetworkMonitorOrchestratorsResponse, + AuthorisedNetworkMonitorsPagedResponse, QueryMsg as NetworkMonitorsQueryMsg, +}; +use serde::Deserialize; +use std::net::SocketAddr; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NetworkMonitorsQueryClient { + async fn query_network_monitors_contract( + &self, + query: NetworkMonitorsQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>; + + async fn get_admin(&self) -> Result { + self.query_network_monitors_contract(NetworkMonitorsQueryMsg::Admin {}) + .await + } + + async fn get_network_monitor_orchestrators( + &self, + ) -> Result { + self.query_network_monitors_contract( + NetworkMonitorsQueryMsg::NetworkMonitorOrchestrators {}, + ) + .await + } + + async fn get_network_monitor_agents_paged( + &self, + start_next_after: Option, + limit: Option, + ) -> Result { + self.query_network_monitors_contract(NetworkMonitorsQueryMsg::NetworkMonitorAgents { + start_next_after, + limit, + }) + .await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PagedNetworkMonitorsQueryClient: NetworkMonitorsQueryClient { + async fn get_all_network_monitor_agents( + &self, + ) -> Result, NyxdError> { + collect_paged!(self, get_network_monitor_agents_paged, authorised) + } +} + +#[async_trait] +impl PagedNetworkMonitorsQueryClient for T where T: NetworkMonitorsQueryClient {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NetworkMonitorsQueryClient for C +where + C: CosmWasmClient + NymContractsProvider + Send + Sync, +{ + async fn query_network_monitors_contract( + &self, + query: NetworkMonitorsQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>, + { + let contract_address = &self + .network_monitors_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("network monitors contract"))?; + self.query_contract_smart(contract_address, &query).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_query_variants_are_covered( + client: C, + msg: NetworkMonitorsQueryMsg, + ) { + match msg { + NetworkMonitorsQueryMsg::Admin {} => client.get_admin().ignore(), + NetworkMonitorsQueryMsg::NetworkMonitorOrchestrators {} => { + client.get_network_monitor_orchestrators().ignore() + } + NetworkMonitorsQueryMsg::NetworkMonitorAgents { .. } => { + client.get_network_monitor_agents_paged(None, None).ignore() + } + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_signing_client.rs new file mode 100644 index 0000000000..bfae9340a8 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/network_monitors_signing_client.rs @@ -0,0 +1,205 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::cosmwasm_client::types::ExecuteResult; +use crate::nyxd::error::NyxdError; +use crate::nyxd::{Coin, Fee, SigningCosmWasmClient}; +use crate::signing::signer::OfflineSigner; +use async_trait::async_trait; +use nym_network_monitors_contract_common::ExecuteMsg as NetworkMonitorsExecuteMsg; +use std::net::SocketAddr; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NetworkMonitorsSigningClient { + async fn execute_network_monitors_contract( + &self, + fee: Option, + msg: NetworkMonitorsExecuteMsg, + memo: String, + funds: Vec, + ) -> Result; + + async fn update_admin( + &self, + admin: String, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::UpdateAdmin { admin }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::UpdateAdmin".into(), + vec![], + ) + .await + } + + async fn authorise_network_monitor_orchestrator( + &self, + address: String, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::AuthoriseNetworkMonitorOrchestrator { address }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::AuthoriseNetworkMonitorOrchestrator".into(), + vec![], + ) + .await + } + + /// Announce (or rotate) the ed25519 identity key of the calling network monitor orchestrator. + /// + /// The caller must already be an authorised orchestrator; the contract validates that + /// `identity_key` is a well-formed base-58 encoding of a 32-byte ed25519 public key. + async fn update_orchestrator_identity_key( + &self, + identity_key: String, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::UpdateOrchestratorIdentityKey { key: identity_key }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::UpdateOrchestratorIdentityKey".into(), + vec![], + ) + .await + } + + async fn revoke_network_monitor_orchestrator( + &self, + address: String, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::RevokeNetworkMonitorOrchestrator { address }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::RevokeNetworkMonitorOrchestrator".into(), + vec![], + ) + .await + } + + async fn authorise_network_monitor( + &self, + mixnet_address: SocketAddr, + bs58_x25519_noise: String, + noise_version: u8, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address, + bs58_x25519_noise, + noise_version, + }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::AuthoriseNetworkMonitor".into(), + vec![], + ) + .await + } + + async fn revoke_network_monitor( + &self, + address: SocketAddr, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::RevokeNetworkMonitor { address }; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::RevokeNetworkMonitor".into(), + vec![], + ) + .await + } + + async fn revoke_all_network_monitors( + &self, + fee: Option, + ) -> Result { + let msg = NetworkMonitorsExecuteMsg::RevokeAllNetworkMonitors; + self.execute_network_monitors_contract( + fee, + msg, + "NetworkMonitorsExecuteMsg::RevokeAllNetworkMonitors".into(), + vec![], + ) + .await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NetworkMonitorsSigningClient for C +where + C: SigningCosmWasmClient + NymContractsProvider + Sync, + NyxdError: From<::Error>, +{ + async fn execute_network_monitors_contract( + &self, + fee: Option, + msg: NetworkMonitorsExecuteMsg, + memo: String, + funds: Vec, + ) -> Result { + let contract_address = &self + .network_monitors_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("network monitors contract"))?; + + let fee = fee.unwrap_or(Fee::Auto(Some(self.simulated_gas_multiplier()))); + + let signer_address = &self.signer_addresses()[0]; + self.execute(signer_address, contract_address, &msg, fee, memo, funds) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_network_monitors_contract_common::ExecuteMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_execute_variants_are_covered( + client: C, + msg: NetworkMonitorsExecuteMsg, + ) { + match msg { + NetworkMonitorsExecuteMsg::UpdateAdmin { admin } => { + client.update_admin(admin, None).ignore() + } + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { address } => client + .authorise_network_monitor_orchestrator(address, None) + .ignore(), + ExecuteMsg::UpdateOrchestratorIdentityKey { key } => { + client.update_orchestrator_identity_key(key, None).ignore() + } + ExecuteMsg::RevokeNetworkMonitorOrchestrator { address } => client + .revoke_network_monitor_orchestrator(address, None) + .ignore(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: address, + bs58_x25519_noise, + noise_version, + } => client + .authorise_network_monitor(address, bs58_x25519_noise, noise_version, None) + .ignore(), + ExecuteMsg::RevokeNetworkMonitor { address } => { + client.revoke_network_monitor(address, None).ignore() + } + ExecuteMsg::RevokeAllNetworkMonitors => { + client.revoke_all_network_monitors(None).ignore() + } + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs index df7f03dd34..793fb1bc13 100644 --- a/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/cosmwasm_client/mod.rs @@ -36,7 +36,7 @@ pub mod logs; pub mod module_traits; pub mod types; -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct SigningClientOptions { gas_price: GasPrice, simulated_gas_multiplier: f32, @@ -80,6 +80,17 @@ impl MaybeSigningClient { opts, } } + + pub(crate) fn clone_query_client(&self) -> MaybeSigningClient + where + C: Clone, + { + MaybeSigningClient { + client: self.client.clone(), + signer: Default::default(), + opts: self.opts.clone(), + } + } } #[cfg(feature = "http-client")] diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index 0c931c2bd5..ed01097ee9 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -24,6 +24,8 @@ use async_trait::async_trait; use cosmrs::tendermint::{abci, evidence::Evidence, Genesis}; use cosmrs::tx::{Raw, SignDoc}; use cosmwasm_std::Addr; +use nym_contracts_common::build_information::CONTRACT_BUILD_INFO_STORAGE_KEY; +use nym_contracts_common::ContractBuildInformation; use nym_network_defaults::{ChainDetails, NymNetworkDetails}; use serde::{de::DeserializeOwned, Serialize}; use std::fmt::Debug; @@ -40,6 +42,7 @@ pub use crate::nyxd::{ fee::Fee, }; pub use crate::rpc::TendermintRpcClient; +pub use bip39; pub use coin::Coin; pub use cosmrs::{ bank::MsgSend, @@ -70,14 +73,19 @@ pub use tendermint_rpc::{ Paging, Request, Response, SimpleRequest, }; +pub use nym_ecash_contract_common; +pub use nym_mixnet_contract_common; +pub use nym_multisig_contract_common; +pub use nym_network_monitors_contract_common; +pub use nym_performance_contract_common; +pub use nym_vesting_contract_common; + #[cfg(feature = "http-client")] use crate::http_client; #[cfg(feature = "http-client")] use crate::{DirectSigningHttpRpcNyxdClient, QueryHttpRpcNyxdClient}; #[cfg(feature = "http-client")] use cosmrs::rpc::{HttpClient, HttpClientUrl}; -use nym_contracts_common::build_information::CONTRACT_BUILD_INFO_STORAGE_KEY; -use nym_contracts_common::ContractBuildInformation; pub mod coin; pub mod contract_traits; @@ -262,6 +270,16 @@ impl NyxdClient { } } + pub fn clone_query_client(&self) -> NyxdClient + where + C: Clone, + { + NyxdClient { + client: self.client.clone_query_client(), + config: self.config.clone(), + } + } + pub fn current_config(&self) -> &Config { &self.config } @@ -289,6 +307,10 @@ impl NyxdClient { pub fn set_simulated_gas_multiplier(&mut self, multiplier: f32) { self.config.simulated_gas_multiplier = multiplier; } + + pub fn get_nym_contracts(&self) -> TypedNymContracts { + self.config.contracts.clone() + } } impl NymContractsProvider for NyxdClient { @@ -303,6 +325,12 @@ impl NymContractsProvider for NyxdClient { fn performance_contract_address(&self) -> Option<&AccountId> { self.config.contracts.performance_contract_address.as_ref() } + fn network_monitors_contract_address(&self) -> Option<&AccountId> { + self.config + .contracts + .network_monitors_contract_address + .as_ref() + } fn ecash_contract_address(&self) -> Option<&AccountId> { self.config.contracts.ecash_contract_address.as_ref() diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/Cargo.toml b/common/cosmwasm-smart-contracts/network-monitors-contract/Cargo.toml new file mode 100644 index 0000000000..47021f35e4 --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nym-network-monitors-contract-common" +description = "Common library for the Nym Network Monitors contract" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +readme.workspace = true +version.workspace = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } + +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } + +[features] +schema = [] + +[lints] +workspace = true diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/src/constants.rs b/common/cosmwasm-smart-contracts/network-monitors-contract/src/constants.rs new file mode 100644 index 0000000000..f3bfb238d2 --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/src/constants.rs @@ -0,0 +1,8 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod storage_keys { + pub const CONTRACT_ADMIN: &str = "contract-admin"; + pub const AUTHORISED_ORCHESTRATORS: &str = "authorised-orchestrators"; + pub const AUTHORISED_NETWORK_MONITORS: &str = "authorised-network-monitors"; +} diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/src/error.rs b/common/cosmwasm-smart-contracts/network-monitors-contract/src/error.rs new file mode 100644 index 0000000000..a2e31d546b --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/src/error.rs @@ -0,0 +1,30 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::Addr; +use cw_controllers::AdminError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum NetworkMonitorsContractError { + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + + #[error(transparent)] + Admin(#[from] AdminError), + + #[error("unauthorised")] + Unauthorized, + + #[error("address {addr} is not an authorised orchestrator")] + NotAnOrchestrator { addr: Addr }, + + #[error("Failed to recover x25519 public key from its base58 representation: {0}")] + MalformedX25519AgentNoiseKey(String), + + #[error("Failed to recover ed25519 public key from its base58 representation: {0}")] + MalformedEd25519OrchestratorIdentityKey(String), + + #[error(transparent)] + StdErr(#[from] cosmwasm_std::StdError), +} diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/src/lib.rs b/common/cosmwasm-smart-contracts/network-monitors-contract/src/lib.rs new file mode 100644 index 0000000000..273ff38d15 --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/src/lib.rs @@ -0,0 +1,11 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod constants; +pub mod error; +pub mod msg; +pub mod types; + +pub use error::*; +pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use types::*; diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/src/msg.rs b/common/cosmwasm-smart-contracts/network-monitors-contract/src/msg.rs new file mode 100644 index 0000000000..a5fbf4d101 --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/src/msg.rs @@ -0,0 +1,78 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use std::net::SocketAddr; + +#[cfg(feature = "schema")] +use crate::{ + AuthorisedNetworkMonitorOrchestratorsResponse, AuthorisedNetworkMonitorsPagedResponse, +}; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of the initial network monitor orchestrator. + pub orchestrator_address: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Change the admin + UpdateAdmin { admin: String }, + + /// Authorise new network monitor orchestrator + AuthoriseNetworkMonitorOrchestrator { address: String }, + + /// Attempt to update the announced identity key of this orchestrator + UpdateOrchestratorIdentityKey { key: String }, + + /// Revoke network monitor orchestrator authorisation. + RevokeNetworkMonitorOrchestrator { address: String }, + + /// Authorise new network monitor (or renew authorisation) + /// granting additional privileges when sending mixnet packets to Nym nodes. + AuthoriseNetworkMonitor { + /// Mixnet address of the agent. + /// The underlying ip address is going to be used as ingress to the nodes, + /// and the full socket address announces the egress and the association with the noise key + mixnet_address: SocketAddr, + + /// Base-58 encoded noise key of the agent. + bs58_x25519_noise: String, + + /// Version of the noise protocol used by the agent. + noise_version: u8, + }, + + /// Revoke network monitor authorisation. + RevokeNetworkMonitor { address: SocketAddr }, + + /// Revoke all network monitor authorisations. + RevokeAllNetworkMonitors, +} + +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + #[cfg_attr(feature = "schema", returns(cw_controllers::AdminResponse))] + Admin {}, + + // no need for pagination as we don't expect even a double digit of those + #[cfg_attr( + feature = "schema", + returns(AuthorisedNetworkMonitorOrchestratorsResponse) + )] + NetworkMonitorOrchestrators {}, + + #[cfg_attr(feature = "schema", returns(AuthorisedNetworkMonitorsPagedResponse))] + NetworkMonitorAgents { + /// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response. + start_next_after: Option, + + /// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default. + limit: Option, + }, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/common/cosmwasm-smart-contracts/network-monitors-contract/src/types.rs b/common/cosmwasm-smart-contracts/network-monitors-contract/src/types.rs new file mode 100644 index 0000000000..b0f9a4a754 --- /dev/null +++ b/common/cosmwasm-smart-contracts/network-monitors-contract/src/types.rs @@ -0,0 +1,53 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Timestamp}; +use std::net::SocketAddr; + +pub type OrchestratorAddress = Addr; + +#[cw_serde] +pub struct AuthorisedNetworkMonitorOrchestrator { + /// The address associated with the network monitor orchestrator. + pub address: Addr, + + /// Base-58 encoded identity key of the orchestrator, announced by the orchestrator itself + /// on startup. + pub identity_key: Option, + + /// Timestamp of when the network monitor was authorised. + pub authorised_at: Timestamp, +} + +#[cw_serde] +pub struct AuthorisedNetworkMonitor { + /// Mixnet address of the agent. + /// The underlying ip address is going to be used as ingress to the nodes, + /// and the full socket address announces the egress and the association with the noise key + pub mixnet_address: SocketAddr, + + /// The address of the orchestrator that authorised the network monitor agent. + pub authorised_by: OrchestratorAddress, + + /// Timestamp of when the network monitor was authorised. + pub authorised_at: Timestamp, + + /// Base-58 encoded noise key of the agent. + pub bs58_x25519_noise: String, + + /// Version of the noise protocol used by the agent. + pub noise_version: u8, +} + +#[cw_serde] +pub struct AuthorisedNetworkMonitorOrchestratorsResponse { + pub authorised: Vec, +} + +#[cw_serde] +pub struct AuthorisedNetworkMonitorsPagedResponse { + pub authorised: Vec, + + pub start_next_after: Option, +} diff --git a/common/crypto/src/lib.rs b/common/crypto/src/lib.rs index e8426bb085..7dc11fa0b4 100644 --- a/common/crypto/src/lib.rs +++ b/common/crypto/src/lib.rs @@ -31,3 +31,5 @@ pub use aes_gcm_siv::{Aes128GcmSiv, Aes256GcmSiv}; pub use blake3; #[cfg(feature = "stream_cipher")] pub use ctr; +#[cfg(feature = "hashing")] +pub use sha2; diff --git a/common/http-api-client/src/path.rs b/common/http-api-client/src/path.rs index b7aeea5e47..39ee964057 100644 --- a/common/http-api-client/src/path.rs +++ b/common/http-api-client/src/path.rs @@ -10,7 +10,9 @@ fn sanitize_fragment(segment: &str) -> &str { segment.trim_matches(|c: char| c.is_whitespace() || c == '/') } +/// Defines a path that can be used to make a request to an API. pub trait RequestPath: Debug { + /// Sanitise the request path by removing empty segments and trimming whitespace and slashes fn to_sanitized_segments(&self) -> Vec<&str>; } diff --git a/common/network-defaults/Cargo.toml b/common/network-defaults/Cargo.toml index 238503673d..823e9fc200 100644 --- a/common/network-defaults/Cargo.toml +++ b/common/network-defaults/Cargo.toml @@ -17,11 +17,10 @@ exclude = ["build.rs"] [dependencies] dotenvy = { workspace = true, optional = true } -log = { workspace = true, optional = true } schemars = { workspace = true, features = ["preserve_order"], optional = true } serde = { workspace = true, features = ["derive"], optional = true } -serde_json = {workspace = true, optional = true } -tracing = {workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } url = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } @@ -30,9 +29,9 @@ utoipa = { workspace = true, optional = true } [features] default = ["env", "network"] -env = ["dotenvy", "log", "serde_json", "tracing"] +env = ["dotenvy", "serde_json", "tracing"] network = ["schemars", "serde", "url"] -utoipa = [ "dep:utoipa" ] +utoipa = ["dep:utoipa"] [build-dependencies] regex = { workspace = true } diff --git a/common/network-defaults/src/env_setup.rs b/common/network-defaults/src/env_setup.rs index 66a8243d00..d7ba6f32c7 100644 --- a/common/network-defaults/src/env_setup.rs +++ b/common/network-defaults/src/env_setup.rs @@ -27,16 +27,20 @@ fn print_env_vars_with_keys_in_file + Copy>(config_env_file: P) { .expect("Invalid path to environment configuration file"); for item in items { let (key, val) = item.expect("Invalid item in environment configuration file"); - log::debug!("{key}: {val}"); + tracing::debug!("{key}: {val}"); } } +pub fn env_configured() -> bool { + std::env::var(var_names::CONFIGURED).is_ok() +} + pub fn setup_env>(config_env_file: Option

) { match std::env::var(var_names::CONFIGURED) { // if the configuration is not already set in the env vars Err(std::env::VarError::NotPresent) => { if let Some(config_env_file) = &config_env_file { - log::debug!( + tracing::debug!( "Loading environment variables from {:?}", config_env_file.as_ref() ); @@ -47,12 +51,12 @@ pub fn setup_env>(config_env_file: Option

) { // if nothing is set, the use mainnet defaults // if the user has not set `CONFIGURED`, then even if they set any of the env variables, // overwrite them - log::debug!("Loading mainnet defaults"); + tracing::debug!("Loading mainnet defaults"); crate::mainnet::export_to_env(); } } Err(_) => { - log::debug!("Environment variables already set. Using them"); + tracing::debug!("Environment variables already set. Using them"); crate::mainnet::export_to_env() } _ => { diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 3c07beefc3..0ce8772863 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -22,6 +22,8 @@ pub const VESTING_CONTRACT_ADDRESS: &str = pub const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; // /\ TODO: this has to be updated once the contract is deployed +pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str = + "n1m3a2ltkjqud8mkmrpqvgllrtv2p4r6js6qwl7p8cqkzrq8jg6e2qwqgl8z"; pub const ECASH_CONTRACT_ADDRESS: &str = "n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun"; pub const GROUP_CONTRACT_ADDRESS: &str = @@ -36,6 +38,10 @@ pub const REWARDING_VALIDATOR_ADDRESS: &str = "n10yyd98e2tuwu0f7ypz9dy3hhjw7v772 pub const NYXD_URL: &str = "https://rpc.nymtech.net"; pub const NYXD_WS: &str = "wss://rpc.nymtech.net/websocket"; +// cluster of lite rpc nodes (not part of consensus, aggressive pruning, no archival state) +pub const NYXD_QUERY_LITE: &str = "https://blockstream.nymtech.net"; +pub const NYXD_WS_LITE: &str = "wss://blockstream.nymtech.net/websocket"; + pub const NYM_API: &str = "https://validator.nymtech.net/api/"; #[cfg(feature = "network")] pub const NYM_APIS: &[ApiUrlConst] = &[ @@ -133,6 +139,11 @@ pub fn read_parsed_var_if_not_default( .map(std::str::FromStr::from_str) } +#[cfg(feature = "env")] +pub fn read_parsed_var(var: &str) -> Result { + std::env::var(var).unwrap_or_default().parse() +} + #[cfg(all(feature = "env", feature = "network"))] pub fn export_to_env() { use crate::var_names; @@ -163,6 +174,14 @@ pub fn export_to_env() { var_names::COCONUT_DKG_CONTRACT_ADDRESS, COCONUT_DKG_CONTRACT_ADDRESS, ); + set_var_to_default( + var_names::PERFORMANCE_CONTRACT_ADDRESS, + PERFORMANCE_CONTRACT_ADDRESS, + ); + set_var_to_default( + var_names::NETWORK_MONITORS_CONTRACT_ADDRESS, + NETWORK_MONITORS_CONTRACT_ADDRESS, + ); set_var_to_default( var_names::REWARDING_VALIDATOR_ADDRESS, REWARDING_VALIDATOR_ADDRESS, @@ -182,6 +201,8 @@ pub fn export_to_env() { var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, ); + set_var_to_default(var_names::NYXD_QUERY_LITE, NYXD_QUERY_LITE); + set_var_to_default(var_names::NYXD_WS_LITE, NYXD_WS_LITE); } #[cfg(all(feature = "env", feature = "network"))] @@ -233,4 +254,6 @@ pub fn export_to_env_if_not_set() { var_names::UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY, ); + set_var_conditionally_to_default(var_names::NYXD_QUERY_LITE, NYXD_QUERY_LITE); + set_var_conditionally_to_default(var_names::NYXD_WS_LITE, NYXD_WS_LITE); } diff --git a/common/network-defaults/src/network.rs b/common/network-defaults/src/network.rs index ccba4af768..868ef8bf81 100644 --- a/common/network-defaults/src/network.rs +++ b/common/network-defaults/src/network.rs @@ -39,6 +39,8 @@ pub struct NymContracts { pub vesting_contract_address: Option, #[serde(default)] pub performance_contract_address: Option, + #[serde(default)] + pub network_monitors_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, pub multisig_contract_address: Option, @@ -72,6 +74,15 @@ pub struct ApiUrl { pub front_hosts: Option>, } +impl From for ApiUrl { + fn from(value: Url) -> Self { + ApiUrl { + url: value.to_string(), + front_hosts: None, + } + } +} + #[derive(Copy, Clone, Debug, Serialize)] pub struct ApiUrlConst<'a> { pub url: &'a str, @@ -178,6 +189,10 @@ impl NymNetworkDetails { .with_group_contract(get_optional_env(var_names::GROUP_CONTRACT_ADDRESS)) .with_multisig_contract(get_optional_env(var_names::MULTISIG_CONTRACT_ADDRESS)) .with_coconut_dkg_contract(get_optional_env(var_names::COCONUT_DKG_CONTRACT_ADDRESS)) + .with_performance_contract(get_optional_env(var_names::PERFORMANCE_CONTRACT_ADDRESS)) + .with_network_monitors_contract(get_optional_env( + var_names::NETWORK_MONITORS_CONTRACT_ADDRESS, + )) .with_nym_vpn_api_url(get_optional_env(var_names::NYM_VPN_API)) .with_nym_vpn_api_urls(nym_vpn_api_urls) .with_nym_api_urls(nym_api_urls) @@ -199,6 +214,9 @@ impl NymNetworkDetails { performance_contract_address: parse_optional_str( mainnet::PERFORMANCE_CONTRACT_ADDRESS, ), + network_monitors_contract_address: parse_optional_str( + mainnet::NETWORK_MONITORS_CONTRACT_ADDRESS, + ), ecash_contract_address: parse_optional_str(mainnet::ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(mainnet::GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(mainnet::MULTISIG_CONTRACT_ADDRESS), @@ -226,7 +244,7 @@ impl NymNetworkDetails { fn set_optional_var(var_name: &str, value: Option) { if let Some(value) = value { - unsafe {set_var(var_name, value)} + unsafe { set_var(var_name, value) } } } unsafe { @@ -364,15 +382,31 @@ impl NymNetworkDetails { self } + #[must_use] + pub fn with_performance_contract>(mut self, contract: Option) -> Self { + self.contracts.performance_contract_address = contract.map(Into::into); + self + } + + #[must_use] + pub fn with_network_monitors_contract>(mut self, contract: Option) -> Self { + self.contracts.network_monitors_contract_address = contract.map(Into::into); + self + } + #[must_use] pub fn with_nym_vpn_api_url>(mut self, endpoint: Option) -> Self { self.nym_vpn_api_url = endpoint.map(Into::into); self } + pub fn set_nym_api_urls>(&mut self, urls: Vec) { + self.nym_api_urls = Some(urls.into_iter().map(Into::into).collect()); + } + #[must_use] - pub fn with_nym_api_urls(mut self, urls: Vec) -> Self { - self.nym_api_urls = Some(urls); + pub fn with_nym_api_urls>(mut self, urls: Vec) -> Self { + self.set_nym_api_urls(urls); self } diff --git a/common/network-defaults/src/sandbox.rs b/common/network-defaults/src/sandbox.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/network-defaults/src/var_names.rs b/common/network-defaults/src/var_names.rs index 4bfb1d5877..96dd561650 100644 --- a/common/network-defaults/src/var_names.rs +++ b/common/network-defaults/src/var_names.rs @@ -18,11 +18,15 @@ pub const ECASH_CONTRACT_ADDRESS: &str = "ECASH_CONTRACT_ADDRESS"; pub const GROUP_CONTRACT_ADDRESS: &str = "GROUP_CONTRACT_ADDRESS"; pub const MULTISIG_CONTRACT_ADDRESS: &str = "MULTISIG_CONTRACT_ADDRESS"; pub const COCONUT_DKG_CONTRACT_ADDRESS: &str = "COCONUT_DKG_CONTRACT_ADDRESS"; +pub const PERFORMANCE_CONTRACT_ADDRESS: &str = "PERFORMANCE_CONTRACT_ADDRESS"; +pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str = "NETWORK_MONITORS_CONTRACT_ADDRESS"; pub const REWARDING_VALIDATOR_ADDRESS: &str = "REWARDING_VALIDATOR_ADDRESS"; pub const NYXD: &str = "NYXD"; pub const NYM_API: &str = "NYM_API"; pub const NYM_APIS: &str = "NYM_APIS"; pub const NYXD_WEBSOCKET: &str = "NYXD_WS"; +pub const NYXD_QUERY_LITE: &str = "NYXD_LITE"; +pub const NYXD_WS_LITE: &str = "NYXD_WS_LITE"; pub const EXIT_POLICY_URL: &str = "EXIT_POLICY"; pub const NYM_VPN_API: &str = "NYM_VPN_API"; pub const NYM_VPN_APIS: &str = "NYM_VPN_APIS"; diff --git a/common/nymnoise/src/config.rs b/common/nymnoise/src/config.rs index 1ef2235928..ab5f7afccd 100644 --- a/common/nymnoise/src/config.rs +++ b/common/nymnoise/src/config.rs @@ -1,19 +1,19 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use arc_swap::ArcSwap; +use nym_crypto::asymmetric::x25519; +use snow::params::NoiseParams; use std::{ collections::HashMap, net::{IpAddr, SocketAddr}, sync::Arc, time::Duration, }; - -use arc_swap::ArcSwap; -use nym_crypto::asymmetric::x25519; -use nym_noise_keys::{NoiseVersion, VersionedNoiseKeyV1}; -use snow::params::NoiseParams; - use strum_macros::{EnumIter, FromRepr}; +use tokio::sync::{Mutex, MutexGuard}; + +pub use nym_noise_keys::{NoiseVersion, VersionedNoiseKeyV1}; #[derive(Default, Debug, Clone, Copy, EnumIter, FromRepr, Eq, PartialEq)] #[repr(u8)] @@ -53,38 +53,125 @@ impl NoisePattern { } } -#[derive(Debug, Default)] -struct SocketAddrToKey { - inner: ArcSwap>, -} - -// SW NOTE : Only for phased upgrade. To remove once we decide all nodes have to support Noise -#[derive(Debug, Default)] -struct IpAddrToVersion { - inner: ArcSwap>, -} - -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct NoiseNetworkView { - keys: Arc, - support: Arc, + inner: Arc, } -impl NoiseNetworkView { - pub fn new_empty() -> Self { - NoiseNetworkView { - keys: Default::default(), - support: Default::default(), +/// Inner state of [`NoiseNetworkView`], shared behind an `Arc`. +/// +/// # Concurrency model +/// +/// Reads (on the packet-processing hot path) use `ArcSwap` and are fully lock-free. +/// Writers must first acquire `update_lock` to serialise concurrent updates, then call +/// `swap_view` to atomically publish the new map. The lock is intentionally *not* wrapping +/// the map itself so that readers are never blocked. +#[derive(Debug)] +struct NoiseNetworkViewInner { + update_lock: Mutex<()>, + nodes: ArcSwap>, +} + +/// A node in the noise network map, keyed by IP address. +/// +/// A single IP can correspond to either one nym-node (which has a single noise key) +/// or one-or-more network monitor agents (each with its own port and noise key). +/// The two variants have independent lifecycles: nym-node entries come from the +/// nym-api topology refresher, while agent entries come from blockchain events. +#[derive(Debug, Clone)] +pub enum NoiseNode { + NymNode { key: VersionedNoiseKeyV1 }, + // due to the structure of network monitor agents, + // it is possible to have multiple destinations with the same host ip address, + // but a different noise key. + // however, we are also guaranteed all of those are going to have a unique port. + // note: we're not storing it in a map, since at maximum we might have maybe 20 or so + // entries under a single ip address and linear look-up of a vec is faster than the overhead of a hashmap + NetworkMonitorAgent { nodes: Vec }, +} + +impl NoiseNode { + pub fn new_nym_node(key: VersionedNoiseKeyV1) -> Self { + NoiseNode::NymNode { key } + } + + pub fn new_agent(socket_addr: SocketAddr, key: VersionedNoiseKeyV1) -> Self { + NoiseNode::NetworkMonitorAgent { + nodes: vec![NetworkMonitorAgentNode { + port: socket_addr.port(), + key, + }], } } - pub fn swap_view(&self, new: HashMap) { - let noise_support = new - .iter() - .map(|(s_addr, key)| (s_addr.ip(), key.supported_version)) - .collect::>(); - self.keys.inner.store(Arc::new(new)); - self.support.inner.store(Arc::new(noise_support)); + pub fn is_nym_node(&self) -> bool { + matches!(self, NoiseNode::NymNode { .. }) + } +} + +/// A single network monitor agent identified by its port on a shared host. +/// +/// Multiple agents may share an IP address but are guaranteed to have unique ports. +#[derive(Debug, Clone)] +pub struct NetworkMonitorAgentNode { + pub port: u16, + pub key: VersionedNoiseKeyV1, +} + +impl NoiseNetworkView { + pub fn new(nodes: HashMap) -> Self { + // ensure we're always storing canonical IPs + NoiseNetworkView { + inner: Arc::new(NoiseNetworkViewInner { + update_lock: Mutex::new(()), + nodes: ArcSwap::from_pointee( + nodes + .into_iter() + .map(|(k, v)| (k.to_canonical(), v)) + .collect(), + ), + }), + } + } + + pub fn new_empty() -> Self { + Self::new(Default::default()) + } + + /// Build a noise view pre-populated with network monitor agents (used at startup). + pub fn new_with_agents(agents: HashMap>) -> Self { + let mut nodes = HashMap::new(); + for (ip, agent_nodes) in agents { + nodes.insert(ip, NoiseNode::NetworkMonitorAgent { nodes: agent_nodes }); + } + Self::new(nodes) + } + + pub async fn get_update_permit(&self) -> MutexGuard<'_, ()> { + self.inner.update_lock.lock().await + } + + /// Atomically replace the noise key map. + /// + /// # Precondition + /// + /// The caller **must** hold the permit returned by [`NoiseNetworkView::get_update_permit`]. + /// Passing the `MutexGuard` by value enforces this at the type level — the guard is dropped + /// (releasing the lock) only after the swap completes, preventing torn writes from concurrent + /// update calls. + pub fn swap_view(&self, _permit: MutexGuard<'_, ()>, new: HashMap) { + // defensive: ensure stored keys are always canonical so lookups (which canonicalise) + // always match. callers should still canonicalise before assembling `new` to keep + // collision resolution deterministic. + let canonical = new + .into_iter() + .map(|(k, v)| (k.to_canonical(), v)) + .collect(); + self.inner.nodes.store(Arc::new(canonical)); + } + + pub fn all_nodes(&self) -> HashMap { + self.inner.nodes.load().as_ref().clone() } } @@ -126,20 +213,38 @@ impl NoiseConfig { self } - pub(crate) fn get_noise_key(&self, s_address: &SocketAddr) -> Option { - self.network.keys.inner.load().get(s_address).copied() + /// Look up the noise key for a specific remote socket address. + /// + /// Used on the **initiator** path where we need the responder's public key + /// to start the handshake. For nym-nodes the port is ignored (one key per IP); + /// for network monitor agents, the port disambiguates which agent's key to use. + pub(crate) fn get_noise_key(&self, address: SocketAddr) -> Option { + let ip_to_check = address.ip().to_canonical(); + let nodes = self.network.inner.nodes.load(); + + // Resolve the noise key for `address` from a loaded snapshot of the node map. + // For [`NoiseNode::NymNode`] entries the port is irrelevant — only the IP is matched. + // For [`NoiseNode::NetworkMonitorAgent`] entries the port selects the specific agent. + match nodes.get(&ip_to_check)? { + NoiseNode::NymNode { key } => Some(*key), + NoiseNode::NetworkMonitorAgent { nodes } => { + let port = address.port(); + nodes.iter().find(|n| n.port == port).map(|n| n.key) + } + } } - // Only for phased update - //SW This can lead to some troubles if two nodes share the same IP and one support Noise but not the other. - // This in only for the progressive update though and there is no workaround - pub(crate) fn get_noise_support(&self, ip_addr: IpAddr) -> Option { - let plain_ip_support = self.network.support.inner.load().get(&ip_addr).copied(); - - // SW default bind address being [::]:1789, it can happen that a responder sees the ipv6-mapped address of the initiator, this check for that - let canonical_ip = &ip_addr.to_canonical(); - let canonical_ip_support = self.network.support.inner.load().get(canonical_ip).copied(); - plain_ip_support.or(canonical_ip_support) + /// Check whether a remote IP is known to support noise. + /// Used on the responder path where we don't need the remote's key + /// (the initiator sends it during the handshake). + // note: in the case of network monitor agents, it must hold + // that ALL agents on given host support it (or don't support it) + pub(crate) fn supports_noise(&self, ip_addr: IpAddr) -> bool { + self.network + .inner + .nodes + .load() + .contains_key(&ip_addr.to_canonical()) } } @@ -169,4 +274,240 @@ mod tests { } } } + + mod noise_key_lookup { + use super::super::*; + use nym_crypto::asymmetric::x25519; + use nym_noise_keys::NoiseVersion; + use nym_test_utils::helpers::deterministic_rng; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + use std::time::Duration; + + fn dummy_key(seed: u8) -> VersionedNoiseKeyV1 { + VersionedNoiseKeyV1 { + supported_version: NoiseVersion::V1, + x25519_pubkey: x25519::PublicKey::from([seed; 32]), + } + } + + fn make_config(nodes: HashMap) -> NoiseConfig { + NoiseConfig::new( + Arc::new(x25519::KeyPair::new(&mut deterministic_rng())), + NoiseNetworkView::new(nodes), + Duration::from_secs(5), + ) + } + + // -- get_noise_key tests -- + + #[test] + fn nym_node_key_returned_regardless_of_port() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let key = dummy_key(1); + let config = make_config(HashMap::from([(ip, NoiseNode::new_nym_node(key))])); + + // any port should resolve to the same key + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 1000)), Some(key)); + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 9999)), Some(key)); + } + + #[test] + fn agent_key_resolved_by_port() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let key_a = dummy_key(1); + let key_b = dummy_key(2); + + let node = NoiseNode::NetworkMonitorAgent { + nodes: vec![ + NetworkMonitorAgentNode { + port: 1000, + key: key_a, + }, + NetworkMonitorAgentNode { + port: 2000, + key: key_b, + }, + ], + }; + let config = make_config(HashMap::from([(ip, node)])); + + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 1000)), Some(key_a)); + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 2000)), Some(key_b)); + } + + #[test] + fn agent_unknown_port_returns_none() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let node = NoiseNode::NetworkMonitorAgent { + nodes: vec![NetworkMonitorAgentNode { + port: 1000, + key: dummy_key(1), + }], + }; + let config = make_config(HashMap::from([(ip, node)])); + + assert!(config.get_noise_key(SocketAddr::new(ip, 9999)).is_none()); + } + + #[test] + fn completely_unknown_address_returns_none() { + let config = make_config(HashMap::new()); + + assert!(config + .get_noise_key(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 80)) + .is_none()); + } + + #[test] + fn canonical_ipv6_fallback_for_nym_node() { + // register under the plain IPv4 address + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let key = dummy_key(1); + let config = make_config(HashMap::from([(v4, NoiseNode::new_nym_node(key))])); + + // query with the IPv4-mapped IPv6 form (::ffff:1.2.3.4) + let v6_mapped = IpAddr::V6(Ipv4Addr::new(1, 2, 3, 4).to_ipv6_mapped()); + assert_eq!( + config.get_noise_key(SocketAddr::new(v6_mapped, 1789)), + Some(key) + ); + } + + #[test] + fn canonical_ipv6_fallback_for_agent() { + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let key = dummy_key(1); + let node = NoiseNode::NetworkMonitorAgent { + nodes: vec![NetworkMonitorAgentNode { port: 1000, key }], + }; + let config = make_config(HashMap::from([(v4, node)])); + + let v6_mapped = IpAddr::V6(Ipv4Addr::new(1, 2, 3, 4).to_ipv6_mapped()); + assert_eq!( + config.get_noise_key(SocketAddr::new(v6_mapped, 1000)), + Some(key) + ); + // wrong port still returns None even with the fallback + assert!(config + .get_noise_key(SocketAddr::new(v6_mapped, 9999)) + .is_none()); + } + + // -- supports_noise tests -- + + #[test] + fn supports_noise_true_for_nym_node() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let config = make_config(HashMap::from([(ip, NoiseNode::new_nym_node(dummy_key(1)))])); + + assert!(config.supports_noise(ip)); + } + + #[test] + fn supports_noise_true_for_agent_ip() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let node = NoiseNode::NetworkMonitorAgent { + nodes: vec![NetworkMonitorAgentNode { + port: 1000, + key: dummy_key(1), + }], + }; + let config = make_config(HashMap::from([(ip, node)])); + + assert!(config.supports_noise(ip)); + } + + #[test] + fn supports_noise_false_for_unknown_ip() { + let config = make_config(HashMap::new()); + + assert!(!config.supports_noise(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))); + } + + #[test] + fn supports_noise_canonical_ipv6_fallback() { + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let config = make_config(HashMap::from([(v4, NoiseNode::new_nym_node(dummy_key(1)))])); + + let v6_mapped = IpAddr::V6(Ipv4Addr::new(1, 2, 3, 4).to_ipv6_mapped()); + assert!(config.supports_noise(v6_mapped)); + } + + // -- new_with_agents test -- + + #[test] + fn new_with_agents_builds_correct_view() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let key_a = dummy_key(1); + let key_b = dummy_key(2); + + let agents = HashMap::from([( + ip, + vec![ + NetworkMonitorAgentNode { + port: 1000, + key: key_a, + }, + NetworkMonitorAgentNode { + port: 2000, + key: key_b, + }, + ], + )]); + + let config = NoiseConfig::new( + Arc::new(x25519::KeyPair::new(&mut deterministic_rng())), + NoiseNetworkView::new_with_agents(agents), + Duration::from_secs(5), + ); + + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 1000)), Some(key_a)); + assert_eq!(config.get_noise_key(SocketAddr::new(ip, 2000)), Some(key_b)); + assert!(config.supports_noise(ip)); + } + + // -- swap_view canonicalisation test -- + + // Regression: an agent registered via blockchain events flows through `swap_view` (called + // from `NetworkMonitorAgentsModule::new_agent` and from the periodic network refresher). + // If a non-canonical (IPv4-mapped IPv6) key reaches `swap_view`, lookups via + // `supports_noise` (which canonicalises) used to miss, producing the + // "can't speak Noise yet, falling back to TCP" warning despite the agent being correctly + // authorised in the routing filter. + #[tokio::test] + async fn swap_view_canonicalises_non_canonical_keys() { + let view = NoiseNetworkView::new_empty(); + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let v6_mapped = IpAddr::V6(Ipv4Addr::new(1, 2, 3, 4).to_ipv6_mapped()); + + let mut nodes = HashMap::new(); + // intentionally insert under the IPv4-mapped form — what a buggy caller might do + nodes.insert( + v6_mapped, + NoiseNode::NetworkMonitorAgent { + nodes: vec![NetworkMonitorAgentNode { + port: 1000, + key: dummy_key(1), + }], + }, + ); + + let permit = view.get_update_permit().await; + view.swap_view(permit, nodes); + + let config = NoiseConfig::new( + Arc::new(x25519::KeyPair::new(&mut deterministic_rng())), + view, + Duration::from_secs(5), + ); + + // lookup via either form must succeed + assert!(config.supports_noise(v4)); + assert!(config.supports_noise(v6_mapped)); + assert!(config + .get_noise_key(SocketAddr::new(v6_mapped, 1000)) + .is_some()); + } + } } diff --git a/common/nymnoise/src/connection.rs b/common/nymnoise/src/connection.rs index 81daa9bc98..0f09edd563 100644 --- a/common/nymnoise/src/connection.rs +++ b/common/nymnoise/src/connection.rs @@ -15,6 +15,12 @@ pub enum Connection { Noise(#[pin] Box>), } +impl Connection { + pub fn is_noise(&self) -> bool { + matches!(self, Connection::Noise(_)) + } +} + impl AsyncRead for Connection where C: AsyncRead + AsyncWrite + Unpin, diff --git a/common/nymnoise/src/lib.rs b/common/nymnoise/src/lib.rs index 5d3ff953cb..ed3cd09bd3 100644 --- a/common/nymnoise/src/lib.rs +++ b/common/nymnoise/src/lib.rs @@ -66,7 +66,7 @@ pub async fn upgrade_noise_initiator( Error::Prereq(Prerequisite::RemotePublicKey) })?; - let Some(key) = config.get_noise_key(&responder_addr) else { + let Some(key) = config.get_noise_key(responder_addr) else { warn!("{responder_addr} can't speak Noise yet, falling back to TCP"); return Ok(Connection::Raw(conn)); }; @@ -106,7 +106,7 @@ pub async fn upgrade_noise_responder( }; // if responder doesn't announce noise support, we fallback to tcp - if config.get_noise_support(initiator_addr.ip()).is_none() { + if !config.supports_noise(initiator_addr.ip()) { warn!("{initiator_addr} can't speak Noise yet, falling back to TCP",); return Ok(Connection::Raw(conn)); }; diff --git a/common/nymsphinx/framing/src/processing.rs b/common/nymsphinx/framing/src/processing.rs index afaba392dd..b5e81f6f51 100644 --- a/common/nymsphinx/framing/src/processing.rs +++ b/common/nymsphinx/framing/src/processing.rs @@ -110,6 +110,12 @@ pub enum PacketProcessingError { PacketReplay, } +impl PacketProcessingError { + pub fn is_replay(&self) -> bool { + matches!(self, PacketProcessingError::PacketReplay) + } +} + pub struct PartialyUnwrappedPacketWithKeyRotation { pub packet: PartiallyUnwrappedPacket, pub used_key_rotation: u32, diff --git a/common/nymsphinx/types/src/lib.rs b/common/nymsphinx/types/src/lib.rs index 1f3010b2f1..8f8b67296a 100644 --- a/common/nymsphinx/types/src/lib.rs +++ b/common/nymsphinx/types/src/lib.rs @@ -29,7 +29,7 @@ pub use sphinx_packet::{ packet::builder::DEFAULT_PAYLOAD_SIZE, payload::{ PAYLOAD_OVERHEAD_SIZE, Payload, - key::{PayloadKey, PayloadKeySeed}, + key::{PayloadKey, PayloadKeySeed, derive_payload_key}, }, route::{Destination, DestinationAddressBytes, Node, NodeAddressBytes, SURBIdentifier}, surb::{SURB, SURBMaterial}, diff --git a/common/nyxd-scraper-psql/src/lib.rs b/common/nyxd-scraper-psql/src/lib.rs index 85fcbb46ef..36ed11bee7 100644 --- a/common/nyxd-scraper-psql/src/lib.rs +++ b/common/nyxd-scraper-psql/src/lib.rs @@ -7,8 +7,9 @@ use nyxd_scraper_shared::NyxdScraper; pub use nyxd_scraper_shared::constants; pub use nyxd_scraper_shared::error::ScraperError; pub use nyxd_scraper_shared::{ - BlockModule, MsgModule, NyxdScraperTransaction, ParsedTransactionResponse, PruningOptions, - PruningStrategy, StartingBlockOpts, TxModule, + BlockModule, DecodedMessage, MsgModule, NyxdScraperTransaction, ParsedTransactionDetails, + ParsedTransactionResponse, PruningOptions, PruningStrategy, StartingBlockOpts, TxModule, + parse_msg, }; pub use storage::models; diff --git a/common/nyxd-scraper-psql/src/storage/helpers.rs b/common/nyxd-scraper-psql/src/storage/helpers.rs index 8d56ee72f7..ecbc06a8ec 100644 --- a/common/nyxd-scraper-psql/src/storage/helpers.rs +++ b/common/nyxd-scraper-psql/src/storage/helpers.rs @@ -9,7 +9,7 @@ use std::str::FromStr; // replicate behaviour of `CosmosMessageAddressesParser` from juno pub(crate) fn parse_addresses_from_events(tx: &ParsedTransactionResponse) -> Vec { let mut addresses: Vec = Vec::new(); - for event in &tx.tx_result.events { + for event in &tx.tx_details.tx_result.events { for attribute in &event.attributes { let Ok(value) = attribute.value_str() else { continue; diff --git a/common/nyxd-scraper-psql/src/storage/transaction.rs b/common/nyxd-scraper-psql/src/storage/transaction.rs index 5aad23dfe1..34923d1983 100644 --- a/common/nyxd-scraper-psql/src/storage/transaction.rs +++ b/common/nyxd-scraper-psql/src/storage/transaction.rs @@ -147,6 +147,7 @@ impl PostgresStorageTransaction { for chain_tx in txs { // bdjuno style, base64 encode them let signatures = chain_tx + .tx_details .tx .signatures .iter() @@ -154,12 +155,14 @@ impl PostgresStorageTransaction { .collect(); let messages = chain_tx - .parsed_messages + .decoded_messages .values() + .map(|msg| &msg.decoded_content) .cloned() .collect::>(); let signer_infos = chain_tx + .tx_details .tx .auth_info .signer_infos @@ -167,28 +170,28 @@ impl PostgresStorageTransaction { .map(|info| proto::cosmos::tx::v1beta1::SignerInfo::from(info.clone())) .collect::>(); - let hash = chain_tx.hash.to_string(); - let height = chain_tx.height.into(); - let index = chain_tx.index as i32; + let hash = chain_tx.tx_details.hash.to_string(); + let height = chain_tx.tx_details.height().into(); + let index = chain_tx.tx_details.index as i32; - let log = serde_json::to_value(chain_tx.tx_result.log.clone()) + let log = serde_json::to_value(chain_tx.tx_details.tx_result.log.clone()) .inspect_err(|e| error!(hash, height, index, "Failed to parse logs: {e}")) .unwrap_or_default(); - let events = &chain_tx.tx_result.events; + let events = &chain_tx.tx_details.tx_result.events; insert_transaction( hash, height, index, - chain_tx.tx_result.code.is_ok(), + chain_tx.tx_details.tx_result.code.is_ok(), serde_json::Value::Array(messages), - chain_tx.tx.body.memo.clone(), + chain_tx.tx_details.tx.body.memo.clone(), signatures, serde_json::to_value(signer_infos)?, - serde_json::to_value(&chain_tx.tx.auth_info.fee)?, - chain_tx.tx_result.gas_wanted, - chain_tx.tx_result.gas_used, - chain_tx.tx_result.log.clone(), + serde_json::to_value(&chain_tx.tx_details.tx.auth_info.fee)?, + chain_tx.tx_details.tx_result.gas_wanted, + chain_tx.tx_details.tx_result.gas_used, + chain_tx.tx_details.tx_result.log.clone(), json!(log), json!(events), self.inner.as_mut(), @@ -207,17 +210,20 @@ impl PostgresStorageTransaction { for chain_tx in txs { let involved_addresses = parse_addresses_from_events(chain_tx); - for (index, msg) in chain_tx.tx.body.messages.iter().enumerate() { - let parsed_message = chain_tx.parsed_messages.get(&index); + for (index, msg) in chain_tx.tx_details.tx.body.messages.iter().enumerate() { + let parsed_message = chain_tx + .decoded_messages + .get(&index) + .map(|msg| &msg.decoded_content); let value = serde_json::to_value(parsed_message)?; insert_message( - chain_tx.hash.to_string(), + chain_tx.tx_details.hash.to_string(), index as i64, msg.type_url.clone(), value, involved_addresses.clone(), - chain_tx.height.into(), + chain_tx.tx_details.height().into(), self.inner.as_mut(), ) .await? diff --git a/common/nyxd-scraper-shared/examples/watcher.rs b/common/nyxd-scraper-shared/examples/watcher.rs index 3d72c8bb5a..bbcbe6a3da 100644 --- a/common/nyxd-scraper-shared/examples/watcher.rs +++ b/common/nyxd-scraper-shared/examples/watcher.rs @@ -33,9 +33,9 @@ impl TxModule for FancyTxModule { async fn handle_tx(&mut self, tx: &ParsedTransactionResponse) -> Result<(), ScraperError> { println!( "✨ got new tx for height {}: {} ({} msgs)", - tx.block.header.height, - tx.hash, - tx.parsed_messages.len() + tx.tx_details.height(), + tx.tx_details.hash, + tx.tx_details.tx.body.messages.len() ); Ok(()) diff --git a/common/nyxd-scraper-shared/src/block_processor/mod.rs b/common/nyxd-scraper-shared/src/block_processor/mod.rs index d29129b790..86f1910a9a 100644 --- a/common/nyxd-scraper-shared/src/block_processor/mod.rs +++ b/common/nyxd-scraper-shared/src/block_processor/mod.rs @@ -281,7 +281,7 @@ where &mut self, block: BlockToProcess, ) -> Result<(), ScraperError> { - info!("processing block at height {}", block.height); + debug!("processing block at height {}", block.height); let full_info = self .rpc_client @@ -291,8 +291,13 @@ where if let Some(tx_info) = &full_info.transactions { debug!("this block has {} transaction(s)", tx_info.len()); for tx in tx_info { - debug!("{} has {} message(s)", tx.hash, tx.tx.body.messages.len()); - for (index, msg) in tx.tx.body.messages.iter().enumerate() { + let details = &tx.tx_details; + debug!( + "{} has {} message(s)", + details.hash, + details.tx.body.messages.len() + ); + for (index, msg) in details.tx.body.messages.iter().enumerate() { debug!("{index}: {:?}", msg.type_url) } } @@ -315,11 +320,24 @@ where for tx_module in &mut self.tx_modules { tx_module.handle_tx(block_tx).await?; } + let tx_details = &block_tx.tx_details; + // the ones concerned with individual messages - for (index, msg) in block_tx.tx.body.messages.iter().enumerate() { + for (index, msg) in tx_details.tx.body.messages.iter().enumerate() { + let Some(decoded) = block_tx.decoded_messages.get(&index) else { + warn!( + "height: {} tx: {} tx_index: {}, msg_index: {index}: message failed to get decoded", + tx_details.height(), + tx_details.hash, + tx_details.index, + ); + continue; + }; for msg_module in &mut self.msg_modules { if msg.type_url == msg_module.type_url() { - msg_module.handle_msg(index, msg, block_tx).await? + msg_module + .handle_msg(index, msg, decoded, tx_details) + .await? } } } diff --git a/common/nyxd-scraper-shared/src/block_processor/types.rs b/common/nyxd-scraper-shared/src/block_processor/types.rs index 044d9bcffc..b835dd69be 100644 --- a/common/nyxd-scraper-shared/src/block_processor/types.rs +++ b/common/nyxd-scraper-shared/src/block_processor/types.rs @@ -7,9 +7,16 @@ use tendermint::{Block, Hash, abci, block, tx}; use tendermint_rpc::endpoint::{block as block_endpoint, block_results, validators}; use tendermint_rpc::event::{Event, EventData}; -// just get all everything out of tx::Response, but parse raw `tx` bytes +/// Message decoded from the raw transaction and converted into json. +/// Note that it might have gone through additional processing as set by the `MessageRegistry` #[derive(Clone, Debug)] -pub struct ParsedTransactionResponse { +pub struct DecodedMessage { + pub type_url: String, + pub decoded_content: serde_json::Value, +} + +#[derive(Clone, Debug)] +pub struct ParsedTransactionDetails { /// The hash of the transaction. /// /// Deserialized from a hex-encoded string (there is a discrepancy between @@ -17,8 +24,6 @@ pub struct ParsedTransactionResponse { /// the Tendermint RPC). pub hash: Hash, - pub height: block::Height, - pub index: u32, pub tx_result: abci::types::ExecTxResult, @@ -27,13 +32,23 @@ pub struct ParsedTransactionResponse { pub proof: Option, - pub parsed_messages: BTreeMap, - - pub parsed_message_urls: BTreeMap, - pub block: Block, } +impl ParsedTransactionDetails { + pub fn height(&self) -> block::Height { + self.block.header.height + } +} + +// just get all everything out of tx::Response, but parse raw `tx` bytes +#[derive(Clone, Debug)] +pub struct ParsedTransactionResponse { + pub tx_details: ParsedTransactionDetails, + + pub decoded_messages: BTreeMap, +} + #[derive(Debug)] pub struct FullBlockInformation { /// Basic block information, including its signers. diff --git a/common/nyxd-scraper-shared/src/error.rs b/common/nyxd-scraper-shared/src/error.rs index 41264896ee..d35a449ca7 100644 --- a/common/nyxd-scraper-shared/src/error.rs +++ b/common/nyxd-scraper-shared/src/error.rs @@ -82,10 +82,8 @@ pub enum ScraperError { source: cosmrs::ErrorReport, }, - #[error("could not parse msg in tx {hash} at index {index} into {type_url}: {source}")] + #[error("could not parse msg of type {type_url}: {source}")] MsgParseFailure { - hash: Hash, - index: usize, type_url: String, #[source] source: cosmrs::ErrorReport, diff --git a/common/nyxd-scraper-shared/src/helpers.rs b/common/nyxd-scraper-shared/src/helpers.rs index 54c5a1cbc0..b519dddb3b 100644 --- a/common/nyxd-scraper-shared/src/helpers.rs +++ b/common/nyxd-scraper-shared/src/helpers.rs @@ -47,7 +47,7 @@ pub fn validator_consensus_address(id: account::Id) -> Result i64 { - txs.iter().map(|tx| tx.tx_result.gas_used).sum() + txs.iter().map(|tx| tx.tx_details.tx_result.gas_used).sum() } pub fn validator_info( diff --git a/common/nyxd-scraper-shared/src/lib.rs b/common/nyxd-scraper-shared/src/lib.rs index 43afe4f83d..e6f00b7609 100644 --- a/common/nyxd-scraper-shared/src/lib.rs +++ b/common/nyxd-scraper-shared/src/lib.rs @@ -15,12 +15,14 @@ pub(crate) mod subscriber; pub mod watcher; pub use block_processor::pruning::{PruningOptions, PruningStrategy}; -pub use block_processor::types::ParsedTransactionResponse; +pub use block_processor::types::{ + DecodedMessage, ParsedTransactionDetails, ParsedTransactionResponse, +}; pub use cosmos_module::{ CosmosModule, message_registry::{MessageRegistry, default_message_registry}, }; pub use cosmrs::Any; -pub use modules::{BlockModule, MsgModule, TxModule}; +pub use modules::{BlockModule, MsgModule, TxModule, parse_msg}; pub use scraper::{Config, NyxdScraper, StartingBlockOpts}; pub use storage::{NyxdScraperStorage, NyxdScraperTransaction}; diff --git a/common/nyxd-scraper-shared/src/modules/mod.rs b/common/nyxd-scraper-shared/src/modules/mod.rs index 1f37d149e0..7e131003ee 100644 --- a/common/nyxd-scraper-shared/src/modules/mod.rs +++ b/common/nyxd-scraper-shared/src/modules/mod.rs @@ -6,5 +6,5 @@ mod msg_module; mod tx_module; pub use block_module::BlockModule; -pub use msg_module::MsgModule; +pub use msg_module::{MsgModule, parse_msg}; pub use tx_module::TxModule; diff --git a/common/nyxd-scraper-shared/src/modules/msg_module.rs b/common/nyxd-scraper-shared/src/modules/msg_module.rs index 93b09bf427..0061e612af 100644 --- a/common/nyxd-scraper-shared/src/modules/msg_module.rs +++ b/common/nyxd-scraper-shared/src/modules/msg_module.rs @@ -1,11 +1,47 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::block_processor::types::ParsedTransactionResponse; +use crate::block_processor::types::{DecodedMessage, ParsedTransactionDetails}; use crate::error::ScraperError; use async_trait::async_trait; use cosmrs::Any; +use cosmrs::tx::Msg; +/// Parse a protobuf `Any` message into a strongly typed Cosmos message. +/// +/// # Example +/// +/// ```rust,ignore +/// let execute_msg: MsgExecuteContract = parse_msg(msg)?; +/// ``` +/// +/// # Errors +/// +/// Returns `ScraperError::MsgParseFailure` if: +/// - The type URL doesn't match the expected type +/// - The protobuf bytes are malformed +/// - The message schema is incompatible with this version of the code +pub fn parse_msg(msg: &Any) -> Result { + T::from_any(msg).map_err(|source| ScraperError::MsgParseFailure { + type_url: msg.type_url.clone(), + source, + }) +} + +/// Trait for modules that process specific message types from blockchain transactions. +/// +/// # Parameters +/// +/// - `index`: Position of this message within the transaction (0-based) +/// - `msg`: Raw protobuf message (use `parse_msg()` to decode) +/// - `decoded_msg`: Pre-decoded JSON representation (may be None for unsupported types) +/// - `tx`: Transaction details including block height, hash, and execution result +/// +/// # Error Handling +/// +/// - Return `Err` for critical failures that should stop block processing +/// - Return `Ok(())` for non-critical errors (e.g., unexpected contract schema) +/// - Log warnings for debugging without propagating errors #[async_trait] pub trait MsgModule { fn type_url(&self) -> String; @@ -14,6 +50,7 @@ pub trait MsgModule { &mut self, index: usize, msg: &Any, - tx: &ParsedTransactionResponse, + decoded_msg: &DecodedMessage, + tx: &ParsedTransactionDetails, ) -> Result<(), ScraperError>; } diff --git a/common/nyxd-scraper-shared/src/rpc_client.rs b/common/nyxd-scraper-shared/src/rpc_client.rs index 8d1df1890e..0d41e907e0 100644 --- a/common/nyxd-scraper-shared/src/rpc_client.rs +++ b/common/nyxd-scraper-shared/src/rpc_client.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use crate::block_processor::types::{ - BlockToProcess, FullBlockInformation, ParsedTransactionResponse, + BlockToProcess, DecodedMessage, FullBlockInformation, ParsedTransactionResponse, }; use crate::error::ScraperError; use crate::helpers::tx_hash; -use crate::{Any, MessageRegistry, default_message_registry}; +use crate::{Any, MessageRegistry, ParsedTransactionDetails, default_message_registry}; use futures::StreamExt; use futures::future::join3; use std::collections::BTreeMap; @@ -77,8 +77,7 @@ impl RpcClient { ) -> Result, ScraperError> { let mut transactions = Vec::with_capacity(raw_transactions.len()); for raw_tx in raw_transactions { - let mut parsed_messages = BTreeMap::new(); - let mut parsed_message_urls = BTreeMap::new(); + let mut decoded_messages = BTreeMap::new(); let tx = cosmrs::Tx::from_bytes(&raw_tx.tx).map_err(|source| { ScraperError::TxParseFailure { hash: raw_tx.hash, @@ -87,22 +86,27 @@ impl RpcClient { })?; for (index, msg) in tx.body.messages.iter().enumerate() { - if let Some(value) = self.decode_or_skip(msg) { - parsed_messages.insert(index, value); - parsed_message_urls.insert(index, msg.type_url.clone()); + if let Some(decoded_content) = self.decode_or_skip(msg) { + decoded_messages.insert( + index, + DecodedMessage { + type_url: msg.type_url.clone(), + decoded_content, + }, + ); } } transactions.push(ParsedTransactionResponse { - hash: raw_tx.hash, - height: raw_tx.height, - index: raw_tx.index, - tx_result: raw_tx.tx_result, - tx, - proof: raw_tx.proof, - parsed_messages, - parsed_message_urls, - block: block.clone(), + tx_details: ParsedTransactionDetails { + hash: raw_tx.hash, + index: raw_tx.index, + tx_result: raw_tx.tx_result, + tx, + proof: raw_tx.proof, + block: block.clone(), + }, + decoded_messages, }) } Ok(transactions) diff --git a/common/nyxd-scraper-shared/src/watcher.rs b/common/nyxd-scraper-shared/src/watcher.rs index 3ba3ea5b4e..52633585cf 100644 --- a/common/nyxd-scraper-shared/src/watcher.rs +++ b/common/nyxd-scraper-shared/src/watcher.rs @@ -14,15 +14,16 @@ use tracing::info; use url::Url; pub struct WatcherConfig { - /// Url to the websocket endpoint of a validator, for example `wss://rpc.nymtech.net/websocket` + /// Url to the websocket endpoint of a validator, for example, `wss://rpc.nymtech.net/websocket` pub websocket_url: Url, - /// Url to the rpc endpoint of a validator, for example `https://rpc.nymtech.net/` + /// Url to the rpc endpoint of a validator, for example, `https://rpc.nymtech.net/` pub rpc_url: Url, } pub struct NyxdWatcherBuilder { config: WatcherConfig, + custom_shutdown: CancellationToken, block_modules: Vec>, tx_modules: Vec>, @@ -33,12 +34,19 @@ impl NyxdWatcherBuilder { pub fn new(config: WatcherConfig) -> Self { NyxdWatcherBuilder { config, + custom_shutdown: CancellationToken::new(), block_modules: vec![], tx_modules: vec![], msg_modules: vec![], } } + #[must_use] + pub fn with_custom_shutdown(mut self, token: CancellationToken) -> Self { + self.custom_shutdown = token; + self + } + #[must_use] pub fn with_block_module(mut self, module: M) -> Self { self.block_modules.push(Box::new(module)); diff --git a/common/nyxd-scraper-sqlite/src/lib.rs b/common/nyxd-scraper-sqlite/src/lib.rs index 56c202b63f..18eb916d43 100644 --- a/common/nyxd-scraper-sqlite/src/lib.rs +++ b/common/nyxd-scraper-sqlite/src/lib.rs @@ -7,8 +7,9 @@ use nyxd_scraper_shared::NyxdScraper; pub use nyxd_scraper_shared::constants; pub use nyxd_scraper_shared::error::ScraperError; pub use nyxd_scraper_shared::{ - BlockModule, MsgModule, NyxdScraperTransaction, ParsedTransactionResponse, PruningOptions, - PruningStrategy, StartingBlockOpts, TxModule, + BlockModule, DecodedMessage, MsgModule, NyxdScraperTransaction, ParsedTransactionDetails, + ParsedTransactionResponse, PruningOptions, PruningStrategy, StartingBlockOpts, TxModule, + parse_msg, }; pub use storage::models; diff --git a/common/nyxd-scraper-sqlite/src/storage/transaction.rs b/common/nyxd-scraper-sqlite/src/storage/transaction.rs index 0cd57b78c8..a1a8ef149b 100644 --- a/common/nyxd-scraper-sqlite/src/storage/transaction.rs +++ b/common/nyxd-scraper-sqlite/src/storage/transaction.rs @@ -132,15 +132,15 @@ impl SqliteStorageTransaction { for chain_tx in txs { insert_transaction( - chain_tx.hash.to_string(), - chain_tx.height.into(), - chain_tx.index as i64, - chain_tx.tx_result.code.is_ok(), - chain_tx.tx.body.messages.len() as i64, - chain_tx.tx.body.memo.clone(), - chain_tx.tx_result.gas_wanted, - chain_tx.tx_result.gas_used, - chain_tx.tx_result.log.clone(), + chain_tx.tx_details.hash.to_string(), + chain_tx.tx_details.height().into(), + chain_tx.tx_details.index as i64, + chain_tx.tx_details.tx_result.code.is_ok(), + chain_tx.tx_details.tx.body.messages.len() as i64, + chain_tx.tx_details.tx.body.memo.clone(), + chain_tx.tx_details.tx_result.gas_wanted, + chain_tx.tx_details.tx_result.gas_used, + chain_tx.tx_details.tx_result.log.clone(), self.0.as_mut(), ) .await?; @@ -156,12 +156,12 @@ impl SqliteStorageTransaction { debug!("persisting messages"); for chain_tx in txs { - for (index, msg) in chain_tx.tx.body.messages.iter().enumerate() { + for (index, msg) in chain_tx.tx_details.tx.body.messages.iter().enumerate() { insert_message( - chain_tx.hash.to_string(), + chain_tx.tx_details.hash.to_string(), index as i64, msg.type_url.clone(), - chain_tx.height.into(), + chain_tx.tx_details.height().into(), self.0.as_mut(), ) .await? diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 1fb526afa4..10663f6bd1 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1180,6 +1180,22 @@ dependencies = [ "rand_chacha", ] +[[package]] +name = "network-monitors" +version = "1.0.0" +dependencies = [ + "anyhow", + "bs58", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw2", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-network-monitors-contract-common", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1456,6 +1472,18 @@ dependencies = [ "regex", ] +[[package]] +name = "nym-network-monitors-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-pemstore" version = "1.20.4" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 0b23099771..342e9029bf 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -10,6 +10,7 @@ members = [ "multisig/cw4-group", "vesting", "performance", + "network-monitors", ] [workspace.package] @@ -19,6 +20,8 @@ homepage = "https://nymtech.net" documentation = "https://nymtech.net" edition = "2021" license = "Apache-2.0" +rust-version = "1.86.0" +readme = "../README.md" [profile.release] opt-level = 3 @@ -67,6 +70,7 @@ nym-ecash-contract-common = "1.20.4" nym-group-contract-common = "1.20.4" nym-mixnet-contract-common = "1.20.4" nym-multisig-contract-common = "1.20.4" +nym-network-monitors-contract-common = "1.20.4" nym-network-defaults = { version = "1.20.4", default-features = false } nym-performance-contract-common = "1.20.4" nym-pool-contract-common = "1.20.4" @@ -94,8 +98,6 @@ unimplemented = "deny" unreachable = "deny" # For local development, import via path instead of crates.io, e.g. -# [patch.crates-io] -# nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" } - [patch.crates-io] +nym-network-monitors-contract-common = { path = "../common/cosmwasm-smart-contracts/network-monitors-contract" } nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } diff --git a/contracts/network-monitors/.cargo/config b/contracts/network-monitors/.cargo/config new file mode 100644 index 0000000000..2fb2c1afdb --- /dev/null +++ b/contracts/network-monitors/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema --features=schema-gen" \ No newline at end of file diff --git a/contracts/network-monitors/Cargo.toml b/contracts/network-monitors/Cargo.toml new file mode 100644 index 0000000000..5a29cf154a --- /dev/null +++ b/contracts/network-monitors/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "network-monitors" +description = "CosmWasm smart contract storing information on Nym network monitors" +version = "1.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = false + +[[bin]] +name = "schema" +required-features = ["schema-gen"] + +[lib] +name = "network_monitors_contract" +crate-type = ["cdylib", "rlib"] + +[dependencies] +bs58 = { workspace = true } +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } + +cosmwasm-schema = { workspace = true, optional = true } + +nym-network-monitors-contract-common = { workspace = true } +nym-contracts-common = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +nym-contracts-common-testing = { workspace = true } + +[features] +schema-gen = ["nym-network-monitors-contract-common/schema", "cosmwasm-schema"] + +[lints] +workspace = true diff --git a/contracts/network-monitors/Makefile b/contracts/network-monitors/Makefile new file mode 100644 index 0000000000..086fa71ad3 --- /dev/null +++ b/contracts/network-monitors/Makefile @@ -0,0 +1,5 @@ +wasm: + RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown + +generate-schema: + cargo schema diff --git a/contracts/network-monitors/schema/network-monitors.json b/contracts/network-monitors/schema/network-monitors.json new file mode 100644 index 0000000000..1d4daa680d --- /dev/null +++ b/contracts/network-monitors/schema/network-monitors.json @@ -0,0 +1,368 @@ +{ + "contract_name": "network-monitors", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "orchestrator_address" + ], + "properties": { + "orchestrator_address": { + "description": "Address of the initial network monitor orchestrator.", + "type": "string" + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Change the admin", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Authorise new network monitor orchestrator", + "type": "object", + "required": [ + "authorise_network_monitor_orchestrator" + ], + "properties": { + "authorise_network_monitor_orchestrator": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke network monitor orchestrator authorisation.", + "type": "object", + "required": [ + "revoke_network_monitor_orchestrator" + ], + "properties": { + "revoke_network_monitor_orchestrator": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Authorise new network monitor (or renew authorisation) granting additional privileges when sending mixnet packets to Nym nodes.", + "type": "object", + "required": [ + "authorise_network_monitor" + ], + "properties": { + "authorise_network_monitor": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke network monitor authorisation.", + "type": "object", + "required": [ + "revoke_network_monitor" + ], + "properties": { + "revoke_network_monitor": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke all network monitor authorisations.", + "type": "string", + "enum": [ + "revoke_all_network_monitors" + ] + } + ] + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "network_monitor_orchestrators" + ], + "properties": { + "network_monitor_orchestrators": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "network_monitor_agents" + ], + "properties": { + "network_monitor_agents": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ], + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "admin": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminResponse", + "description": "Returned from Admin.query_admin()", + "type": "object", + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "network_monitor_agents": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AuthorisedNetworkMonitorsPagedResponse", + "type": "object", + "required": [ + "authorised" + ], + "properties": { + "authorised": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthorisedNetworkMonitor" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ], + "format": "ip" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AuthorisedNetworkMonitor": { + "type": "object", + "required": [ + "address", + "authorised_at", + "authorised_by" + ], + "properties": { + "address": { + "description": "The Ip address associated with the network monitor agent.", + "type": "string", + "format": "ip" + }, + "authorised_at": { + "description": "Timestamp of when the network monitor was authorised.", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + }, + "authorised_by": { + "description": "The address of the orchestrator that authorised the network monitor agent.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "network_monitor_orchestrators": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AuthorisedNetworkMonitorOrchestratorsResponse", + "type": "object", + "required": [ + "authorised" + ], + "properties": { + "authorised": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthorisedNetworkMonitorOrchestrator" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AuthorisedNetworkMonitorOrchestrator": { + "type": "object", + "required": [ + "address", + "authorised_at" + ], + "properties": { + "address": { + "description": "The address associated with the network monitor orchestrator.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "authorised_at": { + "description": "Timestamp of when the network monitor was authorised.", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/network-monitors/schema/raw/execute.json b/contracts/network-monitors/schema/raw/execute.json new file mode 100644 index 0000000000..f1b73c039f --- /dev/null +++ b/contracts/network-monitors/schema/raw/execute.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Change the admin", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Authorise new network monitor orchestrator", + "type": "object", + "required": [ + "authorise_network_monitor_orchestrator" + ], + "properties": { + "authorise_network_monitor_orchestrator": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke network monitor orchestrator authorisation.", + "type": "object", + "required": [ + "revoke_network_monitor_orchestrator" + ], + "properties": { + "revoke_network_monitor_orchestrator": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Authorise new network monitor (or renew authorisation) granting additional privileges when sending mixnet packets to Nym nodes.", + "type": "object", + "required": [ + "authorise_network_monitor" + ], + "properties": { + "authorise_network_monitor": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke network monitor authorisation.", + "type": "object", + "required": [ + "revoke_network_monitor" + ], + "properties": { + "revoke_network_monitor": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke all network monitor authorisations.", + "type": "string", + "enum": [ + "revoke_all_network_monitors" + ] + } + ] +} diff --git a/contracts/network-monitors/schema/raw/instantiate.json b/contracts/network-monitors/schema/raw/instantiate.json new file mode 100644 index 0000000000..76e97a0950 --- /dev/null +++ b/contracts/network-monitors/schema/raw/instantiate.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "orchestrator_address" + ], + "properties": { + "orchestrator_address": { + "description": "Address of the initial network monitor orchestrator.", + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/contracts/network-monitors/schema/raw/migrate.json b/contracts/network-monitors/schema/raw/migrate.json new file mode 100644 index 0000000000..7fbe8c5708 --- /dev/null +++ b/contracts/network-monitors/schema/raw/migrate.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/network-monitors/schema/raw/query.json b/contracts/network-monitors/schema/raw/query.json new file mode 100644 index 0000000000..edffb9922f --- /dev/null +++ b/contracts/network-monitors/schema/raw/query.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "network_monitor_orchestrators" + ], + "properties": { + "network_monitor_orchestrators": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "network_monitor_agents" + ], + "properties": { + "network_monitor_agents": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ], + "format": "ip" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/network-monitors/schema/raw/response_to_admin.json b/contracts/network-monitors/schema/raw/response_to_admin.json new file mode 100644 index 0000000000..c73969ab04 --- /dev/null +++ b/contracts/network-monitors/schema/raw/response_to_admin.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminResponse", + "description": "Returned from Admin.query_admin()", + "type": "object", + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} diff --git a/contracts/network-monitors/schema/raw/response_to_network_monitor_agents.json b/contracts/network-monitors/schema/raw/response_to_network_monitor_agents.json new file mode 100644 index 0000000000..3ee1b74278 --- /dev/null +++ b/contracts/network-monitors/schema/raw/response_to_network_monitor_agents.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AuthorisedNetworkMonitorsPagedResponse", + "type": "object", + "required": [ + "authorised" + ], + "properties": { + "authorised": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthorisedNetworkMonitor" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ], + "format": "ip" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AuthorisedNetworkMonitor": { + "type": "object", + "required": [ + "address", + "authorised_at", + "authorised_by" + ], + "properties": { + "address": { + "description": "The Ip address associated with the network monitor agent.", + "type": "string", + "format": "ip" + }, + "authorised_at": { + "description": "Timestamp of when the network monitor was authorised.", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + }, + "authorised_by": { + "description": "The address of the orchestrator that authorised the network monitor agent.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/network-monitors/schema/raw/response_to_network_monitor_orchestrators.json b/contracts/network-monitors/schema/raw/response_to_network_monitor_orchestrators.json new file mode 100644 index 0000000000..f08a35e9df --- /dev/null +++ b/contracts/network-monitors/schema/raw/response_to_network_monitor_orchestrators.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AuthorisedNetworkMonitorOrchestratorsResponse", + "type": "object", + "required": [ + "authorised" + ], + "properties": { + "authorised": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthorisedNetworkMonitorOrchestrator" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AuthorisedNetworkMonitorOrchestrator": { + "type": "object", + "required": [ + "address", + "authorised_at" + ], + "properties": { + "address": { + "description": "The address associated with the network monitor orchestrator.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "authorised_at": { + "description": "Timestamp of when the network monitor was authorised.", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/network-monitors/src/bin/schema.rs b/contracts/network-monitors/src/bin/schema.rs new file mode 100644 index 0000000000..734d1c3b2a --- /dev/null +++ b/contracts/network-monitors/src/bin/schema.rs @@ -0,0 +1,14 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::write_api; +use nym_network_monitors_contract_common::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/network-monitors/src/contract.rs b/contracts/network-monitors/src/contract.rs new file mode 100644 index 0000000000..1dbd50b16a --- /dev/null +++ b/contracts/network-monitors/src/contract.rs @@ -0,0 +1,179 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::queries::{ + query_admin, query_network_monitor_agents, query_network_monitor_orchestrators, +}; +use crate::storage::NETWORK_MONITORS_CONTRACT_STORAGE; +use crate::transactions::{ + try_authorise_network_monitor, try_authorise_network_monitor_orchestrator, + try_revoke_all_network_monitors, try_revoke_network_monitor, + try_revoke_network_monitor_orchestrator, try_update_contract_admin, + try_update_orchestrator_identity_key, +}; +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use nym_contracts_common::set_build_information; +use nym_network_monitors_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NetworkMonitorsContractError, QueryMsg, +}; + +const CONTRACT_NAME: &str = "crate:nym-network-monitors-contract"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + set_build_information!(deps.storage)?; + + let orchestrator = deps.api.addr_validate(&msg.orchestrator_address)?; + NETWORK_MONITORS_CONTRACT_STORAGE.initialise(deps, env, info.sender, orchestrator)?; + + Ok(Response::default()) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateAdmin { admin } => try_update_contract_admin(deps, info, admin), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { address } => { + try_authorise_network_monitor_orchestrator(deps, env, info, address) + } + ExecuteMsg::UpdateOrchestratorIdentityKey { key } => { + try_update_orchestrator_identity_key(deps, info, key) + } + ExecuteMsg::RevokeNetworkMonitorOrchestrator { address } => { + try_revoke_network_monitor_orchestrator(deps, info, address) + } + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: address, + bs58_x25519_noise, + noise_version, + } => try_authorise_network_monitor( + deps, + env, + info, + address, + bs58_x25519_noise, + noise_version, + ), + ExecuteMsg::RevokeNetworkMonitor { address } => { + try_revoke_network_monitor(deps, info, address) + } + ExecuteMsg::RevokeAllNetworkMonitors => try_revoke_all_network_monitors(deps, info), + } +} + +#[entry_point] +pub fn query(deps: Deps, _: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Admin {} => Ok(to_json_binary(&query_admin(deps)?)?), + QueryMsg::NetworkMonitorOrchestrators {} => { + Ok(to_json_binary(&query_network_monitor_orchestrators(deps)?)?) + } + QueryMsg::NetworkMonitorAgents { + start_next_after, + limit, + } => Ok(to_json_binary(&query_network_monitor_agents( + deps, + start_next_after, + limit, + )?)?), + } +} + +#[entry_point] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> Result { + set_build_information!(deps.storage)?; + cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod contract_instantiation { + use super::*; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + use cosmwasm_std::Addr; + + #[test] + fn sets_contract_admin_to_the_message_sender() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let init_msg = InstantiateMsg { + orchestrator_address: deps.api.addr_make("foo").to_string(), + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + )?; + + NETWORK_MONITORS_CONTRACT_STORAGE + .contract_admin + .assert_admin(deps.as_ref(), &some_sender)?; + + Ok(()) + } + + #[test] + fn sets_the_initial_orchestrator() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("some_sender"); + + let bad_addr = "foo".to_string(); + let good_addr = deps.api.addr_make("foo").to_string(); + + let bad_init_msg = InstantiateMsg { + orchestrator_address: bad_addr.clone(), + }; + + let good_init_msg = InstantiateMsg { + orchestrator_address: good_addr.clone(), + }; + + let res = instantiate( + deps.as_mut(), + env.clone(), + message_info(&admin, &[]), + bad_init_msg, + ); + assert!(res.is_err()); + + let is_orchestrator = NETWORK_MONITORS_CONTRACT_STORAGE + .is_orchestrator(deps.as_ref(), &Addr::unchecked(&good_addr))?; + assert!(!is_orchestrator); + + instantiate(deps.as_mut(), env, message_info(&admin, &[]), good_init_msg)?; + + let is_orchestrator = NETWORK_MONITORS_CONTRACT_STORAGE + .is_orchestrator(deps.as_ref(), &Addr::unchecked(&good_addr))?; + assert!(is_orchestrator); + + Ok(()) + } + } +} diff --git a/contracts/network-monitors/src/lib.rs b/contracts/network-monitors/src/lib.rs new file mode 100644 index 0000000000..8aff9760d1 --- /dev/null +++ b/contracts/network-monitors/src/lib.rs @@ -0,0 +1,11 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod contract; +pub mod queries; +pub mod queued_migrations; +pub mod storage; +pub mod transactions; + +#[cfg(test)] +pub mod testing; diff --git a/contracts/network-monitors/src/queries.rs b/contracts/network-monitors/src/queries.rs new file mode 100644 index 0000000000..27c8d98762 --- /dev/null +++ b/contracts/network-monitors/src/queries.rs @@ -0,0 +1,345 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::{retrieval_limits, AgentStorageKey, NETWORK_MONITORS_CONTRACT_STORAGE}; +use cosmwasm_std::{Deps, StdResult}; +use cw_controllers::AdminResponse; +use cw_storage_plus::Bound; +use nym_network_monitors_contract_common::{ + AuthorisedNetworkMonitorOrchestratorsResponse, AuthorisedNetworkMonitorsPagedResponse, + NetworkMonitorsContractError, +}; +use std::net::SocketAddr; + +pub fn query_admin(deps: Deps) -> Result { + NETWORK_MONITORS_CONTRACT_STORAGE + .contract_admin + .query_admin(deps) + .map_err(Into::into) +} + +// no need for pagination as we don't expect even a double digit of those +pub fn query_network_monitor_orchestrators( + deps: Deps, +) -> Result { + let authorised = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .map(|record| record.map(|(_, details)| details)) + .collect::>>()?; + + Ok(AuthorisedNetworkMonitorOrchestratorsResponse { authorised }) +} + +pub fn query_network_monitor_agents( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::AGENTS_DEFAULT_LIMIT) + .min(retrieval_limits::AGENTS_MAX_LIMIT) as usize; + + let start = start_after.map(|addr| Bound::exclusive(AgentStorageKey::from(addr))); + + let authorised = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .range(deps.storage, start, None, cosmwasm_std::Order::Ascending) + .take(limit) + .map(|record| record.map(|(_, details)| details)) + .collect::>>()?; + + let start_next_after = authorised.last().map(|last| last.mixnet_address); + + Ok(AuthorisedNetworkMonitorsPagedResponse { + authorised, + start_next_after, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod admin_query { + use crate::queries::query_admin; + use crate::testing::init_contract_tester; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::ExecuteMsg; + + #[test] + fn returns_current_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let initial_admin = test.admin_unchecked(); + + // initial + let res = query_admin(test.deps())?; + assert_eq!(res.admin, Some(initial_admin.to_string())); + + let new_admin = test.generate_account(); + + // sanity check + assert_ne!(initial_admin, new_admin); + + // after update + test.execute_msg( + initial_admin.clone(), + &ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + )?; + + let updated_admin = query_admin(test.deps())?; + assert_eq!(updated_admin.admin, Some(new_admin.to_string())); + + Ok(()) + } + } + + #[cfg(test)] + mod network_monitor_orchestrators_query { + use super::*; + use crate::testing::{init_contract_tester, NetworkMonitorsContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ContractOpts}; + use nym_network_monitors_contract_common::ExecuteMsg; + + #[test] + fn returns_empty_list_when_there_are_no_extra_orchestrators() -> anyhow::Result<()> { + // make sure to start with an empty state + let mut test = init_contract_tester(); + test.remove_all_orchestrators(); + + let res = query_network_monitor_orchestrators(test.deps())?; + + assert!(res.authorised.is_empty()); + + Ok(()) + } + + #[test] + fn returns_all_authorised_orchestrators() -> anyhow::Result<()> { + // make sure to start with an empty state + let mut test = init_contract_tester(); + test.remove_all_orchestrators(); + + let orchestrator1 = test.add_orchestrator()?; + let orchestrator2 = test.add_orchestrator()?; + let orchestrator3 = test.add_orchestrator()?; + + let res = query_network_monitor_orchestrators(test.deps())?; + + assert_eq!(res.authorised.len(), 3); + assert!(res.authorised.iter().any(|o| o.address == orchestrator1)); + assert!(res.authorised.iter().any(|o| o.address == orchestrator2)); + assert!(res.authorised.iter().any(|o| o.address == orchestrator3)); + + Ok(()) + } + + #[test] + fn does_not_return_revoked_orchestrators() -> anyhow::Result<()> { + // make sure to start with an empty state + let mut test = init_contract_tester(); + test.remove_all_orchestrators(); + + let orchestrator1 = test.add_orchestrator()?; + let orchestrator2 = test.add_orchestrator()?; + + test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator1.to_string(), + }, + )?; + + let res = query_network_monitor_orchestrators(test.deps())?; + + assert!(!res.authorised.iter().any(|o| o.address == orchestrator1)); + assert!(res.authorised.iter().any(|o| o.address == orchestrator2)); + + Ok(()) + } + + #[test] + fn returns_entries_in_ascending_order() -> anyhow::Result<()> { + // make sure to start with an empty state + let mut test = init_contract_tester(); + test.remove_all_orchestrators(); + + test.add_orchestrator()?; + test.add_orchestrator()?; + test.add_orchestrator()?; + + let res = query_network_monitor_orchestrators(test.deps())?; + + assert!(res + .authorised + .windows(2) + .all(|window| window[0].address <= window[1].address)); + + Ok(()) + } + } + + #[cfg(test)] + mod network_monitor_agents_query { + use super::*; + use crate::testing::{ + init_contract_tester, storage_socket_comp, NetworkMonitorsContract, + NetworkMonitorsContractTesterExt, + }; + use nym_contracts_common_testing::{ContractOpts, ContractTester}; + use std::net::SocketAddr; + + fn storage_sorted_addresses( + test: &mut ContractTester, + n: usize, + ) -> Vec { + let mut ips = Vec::new(); + for _ in 0..n { + ips.push(test.random_socket()); + } + + ips.sort_by(|a, b| storage_socket_comp(*a, *b)); + ips + } + + #[test] + fn returns_empty_response_when_no_agents_are_authorised() -> anyhow::Result<()> { + let test = init_contract_tester(); + + let res = query_network_monitor_agents(test.deps(), None, None)?; + + assert!(res.authorised.is_empty()); + assert_eq!(res.start_next_after, None); + + Ok(()) + } + + #[test] + fn returns_all_authorised_agents_below_default_limit() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let agents = storage_sorted_addresses(&mut test, 5); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents(test.deps(), None, None)?; + + assert_eq!(res.authorised.len(), agents.len()); + assert_eq!(res.start_next_after, agents.last().copied()); + + for agent in &agents { + assert!(res.authorised.iter().any(|a| a.mixnet_address == *agent)); + } + + Ok(()) + } + + #[test] + fn respects_explicit_limit() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let agents = storage_sorted_addresses(&mut test, 5); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents(test.deps(), None, Some(2))?; + + assert_eq!(res.authorised.len(), 2); + assert_eq!(res.authorised[0].mixnet_address, agents[0]); + assert_eq!(res.authorised[1].mixnet_address, agents[1]); + assert_eq!(res.start_next_after, Some(agents[1])); + + Ok(()) + } + + #[test] + fn respects_start_after_for_pagination() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let agents = storage_sorted_addresses(&mut test, 5); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents(test.deps(), Some(agents[1]), Some(2))?; + + assert_eq!(res.authorised.len(), 2); + assert_eq!(res.authorised[0].mixnet_address, agents[2]); + assert_eq!(res.authorised[1].mixnet_address, agents[3]); + assert_eq!(res.start_next_after, Some(agents[3])); + + Ok(()) + } + + #[test] + fn caps_limit_at_maximum() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let total = retrieval_limits::AGENTS_MAX_LIMIT as usize + 20; + let agents = storage_sorted_addresses(&mut test, total); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents( + test.deps(), + None, + Some(retrieval_limits::AGENTS_MAX_LIMIT + 1), + )?; + + assert_eq!( + res.authorised.len(), + retrieval_limits::AGENTS_MAX_LIMIT as usize + ); + assert_eq!( + res.start_next_after, + Some(agents[retrieval_limits::AGENTS_MAX_LIMIT as usize - 1]) + ); + + Ok(()) + } + + #[test] + fn start_next_after_is_none_for_empty_page() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let agents = storage_sorted_addresses(&mut test, 3); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents(test.deps(), Some(agents[2]), Some(10))?; + + assert!(res.authorised.is_empty()); + assert_eq!(res.start_next_after, None); + + Ok(()) + } + + #[test] + fn returns_entries_in_ascending_order() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let agents = storage_sorted_addresses(&mut test, 6); + + for agent in &agents { + test.add_dummy_agent(*agent) + } + + let res = query_network_monitor_agents(test.deps(), None, None)?; + + assert!(res.authorised.windows(2).all(|window| storage_socket_comp( + window[0].mixnet_address, + window[1].mixnet_address + ) + .is_le())); + + Ok(()) + } + } +} diff --git a/contracts/network-monitors/src/queued_migrations.rs b/contracts/network-monitors/src/queued_migrations.rs new file mode 100644 index 0000000000..9285ae3ffc --- /dev/null +++ b/contracts/network-monitors/src/queued_migrations.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 diff --git a/contracts/network-monitors/src/storage.rs b/contracts/network-monitors/src/storage.rs new file mode 100644 index 0000000000..a5a400839b --- /dev/null +++ b/contracts/network-monitors/src/storage.rs @@ -0,0 +1,1626 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdError, StdResult}; +use cw_controllers::Admin; +use cw_storage_plus::{Key, KeyDeserialize, Map, PrimaryKey}; +use nym_network_monitors_contract_common::constants::storage_keys; +use nym_network_monitors_contract_common::{ + AuthorisedNetworkMonitor, AuthorisedNetworkMonitorOrchestrator, NetworkMonitorsContractError, + OrchestratorAddress, +}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +pub const NETWORK_MONITORS_CONTRACT_STORAGE: NetworkMonitorsStorage = NetworkMonitorsStorage::new(); + +/// The storage has an authorisation hierarchy: +/// - At the top there's the contract admin (controlled by Nymtech SA multisig, later by governance) +/// which has ultimate control over the contract and is permitted to authorise new network monitors orchestrators +/// - This is followed by network monitor orchestrators which are permitted to make changes to the set of allowed agents +/// - Finally, at the bottom, authorised network monitor agents which are permitted to send test mixnet packets to Nym nodes +pub struct NetworkMonitorsStorage { + pub(crate) contract_admin: Admin, + pub(crate) authorised_orchestrators: + Map<&'static OrchestratorAddress, AuthorisedNetworkMonitorOrchestrator>, + pub(crate) authorised_agents: Map, +} + +/// CosmWasm storage key encoding a [`SocketAddr`] as a composite primary key. +/// +/// ## On-disk layout +/// +/// The key is encoded as two elements (see [`PrimaryKey::key`]): +/// 1. IP octets — `Val32` (4 bytes) for IPv4, `Val128` (16 bytes) for IPv6 +/// 2. Port — `Val16` (2 bytes, big-endian) +/// +/// cw-storage-plus prepends each element with a 2-byte big-endian length prefix, +/// so the full byte sequence is `[0, ip_len][ip_octets...][0, 2][port_be]`. +/// This means IPv4 keys naturally sort before IPv6, and within the same IP +/// family keys sort by IP octets then by port. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AgentStorageKey(SocketAddr); + +impl PrimaryKey<'_> for AgentStorageKey { + type Prefix = (); + type SubPrefix = (); + type Suffix = Self; + type SuperSuffix = Self; + + fn key(&'_ self) -> Vec> { + let port = self.0.port().to_be_bytes(); + match self.0.ip() { + IpAddr::V4(ipv4) => vec![Key::Val32(ipv4.octets()), Key::Val16(port)], + IpAddr::V6(ipv6) => vec![Key::Val128(ipv6.octets()), Key::Val16(port)], + } + } +} + +impl KeyDeserialize for AgentStorageKey { + type Output = AgentStorageKey; + const KEY_ELEMS: u16 = 2; + + fn from_vec(value: Vec) -> StdResult { + // format: 2-byte length prefix + IP bytes + 2-byte port + if value.len() < 4 { + return Err(StdError::generic_err("invalid socket address length")); + } + + // SAFETY: we're using the correct number of bytes for the conversion + #[allow(clippy::unwrap_used)] + let ip_len = u16::from_be_bytes([value[0], value[1]]) as usize; + let ip_bytes = &value[2..2 + ip_len]; + let port_bytes = &value[2 + ip_len..]; + + #[allow(clippy::unwrap_used)] + let ip = match ip_len { + 4 => IpAddr::V4(Ipv4Addr::from( + TryInto::<[u8; 4]>::try_into(ip_bytes).unwrap(), + )), + 16 => IpAddr::V6(Ipv6Addr::from( + TryInto::<[u8; 16]>::try_into(ip_bytes).unwrap(), + )), + _ => return Err(StdError::generic_err("invalid IP address length")), + }; + + let port = u16::from_be_bytes( + TryInto::<[u8; 2]>::try_into(port_bytes) + .map_err(|_| StdError::generic_err("invalid port length"))?, + ); + + Ok(AgentStorageKey(SocketAddr::new(ip, port))) + } +} + +impl From for AgentStorageKey { + fn from(addr: SocketAddr) -> Self { + Self(addr) + } +} + +impl NetworkMonitorsStorage { + #[allow(clippy::new_without_default)] + pub const fn new() -> Self { + NetworkMonitorsStorage { + contract_admin: Admin::new(storage_keys::CONTRACT_ADMIN), + authorised_orchestrators: Map::new(storage_keys::AUTHORISED_ORCHESTRATORS), + authorised_agents: Map::new(storage_keys::AUTHORISED_NETWORK_MONITORS), + } + } + + pub fn initialise( + &self, + mut deps: DepsMut, + env: Env, + admin: Addr, + orchestrator: Addr, + ) -> Result<(), NetworkMonitorsContractError> { + // set the contract admin + self.contract_admin + .set(deps.branch(), Some(admin.clone()))?; + + // set the initial orchestrator authorisation + self.authorised_orchestrators.save( + deps.storage, + &orchestrator, + &AuthorisedNetworkMonitorOrchestrator { + address: orchestrator.clone(), + identity_key: None, + authorised_at: env.block.time, + }, + )?; + + Ok(()) + } + + fn is_admin(&self, deps: Deps, addr: &Addr) -> Result { + self.contract_admin.is_admin(deps, addr).map_err(Into::into) + } + + fn ensure_is_admin(&self, deps: Deps, addr: &Addr) -> Result<(), NetworkMonitorsContractError> { + self.contract_admin + .assert_admin(deps, addr) + .map_err(Into::into) + } + + pub(crate) fn is_orchestrator( + &self, + deps: Deps, + addr: &Addr, + ) -> Result { + Ok(self + .authorised_orchestrators + .may_load(deps.storage, addr)? + .is_some()) + } + + fn ensure_is_orchestrator( + &self, + deps: Deps, + addr: &Addr, + ) -> Result<(), NetworkMonitorsContractError> { + if !self.is_orchestrator(deps, addr)? { + return Err(NetworkMonitorsContractError::NotAnOrchestrator { addr: addr.clone() }); + } + Ok(()) + } + + pub fn authorise_orchestrator( + &self, + deps: DepsMut, + env: &Env, + sender: &Addr, + orchestrator_address: OrchestratorAddress, + ) -> Result<(), NetworkMonitorsContractError> { + // only contract admin can authorise new orchestrators + self.ensure_is_admin(deps.as_ref(), sender)?; + + // if orchestrator is already authorised, it's a no-op + if self.is_orchestrator(deps.as_ref(), &orchestrator_address)? { + return Ok(()); + } + + self.authorised_orchestrators.save( + deps.storage, + &orchestrator_address, + &AuthorisedNetworkMonitorOrchestrator { + address: orchestrator_address.clone(), + identity_key: None, + authorised_at: env.block.time, + }, + )?; + Ok(()) + } + + /// Overwrite the announced identity key for the orchestrator at `sender`. + /// + /// The orchestrator must already be authorised - the existence of the storage entry is used as + /// the authorisation check itself, avoiding a second load. `identity_key` is stored verbatim + /// (callers are expected to have validated its shape beforehand). + pub fn update_orchestrator_identity_key( + &self, + deps: DepsMut, + sender: &Addr, + identity_key: String, + ) -> Result<(), NetworkMonitorsContractError> { + // ensure the sender is actually a valid orchestrator + // by checking if there is any data stored behind its address + let Some(mut orchestrator_info) = self + .authorised_orchestrators + .may_load(deps.storage, sender)? + else { + return Err(NetworkMonitorsContractError::NotAnOrchestrator { + addr: sender.clone(), + }); + }; + + orchestrator_info.identity_key = Some(identity_key); + self.authorised_orchestrators + .save(deps.storage, sender, &orchestrator_info)?; + + Ok(()) + } + + pub fn remove_orchestrator_authorisation( + &self, + deps: DepsMut, + sender: &Addr, + orchestrator_address: OrchestratorAddress, + ) -> Result<(), NetworkMonitorsContractError> { + self.ensure_is_admin(deps.as_ref(), sender)?; + + self.authorised_orchestrators + .remove(deps.storage, &orchestrator_address); + + // cascade-remove agents authorised by the removed orchestrator + // TODO: optimise it in the future in case there are more agents than could be handled in a single block + let agents_to_remove = self + .authorised_agents + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .filter(|r| { + r.as_ref() + .map(|(_, v)| v.authorised_by == orchestrator_address) + .unwrap_or(true) + }) + .map(|r| r.map(|(k, _)| k)) + .collect::>>()?; + for agent in agents_to_remove { + self.authorised_agents.remove(deps.storage, agent); + } + + Ok(()) + } + + pub fn authorise_monitor( + &self, + deps: DepsMut, + env: &Env, + sender: &Addr, + monitor_address: SocketAddr, + bs58_x25519_noise: String, + noise_version: u8, + ) -> Result<(), NetworkMonitorsContractError> { + // only orchestrators can authorise new monitors + self.ensure_is_orchestrator(deps.as_ref(), sender)?; + + self.authorised_agents.save( + deps.storage, + monitor_address.into(), + &AuthorisedNetworkMonitor { + mixnet_address: monitor_address, + authorised_by: sender.clone(), + authorised_at: env.block.time, + bs58_x25519_noise, + noise_version, + }, + )?; + Ok(()) + } + + pub fn remove_monitor_authorisation( + &self, + deps: DepsMut, + sender: &Addr, + monitor_address: SocketAddr, + ) -> Result<(), NetworkMonitorsContractError> { + // the contract admin or an authorised orchestrator may revoke a monitor + if !self.is_admin(deps.as_ref(), sender)? && !self.is_orchestrator(deps.as_ref(), sender)? { + return Err(NetworkMonitorsContractError::Unauthorized); + } + + self.authorised_agents + .remove(deps.storage, monitor_address.into()); + Ok(()) + } + + pub fn remove_all_monitors( + &self, + deps: DepsMut, + sender: &Addr, + ) -> Result<(), NetworkMonitorsContractError> { + // only the contract admin or an authorised orchestrator can remove all monitors + if !self.is_admin(deps.as_ref(), sender)? && !self.is_orchestrator(deps.as_ref(), sender)? { + return Err(NetworkMonitorsContractError::Unauthorized); + } + + self.authorised_agents.clear(deps.storage); + Ok(()) + } +} + +pub mod retrieval_limits { + pub const AGENTS_DEFAULT_LIMIT: u32 = 100; + pub const AGENTS_MAX_LIMIT: u32 = 200; +} + +#[cfg(test)] +mod tests { + + #[cfg(test)] + mod agent_storage_key { + use super::super::AgentStorageKey; + use cw_storage_plus::{KeyDeserialize, Map, PrimaryKey}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + #[test] + fn ipv4_key_roundtrips_through_joined_key_and_from_vec() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)), 8080); + let key = AgentStorageKey::from(addr); + let joined = key.joined_key(); + let recovered = AgentStorageKey::from_vec(joined).unwrap(); + assert_eq!(recovered.0, addr); + } + + #[test] + fn ipv6_key_roundtrips_through_joined_key_and_from_vec() { + let addr = SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + 443, + ); + let key = AgentStorageKey::from(addr); + let joined = key.joined_key(); + let recovered = AgentStorageKey::from_vec(joined).unwrap(); + assert_eq!(recovered.0, addr); + } + + #[test] + fn port_zero_roundtrips() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 0); + let key = AgentStorageKey::from(addr); + let joined = key.joined_key(); + let recovered = AgentStorageKey::from_vec(joined).unwrap(); + assert_eq!(recovered.0, addr); + } + + #[test] + fn port_max_roundtrips() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), u16::MAX); + let key = AgentStorageKey::from(addr); + let joined = key.joined_key(); + let recovered = AgentStorageKey::from_vec(joined).unwrap(); + assert_eq!(recovered.0, addr); + } + + #[test] + fn same_ip_different_ports_produce_different_keys() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let a = AgentStorageKey::from(SocketAddr::new(ip, 1000)); + let b = AgentStorageKey::from(SocketAddr::new(ip, 2000)); + assert_ne!(a.joined_key(), b.joined_key()); + } + + #[test] + fn ipv4_keys_sort_by_ip_then_port() { + let addrs = [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 2000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 1000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 500), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9999), + ]; + + let mut keys: Vec<_> = addrs + .iter() + .map(|a| (AgentStorageKey::from(*a).joined_key(), *a)) + .collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + + let sorted_addrs: Vec<_> = keys.iter().map(|(_, a)| *a).collect(); + assert_eq!( + sorted_addrs, + vec![ + // 1.2.3.4 < 10.0.0.1 < 10.0.0.2 + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 9999), + // same IP, port 1000 < 2000 + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 1000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 2000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 500), + ] + ); + } + + #[test] + fn ipv4_sorts_before_ipv6() { + let v4 = AgentStorageKey::from(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)), + 65535, + )); + let v6 = AgentStorageKey::from(SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), + 1, + )); + assert!(v4.joined_key() < v6.joined_key()); + } + + #[test] + fn map_save_and_load_roundtrip_ipv4() { + use cosmwasm_std::testing::MockStorage; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 8080); + map.save(&mut storage, addr.into(), &"agent-v4".to_string()) + .unwrap(); + + let loaded = map.load(&storage, addr.into()).unwrap(); + assert_eq!(loaded, "agent-v4"); + } + + #[test] + fn map_save_and_load_roundtrip_ipv6() { + use cosmwasm_std::testing::MockStorage; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let addr = SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + 443, + ); + map.save(&mut storage, addr.into(), &"agent-v6".to_string()) + .unwrap(); + + let loaded = map.load(&storage, addr.into()).unwrap(); + assert_eq!(loaded, "agent-v6"); + } + + #[test] + fn map_stores_same_ip_different_ports_independently() { + use cosmwasm_std::testing::MockStorage; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let agent_a = SocketAddr::new(ip, 1000); + let agent_b = SocketAddr::new(ip, 2000); + + map.save(&mut storage, agent_a.into(), &"agent-a".to_string()) + .unwrap(); + map.save(&mut storage, agent_b.into(), &"agent-b".to_string()) + .unwrap(); + + assert_eq!(map.load(&storage, agent_a.into()).unwrap(), "agent-a"); + assert_eq!(map.load(&storage, agent_b.into()).unwrap(), "agent-b"); + } + + #[test] + fn map_remove_one_agent_preserves_other_on_same_ip() { + use cosmwasm_std::testing::MockStorage; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let agent_a = SocketAddr::new(ip, 1000); + let agent_b = SocketAddr::new(ip, 2000); + + map.save(&mut storage, agent_a.into(), &"agent-a".to_string()) + .unwrap(); + map.save(&mut storage, agent_b.into(), &"agent-b".to_string()) + .unwrap(); + + map.remove(&mut storage, agent_a.into()); + + assert!(map.may_load(&storage, agent_a.into()).unwrap().is_none()); + assert_eq!(map.load(&storage, agent_b.into()).unwrap(), "agent-b"); + } + + #[test] + fn map_range_returns_correct_order_with_mixed_ips_and_ports() { + use cosmwasm_std::testing::MockStorage; + use cosmwasm_std::{Order, StdResult}; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let addrs = [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 2000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 1000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 80), + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 443), + ]; + + for addr in &addrs { + map.save(&mut storage, (*addr).into(), &addr.to_string()) + .unwrap(); + } + + let all: Vec = map + .range(&storage, None, None, Order::Ascending) + .map(|r: StdResult<(AgentStorageKey, String)>| r.unwrap().0 .0) + .collect(); + + assert_eq!( + all, + vec![ + // IPv4 sorted by octets, then port + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 80), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 1000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 2000), + // IPv6 after all IPv4 + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 443), + ] + ); + } + + #[test] + fn map_range_with_exclusive_bound_skips_exact_match() { + use cosmwasm_std::testing::MockStorage; + use cosmwasm_std::{Order, StdResult}; + use cw_storage_plus::Bound; + + let map: Map = Map::new("test"); + let mut storage = MockStorage::new(); + + let addrs = [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 1000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 2000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 3000), + ]; + + for addr in &addrs { + map.save(&mut storage, (*addr).into(), &addr.to_string()) + .unwrap(); + } + + // paginate after the second entry + let start = Bound::exclusive(AgentStorageKey::from(addrs[1])); + let page: Vec = map + .range(&storage, Some(start), None, Order::Ascending) + .map(|r: StdResult<(AgentStorageKey, String)>| r.unwrap().0 .0) + .collect(); + + assert_eq!(page, vec![addrs[2]]); + } + } + + #[cfg(test)] + mod network_monitors_storage { + use crate::storage::NetworkMonitorsStorage; + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + + #[cfg(test)] + mod initialisation { + use crate::storage::NetworkMonitorsStorage; + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + + #[test] + fn sets_contract_admin() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin1 = deps.api.addr_make("first-admin"); + let admin2 = deps.api.addr_make("second-admin"); + let orchestrator = deps.api.addr_make("orchestrator"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin1.clone(), + orchestrator.clone(), + )?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin1).is_ok()); + + let mut deps = mock_dependencies(); + storage.initialise(deps.as_mut(), env.clone(), admin2.clone(), orchestrator)?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin2).is_ok()); + + Ok(()) + } + + #[test] + fn sets_the_initial_orchestrator() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let orchestrator1 = deps.api.addr_make("orchestrator"); + let orchestrator2 = deps.api.addr_make("orchestrator"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + orchestrator1.clone(), + )?; + assert!(storage + .ensure_is_orchestrator(deps.as_ref(), &orchestrator1) + .is_ok()); + + let mut deps = mock_dependencies(); + storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + orchestrator2.clone(), + )?; + assert!(storage + .ensure_is_orchestrator(deps.as_ref(), &orchestrator2) + .is_ok()); + + Ok(()) + } + } + + #[test] + fn checking_for_admin() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_admin = deps.api.addr_make("non-admin"); + let orchestrator = deps.api.addr_make("orchestrator"); + + storage.initialise(deps.as_mut(), env, admin.clone(), orchestrator)?; + assert!(storage.is_admin(deps.as_ref(), &admin)?); + assert!(!storage.is_admin(deps.as_ref(), &non_admin)?); + + Ok(()) + } + + #[test] + fn ensuring_admin_privileges() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_admin = deps.api.addr_make("non-admin"); + let orchestrator = deps.api.addr_make("orchestrator"); + + storage.initialise(deps.as_mut(), env, admin.clone(), orchestrator)?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin).is_ok()); + assert!(storage.ensure_is_admin(deps.as_ref(), &non_admin).is_err()); + + Ok(()) + } + + #[test] + fn checking_for_orchestrator() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_orchestrator = deps.api.addr_make("non-orchestrator"); + let orchestrator = deps.api.addr_make("orchestrator"); + + storage.initialise(deps.as_mut(), env, admin, orchestrator.clone())?; + assert!(storage.is_orchestrator(deps.as_ref(), &orchestrator)?); + assert!(!storage.is_orchestrator(deps.as_ref(), &non_orchestrator)?); + + Ok(()) + } + + #[test] + fn ensuring_orchestrator_privileges() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_orchestrator = deps.api.addr_make("non-orchestrator"); + let orchestrator = deps.api.addr_make("orchestrator"); + + storage.initialise(deps.as_mut(), env, admin, orchestrator.clone())?; + assert!(storage + .ensure_is_orchestrator(deps.as_ref(), &orchestrator) + .is_ok()); + assert!(storage + .ensure_is_orchestrator(deps.as_ref(), &non_orchestrator) + .is_err()); + + Ok(()) + } + + #[cfg(test)] + mod authorising_orchestrator { + use super::*; + use crate::testing::init_contract_tester; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::NetworkMonitorsContractError; + + #[test] + fn can_only_be_done_by_admin() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let non_admin = tester.generate_account(); + let orchestrator = tester.generate_account(); + + let env = tester.env(); + let deps = tester.deps_mut(); + let res = storage + .authorise_orchestrator(deps, &env, &non_admin, orchestrator.clone()) + .unwrap_err(); + assert_eq!( + NetworkMonitorsContractError::Admin(AdminError::NotAdmin {}), + res + ); + + let env = tester.env(); + let deps = tester.deps_mut(); + let res2 = storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone()); + assert_eq!(res2, Ok(())); + + Ok(()) + } + + #[test] + fn inserts_new_entry_for_fresh_accounts() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.generate_account(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + assert!(storage + .authorised_orchestrators + .may_load(deps.storage, &orchestrator)? + .is_none()); + storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone())?; + + let info = storage + .authorised_orchestrators + .load(&tester, &orchestrator)?; + + assert_eq!(info.address, orchestrator); + assert_eq!(info.authorised_at, env.block.time); + + Ok(()) + } + + #[test] + fn no_op_for_older_accounts() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.generate_account(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone())?; + let info = storage + .authorised_orchestrators + .load(&tester, &orchestrator)?; + + tester.advance_day_of_blocks(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone())?; + + let updated_info = storage + .authorised_orchestrators + .load(&tester, &orchestrator)?; + + assert_eq!(info, updated_info); + + Ok(()) + } + } + + #[cfg(test)] + mod removing_orchestrator_authorisation { + use super::*; + use crate::testing::{init_contract_tester, NetworkMonitorsContractTesterExt}; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::NetworkMonitorsContractError; + + #[test] + fn can_only_be_done_by_admin() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let non_admin = tester.generate_account(); + let orchestrator = tester.generate_account(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone())?; + + let deps = tester.deps_mut(); + let res = storage + .remove_orchestrator_authorisation(deps, &non_admin, orchestrator.clone()) + .unwrap_err(); + assert_eq!( + NetworkMonitorsContractError::Admin(AdminError::NotAdmin {}), + res + ); + + let deps = tester.deps_mut(); + let res2 = storage.remove_orchestrator_authorisation(deps, &admin, orchestrator); + assert_eq!(res2, Ok(())); + + Ok(()) + } + + #[test] + fn deletes_entry_from_storage() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.generate_account(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_orchestrator(deps, &env, &admin, orchestrator.clone())?; + + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator)? + .is_some()); + + let deps = tester.deps_mut(); + storage.remove_orchestrator_authorisation(deps, &admin, orchestrator.clone())?; + + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator)? + .is_none()); + + Ok(()) + } + + #[test] + fn no_op_for_non_existent_entries() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.generate_account(); + + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator)? + .is_none()); + + let deps = tester.deps_mut(); + let res = + storage.remove_orchestrator_authorisation(deps, &admin, orchestrator.clone()); + assert_eq!(res, Ok(())); + + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator)? + .is_none()); + + Ok(()) + } + + #[test] + fn removes_agents_authorised_by_the_removed_orchestrator() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.add_orchestrator()?; + + let agent1 = tester.random_socket(); + let agent2 = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent1, + "test_noise_key".to_string(), + 1, + )?; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent2, + "test_noise_key".to_string(), + 1, + )?; + + // sanity: both agents present + assert!(storage + .authorised_agents + .may_load(&tester, agent1.into())? + .is_some()); + assert!(storage + .authorised_agents + .may_load(&tester, agent2.into())? + .is_some()); + + let deps = tester.deps_mut(); + storage.remove_orchestrator_authorisation(deps, &admin, orchestrator.clone())?; + + // orchestrator is gone + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator)? + .is_none()); + + // its agents are cascade-removed + assert!(storage + .authorised_agents + .may_load(&tester, agent1.into())? + .is_none()); + assert!(storage + .authorised_agents + .may_load(&tester, agent2.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn does_not_remove_agents_authorised_by_other_orchestrators() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator_a = tester.add_orchestrator()?; + let orchestrator_b = tester.add_orchestrator()?; + + let agent_a = tester.random_socket(); + let agent_b = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator_a, + agent_a, + "test_noise_key".to_string(), + 1, + )?; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator_b, + agent_b, + "test_noise_key".to_string(), + 1, + )?; + + let deps = tester.deps_mut(); + storage.remove_orchestrator_authorisation(deps, &admin, orchestrator_a.clone())?; + + // orchestrator_a's agent is gone + assert!(storage + .authorised_agents + .may_load(&tester, agent_a.into())? + .is_none()); + + // orchestrator_b's agent is untouched + let remaining = storage.authorised_agents.load(&tester, agent_b.into())?; + assert_eq!(remaining.mixnet_address, agent_b); + assert_eq!(remaining.authorised_by, orchestrator_b); + + // orchestrator_b itself is untouched + assert!(storage + .authorised_orchestrators + .may_load(&tester, &orchestrator_b)? + .is_some()); + + Ok(()) + } + } + + #[cfg(test)] + mod authorising_monitors { + use super::*; + use crate::testing::{init_contract_tester, NetworkMonitorsContractTesterExt}; + use nym_contracts_common_testing::{ChainOpts, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::NetworkMonitorsContractError; + + #[test] + fn can_only_be_done_by_an_orchestrator() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let orchestrator = tester.add_orchestrator()?; + let non_orchestrator = tester.generate_account(); + let agent = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + let res = storage + .authorise_monitor( + deps, + &env, + &non_orchestrator, + agent, + "test_noise_key".to_string(), + 1, + ) + .unwrap_err(); + assert_eq!( + NetworkMonitorsContractError::NotAnOrchestrator { + addr: non_orchestrator.clone() + }, + res + ); + + let env = tester.env(); + let deps = tester.deps_mut(); + let res2 = storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + ); + assert_eq!(res2, Ok(())); + + Ok(()) + } + + #[test] + fn inserts_new_entry_for_fresh_accounts() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + // IPV4: + let orchestrator = tester.add_orchestrator()?; + let agent = tester.random_socket_ipv4(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + assert!(storage + .authorised_agents + .may_load(deps.storage, agent.into())? + .is_none()); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let info = storage.authorised_agents.load(&tester, agent.into())?; + + assert_eq!(info.mixnet_address, agent); + assert_eq!(info.authorised_by, orchestrator); + assert_eq!(info.authorised_at, env.block.time); + + tester.advance_day_of_blocks(); + + // IPV6: + let agent = tester.random_socket_ipv6(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + assert!(storage + .authorised_agents + .may_load(deps.storage, agent.into())? + .is_none()); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let info = storage.authorised_agents.load(&tester, agent.into())?; + + assert_eq!(info.mixnet_address, agent); + assert_eq!(info.authorised_by, orchestrator); + assert_eq!(info.authorised_at, env.block.time); + + Ok(()) + } + + #[test] + fn updates_timestamp_for_older_accounts() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + // IPV4: + let orchestrator = tester.add_orchestrator()?; + let agent = tester.random_socket_ipv4(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let initial_time = env.block.time; + tester.advance_day_of_blocks(); + let new_expected_time = tester.env().block.time; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let updated_info = storage.authorised_agents.load(&tester, agent.into())?; + + assert_eq!(updated_info.mixnet_address, agent); + assert_eq!(updated_info.authorised_by, orchestrator); + assert_ne!(updated_info.authorised_at, initial_time); + assert_eq!(updated_info.authorised_at, new_expected_time); + + tester.advance_day_of_blocks(); + + // IPV6: + let agent = tester.random_socket_ipv6(); + + let env = tester.env(); + let deps = tester.deps_mut(); + + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let initial_time = env.block.time; + tester.advance_day_of_blocks(); + let new_expected_time = tester.env().block.time; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let updated_info = storage.authorised_agents.load(&tester, agent.into())?; + + assert_eq!(updated_info.mixnet_address, agent); + assert_eq!(updated_info.authorised_by, orchestrator); + assert_ne!(updated_info.authorised_at, initial_time); + assert_eq!(updated_info.authorised_at, new_expected_time); + + Ok(()) + } + } + + #[cfg(test)] + mod removing_monitor_authorisation { + use super::*; + use crate::testing::{init_contract_tester, NetworkMonitorsContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ChainOpts, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::NetworkMonitorsContractError; + + #[test] + fn rejects_non_privileged_accounts() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let orchestrator = tester.add_orchestrator()?; + let non_privileged = tester.generate_account(); + let agent = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let deps = tester.deps_mut(); + let res = storage + .remove_monitor_authorisation(deps, &non_privileged, agent) + .unwrap_err(); + assert_eq!(NetworkMonitorsContractError::Unauthorized, res); + + let deps = tester.deps_mut(); + let res2 = storage.remove_monitor_authorisation(deps, &orchestrator, agent); + assert_eq!(res2, Ok(())); + + Ok(()) + } + + #[test] + fn can_be_done_by_admin() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.add_orchestrator()?; + let agent = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + let deps = tester.deps_mut(); + storage.remove_monitor_authorisation(deps, &admin, agent)?; + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn deletes_entry_from_storage() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + // IPV4: + let orchestrator = tester.add_orchestrator()?; + let agent = tester.random_socket_ipv4(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_some()); + + let deps = tester.deps_mut(); + storage.remove_monitor_authorisation(deps, &orchestrator, agent)?; + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + + tester.advance_day_of_blocks(); + + // IPV6: + let agent = tester.random_socket_ipv6(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent, + "test_noise_key".to_string(), + 1, + )?; + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_some()); + + let deps = tester.deps_mut(); + storage.remove_monitor_authorisation(deps, &orchestrator, agent)?; + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn no_op_for_non_existent_entries() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let orchestrator = tester.add_orchestrator()?; + let agent = tester.random_socket(); + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + + let deps = tester.deps_mut(); + let res = storage.remove_monitor_authorisation(deps, &orchestrator, agent); + assert_eq!(res, Ok(())); + + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + + Ok(()) + } + } + + #[cfg(test)] + mod removing_all_monitors { + use super::*; + use crate::testing::{ + init_contract_tester, NetworkMonitorsContract, NetworkMonitorsContractTesterExt, + }; + use cosmwasm_std::Addr; + use nym_contracts_common_testing::{AdminExt, ContractOpts, ContractTester, RandExt}; + use nym_network_monitors_contract_common::NetworkMonitorsContractError; + + fn setup_prepopulated_tester() -> (ContractTester, Addr) { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + let orchestrator = tester.add_orchestrator().unwrap(); + + // Prepopulate with several agents + let agent1 = tester.random_socket(); + let agent2 = tester.random_socket(); + let agent3 = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage + .authorise_monitor( + deps, + &env, + &orchestrator, + agent1, + "test_noise_key".to_string(), + 1, + ) + .unwrap(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage + .authorise_monitor( + deps, + &env, + &orchestrator, + agent2, + "test_noise_key".to_string(), + 1, + ) + .unwrap(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage + .authorise_monitor( + deps, + &env, + &orchestrator, + agent3, + "test_noise_key".to_string(), + 1, + ) + .unwrap(); + + // sanity check to make sure all agents got added + let all_agents = tester.all_agents(); + assert_eq!(all_agents.len(), 3); + assert!(all_agents.contains(&agent1)); + assert!(all_agents.contains(&agent2)); + assert!(all_agents.contains(&agent3)); + + (tester, orchestrator) + } + + #[test] + fn can_be_done_by_admin() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + + let (mut tester, _) = setup_prepopulated_tester(); + let admin = tester.admin_unchecked(); + + let all_agents = tester.all_agents(); + + // Admin can call this method + let deps = tester.deps_mut(); + storage.remove_all_monitors(deps, &admin)?; + + // Verify all agents are cleared + for agent in all_agents { + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + } + + assert!(tester.all_agents().is_empty()); + + Ok(()) + } + + #[test] + fn can_be_done_by_orchestrator() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let (mut tester, orchestrator) = setup_prepopulated_tester(); + + let all_agents = tester.all_agents(); + + let deps = tester.deps_mut(); + storage.remove_all_monitors(deps, &orchestrator)?; + + // Verify all agents are cleared + for agent in all_agents { + assert!(storage + .authorised_agents + .may_load(&tester, agent.into())? + .is_none()); + } + + assert!(tester.all_agents().is_empty()); + + Ok(()) + } + + #[test] + fn cannot_be_done_by_non_privileged_account() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let (mut tester, _) = setup_prepopulated_tester(); + + let non_privileged = tester.generate_account(); + + // Non-privileged account cannot call this method + let deps = tester.deps_mut(); + let res = storage + .remove_all_monitors(deps, &non_privileged) + .unwrap_err(); + assert_eq!(NetworkMonitorsContractError::Unauthorized, res); + + Ok(()) + } + + #[test] + fn cannot_be_done_by_revoked_orchestrator() -> anyhow::Result<()> { + let storage = NetworkMonitorsStorage::new(); + let (mut tester, orchestrator) = setup_prepopulated_tester(); + + let admin = tester.admin_unchecked(); + + let deps = tester.deps_mut(); + + // Revoke orchestrator privileges (cascade-removes its agents) + storage.remove_orchestrator_authorisation(deps, &admin, orchestrator.clone())?; + + // snapshot the post-revocation agent set so we can assert the failed + // remove_all_monitors call below does not further mutate storage + let post_revoke_agents = tester.all_agents(); + + // Verify revoked orchestrator cannot call remove_all_monitors + let deps = tester.deps_mut(); + let res = storage + .remove_all_monitors(deps, &orchestrator) + .unwrap_err(); + assert_eq!(NetworkMonitorsContractError::Unauthorized, res); + + // Verify the failed attempt did not mutate the agent set + assert_eq!(tester.all_agents(), post_revoke_agents); + + Ok(()) + } + + #[test] + fn clears_all_agents() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let storage = NetworkMonitorsStorage::new(); + + let admin = tester.admin_unchecked(); + let orchestrator = tester.add_orchestrator()?; + + // Prepopulate with multiple agents + let agent1 = tester.random_socket(); + let agent2 = tester.random_socket(); + let agent3 = tester.random_socket(); + let agent4 = tester.random_socket(); + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent1, + "test_noise_key".to_string(), + 1, + )?; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent2, + "test_noise_key".to_string(), + 1, + )?; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent3, + "test_noise_key".to_string(), + 1, + )?; + + let env = tester.env(); + let deps = tester.deps_mut(); + storage.authorise_monitor( + deps, + &env, + &orchestrator, + agent4, + "test_noise_key".to_string(), + 1, + )?; + + // Verify agents are present + assert!(storage + .authorised_agents + .may_load(&tester, agent1.into())? + .is_some()); + assert!(storage + .authorised_agents + .may_load(&tester, agent2.into())? + .is_some()); + assert!(storage + .authorised_agents + .may_load(&tester, agent3.into())? + .is_some()); + assert!(storage + .authorised_agents + .may_load(&tester, agent4.into())? + .is_some()); + + // Remove all monitors + let deps = tester.deps_mut(); + storage.remove_all_monitors(deps, &admin)?; + + // Verify all agents are cleared + assert!(storage + .authorised_agents + .may_load(&tester, agent1.into())? + .is_none()); + assert!(storage + .authorised_agents + .may_load(&tester, agent2.into())? + .is_none()); + assert!(storage + .authorised_agents + .may_load(&tester, agent3.into())? + .is_none()); + assert!(storage + .authorised_agents + .may_load(&tester, agent4.into())? + .is_none()); + + Ok(()) + } + } + } +} diff --git a/contracts/network-monitors/src/testing/mod.rs b/contracts/network-monitors/src/testing/mod.rs new file mode 100644 index 0000000000..f2222627c4 --- /dev/null +++ b/contracts/network-monitors/src/testing/mod.rs @@ -0,0 +1,188 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::contract::{execute, instantiate, migrate, query}; +use cosmwasm_std::{Addr, Order}; +use nym_contracts_common_testing::{ + mock_dependencies, AdminExt, ChainOpts, CommonStorageKeys, ContractFn, ContractOpts, + ContractTester, DenomExt, PermissionedFn, QueryFn, RandExt, Rng, RngCore, TestableNymContract, +}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use crate::storage::NetworkMonitorsStorage; +use nym_network_monitors_contract_common::constants::storage_keys; +use nym_network_monitors_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NetworkMonitorsContractError, QueryMsg, +}; + +pub struct NetworkMonitorsContract; + +impl TestableNymContract for NetworkMonitorsContract { + const NAME: &'static str = "nym-network-monitors-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = NetworkMonitorsContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn base_init_msg() -> Self::InitMsg { + let deps = mock_dependencies(); + InstantiateMsg { + orchestrator_address: deps.api.addr_make("initial-dummy-orchestrator").to_string(), + } + } +} + +pub fn init_contract_tester() -> ContractTester { + NetworkMonitorsContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN) +} + +pub trait NetworkMonitorsContractTesterExt: + ContractOpts< + ExecuteMsg = ExecuteMsg, + QueryMsg = QueryMsg, + ContractError = NetworkMonitorsContractError, + > + ChainOpts + + AdminExt + + DenomExt + + RandExt +{ + fn add_orchestrator(&mut self) -> Result { + let admin = self.admin_unchecked(); + let addr = self.generate_account(); + self.execute_raw( + admin, + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: addr.to_string(), + }, + )?; + Ok(addr) + } + + fn remove_all_orchestrators(&mut self) { + let orchestrators = self.all_orchestrators(); + for orchestrator in orchestrators { + self.execute_raw( + self.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + ) + .unwrap(); + } + } + + fn add_dummy_agent(&mut self, agent: SocketAddr) { + let orchestrators = self.all_orchestrators(); + let orchestrator = match orchestrators.first() { + Some(orchestrator) => orchestrator.clone(), + None => self.add_orchestrator().unwrap().clone(), + }; + + self.execute_raw( + orchestrator, + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: "11111111111111111111111111111111".to_string(), + noise_version: 1, + }, + ) + .unwrap(); + } + + fn random_ipv4(&mut self) -> IpAddr { + let rng = self.raw_rng(); + IpAddr::V4(Ipv4Addr::new(rng.gen(), rng.gen(), rng.gen(), rng.gen())) + } + + fn random_ipv6(&mut self) -> IpAddr { + let rng = self.raw_rng(); + IpAddr::V6(Ipv6Addr::new( + rng.gen(), + rng.gen(), + rng.gen(), + rng.gen(), + rng.gen(), + rng.gen(), + rng.gen(), + rng.gen(), + )) + } + + fn random_ip(&mut self) -> IpAddr { + let rng = self.raw_rng(); + + // toss a coin, if even => ipv4, if odd => ipv6 + if rng.next_u32() % 2 == 0 { + self.random_ipv4() + } else { + self.random_ipv6() + } + } + + fn random_socket_ipv4(&mut self) -> SocketAddr { + let port = self.raw_rng().gen(); + SocketAddr::new(self.random_ipv4(), port) + } + + fn random_socket_ipv6(&mut self) -> SocketAddr { + let port = self.raw_rng().gen(); + SocketAddr::new(self.random_ipv6(), port) + } + + fn random_socket(&mut self) -> SocketAddr { + let port = self.raw_rng().gen(); + SocketAddr::new(self.random_ip(), port) + } + + fn all_agents(&self) -> Vec { + NetworkMonitorsStorage::new() + .authorised_agents + .range(self.storage(), None, None, Order::Ascending) + .map(|record| record.unwrap().1.mixnet_address) + .collect() + } + + fn all_orchestrators(&self) -> Vec { + NetworkMonitorsStorage::new() + .authorised_orchestrators + .range(self.storage(), None, None, Order::Ascending) + .map(|record| record.unwrap().0) + .collect() + } +} + +impl NetworkMonitorsContractTesterExt for ContractTester {} + +/// Compare SocketAddrs in the same order as the storage key encoding. +/// +/// Storage keys are: `[0, ip_len] [ip_octets...] [port_be_bytes]` +/// This means IPv4 (len=4) always sorts before IPv6 (len=16), +/// within the same type keys sort by IP octets then by port. +pub(crate) fn storage_socket_comp(a: SocketAddr, b: SocketAddr) -> std::cmp::Ordering { + let ip_ord = match (a.ip(), b.ip()) { + (IpAddr::V4(a), IpAddr::V4(b)) => a.octets().cmp(&b.octets()), + (IpAddr::V6(a), IpAddr::V6(b)) => a.octets().cmp(&b.octets()), + // length prefix [0, 4] < [0, 16] so all IPv4 sorts before all IPv6 + (IpAddr::V4(_), IpAddr::V6(_)) => std::cmp::Ordering::Less, + (IpAddr::V6(_), IpAddr::V4(_)) => std::cmp::Ordering::Greater, + }; + ip_ord.then(a.port().cmp(&b.port())) +} diff --git a/contracts/network-monitors/src/transactions.rs b/contracts/network-monitors/src/transactions.rs new file mode 100644 index 0000000000..a2baafb78e --- /dev/null +++ b/contracts/network-monitors/src/transactions.rs @@ -0,0 +1,1096 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::NETWORK_MONITORS_CONTRACT_STORAGE; +use cosmwasm_std::{DepsMut, Env, MessageInfo, Response}; +use nym_network_monitors_contract_common::NetworkMonitorsContractError; +use std::net::SocketAddr; + +pub fn try_update_contract_admin( + deps: DepsMut<'_>, + info: MessageInfo, + new_admin: String, +) -> Result { + let new_admin = deps.api.addr_validate(&new_admin)?; + + let res = NETWORK_MONITORS_CONTRACT_STORAGE + .contract_admin + .execute_update_admin(deps, info, Some(new_admin))?; + + Ok(res) +} + +pub fn try_authorise_network_monitor_orchestrator( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + orchestrator_address: String, +) -> Result { + let orchestrator_address = deps.api.addr_validate(&orchestrator_address)?; + NETWORK_MONITORS_CONTRACT_STORAGE.authorise_orchestrator( + deps, + &env, + &info.sender, + orchestrator_address, + )?; + + Ok(Response::new()) +} + +/// Update the announced ed25519 identity key of the orchestrator submitting the transaction. +/// +/// The sender must already be an authorised orchestrator - this is enforced by +/// [`NetworkMonitorsStorage::update_orchestrator_identity_key`] via the `NotAnOrchestrator` error +/// when no entry exists for the sender. +/// +/// Only shape-level validation is performed on `identity_key` (valid base58 encoding a 32-byte +/// ed25519 public key). The key is not verified to be a valid curve point, as doing so on-chain +/// is disproportionately expensive relative to the downstream risk - a malformed key will simply +/// fail signature verification when used. +pub fn try_update_orchestrator_identity_key( + deps: DepsMut<'_>, + info: MessageInfo, + identity_key: String, +) -> Result { + // perform basic validation of the key, i.e. is it valid base58 and is it 32 bytes (i.e. ed25519)? + let mut public_key = [0u8; 32]; + let used = bs58::decode(&identity_key) + .onto(&mut public_key) + .map_err(|err| { + NetworkMonitorsContractError::MalformedEd25519OrchestratorIdentityKey(err.to_string()) + })?; + + if used != 32 { + return Err( + NetworkMonitorsContractError::MalformedEd25519OrchestratorIdentityKey( + "Too few bytes provided for the public key".into(), + ), + ); + } + + NETWORK_MONITORS_CONTRACT_STORAGE.update_orchestrator_identity_key( + deps, + &info.sender, + identity_key, + )?; + + Ok(Response::new()) +} + +pub fn try_revoke_network_monitor_orchestrator( + deps: DepsMut<'_>, + info: MessageInfo, + orchestrator_address: String, +) -> Result { + let orchestrator_address = deps.api.addr_validate(&orchestrator_address)?; + + NETWORK_MONITORS_CONTRACT_STORAGE.remove_orchestrator_authorisation( + deps, + &info.sender, + orchestrator_address, + )?; + + Ok(Response::new()) +} + +pub fn try_authorise_network_monitor( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + network_monitor_address: SocketAddr, + bs58_x25519_noise: String, + noise_version: u8, +) -> Result { + // perform basic validation of the key, i.e. is it valid base58 and is it 32 bytes (i.e. x25519)? + let mut public_key = [0u8; 32]; + let used = bs58::decode(&bs58_x25519_noise) + .onto(&mut public_key) + .map_err(|err| { + NetworkMonitorsContractError::MalformedX25519AgentNoiseKey(err.to_string()) + })?; + + if used != 32 { + return Err(NetworkMonitorsContractError::MalformedX25519AgentNoiseKey( + "Too few bytes provided for the public key".into(), + )); + } + + NETWORK_MONITORS_CONTRACT_STORAGE.authorise_monitor( + deps, + &env, + &info.sender, + network_monitor_address, + bs58_x25519_noise, + noise_version, + )?; + + Ok(Response::new()) +} + +pub fn try_revoke_network_monitor( + deps: DepsMut<'_>, + info: MessageInfo, + network_monitor_address: SocketAddr, +) -> Result { + NETWORK_MONITORS_CONTRACT_STORAGE.remove_monitor_authorisation( + deps, + &info.sender, + network_monitor_address, + )?; + Ok(Response::new()) +} + +pub fn try_revoke_all_network_monitors( + deps: DepsMut<'_>, + info: MessageInfo, +) -> Result { + NETWORK_MONITORS_CONTRACT_STORAGE.remove_all_monitors(deps, &info.sender)?; + Ok(Response::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, NetworkMonitorsContractTesterExt}; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::ExecuteMsg; + + // bs58 encoding of 32 zero bytes — a syntactically valid x25519 key for tests + const TEST_NOISE_KEY: &str = "11111111111111111111111111111111"; + + #[cfg(test)] + mod updating_contract_admin { + use super::*; + use crate::testing::init_contract_tester; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + use nym_network_monitors_contract_common::ExecuteMsg; + + #[test] + fn can_only_be_performed_by_current_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let random_acc = test.generate_account(); + let new_admin = test.generate_account(); + let res = test + .execute_raw( + random_acc, + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + res, + NetworkMonitorsContractError::Admin(AdminError::NotAdmin {}) + ); + + let actual_admin = test.admin_unchecked(); + let res = test.execute_raw( + actual_admin.clone(), + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + }, + ); + assert!(res.is_ok()); + + let updated_admin = test.admin_unchecked(); + assert_eq!(new_admin, updated_admin); + + Ok(()) + } + + #[test] + fn requires_providing_valid_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let bad_account = "definitely-not-valid-account"; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: bad_account.to_string(), + }, + ); + + assert!(res.is_err()); + + let empty_account = ""; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: empty_account.to_string(), + }, + ); + + assert!(res.is_err()); + + Ok(()) + } + } + + #[cfg(test)] + mod authorising_network_monitor_orchestrator { + use super::*; + use cw_controllers::AdminError; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let non_admin = test.generate_account(); + let orchestrator = test.generate_account(); + + let res = test + .execute_raw( + non_admin.clone(), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + res, + NetworkMonitorsContractError::Admin(AdminError::NotAdmin {}) + ); + + let admin = test.admin_unchecked(); + let res = test.execute_raw( + admin, + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_providing_valid_orchestrator_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let bad_address = "definitely-not-valid-account"; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: bad_address.to_string(), + }, + ); + assert!(res.is_err()); + + let good_address = test.generate_account(); + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: good_address.to_string(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn inserts_new_entry_for_fresh_accounts() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.generate_account(); + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .may_load(test.storage(), &orchestrator)? + .is_none()); + + test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + )?; + + let info = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + + assert_eq!(info.address, orchestrator); + + Ok(()) + } + + #[test] + fn is_noop_for_already_authorised_accounts() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.generate_account(); + let admin = test.admin_unchecked(); + + test.execute_raw( + admin.clone(), + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + )?; + + let info = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + + test.execute_raw( + admin, + ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + )?; + + let updated = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + + assert_eq!(info, updated); + + Ok(()) + } + } + + #[cfg(test)] + mod updating_orchestrator_identity_key { + use super::*; + + /// Base58 encoding of 32 bytes - a valid ed25519 key shape. + fn valid_identity_key() -> String { + bs58::encode([7u8; 32]).into_string() + } + + #[test] + fn can_only_be_performed_by_authorised_orchestrator() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let non_orchestrator = test.generate_account(); + + let res = test + .execute_raw( + non_orchestrator.clone(), + ExecuteMsg::UpdateOrchestratorIdentityKey { + key: valid_identity_key(), + }, + ) + .unwrap_err(); + assert_eq!( + res, + NetworkMonitorsContractError::NotAnOrchestrator { + addr: non_orchestrator + } + ); + + let orchestrator = test.add_orchestrator()?; + let res = test.execute_raw( + orchestrator, + ExecuteMsg::UpdateOrchestratorIdentityKey { + key: valid_identity_key(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn rejects_key_that_is_not_valid_base58() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + // '0', 'O', 'I', 'l' are not in the bitcoin alphabet used by bs58 + let res = test + .execute_raw( + orchestrator, + ExecuteMsg::UpdateOrchestratorIdentityKey { + key: "not_valid_base58_0OIl".to_string(), + }, + ) + .unwrap_err(); + assert!(matches!( + res, + NetworkMonitorsContractError::MalformedEd25519OrchestratorIdentityKey(_) + )); + + Ok(()) + } + + #[test] + fn rejects_key_that_is_too_short() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + // 16 bytes, not 32 + let too_short = bs58::encode([1u8; 16]).into_string(); + let res = test + .execute_raw( + orchestrator, + ExecuteMsg::UpdateOrchestratorIdentityKey { key: too_short }, + ) + .unwrap_err(); + assert!(matches!( + res, + NetworkMonitorsContractError::MalformedEd25519OrchestratorIdentityKey(_) + )); + + Ok(()) + } + + #[test] + fn rejects_key_that_is_too_long() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + // 33 bytes, not 32 - decoder should bail out because the destination buffer is too small + let too_long = bs58::encode([1u8; 33]).into_string(); + let res = test + .execute_raw( + orchestrator, + ExecuteMsg::UpdateOrchestratorIdentityKey { key: too_long }, + ) + .unwrap_err(); + assert!(matches!( + res, + NetworkMonitorsContractError::MalformedEd25519OrchestratorIdentityKey(_) + )); + + Ok(()) + } + + #[test] + fn stores_provided_key_against_orchestrator_entry() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + // freshly authorised orchestrator has no announced identity key + let info = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + assert!(info.identity_key.is_none()); + + let key = valid_identity_key(); + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::UpdateOrchestratorIdentityKey { key: key.clone() }, + )?; + + let updated = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + assert_eq!(updated.identity_key.as_deref(), Some(key.as_str())); + + Ok(()) + } + + #[test] + fn overwrites_previously_announced_key() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + let first_key = bs58::encode([1u8; 32]).into_string(); + let second_key = bs58::encode([2u8; 32]).into_string(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::UpdateOrchestratorIdentityKey { + key: first_key.clone(), + }, + )?; + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::UpdateOrchestratorIdentityKey { + key: second_key.clone(), + }, + )?; + + let info = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator)?; + assert_eq!(info.identity_key.as_deref(), Some(second_key.as_str())); + + Ok(()) + } + + #[test] + fn updating_one_orchestrator_key_does_not_affect_others() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator_a = test.add_orchestrator()?; + let orchestrator_b = test.add_orchestrator()?; + + let key_a = bs58::encode([10u8; 32]).into_string(); + test.execute_raw( + orchestrator_a.clone(), + ExecuteMsg::UpdateOrchestratorIdentityKey { key: key_a.clone() }, + )?; + + let info_a = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator_a)?; + let info_b = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .load(test.storage(), &orchestrator_b)?; + + assert_eq!(info_a.identity_key.as_deref(), Some(key_a.as_str())); + assert!(info_b.identity_key.is_none()); + + Ok(()) + } + } + + #[cfg(test)] + mod revoking_network_monitor_orchestrator { + use super::*; + use cw_controllers::AdminError; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let orchestrator = test.add_orchestrator()?; + let non_admin = test.generate_account(); + + let res = test + .execute_raw( + non_admin.clone(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + res, + NetworkMonitorsContractError::Admin(AdminError::NotAdmin {}) + ); + + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_providing_valid_orchestrator_address() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: "definitely-not-valid-account".to_string(), + }, + ); + assert!(res.is_err()); + + let valid_but_missing = test.generate_account(); + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: valid_but_missing.to_string(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn deletes_entry_from_storage() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .may_load(test.storage(), &orchestrator)? + .is_some()); + + test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + )?; + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_orchestrators + .may_load(test.storage(), &orchestrator)? + .is_none()); + + Ok(()) + } + } + + #[cfg(test)] + mod authorising_network_monitor { + use super::*; + use nym_contracts_common_testing::ChainOpts; + + #[test] + fn can_only_be_performed_by_orchestrator() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let non_orchestrator = test.generate_account(); + let agent = test.random_socket(); + + let res = test + .execute_raw( + non_orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + ) + .unwrap_err(); + + assert_eq!( + res, + NetworkMonitorsContractError::NotAnOrchestrator { + addr: non_orchestrator + } + ); + + let orchestrator = test.add_orchestrator()?; + let res = test.execute_raw( + orchestrator, + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn inserts_new_entry_for_fresh_agents() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let info = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .load(test.storage(), agent.into())?; + + assert_eq!(info.mixnet_address, agent); + assert_eq!(info.authorised_by, orchestrator); + + Ok(()) + } + + #[test] + fn renews_existing_agent_authorisation() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let initial = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .load(test.storage(), agent.into())?; + + test.advance_day_of_blocks(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let updated = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .load(test.storage(), agent.into())?; + + assert_eq!(updated.mixnet_address, agent); + assert_eq!(updated.authorised_by, orchestrator); + assert!(updated.authorised_at > initial.authorised_at); + + Ok(()) + } + } + + #[cfg(test)] + mod revoking_network_monitor { + use super::*; + + #[test] + fn can_be_performed_by_orchestrator() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let res = test.execute_raw( + orchestrator, + ExecuteMsg::RevokeNetworkMonitor { address: agent }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn can_be_performed_by_admin() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let admin = test.admin_unchecked(); + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + test.execute_raw( + orchestrator, + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let res = test.execute_raw(admin, ExecuteMsg::RevokeNetworkMonitor { address: agent }); + assert!(res.is_ok()); + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn rejects_non_privileged_accounts() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + + let orchestrator = test.add_orchestrator()?; + let non_privileged = test.generate_account(); + let agent = test.random_socket(); + + test.execute_raw( + orchestrator, + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + let res = test + .execute_raw( + non_privileged, + ExecuteMsg::RevokeNetworkMonitor { address: agent }, + ) + .unwrap_err(); + + assert_eq!(res, NetworkMonitorsContractError::Unauthorized); + + Ok(()) + } + + #[test] + fn deletes_entry_from_storage() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_some()); + + test.execute_raw( + orchestrator, + ExecuteMsg::RevokeNetworkMonitor { address: agent }, + )?; + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn is_noop_for_non_existent_entries() -> anyhow::Result<()> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + let agent = test.random_socket(); + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + + let res = test.execute_raw( + orchestrator, + ExecuteMsg::RevokeNetworkMonitor { address: agent }, + ); + assert!(res.is_ok()); + + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + + Ok(()) + } + + #[test] + fn revoking_one_agent_preserves_other_on_same_host() -> anyhow::Result<()> { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + // two agents on the same IP but different ports + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + let agent_a = SocketAddr::new(ip, 1000); + let agent_b = SocketAddr::new(ip, 2000); + + // two syntactically valid (32-byte) bs58 x25519 keys + let noise_key_a = TEST_NOISE_KEY.to_string(); + let noise_key_b = "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi".to_string(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent_a, + bs58_x25519_noise: noise_key_a, + noise_version: 1, + }, + )?; + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent_b, + bs58_x25519_noise: noise_key_b.clone(), + noise_version: 1, + }, + )?; + + // both exist + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent_a.into())? + .is_some()); + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent_b.into())? + .is_some()); + + // revoke agent_a + test.execute_raw( + orchestrator, + ExecuteMsg::RevokeNetworkMonitor { address: agent_a }, + )?; + + // agent_a gone, agent_b still present + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent_a.into())? + .is_none()); + let remaining = NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .load(test.storage(), agent_b.into())?; + assert_eq!(remaining.mixnet_address, agent_b); + assert_eq!( + remaining.bs58_x25519_noise, + "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi" + ); + + Ok(()) + } + } + + #[cfg(test)] + mod revoking_all_network_monitors { + use super::*; + + fn setup_prepopulated_tester() -> anyhow::Result<( + nym_contracts_common_testing::ContractTester, + cosmwasm_std::Addr, + )> { + let mut test = init_contract_tester(); + let orchestrator = test.add_orchestrator()?; + + let agent1 = test.random_socket(); + let agent2 = test.random_socket(); + let agent3 = test.random_socket(); + + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent1, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent2, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + test.execute_raw( + orchestrator.clone(), + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address: agent3, + bs58_x25519_noise: TEST_NOISE_KEY.to_string(), + noise_version: 1, + }, + )?; + + Ok((test, orchestrator)) + } + + #[test] + fn can_be_performed_by_admin() -> anyhow::Result<()> { + let (mut test, _) = setup_prepopulated_tester()?; + let agents = test.all_agents(); + + test.execute_raw(test.admin_unchecked(), ExecuteMsg::RevokeAllNetworkMonitors)?; + + for agent in agents { + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + } + + assert!(test.all_agents().is_empty()); + + Ok(()) + } + + #[test] + fn can_be_performed_by_orchestrator() -> anyhow::Result<()> { + let (mut test, orchestrator) = setup_prepopulated_tester()?; + let agents = test.all_agents(); + + test.execute_raw(orchestrator, ExecuteMsg::RevokeAllNetworkMonitors)?; + + for agent in agents { + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + } + + assert!(test.all_agents().is_empty()); + + Ok(()) + } + + #[test] + fn cannot_be_performed_by_non_privileged_account() -> anyhow::Result<()> { + let (mut test, _) = setup_prepopulated_tester()?; + let agents = test.all_agents(); + let random_acc = test.generate_account(); + + let res = test + .execute_raw(random_acc, ExecuteMsg::RevokeAllNetworkMonitors) + .unwrap_err(); + + assert_eq!(res, NetworkMonitorsContractError::Unauthorized); + assert_eq!(test.all_agents(), agents); + + Ok(()) + } + + #[test] + fn cannot_be_performed_by_revoked_orchestrator() -> anyhow::Result<()> { + let (mut test, orchestrator) = setup_prepopulated_tester()?; + + test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeNetworkMonitorOrchestrator { + address: orchestrator.to_string(), + }, + )?; + + // snapshot after revocation (cascade-delete has already run); the failed + // call below must not mutate this set + let post_revoke_agents = test.all_agents(); + + let res = test + .execute_raw(orchestrator, ExecuteMsg::RevokeAllNetworkMonitors) + .unwrap_err(); + + assert_eq!(res, NetworkMonitorsContractError::Unauthorized); + assert_eq!(test.all_agents(), post_revoke_agents); + + Ok(()) + } + + #[test] + fn clears_all_agents() -> anyhow::Result<()> { + let (mut test, _) = setup_prepopulated_tester()?; + let agents = test.all_agents(); + + test.execute_raw(test.admin_unchecked(), ExecuteMsg::RevokeAllNetworkMonitors)?; + + for agent in agents { + assert!(NETWORK_MONITORS_CONTRACT_STORAGE + .authorised_agents + .may_load(test.storage(), agent.into())? + .is_none()); + } + + assert!(test.all_agents().is_empty()); + + Ok(()) + } + } +} diff --git a/envs/sandbox.env b/envs/sandbox.env index 8495e73e18..cd929f8c4b 100644 --- a/envs/sandbox.env +++ b/envs/sandbox.env @@ -17,6 +17,7 @@ GROUP_CONTRACT_ADDRESS=n1ewmwz97xm0h8rdk8sw7h9mwn866qkx9hl9zlmagqfkhuzvwk5hhq844 MULTISIG_CONTRACT_ADDRESS=n1tz0setr8vkh9udp8xyxgpqc89ns27k4d0jx2h942hr0ax63yjhmqz6xct8 COCONUT_DKG_CONTRACT_ADDRESS=n1v3n2ly2dp3a9ng3ff6rh26yfkn0pc5hed7w2shc5u9ca5c865utqj5elvh ECASH_CONTRACT_ADDRESS=n1v3vydvs2ued84yv3khqwtgldmgwn0elljsdh08dr5s2j9x4rc5fs9jlwz9 +NETWORK_MONITORS_CONTRACT_ADDRESS=n1x5krtvyqklj360x38v62ze42g8s8trfsfqzlv8c9296chcpvqadssqnem5 STATISTICS_SERVICE_DOMAIN_ADDRESS="http://0.0.0.0" NYXD=https://rpc.sandbox.nymtech.net diff --git a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs index 189a5a4ca6..5dfcf036f5 100644 --- a/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs +++ b/gateway/src/node/client_handling/websocket/connection_handler/authenticated.rs @@ -241,7 +241,7 @@ impl AuthenticatedHandler { .inner .shared_state .outbound_mix_sender - .forward_packet(mix_packet) + .forward_client_packet_without_delay(mix_packet) { error!("We failed to forward requested mix packet - {err}. Presumably our mix forwarder has crashed. We cannot continue."); process::exit(1); diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 89722e9f72..82de02979e 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -77,6 +77,8 @@ pub struct LocalAuthenticatorOpts { pub struct GatewayTasksBuilder { config: Config, + network: NymNetworkDetails, + network_requester_opts: Option, ip_packet_router_opts: Option, @@ -120,6 +122,7 @@ impl GatewayTasksBuilder { #[allow(clippy::too_many_arguments)] pub fn new( config: Config, + network: NymNetworkDetails, identity: Arc, storage: GatewayStorage, mix_packet_sender: MixForwardingSender, @@ -133,6 +136,7 @@ impl GatewayTasksBuilder { ) -> GatewayTasksBuilder { GatewayTasksBuilder { config, + network, network_requester_opts: None, ip_packet_router_opts: None, authenticator_opts: None, @@ -184,8 +188,7 @@ impl GatewayTasksBuilder { .choose(&mut thread_rng()) .ok_or(GatewayError::NoNyxdAvailable)?; - let network_details = NymNetworkDetails::new_from_env(); - let client_config = nyxd::Config::try_from_nym_network_details(&network_details)?; + let client_config = nyxd::Config::try_from_nym_network_details(&self.network)?; let nyxd_client = DirectSigningHttpRpcNyxdClient::connect_with_mnemonic( client_config, diff --git a/nym-api/migrations/20260424120000_stress_testing.sql b/nym-api/migrations/20260424120000_stress_testing.sql new file mode 100644 index 0000000000..c437f19a64 --- /dev/null +++ b/nym-api/migrations/20260424120000_stress_testing.sql @@ -0,0 +1,29 @@ +/* + * Copyright 2026 - Nym Technologies SA + * SPDX-License-Identifier: GPL-3.0-only + */ + +CREATE TABLE nym_node_stress_testing_result +( + -- Orchestrator-local testrun id that produced this result. Paired with `submitter_pubkey` + -- it uniquely identifies a measurement and lets us dedupe retried submissions (the + -- orchestrator uses at-least-once delivery and may re-POST the same row after a crash + -- between a successful POST and its watermark update). + testrun_id INTEGER NOT NULL, + + -- Base58-encoded ed25519 identity key of the submitting orchestrator. Part of the primary + -- key so distinct orchestrators can coincidentally share a `testrun_id` without colliding. + submitter_pubkey TEXT NOT NULL, + + -- unfortunately, due to legacy reasons we have separate tables for mixnodes and gateways + -- so that we can't put a reference constraint here + node_id INTEGER NOT NULL, + + result REAL NOT NULL, + + was_reachable BOOLEAN NOT NULL, + + test_timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + PRIMARY KEY (testrun_id, submitter_pubkey) +); diff --git a/nym-api/nym-api-requests/src/models/network_monitor.rs b/nym-api/nym-api-requests/src/models/network_monitor.rs index c253bd28ca..3075a76e88 100644 --- a/nym-api/nym-api-requests/src/models/network_monitor.rs +++ b/nym-api/nym-api-requests/src/models/network_monitor.rs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use crate::pagination::PaginatedResponse; +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey; use nym_mixnet_contract_common::NodeId; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -72,3 +74,111 @@ pub struct GatewayCoreStatusResponse { pub identity: String, pub count: i64, } + +pub use v3::*; + +/// Request/response types for the v3 network-monitor flow, in which an orchestrator submits +/// stress testing results to nym-api via signed batches. +pub mod v3 { + use super::*; + use crate::signable::SignedMessage; + use std::time::Duration; + use time::OffsetDateTime; + + /// Signed envelope posted by a network monitor orchestrator to + /// `POST /v3/nym-nodes/stress-testing/batch-submit`. + /// + /// The signature is checked against the `signer` field of the inner + /// [`StressTestBatchSubmissionContent`], which must also match one of the orchestrators + /// registered in the network-monitors contract. + pub type StressTestBatchSubmission = SignedMessage; + + /// Confirmation returned to an orchestrator after a successful submission. + /// Currently empty — exists to give the response an explicit type rather than + /// relying on `Json(())`. + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] + pub struct StressTestBatchSubmissionResponse {} + + /// Single stress-test measurement for one node, produced by a network monitor orchestrator. + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] + pub struct StressTestResult { + /// Orchestrator-local id of the test run that produced this result. Combined with the + /// batch's `signer` it uniquely identifies the measurement, allowing nym-api to dedupe + /// retried submissions on the at-least-once delivery path. + pub testrun_id: i64, + + /// Contract-assigned id of the node that was tested. + pub node_id: NodeId, + + /// Whether the tested node was acting as a mixnode during the measurement. + /// + /// Included explicitly (rather than inferred from on-chain role) so the API can reject or + /// route entries that don't match the expected role without re-querying the contract. + pub is_mixnode: bool, + + #[schema(value_type = String)] + #[serde(with = "time::serde::rfc3339")] + pub test_timestamp: OffsetDateTime, + + /// Measured performance score in the `[0.0, 1.0]` range. + pub test_performance: f64, + + /// Whether the node responded at all during testing. + /// + /// Recorded alongside `test_performance` so that a genuine 0.0 score (node responded but + /// dropped everything) can be distinguished from the node being offline entirely. + pub was_reachable: bool, + } + + /// Body of a stress-test batch submission, signed by a network monitor orchestrator. + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] + pub struct StressTestBatchSubmissionContent { + /// ed25519 identity key of the submitting orchestrator. Must match an entry in the + /// network-monitors contract for the batch to be accepted. + #[schema(value_type = String)] + #[serde(with = "ed25519::bs58_ed25519_pubkey")] + pub signer: ed25519::PublicKey, + + /// Time at which this batch was produced. Also used as a monotonic nonce for replay + /// protection: the API rejects submissions whose timestamp is not strictly greater than + /// the orchestrator's previous accepted submission. + #[schema(value_type = String)] + #[serde(with = "time::serde::rfc3339")] + pub timestamp: OffsetDateTime, + + pub results: Vec, + } + + impl StressTestBatchSubmissionContent { + /// Build a batch submission body stamped with the current UTC time. + pub fn new(signer: ed25519::PublicKey, results: Vec) -> Self { + StressTestBatchSubmissionContent { + signer, + timestamp: OffsetDateTime::now_utc(), + results, + } + } + + /// Whether this submission is older than `max_age` relative to the current UTC time. + /// + /// Used server-side to reject submissions that have been sitting around too long, even if + /// they are otherwise well-formed and correctly signed. + pub fn is_stale(&self, max_age: Duration) -> bool { + self.timestamp + max_age < OffsetDateTime::now_utc() + } + } + + /// Response body for `GET /v3/nym-nodes/stress-testing/known-monitors/{identity_key}`, + /// used by orchestrators to check whether this nym-api currently recognises their key. + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] + pub struct KnownNetworkMonitorResponse { + /// The ed25519 identity key that was queried (base58-encoded on the wire). + #[serde(with = "bs58_ed25519_pubkey")] + #[schema(value_type = String)] + pub identity_key: ed25519::PublicKey, + + /// Whether the queried identity key is currently recognised by this nym-api + /// as an authorised network monitor permitted to submit stress testing results. + pub authorised: bool, + } +} diff --git a/nym-api/nym-api-requests/src/models/node_status.rs b/nym-api/nym-api-requests/src/models/node_status.rs index 6e432c250e..58e55a057c 100644 --- a/nym-api/nym-api-requests/src/models/node_status.rs +++ b/nym-api/nym-api-requests/src/models/node_status.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::helpers::PlaceholderJsonSchemaImpl; +use crate::models::DisplayRole; use crate::pagination::PaginatedResponse; use cosmwasm_std::Decimal; use nym_contracts_common::{IdentityKey, NaiveFloat}; @@ -15,7 +16,6 @@ use std::time::Duration; use time::{Date, OffsetDateTime}; use utoipa::ToSchema; -use crate::models::DisplayRole; pub use config_score::*; pub type StakeSaturation = Decimal; @@ -406,17 +406,17 @@ pub struct NodePerformance { feature = "generate-ts", ts( export, - export_to = "ts-packages/types/src/types/rust/NodeAnnotation.ts" + export_to = "ts-packages/types/src/types/rust/NodeAnnotationV1.ts" ) )] -pub struct NodeAnnotation { +pub struct NodeAnnotationV1 { #[cfg_attr(feature = "generate-ts", ts(type = "string"))] // legacy #[schema(value_type = String)] pub last_24h_performance: Performance, pub current_role: Option, - pub detailed_performance: DetailedNodePerformance, + pub detailed_performance: DetailedNodePerformanceV1, } #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -425,11 +425,47 @@ pub struct NodeAnnotation { feature = "generate-ts", ts( export, - export_to = "ts-packages/types/src/types/rust/DetailedNodePerformance.ts" + export_to = "ts-packages/types/src/types/rust/NodeAnnotationV2.ts" + ) +)] +pub struct NodeAnnotationV2 { + pub current_role: Option, + + pub detailed_performance: DetailedNodePerformanceV2, +} + +impl From for NodeAnnotationV1 { + fn from(value: NodeAnnotationV2) -> Self { + // map it from 0-1 range into 0-100 + let scaled_performance = + value.detailed_performance.performance_score.clamp(0.0, 1.0) * 100.; + #[allow(clippy::unwrap_used)] + let legacy_performance = + Performance::from_percentage_value(scaled_performance as u64).unwrap(); + + NodeAnnotationV1 { + last_24h_performance: legacy_performance, + current_role: value.current_role, + detailed_performance: DetailedNodePerformanceV1 { + performance_score: value.detailed_performance.performance_score, + routing_score: value.detailed_performance.routing_score, + config_score: value.detailed_performance.config_score, + }, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/DetailedNodePerformanceV1.ts" ) )] #[non_exhaustive] -pub struct DetailedNodePerformance { +pub struct DetailedNodePerformanceV1 { /// routing_score * config_score pub performance_score: f64, @@ -437,12 +473,12 @@ pub struct DetailedNodePerformance { pub config_score: ConfigScore, } -impl DetailedNodePerformance { +impl DetailedNodePerformanceV1 { pub fn new( performance_score: f64, routing_score: RoutingScore, config_score: ConfigScore, - ) -> DetailedNodePerformance { + ) -> DetailedNodePerformanceV1 { Self { performance_score, routing_score, @@ -455,6 +491,47 @@ impl DetailedNodePerformance { } } +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/DetailedNodePerformanceV2.ts" + ) +)] +#[non_exhaustive] +pub struct DetailedNodePerformanceV2 { + /// routing_score * config_score + /// or + /// routing_score * config_score * stress_testing_score, if enabled + pub performance_score: f64, + + pub routing_score: RoutingScore, + pub config_score: ConfigScore, + pub stress_testing_score: StressTestingScore, +} + +impl DetailedNodePerformanceV2 { + pub fn new( + performance_score: f64, + routing_score: RoutingScore, + config_score: ConfigScore, + stress_testing_score: StressTestingScore, + ) -> DetailedNodePerformanceV2 { + Self { + performance_score, + routing_score, + config_score, + stress_testing_score, + } + } + + pub fn to_rewarding_performance(&self) -> Performance { + Performance::naive_try_from_f64(self.performance_score).unwrap_or_default() + } +} + #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] #[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] #[cfg_attr( @@ -481,6 +558,32 @@ impl RoutingScore { } } +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/StressTestingScore.ts" + ) +)] +pub struct StressTestingScore { + pub score: f64, + /// Distinguishes a genuine zero score (node was tested and scored 0) from + /// "node was unreachable" (no successful sample was collected). Consumers may use + /// this to decide whether to penalise the node or treat the score as missing. + pub was_reachable: bool, +} + +impl StressTestingScore { + pub fn unreachable() -> Self { + StressTestingScore { + score: 0.0, + was_reachable: false, + } + } +} + #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] #[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] #[cfg_attr( @@ -541,13 +644,28 @@ impl ConfigScore { feature = "generate-ts", ts( export, - export_to = "ts-packages/types/src/types/rust/AnnotationResponse.ts" + export_to = "ts-packages/types/src/types/rust/AnnotationResponseV1.ts" ) )] -pub struct AnnotationResponse { +pub struct AnnotationResponseV1 { #[schema(value_type = u32)] pub node_id: NodeId, - pub annotation: Option, + pub annotation: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/AnnotationResponseV2.ts" + ) +)] +pub struct AnnotationResponseV2 { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub annotation: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] diff --git a/nym-api/nym-api-requests/src/signable.rs b/nym-api/nym-api-requests/src/signable.rs index 5b1bb90584..a404716a2b 100644 --- a/nym-api/nym-api-requests/src/signable.rs +++ b/nym-api/nym-api-requests/src/signable.rs @@ -55,7 +55,8 @@ impl SignedMessage { pub(crate) mod sealed { use crate::ecash::models::*; use crate::models::{ - ChainBlocksStatusResponseBody, DetailedSignersStatusResponseBody, SignersStatusResponseBody, + v3, ChainBlocksStatusResponseBody, DetailedSignersStatusResponseBody, + SignersStatusResponseBody, }; pub trait Sealed {} @@ -72,4 +73,7 @@ pub(crate) mod sealed { impl Sealed for ChainBlocksStatusResponseBody {} impl Sealed for SignersStatusResponseBody {} impl Sealed for DetailedSignersStatusResponseBody {} + + // v3 stress testing + impl Sealed for v3::StressTestBatchSubmissionContent {} } diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 2640a25009..14e50cfbac 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -18,6 +18,7 @@ use crate::support::http::state::chain_status::ChainStatusCache; use crate::support::http::state::contract_details::ContractDetailsCache; use crate::support::http::state::force_refresh::ForcedRefresh; use crate::support::http::state::mixnet_contract_cache::MixnetContractCacheState; +use crate::support::http::state::network_monitors::{LastNMSubmissions, NetworkMonitorsCache}; use crate::support::http::state::node_annotations_cache::NodeAnnotationsCache; use crate::support::http::state::AppState; use crate::support::nyxd::Client; @@ -1301,7 +1302,9 @@ impl TestFixture { ecash_signers_cache: Default::default(), address_info_cache: AddressInfoCache::new(Duration::from_secs(42), 1000), forced_refresh: ForcedRefresh::new(true), + network_monitor_submissions: LastNMSubmissions::new(), mixnet_contract_cache, + network_monitors_cache: NetworkMonitorsCache::new(Duration::from_secs(42)), node_annotations_cache, storage, described_nodes_cache: SharedCache::::new(), diff --git a/nym-api/src/epoch_operations/helpers.rs b/nym-api/src/epoch_operations/helpers.rs index a353dc0d29..bd41a72ac8 100644 --- a/nym-api/src/epoch_operations/helpers.rs +++ b/nym-api/src/epoch_operations/helpers.rs @@ -4,7 +4,7 @@ use crate::epoch_operations::EpochAdvancer; use crate::support::caching::cache::UninitialisedCache; use cosmwasm_std::{Decimal, Fraction}; -use nym_api_requests::models::NodeAnnotation; +use nym_api_requests::models::NodeAnnotationV2; use nym_mixnet_contract_common::helpers::IntoBaseDecimal; use nym_mixnet_contract_common::reward_params::{NodeRewardingParameters, Performance, WorkFactor}; use nym_mixnet_contract_common::{ @@ -211,7 +211,7 @@ fn determine_per_node_work( impl EpochAdvancer { fn load_performance( status_cache: &Result< - RwLockReadGuard<'_, HashMap>, + RwLockReadGuard<'_, HashMap>, UninitialisedCache, >, node_id: NodeId, diff --git a/nym-api/src/network_monitor/monitor/preparer.rs b/nym-api/src/network_monitor/monitor/preparer.rs index 10e8f6a48d..cadb38c223 100644 --- a/nym-api/src/network_monitor/monitor/preparer.rs +++ b/nym-api/src/network_monitor/monitor/preparer.rs @@ -8,8 +8,7 @@ use crate::node_describe_cache::cache::DescribedNodes; use crate::node_describe_cache::NodeDescriptionTopologyExt; use crate::node_status_api::NodeStatusCache; use crate::support::caching::cache::SharedCache; -use nym_api_requests::models::{NodeAnnotation, NymNodeDescriptionV2}; -use nym_contracts_common::NaiveFloat; +use nym_api_requests::models::{NodeAnnotationV2, NymNodeDescriptionV2}; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_mixnet_contract_common::{LegacyMixLayer, NodeId}; use nym_node_tester_utils::node::{NodeType, TestableNode}; @@ -205,7 +204,7 @@ impl PacketPreparer { &self, rng: &mut R, current_rotation_id: u32, - node_statuses: &HashMap, + node_statuses: &HashMap, mixing_nym_nodes: impl Iterator + 'a, ) -> HashMap> { let mut layered_mixes = HashMap::new(); @@ -219,7 +218,7 @@ impl PacketPreparer { // if the node is not present, default to 0.5 let weight = node_statuses .get(&mixing_nym_node.node_id) - .map(|node| node.last_24h_performance.naive_to_f64()) + .map(|node| node.detailed_performance.performance_score) .unwrap_or(0.5); let layer = self.random_legacy_layer(rng); let layer_mixes = layered_mixes.entry(layer).or_insert_with(Vec::new); @@ -245,7 +244,7 @@ impl PacketPreparer { fn to_legacy_gateway_nodes<'a>( &self, current_rotation_id: u32, - node_statuses: &HashMap, + node_statuses: &HashMap, gateway_capable_nym_nodes: impl Iterator + 'a, ) -> Vec<(RoutingNode, f64)> { let mut gateways = Vec::new(); @@ -259,7 +258,7 @@ impl PacketPreparer { // if the node is not present, default to 0.5 let weight = node_statuses .get(&gateway_capable_node.node_id) - .map(|node| node.last_24h_performance.naive_to_f64()) + .map(|node| node.detailed_performance.performance_score) .unwrap_or(0.5); gateways.push((parsed_node, weight)) } diff --git a/nym-api/src/node_describe_cache/mod.rs b/nym-api/src/node_describe_cache/mod.rs index 976904ce2c..512b4c5d9e 100644 --- a/nym-api/src/node_describe_cache/mod.rs +++ b/nym-api/src/node_describe_cache/mod.rs @@ -3,7 +3,6 @@ use crate::support::caching::cache::UninitialisedCache; use nym_api_requests::models::{NymNodeDescriptionV1, NymNodeDescriptionV2}; -use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT; use nym_mixnet_contract_common::NodeId; use nym_node_requests::api::client::NymNodeApiClientError; use nym_topology::node::RoutingNodeError; @@ -23,18 +22,11 @@ pub enum NodeDescribeCacheError { source: UninitialisedCache, }, - #[error("node {node_id} has provided malformed host information ({host}: {source}")] - MalformedHost { - host: String, + #[error(transparent)] + ClientRetrievalFailure(#[from] nym_node_requests::error::Error), - node_id: NodeId, - - #[source] - source: Box, - }, - - #[error("node {node_id} with host '{host}' doesn't seem to expose its declared http port nor any of the standard API ports, i.e.: 80, 443 or {}", DEFAULT_NYM_NODE_HTTP_PORT)] - NoHttpPortsAvailable { host: String, node_id: NodeId }, + #[error("failed to retrieve host information of node {node_id} - this is most likely a bug")] + NoHostInformationAvailable { node_id: NodeId, host: String }, #[error("failed to query node {node_id}: {source}")] ApiFailure { @@ -44,17 +36,6 @@ pub enum NodeDescribeCacheError { source: Box, }, - // TODO: perhaps include more details here like whether key/signature/payload was malformed - #[error("could not verify signed host information for node {node_id}")] - MissignedHostInformation { node_id: NodeId }, - - #[error("identity of node {node_id} does not match. expected {expected} but got {got}")] - MismatchedIdentity { - node_id: NodeId, - expected: String, - got: String, - }, - #[error("node {node_id} is announcing an illegal ip address")] IllegalIpAddress { node_id: NodeId }, } diff --git a/nym-api/src/node_describe_cache/refresh.rs b/nym-api/src/node_describe_cache/refresh.rs index 801aa5dc29..3a52e688a6 100644 --- a/nym-api/src/node_describe_cache/refresh.rs +++ b/nym-api/src/node_describe_cache/refresh.rs @@ -5,13 +5,10 @@ use crate::node_describe_cache::query_helpers::query_for_described_data; use crate::node_describe_cache::NodeDescribeCacheError; use nym_api_requests::models::{DescribedNodeTypeV2, NymNodeDescriptionV2}; use nym_bin_common::bin_info; -use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT; use nym_crypto::asymmetric::ed25519; use nym_mixnet_contract_common::{NodeId, NymNodeDetails}; -use nym_node_requests::api::client::NymNodeApiClientExt; -use nym_validator_client::UserAgent; -use std::time::Duration; -use tracing::debug; +use nym_node_requests::api::helpers::NymNodeApiClientRetriever; +use tracing::{debug, error}; #[derive(Debug)] pub(crate) struct RefreshData { @@ -69,93 +66,39 @@ impl RefreshData { } } -async fn try_get_client( - host: &str, - node_id: NodeId, - custom_port: Option, -) -> Result { - // first try the standard port in case the operator didn't put the node behind the proxy, - // then default https (443) - // finally default http (80) - let mut addresses_to_try = vec![ - format!("http://{host}:{DEFAULT_NYM_NODE_HTTP_PORT}"), // 'standard' nym-node - format!("https://{host}"), // node behind https proxy (443) - format!("http://{host}"), // node behind http proxy (80) - ]; - - // note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via - // the 'custom_port' since it should have been present in the contract. - - if let Some(port) = custom_port { - addresses_to_try.insert(0, format!("http://{host}:{port}")); - } - - for address in addresses_to_try { - // if provided host was malformed, no point in continuing - let client = match nym_node_requests::api::Client::builder(address).and_then(|b| { - b.with_timeout(Duration::from_secs(5)) - .no_hickory_dns() - .with_user_agent(UserAgent::from(bin_info!())) - .build() - }) { - Ok(client) => client, - Err(err) => { - return Err(NodeDescribeCacheError::MalformedHost { - host: host.to_string(), - node_id, - source: Box::new(err), - }); - } - }; - - if let Ok(health) = client.get_health().await { - if health.status.is_up() { - return Ok(client); - } - } - } - - Err(NodeDescribeCacheError::NoHttpPortsAvailable { - host: host.to_string(), - node_id, - }) -} - async fn try_get_description( data: RefreshData, allow_all_ips: bool, ) -> Result { - let client = try_get_client(&data.host, data.node_id, data.port).await?; + let client = NymNodeApiClientRetriever::new(bin_info!()) + .with_expected_identity(Some(data.expected_identity.to_base58_string())) + .with_verify_host_information() + .with_custom_port(data.port) + .get_client(&data.host, data.node_id) + .await?; - let map_query_err = |err| NodeDescribeCacheError::ApiFailure { - node_id: data.node_id, - source: Box::new(err), + let host_info = match client.host_information { + Some(host_info) => host_info, + // this branch should be impossible unless unexpected code changes occurred + None => { + error!( + "failed to retrieve host information of node {} - this is most likely a bug", + data.node_id + ); + return Err(NodeDescribeCacheError::NoHostInformationAvailable { + node_id: data.node_id, + host: data.host.to_string(), + }); + } }; - let host_info = client.get_host_information().await.map_err(map_query_err)?; - - // check if the identity key matches the information provided during bonding - if data.expected_identity != host_info.keys.ed25519_identity { - return Err(NodeDescribeCacheError::MismatchedIdentity { - node_id: data.node_id, - expected: data.expected_identity.to_base58_string(), - got: host_info.keys.ed25519_identity.to_base58_string(), - }); - } - - if !host_info.verify_host_information() { - return Err(NodeDescribeCacheError::MissignedHostInformation { - node_id: data.node_id, - }); - } - if !allow_all_ips && !host_info.data.check_ips() { return Err(NodeDescribeCacheError::IllegalIpAddress { node_id: data.node_id, }); } - let node_info = query_for_described_data(&client, data.node_id).await?; + let node_info = query_for_described_data(&client.client, data.node_id).await?; let description = node_info.into_node_description(host_info.data); Ok(NymNodeDescriptionV2 { diff --git a/nym-api/src/node_performance/provider/contract_provider.rs b/nym-api/src/node_performance/provider/contract_provider.rs index 29923ccd20..ce9928df52 100644 --- a/nym-api/src/node_performance/provider/contract_provider.rs +++ b/nym-api/src/node_performance/provider/contract_provider.rs @@ -68,7 +68,7 @@ impl ContractPerformanceProvider { pub(crate) async fn node_routing_scores( &self, - node_ids: Vec, + node_ids: &[NodeId], epoch_id: EpochId, ) -> Result { let Some(first) = node_ids.first() else { @@ -84,7 +84,7 @@ impl ContractPerformanceProvider { })?; let mut scores = HashMap::new(); - for node_id in node_ids { + for &node_id in node_ids { let score = self.node_routing_score_with_fallback(&contract_cache, node_id, epoch_id); scores.insert(node_id, score); } diff --git a/nym-api/src/node_performance/provider/legacy_storage_provider.rs b/nym-api/src/node_performance/provider/legacy_storage_provider.rs index 99a886a19b..953421b0f6 100644 --- a/nym-api/src/node_performance/provider/legacy_storage_provider.rs +++ b/nym-api/src/node_performance/provider/legacy_storage_provider.rs @@ -2,55 +2,66 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::mixnet_contract_cache::cache::MixnetContractCache; -use crate::node_performance::provider::PerformanceRetrievalFailure; +use crate::node_performance::provider::{NodesStressTestingScores, PerformanceRetrievalFailure}; use crate::support::caching::cache::UninitialisedCache; use crate::support::storage::NymApiStorage; -use nym_api_requests::models::RoutingScore; +use nym_api_requests::models::{RoutingScore, StressTestingScore}; use nym_mixnet_contract_common::{EpochId, NodeId}; +use std::collections::HashMap; +use std::time::Duration; +use time::OffsetDateTime; pub(crate) struct LegacyStoragePerformanceProvider { + /// Specifies the duration of the rolling average used for stress testing score. + stress_testing_data_period: Duration, + storage: NymApiStorage, mixnet_contract_cache: MixnetContractCache, } impl LegacyStoragePerformanceProvider { - pub(crate) fn new(storage: NymApiStorage, mixnet_contract_cache: MixnetContractCache) -> Self { + pub(crate) fn new( + storage: NymApiStorage, + mixnet_contract_cache: MixnetContractCache, + stress_testing_data_period: Duration, + ) -> Self { LegacyStoragePerformanceProvider { + stress_testing_data_period, storage, mixnet_contract_cache, } } - async fn map_epoch_id_to_end_unix_timestamp( + async fn map_epoch_id_to_end_timestamp( &self, epoch_id: EpochId, - ) -> Result { + ) -> Result { let interval_details = self.mixnet_contract_cache.current_interval().await?; let duration = interval_details.epoch_length(); let current_end = interval_details.current_epoch_end(); let current_id = interval_details.current_epoch_absolute_id(); if current_id == epoch_id { - return Ok(current_end.unix_timestamp()); + return Ok(current_end); } if current_id < epoch_id { let diff = epoch_id - current_id; let end = current_end + diff * duration; - return Ok(end.unix_timestamp()); + return Ok(end); } // epoch_id > current_id let diff = current_id - epoch_id; let end = current_end - diff * duration; - Ok(end.unix_timestamp()) + Ok(end) } - pub(crate) async fn epoch_id_unix_timestamp( + pub(crate) async fn epoch_id_timestamp( &self, epoch_id: EpochId, - ) -> Result { - self.map_epoch_id_to_end_unix_timestamp(epoch_id) + ) -> Result { + self.map_epoch_id_to_end_timestamp(epoch_id) .await .map_err(|_| { PerformanceRetrievalFailure::new( @@ -66,7 +77,7 @@ impl LegacyStoragePerformanceProvider { node_id: NodeId, epoch_id: EpochId, ) -> Result { - let end_ts = self.epoch_id_unix_timestamp(epoch_id).await?; + let end_ts = self.epoch_id_timestamp(epoch_id).await?.unix_timestamp(); self.get_node_routing_score_with_unix_timestamp(node_id, epoch_id, end_ts) .await } @@ -88,4 +99,56 @@ impl LegacyStoragePerformanceProvider { let score = reliability / 100.; Ok(RoutingScore::new(score as f64)) } + + pub(crate) async fn node_stress_testing_score( + &self, + node_id: NodeId, + epoch_id: EpochId, + ) -> Result { + let end_ts = self.epoch_id_timestamp(epoch_id).await?; + let start_ts = end_ts - self.stress_testing_data_period; + + self.node_stress_testing_score_in_range(node_id, epoch_id, start_ts, end_ts) + .await + } + + pub(crate) async fn node_stress_testing_score_in_range( + &self, + node_id: NodeId, + epoch_id: EpochId, + start_ts: OffsetDateTime, + end_ts: OffsetDateTime, + ) -> Result { + let result = self + .storage + .get_average_node_stress_test_score(node_id, start_ts, end_ts) + .await + .map_err(|err| PerformanceRetrievalFailure::new(node_id, epoch_id, err.to_string()))?; + + match result { + None => Ok(StressTestingScore::unreachable()), + Some(result) => Ok(result.into()), + } + } + + pub(crate) async fn get_node_stress_testing_scores( + &self, + node_ids: &[NodeId], + epoch_id: EpochId, + ) -> Result { + let mut scores = HashMap::new(); + + let end_ts = self.epoch_id_timestamp(epoch_id).await?; + let start_ts = end_ts - self.stress_testing_data_period; + + for &node_id in node_ids { + scores.insert( + node_id, + self.node_stress_testing_score_in_range(node_id, epoch_id, start_ts, end_ts) + .await, + ); + } + + Ok(NodesStressTestingScores { inner: scores }) + } } diff --git a/nym-api/src/node_performance/provider/mod.rs b/nym-api/src/node_performance/provider/mod.rs index f8b1bf61a4..43446e446e 100644 --- a/nym-api/src/node_performance/provider/mod.rs +++ b/nym-api/src/node_performance/provider/mod.rs @@ -4,11 +4,11 @@ use crate::node_performance::provider::contract_provider::ContractPerformanceProvider; use async_trait::async_trait; use legacy_storage_provider::LegacyStoragePerformanceProvider; -use nym_api_requests::models::RoutingScore; +use nym_api_requests::models::{RoutingScore, StressTestingScore}; use nym_mixnet_contract_common::{EpochId, NodeId}; use std::collections::HashMap; use thiserror::Error; -use tracing::debug; +use tracing::{debug, error}; pub(crate) mod contract_provider; pub(crate) mod legacy_storage_provider; @@ -31,6 +31,41 @@ impl PerformanceRetrievalFailure { } } +pub(crate) struct NodesStressTestingScores { + inner: HashMap>, +} + +impl NodesStressTestingScores { + pub(crate) fn get_or_log(&self, node_id: NodeId) -> StressTestingScore { + match self.inner.get(&node_id) { + Some(Ok(score)) => *score, + Some(Err(err)) => { + debug!("{err}"); + StressTestingScore::unreachable() + } + None => StressTestingScore::unreachable(), + } + } + + /// Number of nodes for which the orchestrator has produced at least one reachable sample + /// in the configured window. Used by the refresher to gate whether stress-testing data is + /// applied at all: if the orchestrator is down or has not yet submitted anything, this + /// returns 0 and the refresher falls back to routing × config score only. + /// + /// Note: nodes that were tested but found unreachable (`was_reachable=false`) intentionally + /// do **not** count here. Counting them would let a single recently-rebooted orchestrator + /// pass the threshold while every node it touched still scored 0. + pub(crate) fn available_count(&self) -> usize { + self.inner + .iter() + .filter(|(_, v)| match v { + Ok(score) => score.was_reachable, + Err(_) => false, + }) + .count() + } +} + pub(crate) struct NodesRoutingScores { inner: HashMap>, } @@ -57,24 +92,39 @@ impl NodesRoutingScores { pub(crate) trait NodePerformanceProvider { /// Obtain a performance/routing score of a particular node for given epoch #[allow(unused)] - async fn get_node_score( + async fn get_node_routing_score( &self, node_id: NodeId, epoch_id: EpochId, ) -> Result; /// An optimisation for obtaining node scores of multiple nodes at once - async fn get_batch_node_scores( + async fn get_batch_node_routing_scores( &self, - node_ids: Vec, + node_ids: &[NodeId], epoch_id: EpochId, ) -> Result; + + /// Obtain a stress-testing score of a particular node for given epoch + #[allow(unused)] + async fn get_node_stress_testing_score( + &self, + node_id: NodeId, + epoch_id: EpochId, + ) -> Result; + + /// An optimisation for obtaining node scores of multiple nodes at once + async fn get_batch_node_stress_testing_scores( + &self, + node_ids: &[NodeId], + epoch_id: EpochId, + ) -> Result; } #[async_trait] impl NodePerformanceProvider for ContractPerformanceProvider { #[allow(unused)] - async fn get_node_score( + async fn get_node_routing_score( &self, node_id: NodeId, epoch_id: EpochId, @@ -82,19 +132,45 @@ impl NodePerformanceProvider for ContractPerformanceProvider { self.node_routing_score(node_id, epoch_id).await } - async fn get_batch_node_scores( + async fn get_batch_node_routing_scores( &self, - node_ids: Vec, + node_ids: &[NodeId], epoch_id: EpochId, ) -> Result { self.node_routing_scores(node_ids, epoch_id).await } + + async fn get_node_stress_testing_score( + &self, + node_id: NodeId, + epoch_id: EpochId, + ) -> Result { + error!("stress testing data not available in contract data"); + Err(PerformanceRetrievalFailure { + node_id, + epoch_id, + error: "stress testing data not available in contract data".to_string(), + }) + } + + async fn get_batch_node_stress_testing_scores( + &self, + _: &[NodeId], + epoch_id: EpochId, + ) -> Result { + error!("stress testing data not available in contract data"); + Err(PerformanceRetrievalFailure { + node_id: 0, + epoch_id, + error: "stress testing data not available in contract data".to_string(), + }) + } } #[async_trait] impl NodePerformanceProvider for LegacyStoragePerformanceProvider { #[allow(unused)] - async fn get_node_score( + async fn get_node_routing_score( &self, node_id: NodeId, epoch_id: EpochId, @@ -102,15 +178,15 @@ impl NodePerformanceProvider for LegacyStoragePerformanceProvider { self.node_routing_score(node_id, epoch_id).await } - async fn get_batch_node_scores( + async fn get_batch_node_routing_scores( &self, - node_ids: Vec, + node_ids: &[NodeId], epoch_id: EpochId, ) -> Result { let mut scores = HashMap::new(); - let epoch_timestamp = self.epoch_id_unix_timestamp(epoch_id).await?; - for node_id in node_ids { + let epoch_timestamp = self.epoch_id_timestamp(epoch_id).await?.unix_timestamp(); + for &node_id in node_ids { scores.insert( node_id, self.get_node_routing_score_with_unix_timestamp(node_id, epoch_id, epoch_timestamp) @@ -120,4 +196,22 @@ impl NodePerformanceProvider for LegacyStoragePerformanceProvider { Ok(NodesRoutingScores { inner: scores }) } + + #[allow(unused)] + async fn get_node_stress_testing_score( + &self, + node_id: NodeId, + epoch_id: EpochId, + ) -> Result { + self.node_stress_testing_score(node_id, epoch_id).await + } + + async fn get_batch_node_stress_testing_scores( + &self, + node_ids: &[NodeId], + epoch_id: EpochId, + ) -> Result { + self.get_node_stress_testing_scores(node_ids, epoch_id) + .await + } } diff --git a/nym-api/src/node_status_api/cache/data.rs b/nym-api/src/node_status_api/cache/data.rs index 0457d4c8c8..50a9deff97 100644 --- a/nym-api/src/node_status_api/cache/data.rs +++ b/nym-api/src/node_status_api/cache/data.rs @@ -1,7 +1,7 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_api_requests::models::NodeAnnotation; +use nym_api_requests::models::NodeAnnotationV2; use nym_mixnet_contract_common::NodeId; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -10,11 +10,11 @@ use std::collections::HashMap; #[allow(deprecated)] pub(crate) struct NodeStatusCacheData { /// Basic annotation for nym-nodes - pub(crate) node_annotations: HashMap, + pub(crate) node_annotations: HashMap, } -impl From> for NodeStatusCacheData { - fn from(node_annotations: HashMap) -> Self { +impl From> for NodeStatusCacheData { + fn from(node_annotations: HashMap) -> Self { NodeStatusCacheData { node_annotations } } } diff --git a/nym-api/src/node_status_api/cache/mod.rs b/nym-api/src/node_status_api/cache/mod.rs index 9a16762c32..9f41f176ef 100644 --- a/nym-api/src/node_status_api/cache/mod.rs +++ b/nym-api/src/node_status_api/cache/mod.rs @@ -5,7 +5,7 @@ use self::data::NodeStatusCacheData; use crate::node_performance::provider::PerformanceRetrievalFailure; use crate::support::caching::cache::{SharedCache, UninitialisedCache}; use crate::support::caching::Cache; -use nym_api_requests::models::NodeAnnotation; +use nym_api_requests::models::NodeAnnotationV2; use nym_mixnet_contract_common::NodeId; use std::collections::HashMap; use std::path::Path; @@ -73,7 +73,7 @@ impl NodeStatusCache { } /// Updates the cache with the latest data. - async fn update(&self, node_annotations: HashMap) { + async fn update(&self, node_annotations: HashMap) { if self .inner .try_overwrite_old_value(node_annotations, "node-status") @@ -100,7 +100,7 @@ impl NodeStatusCache { pub(crate) async fn node_annotations( &self, - ) -> Result>, UninitialisedCache> { + ) -> Result>, UninitialisedCache> { self.get(|c| &c.node_annotations).await } } diff --git a/nym-api/src/node_status_api/cache/refresher.rs b/nym-api/src/node_status_api/cache/refresher.rs index bf5d0033c5..8b56a9ffb1 100644 --- a/nym-api/src/node_status_api/cache/refresher.rs +++ b/nym-api/src/node_status_api/cache/refresher.rs @@ -4,32 +4,52 @@ use super::NodeStatusCache; use crate::mixnet_contract_cache::cache::data::ConfigScoreData; use crate::node_describe_cache::cache::DescribedNodes; -use crate::node_performance::provider::{NodePerformanceProvider, NodesRoutingScores}; +use crate::node_performance::provider::{ + NodePerformanceProvider, NodesRoutingScores, NodesStressTestingScores, +}; use crate::node_status_api::cache::config_score::calculate_config_score; -use crate::node_status_api::models::Uptime; use crate::support::caching::cache::SharedCache; use crate::support::caching::refresher::RefreshRequester; +use crate::support::caching::CacheNotificationWatcher; use crate::{ mixnet_contract_cache::cache::MixnetContractCache, node_status_api::cache::NodeStatusCacheError, support::caching::CacheNotification, }; use ::time::OffsetDateTime; -use nym_api_requests::models::{DetailedNodePerformance, NodeAnnotation}; +use nym_api_requests::models::{DetailedNodePerformanceV2, NodeAnnotationV2}; use nym_mixnet_contract_common::{NodeId, NymNodeDetails}; use nym_task::ShutdownToken; use nym_topology::CachedEpochRewardedSet; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; -use tokio::sync::watch; use tokio::time; use tracing::{error, info, trace, warn}; +pub(crate) struct NodeStatusCacheConfig { + pub(crate) fallback_caching_interval: Duration, + + /// Specify whether external stress testing data should be used for calculating node performance + /// score used for rewarding and active set selection + /// note: this can only be enabled if use_performance_contract_data is set to false! + pub use_stress_testing_data: bool, + + /// If `use_stress_testing_data` is set to true, this specifies the minimum % of nodes, + /// that must have their stress data available in the `stress_testing_data_period`, + /// in order to include that metric in performance calculation. + /// This is done to protect against Network Monitor failures and not receiving any data. + pub minimum_available_stress_testing_results: f32, + + /// If use_stress_testing_data is enabled, specifies the weight of the stress testing score in the overall performance score. + pub stress_testing_score_weight: f64, +} + // Long running task responsible for keeping the node status cache up-to-date. pub struct NodeStatusCacheRefresher { + config: NodeStatusCacheConfig, + // Main stored data cache: NodeStatusCache, - fallback_caching_interval: Duration, // Sources for when refreshing data mixnet_contract_cache: MixnetContractCache, @@ -37,11 +57,11 @@ pub struct NodeStatusCacheRefresher { /// channel notifying us when mixnet cache has been refreshed, /// so that this cache could also be recreated - mixnet_contract_cache_listener: watch::Receiver, + mixnet_contract_cache_listener: CacheNotificationWatcher, /// channel notifying us when the describe cache has been refreshed, /// so that this cache could also be recreated - describe_cache_listener: watch::Receiver, + describe_cache_listener: CacheNotificationWatcher, /// channel explicitly requesting cache refresh. it does not follow the usual rate limiting refresh_requester: RefreshRequester, @@ -57,17 +77,17 @@ impl NodeStatusCacheRefresher { #[allow(clippy::too_many_arguments)] pub(crate) fn new( cache: NodeStatusCache, - fallback_caching_interval: Duration, + config: NodeStatusCacheConfig, contract_cache: MixnetContractCache, described_cache: SharedCache, - contract_cache_listener: watch::Receiver, - describe_cache_listener: watch::Receiver, + contract_cache_listener: CacheNotificationWatcher, + describe_cache_listener: CacheNotificationWatcher, performance_provider: Box, on_disk_file: PathBuf, ) -> Self { Self { cache, - fallback_caching_interval, + config, mixnet_contract_cache: contract_cache, described_cache, mixnet_contract_cache_listener: contract_cache_listener, @@ -85,7 +105,7 @@ impl NodeStatusCacheRefresher { /// Runs the node status cache refresher task. pub async fn run(&mut self, shutdown_token: ShutdownToken) { let mut last_update = OffsetDateTime::now_utc(); - let mut fallback_interval = time::interval(self.fallback_caching_interval); + let mut fallback_interval = time::interval(self.config.fallback_caching_interval); loop { tokio::select! { biased; @@ -171,7 +191,7 @@ impl NodeStatusCacheRefresher { return; } - if OffsetDateTime::now_utc() - *last_updated < self.fallback_caching_interval { + if OffsetDateTime::now_utc() - *last_updated < self.config.fallback_caching_interval { // don't update too often trace!("not updating the cache since they've been updated recently"); return; @@ -186,32 +206,65 @@ impl NodeStatusCacheRefresher { &self, config_score_data: &ConfigScoreData, routing_scores: &NodesRoutingScores, + stress_testing_scores: &NodesStressTestingScores, nym_nodes: &[NymNodeDetails], rewarded_set: &CachedEpochRewardedSet, described_nodes: &DescribedNodes, - ) -> HashMap { + ) -> HashMap { let mut annotations = HashMap::new(); + if nym_nodes.is_empty() { + return annotations; + } + + let use_stress_testing_scores = self.config.use_stress_testing_data; + let threshold = self.config.minimum_available_stress_testing_results; + let available_ratio = + stress_testing_scores.available_count() as f32 / nym_nodes.len() as f32; + + // Guard against an orchestrator outage silently slashing every node's performance: + // if too few nodes have a reachable stress-test sample for the configured window we + // assume the orchestrator (rather than the network) is at fault and fall back to the + // routing × config score alone. The threshold is a network-wide ratio, not per-node — + // once it is met, individual nodes without data still score 0 in the weighted formula + // by design, on the assumption that the orchestrator tried to test them and failed. + let include_stress_testing = use_stress_testing_scores && available_ratio >= threshold; + + if use_stress_testing_scores && !include_stress_testing { + info!( + "not using stress testing data for performance calculation: \ + available ratio {available_ratio:.3} is below threshold {threshold:.3}" + ); + } + + // stress testing + let sw = self.config.stress_testing_score_weight; + + // not stress testing + let nsw = 1.0 - sw; for nym_node in nym_nodes { let node_id = nym_node.node_id(); let routing_score = routing_scores.get_or_log(node_id); let config_score = calculate_config_score(config_score_data, described_nodes.get_node(&node_id)); + let stress_testing_score = stress_testing_scores.get_or_log(node_id); - let performance = routing_score.score * config_score.score; - // map it from 0-1 range into 0-100 - let scaled_performance = performance * 100.; - let legacy_performance = Uptime::new(scaled_performance as f32).into(); + let performance = if include_stress_testing { + // use weighted arithmetic mean (we don't want a single 0 to cause the whole thing to be 0) + sw * stress_testing_score.score + nsw * routing_score.score * config_score.score + } else { + routing_score.score * config_score.score + }; annotations.insert( nym_node.node_id(), - NodeAnnotation { - last_24h_performance: legacy_performance, + NodeAnnotationV2 { current_role: rewarded_set.role(nym_node.node_id()).map(|r| r.into()), - detailed_performance: DetailedNodePerformance::new( + detailed_performance: DetailedNodePerformanceV2::new( performance, routing_score, config_score, + stress_testing_score, ), }, ); @@ -238,12 +291,20 @@ impl NodeStatusCacheRefresher { let all_ids = nym_nodes .iter() .map(|n| n.bond_information.node_id) - .collect(); + .collect::>(); // note: any internal errors imply failures for that node in particular let routing_scores = self .performance_provider - .get_batch_node_scores(all_ids, current_interval.current_epoch_absolute_id()) + .get_batch_node_routing_scores(&all_ids, current_interval.current_epoch_absolute_id()) + .await?; + + let stress_testing_scores = self + .performance_provider + .get_batch_node_stress_testing_scores( + &all_ids, + current_interval.current_epoch_absolute_id(), + ) .await?; // Create annotated data @@ -251,6 +312,7 @@ impl NodeStatusCacheRefresher { .produce_node_annotations( &config_score_data, &routing_scores, + &stress_testing_scores, &nym_nodes, &rewarded_set, &described, diff --git a/nym-api/src/node_status_api/handlers/without_monitor.rs b/nym-api/src/node_status_api/handlers/without_monitor.rs index 08d8b7c9da..882be3b403 100644 --- a/nym-api/src/node_status_api/handlers/without_monitor.rs +++ b/nym-api/src/node_status_api/handlers/without_monitor.rs @@ -10,6 +10,7 @@ use axum::Router; use nym_types::monitoring::MonitorMessage; use tracing::error; +#[allow(deprecated)] pub(super) fn mandatory_routes() -> Router { Router::new() .route( @@ -33,6 +34,7 @@ pub(super) fn mandatory_routes() -> Router { (status = 500, body = String, description = "TBD"), ), )] +#[deprecated] pub(crate) async fn submit_gateway_monitoring_results( State(state): State, Json(message): Json, @@ -77,6 +79,7 @@ pub(crate) async fn submit_gateway_monitoring_results( (status = 500, body = String, description = "TBD"), ), )] +#[deprecated] pub(crate) async fn submit_node_monitoring_results( State(state): State, Json(message): Json, diff --git a/nym-api/src/node_status_api/mod.rs b/nym-api/src/node_status_api/mod.rs index afd8265e35..00bb136e8c 100644 --- a/nym-api/src/node_status_api/mod.rs +++ b/nym-api/src/node_status_api/mod.rs @@ -4,6 +4,7 @@ use self::cache::refresher::NodeStatusCacheRefresher; use crate::node_describe_cache::cache::DescribedNodes; use crate::node_performance::provider::NodePerformanceProvider; +use crate::node_status_api::cache::refresher::NodeStatusCacheConfig; use crate::support::caching::cache::SharedCache; use crate::support::caching::refresher::RefreshRequester; use crate::support::config; @@ -34,7 +35,7 @@ pub(crate) const ONE_DAY: Duration = Duration::from_secs(86400); /// caching interval that is twice the nym contract cache #[allow(clippy::too_many_arguments)] pub(crate) fn start_cache_refresh( - config: &config::NodeStatusAPI, + config: &config::Config, nym_contract_cache_state: &MixnetContractCache, described_cache: &SharedCache, node_status_cache_state: &NodeStatusCache, @@ -44,9 +45,22 @@ pub(crate) fn start_cache_refresh( on_disk_file: PathBuf, shutdown_manager: &ShutdownManager, ) -> RefreshRequester { + let config = NodeStatusCacheConfig { + fallback_caching_interval: config.node_status_api.debug.caching_interval, + use_stress_testing_data: config.performance_provider.debug.use_stress_testing_data, + minimum_available_stress_testing_results: config + .performance_provider + .debug + .minimum_available_stress_testing_results, + stress_testing_score_weight: config + .performance_provider + .debug + .stress_testing_score_weight, + }; + let mut nym_api_cache_refresher = NodeStatusCacheRefresher::new( node_status_cache_state.to_owned(), - config.debug.caching_interval, + config, nym_contract_cache_state.to_owned(), described_cache.clone(), nym_contract_cache_listener, diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index d242f328cf..38954f43e7 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -3,3 +3,4 @@ pub mod v1; pub mod v2; +pub mod v3; diff --git a/nym-api/src/nym_nodes/handlers/v1.rs b/nym-api/src/nym_nodes/handlers/v1.rs index ce004731ed..e1513eeb68 100644 --- a/nym-api/src/nym_nodes/handlers/v1.rs +++ b/nym-api/src/nym_nodes/handlers/v1.rs @@ -8,7 +8,7 @@ use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; use nym_api_requests::models::{ - AnnotationResponse, NodeDatePerformanceResponse, NodePerformanceResponse, NodeRefreshBody, + AnnotationResponseV1, NodeDatePerformanceResponse, NodePerformanceResponse, NodeRefreshBody, NoiseDetails, NymNodeDescriptionV1, PerformanceHistoryResponse, RewardedSetResponse, StakeSaturationResponse, UptimeHistoryResponse, }; @@ -265,9 +265,9 @@ async fn get_described_nodes( context_path = "/v1/nym-nodes", responses( (status = 200, content( - (AnnotationResponse = "application/json"), - (AnnotationResponse = "application/yaml"), - (AnnotationResponse = "application/bincode") + (AnnotationResponseV1 = "application/json"), + (AnnotationResponseV1 = "application/yaml"), + (AnnotationResponseV1 = "application/bincode") )) ), params(NodeIdParam, OutputParams), @@ -276,14 +276,14 @@ async fn get_node_annotation( Path(NodeIdParam { node_id }): Path, Query(output): Query, State(state): State, -) -> AxumResult> { +) -> AxumResult> { let output = output.output.unwrap_or_default(); let annotations = state.node_status_cache().node_annotations().await?; - Ok(output.to_response(AnnotationResponse { + Ok(output.to_response(AnnotationResponseV1 { node_id, - annotation: annotations.get(&node_id).cloned(), + annotation: annotations.get(&node_id).cloned().map(Into::into), })) } @@ -314,7 +314,7 @@ async fn get_current_node_performance( node_id, performance: annotations .get(&node_id) - .map(|n| n.last_24h_performance.naive_to_f64()), + .map(|n| n.detailed_performance.performance_score), })) } diff --git a/nym-api/src/nym_nodes/handlers/v2.rs b/nym-api/src/nym_nodes/handlers/v2.rs index ee49e575fa..75f60d1316 100644 --- a/nym-api/src/nym_nodes/handlers/v2.rs +++ b/nym-api/src/nym_nodes/handlers/v2.rs @@ -2,19 +2,20 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::node_status_api::models::AxumResult; -use crate::support::http::helpers::PaginationRequest; +use crate::support::http::helpers::{NodeIdParam, PaginationRequest}; use crate::support::http::state::AppState; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::Router; -use nym_api_requests::models::NymNodeDescriptionV2; +use nym_api_requests::models::{AnnotationResponseV2, NymNodeDescriptionV2}; use nym_api_requests::pagination::{PaginatedResponse, Pagination}; -use nym_http_api_common::FormattedResponse; +use nym_http_api_common::{FormattedResponse, OutputParams}; use tower_http::compression::CompressionLayer; pub(crate) fn routes() -> Router { Router::new() .route("/described", get(get_described_nodes)) + .route("/annotation/:node_id", get(get_node_annotation)) .layer(CompressionLayer::new()) } @@ -52,3 +53,30 @@ async fn get_described_nodes( data: descriptions, })) } + +#[utoipa::path( + tag = "Nym Nodes", + get, + path = "/annotation/{node_id}", + context_path = "/v2/nym-nodes", + responses( + (status = 200, content( + (AnnotationResponseV2 = "application/json"), + (AnnotationResponseV2 = "application/yaml"), + (AnnotationResponseV2 = "application/bincode") + )) + ), + params(NodeIdParam, OutputParams), +)] +async fn get_node_annotation( + Path(NodeIdParam { node_id }): Path, + Query(output): Query, + State(state): State, +) -> AxumResult> { + let annotations = state.node_status_cache().node_annotations().await?; + + Ok(output.get_output().to_response(AnnotationResponseV2 { + node_id, + annotation: annotations.get(&node_id).cloned(), + })) +} diff --git a/nym-api/src/nym_nodes/handlers/v3.rs b/nym-api/src/nym_nodes/handlers/v3.rs new file mode 100644 index 0000000000..20dbfe9bdc --- /dev/null +++ b/nym-api/src/nym_nodes/handlers/v3.rs @@ -0,0 +1,178 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::{ApiResult, AxumErrorResponse}; +use crate::support::http::state::AppState; +use crate::support::storage::models::NymNodeStressTestingResult; +use axum::extract::{Path, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use nym_api_requests::models::network_monitor::KnownNetworkMonitorResponse; +use nym_api_requests::models::{StressTestBatchSubmission, StressTestBatchSubmissionResponse}; +use nym_crypto::asymmetric::ed25519; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::error; + +/// Accept a batch of stress-test results from an authorised network monitor orchestrator. +/// +/// The batch is rejected unless all of the following hold: +/// - the submission timestamp is within a short staleness window of the current time, +/// - the signer's key is currently registered in the network-monitors contract, +/// - the submission timestamp is strictly greater than the signer's previous accepted submission +/// (timestamp-based replay protection, so orchestrators don't need to keep a nonce counter), +/// - the signature on the body verifies against the signer's key. +/// +/// Individual result entries that fail per-entry validation (non-mixnode role, performance score +/// outside `[0.0, 1.0]`) are logged as errors and dropped, but do not fail the batch. +#[utoipa::path( + tag = "Nym Nodes", + post, + path = "/stress-testing/batch-submit", + context_path = "/v3/nym-nodes", + responses( + (status = 200, description = "the submitted batch has been accepted and stored"), + (status = 400, description = "the submitted request is stale or replayed"), + (status = 401, description = "the submitted request was unauthorised or failed integrity check"), + ), +)] +async fn batch_submit_stress_testing_results( + State(state): State, + Json(body): Json, +) -> ApiResult> { + // 1. check if the request is not stale + // TODO: make the timeout configurable. currently we have an issue of no easy way of + // propagating config values, but hardcoding it to 30s is fine for now + if body.body.is_stale(Duration::from_secs(30)) { + return Err(AxumErrorResponse::bad_request( + "request is stale, please resubmit it with a fresh timestamp", + )); + } + + // 2. check if the sent public key is even in the authorised set + if !state + .network_monitors() + .is_authorised(&state.nyxd_client, &body.body.signer) + .await? + { + return Err(AxumErrorResponse::unauthorised( + "the provided public key does not correspond to any known network monitor", + )); + } + + // 3. check if the request is not replayed (i.e. timestamp is not smaller than the latest known submission) + let last_request = state + .network_monitor_submissions + .submitted(body.body.signer) + .await; + + // if we have no known requests, we might have just restarted + // so we use the time of when we came back online - it's impossible there were any other requests since then + let last_known = match last_request { + Some(last) => last, + None => { + let uptime = state.api_status.uptime(); + OffsetDateTime::now_utc() - uptime + } + }; + + if body.body.timestamp <= last_known { + return Err(AxumErrorResponse::bad_request( + "each request must have an explicitly greater timestamp than the previous one", + )); + } + + // 4. verify the signature on the request + if !body.verify_signature(&body.body.signer) { + return Err(AxumErrorResponse::unauthorised( + "the provided request failed integrity check", + )); + } + + // 5. update the latest submission timestamp + state + .network_monitor_submissions + .set_submitted(body.body.signer, body.body.timestamp) + .await; + + // 6. process received results + let signer = body.body.signer; + let mut mixnode_results = Vec::with_capacity(body.body.results.len()); + for result in body.body.results { + if !result.is_mixnode { + error!( + %signer, + node_id = result.node_id, + "received a stress testing result for a non-mixnode entry which should never happen - is the nym-api outdated?" + ); + continue; + } + if !(0.0..=1.0).contains(&result.test_performance) { + error!( + %signer, + node_id = result.node_id, + test_performance = result.test_performance, + "received a stress testing result with performance outside the [0, 1] range - is the monitor misconfigured?" + ); + continue; + } + mixnode_results.push(NymNodeStressTestingResult::from_submission(&signer, result)); + } + + state + .storage() + .insert_nym_node_stress_testing_results(mixnode_results) + .await?; + + Ok(Json(StressTestBatchSubmissionResponse {})) +} + +/// Report whether the given identity key is currently recognised by this nym-api as an +/// authorised network monitor orchestrator. +/// +/// Intended for orchestrators to self-check after (re)announcing their key on-chain - a +/// successful response with `authorised: true` means this nym-api has picked up the chain change +/// and is ready to accept stress-test submissions signed by that key. +#[utoipa::path( + tag = "Nym Nodes", + get, + path = "/stress-testing/known-monitors/{identity_key}", + context_path = "/v3/nym-nodes", + params( + ("identity_key" = String, Path, description = "base58-encoded ed25519 identity key of the queried network monitor"), + ), + responses( + (status = 200, body = KnownNetworkMonitorResponse), + (status = 400, description = "the provided identity key is not a valid base58-encoded ed25519 public key"), + ), +)] +async fn known_network_monitor( + Path(identity_key): Path, + State(state): State, +) -> ApiResult> { + let identity_key = ed25519::PublicKey::from_base58_string(&identity_key) + .map_err(|err| AxumErrorResponse::bad_request(format!("malformed identity key: {err}")))?; + + let known = state + .network_monitors() + .get_or_refresh(&state.nyxd_client) + .await?; + + let authorised = known.contains(&identity_key); + + Ok(Json(KnownNetworkMonitorResponse { + identity_key, + authorised, + })) +} + +fn stress_testing_routes() -> Router { + Router::new() + .route("/batch-submit", post(batch_submit_stress_testing_results)) + .route("/known-monitors/:identity_key", get(known_network_monitor)) +} + +/// Build the `/v3/nym-nodes` subtree hosting the v3 network-monitor endpoints. +pub(crate) fn routes() -> Router { + Router::new().nest("/stress-testing", stress_testing_routes()) +} diff --git a/nym-api/src/status/mod.rs b/nym-api/src/status/mod.rs index f7ff611d6d..2abb636267 100644 --- a/nym-api/src/status/mod.rs +++ b/nym-api/src/status/mod.rs @@ -6,6 +6,7 @@ use nym_bin_common::bin_info; use nym_bin_common::build_information::BinaryBuildInformation; use std::ops::Deref; use std::sync::Arc; +use std::time::Duration; use tokio::time::Instant; pub(crate) mod handlers; @@ -15,6 +16,12 @@ pub(crate) struct ApiStatusState { inner: Arc, } +impl ApiStatusState { + pub(crate) fn uptime(&self) -> Duration { + self.inner.startup_time.elapsed() + } +} + impl Deref for ApiStatusState { type Target = ApiStatusStateInner; fn deref(&self) -> &Self::Target { diff --git a/nym-api/src/support/caching/mod.rs b/nym-api/src/support/caching/mod.rs index babc6375d1..93682d3665 100644 --- a/nym-api/src/support/caching/mod.rs +++ b/nym-api/src/support/caching/mod.rs @@ -4,6 +4,8 @@ pub(crate) mod cache; pub(crate) mod refresher; +use tokio::sync::watch; + // don't break existing imports pub(crate) use cache::Cache; @@ -13,3 +15,5 @@ pub enum CacheNotification { Start, Updated, } + +pub type CacheNotificationWatcher = watch::Receiver; diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 2a120cc6fd..250e6bc000 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -27,6 +27,7 @@ use crate::support::http::state::chain_status::ChainStatusCache; use crate::support::http::state::contract_details::ContractDetailsCache; use crate::support::http::state::force_refresh::ForcedRefresh; use crate::support::http::state::mixnet_contract_cache::MixnetContractCacheState; +use crate::support::http::state::network_monitors::{LastNMSubmissions, NetworkMonitorsCache}; use crate::support::http::state::node_annotations_cache::NodeAnnotationsCache; use crate::support::http::state::AppState; use crate::support::http::{RouterBuilder, TASK_MANAGER_TIMEOUT_S}; @@ -297,6 +298,7 @@ async fn start_nym_api_tasks(mut config: Config) -> anyhow::Result anyhow::Result anyhow::Result/config` @@ -104,7 +111,7 @@ pub fn default_data_directory>(id: P) -> PathBuf { .join(DEFAULT_DATA_DIR) } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] pub struct Config { // additional metadata holding on-disk location of this config file #[serde(skip)] @@ -130,6 +137,9 @@ pub struct Config { #[serde(default)] pub contracts_info_cache: ContractsInfoCache, + #[serde(default)] + pub network_monitors_cache: NetworkMonitorsCache, + pub rewarding: Rewarding, #[serde(default)] @@ -159,6 +169,7 @@ impl Config { node_status_api: NodeStatusAPI::new_default(id.as_ref()), describe_cache: Default::default(), contracts_info_cache: Default::default(), + network_monitors_cache: Default::default(), rewarding: Default::default(), signers_cache: Default::default(), ecash_signer: EcashSigner::new_default(id.as_ref()), @@ -183,6 +194,15 @@ impl Config { NymApiPaths::new_default(&self.base.id).persistent_cache_directory; } + if self.performance_provider.debug.use_stress_testing_data + && self.performance_provider.use_performance_contract_data + { + bail!( + "[performance_provider.debug].use_stress_testing_data cannot be enabled while \ + [performance_provider].use_performance_contract_data is also enabled" + ) + } + self.ecash_signer.validate()?; Ok(()) @@ -359,6 +379,24 @@ impl Default for ContractsInfoCache { } } +/// Configuration for the in-memory cache of authorised network-monitor orchestrators. +/// +/// Controls how often nym-api re-queries the network-monitors contract for the authorised set; +/// a new orchestrator registering on-chain will not be recognised for submissions until the next +/// refresh triggered by this TTL. +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NetworkMonitorsCache { + pub time_to_live: Duration, +} + +impl Default for NetworkMonitorsCache { + fn default() -> Self { + NetworkMonitorsCache { + time_to_live: DEFAULT_NETWORK_MONITORS_CACHE_TTL, + } + } +} + #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct MixnetContractCache { #[serde(default)] @@ -389,7 +427,7 @@ impl Default for MixnetContractCacheDebug { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] pub struct PerformanceProvider { /// Specifies whether this nym-api should attempt to retrieve node performance /// information from the performance contract. @@ -409,7 +447,7 @@ impl Default for PerformanceProvider { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] pub struct PerformanceProviderDebug { /// Specifies interval of polling the performance contract. Note it is only applicable /// if the contract data is being used. @@ -425,6 +463,26 @@ pub struct PerformanceProviderDebug { /// Specify the maximum number of epoch entries to be kept in the cache in case we needed non-current data // (currently we need an equivalent of full day worth of data for legacy endpoints) pub max_epoch_entries_to_retain: usize, + + // this is semi-temporary. in the long-run this information will be stored in a smart contract + // and the nym-api will have no influence over its usage + /// Specify whether external stress testing data should be used for calculating node performance + /// score used for rewarding and active set selection + /// note: this can only be enabled if use_performance_contract_data is set to false! + pub use_stress_testing_data: bool, + + /// If `use_stress_testing_data` is set to true, this specifies the minimum % of nodes, + /// that must have their stress data available in the `stress_testing_data_period`, + /// in order to include that metric in performance calculation. + /// This is done to protect against Network Monitor failures and not receiving any data. + pub minimum_available_stress_testing_results: f32, + + /// If use_stress_testing_data is enabled, specifies the weight of the stress testing score in the overall performance score. + pub stress_testing_score_weight: f64, + + /// Specifies the duration of the rolling average used for stress testing score. + #[serde(with = "humantime_serde")] + pub stress_testing_data_period: Duration, } #[allow(clippy::derivable_impls)] @@ -434,6 +492,12 @@ impl Default for PerformanceProviderDebug { contract_polling_interval: DEFAULT_PERFORMANCE_CONTRACT_POLLING_INTERVAL, max_performance_fallback_epochs: DEFAULT_PERFORMANCE_CONTRACT_FALLBACK_EPOCHS, max_epoch_entries_to_retain: DEFAULT_PERFORMANCE_CONTRACT_RETAINED_EPOCHS, + + // set this to true once sufficient number of nodes are being tested + use_stress_testing_data: false, + minimum_available_stress_testing_results: DEFAULT_MIN_STRESS_TESTED_NODES, + stress_testing_score_weight: DEFAULT_STRESS_TESTING_SCORE_WEIGHT, + stress_testing_data_period: DEFAULT_MIN_STRESS_TESTING_DATA_INTERVAL, } } } @@ -657,7 +721,7 @@ impl Default for DescribeCacheDebug { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(default)] pub struct Rewarding { /// Specifies whether rewarding service is enabled in this process. @@ -679,7 +743,7 @@ impl Default for Rewarding { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(default)] pub struct RewardingDebug { /// Specifies the minimum percentage of monitor test run data present in order to diff --git a/nym-api/src/support/config/template.rs b/nym-api/src/support/config/template.rs index 7b19567caa..7e038c1aa3 100644 --- a/nym-api/src/support/config/template.rs +++ b/nym-api/src/support/config/template.rs @@ -79,6 +79,29 @@ per_node_test_packets = {{ network_monitor.debug.per_node_test_packets }} # Path to the database file containing uptime statuses for all mixnodes and gateways. database_path = '{{ node_status_api.storage_paths.database_path }}' + +##### performance provider config options ##### +[performance_provider] + +# Specifies whether this nym-api should attempt to retrieve node performance +# information from the performance contract. +use_performance_contract_data = {{ performance_provider.use_performance_contract_data }} + +[performance_provider.debug] +# Specify whether external stress testing data should be used for calculating node performance +# score used for rewarding and active set selection +# note: this can only be enabled if use_performance_contract_data is set to false! +use_stress_testing_data = {{ performance_provider.debug.use_stress_testing_data }} + +# If `use_stress_testing_data` is set to true, this specifies the minimum % of nodes, +# that must have their stress data available in the `stress_testing_data_period`, +# in order to include that metric in performance calculation. +# This is done to protect against Network Monitor failures and not receiving any data. +minimum_available_stress_testing_results = {{ performance_provider.debug.minimum_available_stress_testing_results }} + +# Specifies the duration of the rolling average used for stress testing score. +stress_testing_data_period = '{{ performance_provider.debug.stress_testing_data_period }}' + ##### rewarding config options ##### [rewarding] @@ -93,6 +116,7 @@ enabled = {{ rewarding.enabled }} # Note, only values in range 0-100 are valid minimum_interval_monitor_threshold = {{ rewarding.debug.minimum_interval_monitor_threshold }} + [ecash_signer] # Specifies whether ecash signing protocol is enabled in this process. diff --git a/nym-api/src/support/http/router.rs b/nym-api/src/support/http/router.rs index 00ab46c8af..7053bc6200 100644 --- a/nym-api/src/support/http/router.rs +++ b/nym-api/src/support/http/router.rs @@ -68,7 +68,9 @@ impl RouterBuilder { } fn v3_routes() -> Router { - Router::new().nest("/unstable", unstable_routes_v3()) + Router::new() + .nest("/nym-nodes", nym_nodes::handlers::v3::routes()) + .nest("/unstable", unstable_routes_v3()) } /// All routes should be, if possible, added here. Exceptions are e.g. diff --git a/nym-api/src/support/http/state/contract_details.rs b/nym-api/src/support/http/state/contract_details.rs index 5380a92809..a776746120 100644 --- a/nym-api/src/support/http/state/contract_details.rs +++ b/nym-api/src/support/http/state/contract_details.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::node_status_api::models::AxumErrorResponse; +use crate::support::http::state::helpers::ChainSharedCacheWithTtl; use crate::support::nyxd::Client; use nym_contracts_common::ContractBuildInformation; use nym_validator_client::nyxd::contract_traits::{ @@ -10,10 +11,7 @@ use nym_validator_client::nyxd::contract_traits::{ use nym_validator_client::nyxd::error::NyxdError; use nym_validator_client::nyxd::AccountId; use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; -use time::OffsetDateTime; -use tokio::sync::RwLock; type ContractAddress = String; @@ -41,142 +39,83 @@ impl CachedContractInfo { } #[derive(Clone)] -pub(crate) struct ContractDetailsCache { - cache_ttl: Duration, - inner: Arc>, +pub(crate) struct ContractDetailsCache(ChainSharedCacheWithTtl); + +async fn refresh(nyxd_client: &Client) -> Result { + use crate::query_guard; + + let mut updated = HashMap::new(); + + let client_guard = nyxd_client.read().await; + + let mixnet = query_guard!(client_guard, mixnet_contract_address()); + let vesting = query_guard!(client_guard, vesting_contract_address()); + let coconut_dkg = query_guard!(client_guard, dkg_contract_address()); + let group = query_guard!(client_guard, group_contract_address()); + let multisig = query_guard!(client_guard, multisig_contract_address()); + let ecash = query_guard!(client_guard, ecash_contract_address()); + let performance = query_guard!(client_guard, performance_contract_address()); + let network_monitors = query_guard!(client_guard, network_monitors_contract_address()); + + for (address, name) in [ + (mixnet, "nym-mixnet-contract"), + (vesting, "nym-vesting-contract"), + (coconut_dkg, "nym-coconut-dkg-contract"), + (group, "nym-cw4-group-contract"), + (multisig, "nym-cw3-multisig-contract"), + (ecash, "nym-ecash-contract"), + (performance, "nym-performance-contract"), + (network_monitors, "nym-network-monitors-contract"), + ] { + let (cw2, build_info) = if let Some(address) = address { + let cw2 = query_guard!(client_guard, try_get_cw2_contract_version(address).await); + let mut build_info = query_guard!( + client_guard, + try_get_contract_build_information(address).await + ); + + // for backwards compatibility until we migrate the contracts + if build_info.is_none() { + match name { + "nym-mixnet-contract" => { + build_info = Some(query_guard!( + client_guard, + get_mixnet_contract_version().await + )?) + } + "nym-vesting-contract" => { + build_info = Some(query_guard!( + client_guard, + get_vesting_contract_version().await + )?) + } + _ => (), + } + } + + (cw2, build_info) + } else { + (None, None) + }; + + updated.insert( + name.to_string(), + CachedContractInfo::new(address, cw2, build_info), + ); + } + + Ok(updated) } impl ContractDetailsCache { pub(crate) fn new(cache_ttl: Duration) -> Self { - ContractDetailsCache { - cache_ttl, - inner: Arc::new(RwLock::new(ContractDetailsCacheInner::new())), - } - } -} - -struct ContractDetailsCacheInner { - last_refreshed_at: OffsetDateTime, - cache_value: CachedContractsInfo, -} - -impl ContractDetailsCacheInner { - pub(crate) fn new() -> Self { - ContractDetailsCacheInner { - last_refreshed_at: OffsetDateTime::UNIX_EPOCH, - cache_value: Default::default(), - } + ContractDetailsCache(ChainSharedCacheWithTtl::new(cache_ttl)) } - fn is_valid(&self, ttl: Duration) -> bool { - if self.last_refreshed_at + ttl > OffsetDateTime::now_utc() { - return true; - } - false - } - - async fn retrieve_nym_contracts_info( - &self, - nyxd_client: &Client, - ) -> Result { - use crate::query_guard; - - let mut updated = HashMap::new(); - - let client_guard = nyxd_client.read().await; - - let mixnet = query_guard!(client_guard, mixnet_contract_address()); - let vesting = query_guard!(client_guard, vesting_contract_address()); - let coconut_dkg = query_guard!(client_guard, dkg_contract_address()); - let group = query_guard!(client_guard, group_contract_address()); - let multisig = query_guard!(client_guard, multisig_contract_address()); - let ecash = query_guard!(client_guard, ecash_contract_address()); - let performance = query_guard!(client_guard, performance_contract_address()); - - for (address, name) in [ - (mixnet, "nym-mixnet-contract"), - (vesting, "nym-vesting-contract"), - (coconut_dkg, "nym-coconut-dkg-contract"), - (group, "nym-cw4-group-contract"), - (multisig, "nym-cw3-multisig-contract"), - (ecash, "nym-ecash-contract"), - (performance, "nym-performance-contract"), - ] { - let (cw2, build_info) = if let Some(address) = address { - let cw2 = query_guard!(client_guard, try_get_cw2_contract_version(address).await); - let mut build_info = query_guard!( - client_guard, - try_get_contract_build_information(address).await - ); - - // for backwards compatibility until we migrate the contracts - if build_info.is_none() { - match name { - "nym-mixnet-contract" => { - build_info = Some(query_guard!( - client_guard, - get_mixnet_contract_version().await - )?) - } - "nym-vesting-contract" => { - build_info = Some(query_guard!( - client_guard, - get_vesting_contract_version().await - )?) - } - _ => (), - } - } - - (cw2, build_info) - } else { - (None, None) - }; - - updated.insert( - name.to_string(), - CachedContractInfo::new(address, cw2, build_info), - ); - } - - Ok(updated) - } -} - -impl ContractDetailsCache { pub(crate) async fn get_or_refresh( &self, client: &Client, ) -> Result { - if let Some(cached) = self.check_cache().await { - return Ok(cached); - } - - self.refresh(client).await - } - - async fn check_cache(&self) -> Option { - let guard = self.inner.read().await; - if guard.is_valid(self.cache_ttl) { - return Some(guard.cache_value.clone()); - } - None - } - - async fn refresh(&self, client: &Client) -> Result { - // 1. attempt to get write lock permit - let mut guard = self.inner.write().await; - - // 2. check if another task hasn't already updated the cache whilst we were waiting for the permit - if guard.is_valid(self.cache_ttl) { - return Ok(guard.cache_value.clone()); - } - - // 3. attempt to query the chain for the contracts data - let updated_values = guard.retrieve_nym_contracts_info(client).await?; - guard.last_refreshed_at = OffsetDateTime::now_utc(); - guard.cache_value = updated_values.clone(); - - Ok(updated_values) + self.0.get_or_refresh(client, refresh).await } } diff --git a/nym-api/src/support/http/state/helpers.rs b/nym-api/src/support/http/state/helpers.rs index 07581ae53d..bea9ec8adf 100644 --- a/nym-api/src/support/http/state/helpers.rs +++ b/nym-api/src/support/http/state/helpers.rs @@ -1,10 +1,15 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::node_status_api::models::AxumErrorResponse; use crate::support::caching::refresher::RefreshRequester; +use crate::support::nyxd::Client; +use nym_validator_client::nyxd::error::NyxdError; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; +use std::time::Duration; use time::OffsetDateTime; +use tokio::sync::RwLock; #[derive(Clone)] pub(crate) struct Refreshing { @@ -36,3 +41,123 @@ impl Refreshing { self.handle.request_cache_refresh(); } } + +/// Shared, TTL-gated cache for values that are (re)hydrated from the nyxd chain on demand. +/// +/// The cache collapses the common "check cache, otherwise refresh" pattern used across the various +/// chain-backed state caches (chain status, contract details, ...) into a single generic type. +/// Callers plug in a type-specific `refresh_fn` that knows how to fetch `T` from the chain; this +/// type handles the locking, TTL check, and single-flight behavior. +/// +/// Concurrency model: +/// - Reads happen under a read lock; if the cached value is present and within TTL it is returned +/// immediately. +/// - If the cached value is missing or stale, a single writer takes the write lock, re-checks the +/// TTL (so a refresh that completed while we were waiting isn't redundantly repeated) and then +/// invokes `refresh_fn`. Other concurrent callers will block on the write lock and observe the +/// freshly populated value instead of each running their own query against the chain. +#[derive(Clone)] +pub(crate) struct ChainSharedCacheWithTtl { + cache_ttl: Duration, + inner: Arc>>>, +} + +impl ChainSharedCacheWithTtl +where + T: Clone, +{ + pub(crate) fn new(cache_ttl: Duration) -> Self { + ChainSharedCacheWithTtl { + cache_ttl, + inner: Arc::new(RwLock::new(None)), + } + } + + /// Return the cached value if it is still fresh, otherwise refresh it via `refresh_fn`. + /// + /// Takes the read-only fast path when the cache is warm and only escalates to a write-locked + /// refresh when the value is missing or expired. + pub(crate) async fn get_or_refresh( + &self, + client: &Client, + refresh_fn: F, + ) -> Result + where + F: AsyncFn(&Client) -> Result, + { + if let Some(cached) = self.check_cache().await { + return Ok(cached); + } + + self.refresh(client, refresh_fn).await + } + + /// Return the cached value if present and within TTL without attempting a refresh. + async fn check_cache(&self) -> Option + where + T: Clone, + { + let guard = self.inner.read().await; + let inner = guard.as_ref()?; + if inner.is_valid(self.cache_ttl) { + return Some(inner.value.clone()); + } + None + } + + /// Forcibly re-query the chain via `refresh_fn` and replace the cached value. + /// + /// The double-checked TTL guard after acquiring the write lock prevents the common + /// thundering-herd case where many concurrent callers all observe a stale cache at once - only + /// the first one to acquire the write lock will actually hit the chain. + async fn refresh(&self, client: &Client, refresh_fn: F) -> Result + where + F: AsyncFn(&Client) -> Result, + T: Clone, + { + // 1. attempt to get write lock permit + let mut guard = self.inner.write().await; + + // 2. check if another task hasn't already updated the cache whilst we were waiting for the permit + if let Some(cached) = guard.as_ref() { + if cached.is_valid(self.cache_ttl) { + return Ok(cached.clone_value()); + } + } + + let refresh_res = refresh_fn(client).await?; + + *guard = Self::new_inner(refresh_res.clone()); + Ok(refresh_res) + } + + fn new_inner(value: T) -> Option> { + Some(ChainSharedCacheWithTtlInner::new(value)) + } +} + +/// Cached value alongside the timestamp at which it was fetched, used to evaluate the TTL. +struct ChainSharedCacheWithTtlInner { + last_refreshed_at: OffsetDateTime, + value: T, +} + +impl ChainSharedCacheWithTtlInner { + fn new(value: T) -> Self { + ChainSharedCacheWithTtlInner { + last_refreshed_at: OffsetDateTime::now_utc(), + value, + } + } + + fn is_valid(&self, ttl: Duration) -> bool { + self.last_refreshed_at + ttl > OffsetDateTime::now_utc() + } + + fn clone_value(&self) -> T + where + T: Clone, + { + self.value.clone() + } +} diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index 35c2416f85..b2b4ae1bac 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -16,6 +16,7 @@ use crate::support::http::state::chain_status::ChainStatusCache; use crate::support::http::state::contract_details::ContractDetailsCache; use crate::support::http::state::force_refresh::ForcedRefresh; use crate::support::http::state::mixnet_contract_cache::MixnetContractCacheState; +use crate::support::http::state::network_monitors::{LastNMSubmissions, NetworkMonitorsCache}; use crate::support::http::state::node_annotations_cache::NodeAnnotationsCache; use crate::support::nyxd::Client; use crate::support::storage; @@ -32,6 +33,7 @@ pub(crate) mod contract_details; pub(crate) mod force_refresh; pub(crate) mod helpers; pub(crate) mod mixnet_contract_cache; +pub(crate) mod network_monitors; pub(crate) mod node_annotations_cache; #[derive(Clone)] @@ -56,9 +58,17 @@ pub(crate) struct AppState { /// It is used to prevent DoS by nodes constantly requesting the refresh. pub(crate) forced_refresh: ForcedRefresh, + /// Holds information on when the last stress testing submission was made by a network monitor. + /// It is used to prevent replay attacks by attempting to re-use request timestamps + /// Timestamps are used instead of nonces to avoid NM having to keep state with the expected values. + pub(crate) network_monitor_submissions: LastNMSubmissions, + /// Holds cached state of the Nym Mixnet contract, e.g. bonded nym-nodes, rewarded set, current interval. pub(crate) mixnet_contract_cache: MixnetContractCacheState, + /// Holds information on known network monitors that are permitted to submit stress testing results. + pub(crate) network_monitors_cache: NetworkMonitorsCache, + /// Holds processed information on network nodes, i.e. performance, config scores, etc. // TODO: also perhaps redundant? pub(crate) node_annotations_cache: NodeAnnotationsCache, @@ -102,6 +112,12 @@ impl FromRef for Arc { } } +impl FromRef for NetworkMonitorsCache { + fn from_ref(app_state: &AppState) -> Self { + app_state.network_monitors_cache.clone() + } +} + impl FromRef for MixnetContractCache { fn from_ref(app_state: &AppState) -> Self { app_state.mixnet_contract_cache.inner_cache.clone() @@ -155,6 +171,11 @@ impl AppState { &self.described_nodes_cache } + /// Access the cache of authorised network-monitor orchestrators. + pub(crate) fn network_monitors(&self) -> &NetworkMonitorsCache { + &self.network_monitors_cache + } + pub(crate) fn storage(&self) -> &storage::NymApiStorage { &self.storage } diff --git a/nym-api/src/support/http/state/network_monitors.rs b/nym-api/src/support/http/state/network_monitors.rs new file mode 100644 index 0000000000..f8a7e1b1ca --- /dev/null +++ b/nym-api/src/support/http/state/network_monitors.rs @@ -0,0 +1,114 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::AxumErrorResponse; +use crate::support::http::state::helpers::ChainSharedCacheWithTtl; +use crate::support::nyxd::Client; +use nym_crypto::asymmetric::ed25519; +use nym_validator_client::nyxd::error::NyxdError; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::sync::RwLock; +use tracing::{error, warn}; + +/// Per-orchestrator high-water mark of accepted submission timestamps, kept in-memory to provide +/// replay protection for the stress-test submission endpoint. +#[derive(Clone)] +pub(crate) struct LastNMSubmissions { + pub(crate) submissions: Arc>>, +} + +impl LastNMSubmissions { + pub(crate) fn new() -> LastNMSubmissions { + LastNMSubmissions { + submissions: Arc::new(Default::default()), + } + } + + /// Last accepted submission timestamp for particular network monitor + pub(crate) async fn submitted(&self, nm: ed25519::PublicKey) -> Option { + self.submissions.read().await.get(&nm).copied() + } + + /// Record `timestamp` as the most recent accepted submission for `nm`. + /// + /// Callers are responsible for ensuring `timestamp` passes the monotonicity check against + /// [`submitted`][Self::submitted] before calling this. + pub(crate) async fn set_submitted(&self, nm: ed25519::PublicKey, timestamp: OffsetDateTime) { + self.submissions.write().await.insert(nm, timestamp); + } +} + +/// Snapshot of identity keys for network monitor orchestrators currently registered in the +/// network-monitors contract. +#[derive(Clone)] +pub(crate) struct KnownNetworkMonitors { + known: HashSet, +} + +impl KnownNetworkMonitors { + pub(crate) fn contains(&self, key: &ed25519::PublicKey) -> bool { + self.known.contains(key) + } +} + +/// TTL-gated cache over [`KnownNetworkMonitors`] so that every submission doesn't re-query the +/// network-monitors contract; refresh happens lazily on the first request after the TTL expires. +#[derive(Clone)] +pub(crate) struct NetworkMonitorsCache(ChainSharedCacheWithTtl); + +impl NetworkMonitorsCache { + pub(crate) fn new(cache_ttl: Duration) -> Self { + NetworkMonitorsCache(ChainSharedCacheWithTtl::new(cache_ttl)) + } + + /// Return the currently-cached set of known orchestrators, refreshing from chain if stale. + pub(crate) async fn get_or_refresh( + &self, + client: &Client, + ) -> Result { + self.0.get_or_refresh(client, refresh).await + } + + /// Shortcut for "is this key in the current (possibly just-refreshed) orchestrator set?". + pub(crate) async fn is_authorised( + &self, + nyxd_client: &Client, + key: &ed25519::PublicKey, + ) -> Result { + Ok(self.get_or_refresh(nyxd_client).await?.known.contains(key)) + } +} + +/// Fetch the orchestrator set from the network-monitors contract and decode each entry's identity +/// key. Orchestrators without an announced key, or with an unparseable one, are logged and +/// skipped - the rest still populate the cache so one bad entry doesn't take down submissions for +/// everyone. +async fn refresh(client: &Client) -> Result { + if let Err(err) = client.get_network_monitors_contract_address().await { + warn!("network monitor contract address not set - can't accept any stress testing results"); + return Err(err); + } + + let known_monitors = client.get_all_network_monitor_orchestrators().await?; + let mut updated_monitors = HashSet::new(); + for monitor in known_monitors { + let Some(public_key) = monitor.identity_key else { + warn!("{} orchestrator is authorised but has not announced its public key - is the process running correctly?", monitor.address); + continue; + }; + let parsed = match ed25519::PublicKey::from_base58_string(&public_key) { + Ok(key) => key, + Err(err) => { + error!("failed to parse public key for {}: {err}", monitor.address); + continue; + } + }; + updated_monitors.insert(parsed); + } + Ok(KnownNetworkMonitors { + known: updated_monitors, + }) +} diff --git a/nym-api/src/support/nyxd/mod.rs b/nym-api/src/support/nyxd/mod.rs index 6de33c4738..e008cb4fe7 100644 --- a/nym-api/src/support/nyxd/mod.rs +++ b/nym-api/src/support/nyxd/mod.rs @@ -39,9 +39,11 @@ use nym_validator_client::nyxd::contract_traits::performance_query_client::{ LastSubmission, NodePerformance, }; use nym_validator_client::nyxd::contract_traits::{ - PagedDkgQueryClient, PagedPerformanceQueryClient, PerformanceQueryClient, + NetworkMonitorsQueryClient, PagedDkgQueryClient, PagedPerformanceQueryClient, + PerformanceQueryClient, TypedNymContracts, }; use nym_validator_client::nyxd::error::NyxdError; +use nym_validator_client::nyxd::nym_network_monitors_contract_common::AuthorisedNetworkMonitorOrchestrator; use nym_validator_client::nyxd::Coin; use nym_validator_client::nyxd::{ contract_traits::{ @@ -167,6 +169,12 @@ impl Client { } } + /// Return the full set of Nym contract addresses currently configured on this client. + #[allow(dead_code)] + pub(crate) async fn known_contracts(&self) -> TypedNymContracts { + nyxd_query!(self, get_nym_contracts()) + } + pub(crate) async fn get_ecash_contract_address(&self) -> Result { nyxd_query!( self, @@ -176,6 +184,22 @@ impl Client { ) } + /// Return the configured network-monitors contract address. + /// + /// Returns [`NyxdError::UnavailableContractAddress`] if the address is not configured - this + /// is the signal used upstream (e.g. in `NetworkMonitorsCache`) to warn that stress-test + /// submissions cannot be accepted. + pub(crate) async fn get_network_monitors_contract_address( + &self, + ) -> Result { + nyxd_query!( + self, + network_monitors_contract_address().cloned().ok_or_else(|| { + NyxdError::unavailable_contract_address("network monitors contract") + }) + ) + } + pub(crate) async fn get_rewarding_validator_address(&self) -> Result { let cosmwasm_addr = nyxd_query!( self, @@ -417,6 +441,17 @@ impl Client { ) -> Result, NyxdError> { nyxd_query!(self, get_all_epoch_performance(epoch_id).await) } + + /// Query the network-monitors contract for the full set of authorised orchestrators. + /// + /// This returns every orchestrator registered in the contract regardless of whether they + /// have announced an identity key yet - callers are responsible for filtering entries with + /// `identity_key == None`. + pub(crate) async fn get_all_network_monitor_orchestrators( + &self, + ) -> Result, NyxdError> { + Ok(nyxd_query!(self, get_network_monitor_orchestrators().await)?.authorised) + } } #[async_trait] diff --git a/nym-api/src/support/storage/manager.rs b/nym-api/src/support/storage/manager.rs index 710481d41e..fc24ccc757 100644 --- a/nym-api/src/support/storage/manager.rs +++ b/nym-api/src/support/storage/manager.rs @@ -5,8 +5,9 @@ use crate::node_status_api::models::{HistoricalUptime as ApiHistoricalUptime, Up use crate::node_status_api::utils::{ActiveGatewayStatuses, ActiveMixnodeStatuses}; use crate::support::storage::models::{ ActiveGateway, ActiveMixnode, GatewayDetails, HistoricalUptime, MixnodeDetails, - MonitorRunReport, MonitorRunScore, NodeStatus, RewardingReport, TestedGatewayStatus, - TestedMixnodeStatus, TestingRoute, + MonitorRunReport, MonitorRunScore, NodeStatus, NymNodeStressTestingResult, + RetrievedAverageStressTestResult, RewardingReport, TestedGatewayStatus, TestedMixnodeStatus, + TestingRoute, }; use crate::support::storage::DbIdCache; use nym_mixnet_contract_common::{EpochId, IdentityKey, NodeId}; @@ -308,6 +309,42 @@ impl StorageManager { Ok(result.reliability) } + /// Returns the rolling average stress-testing score for a single node over the given window. + /// + /// `was_reachable` is `MAX(was_reachable)` so it is `true` if **any** sample reported the + /// node reachable, and `false` only if every sample marked it unreachable — matching the + /// "false only when all entries are false" rule used by the consumers. + /// + /// `GROUP BY node_id` (rather than a bare aggregate) ensures that an empty selection yields + /// no row (so `fetch_optional` returns `None`) instead of a single all-NULL row that would + /// be indistinguishable from "node was never tested". + pub(super) async fn get_average_node_stress_test_score( + &self, + node_id: i64, + start_ts: OffsetDateTime, + end_ts: OffsetDateTime, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + RetrievedAverageStressTestResult, + r#" + SELECT + node_id as "node_id!: NodeId", + AVG(result) as "result!: f64", + MAX(was_reachable) as "was_reachable!: bool" + FROM nym_node_stress_testing_result + WHERE node_id = ? + AND test_timestamp >= ? + AND test_timestamp <= ? + GROUP BY node_id + "#, + node_id, + start_ts, + end_ts, + ) + .fetch_optional(&self.connection_pool) + .await + } + /// Gets all reliability statuses for gateway with particular id that were inserted /// into the database within the specified time interval. /// @@ -779,6 +816,38 @@ impl StorageManager { .await } + /// Batch-insert the given stress-testing results into the `nym_node_stress_testing_result` + /// table. An empty input is a no-op rather than an invalid-SQL error, since an incoming + /// submission may legitimately contain no mixnode entries after filtering. + /// + /// Uses `INSERT OR IGNORE` so that a retried submission carrying the same + /// `(testrun_id, submitter_pubkey)` pair - expected under the orchestrator's at-least-once + /// delivery semantics - silently drops the duplicate rows rather than failing the batch. + pub(super) async fn insert_nym_node_stress_testing_results( + &self, + results: Vec, + ) -> Result<(), sqlx::Error> { + if results.is_empty() { + return Ok(()); + } + + let mut query_builder = sqlx::QueryBuilder::new( + "INSERT OR IGNORE INTO nym_node_stress_testing_result (testrun_id, submitter_pubkey, node_id, result, was_reachable, test_timestamp) ", + ); + + query_builder.push_values(results, |mut b, entry| { + b.push_bind(entry.testrun_id) + .push_bind(entry.submitter_pubkey) + .push_bind(entry.node_id) + .push_bind(entry.result) + .push_bind(entry.was_reachable) + .push_bind(entry.test_timestamp); + }); + + query_builder.build().execute(&self.connection_pool).await?; + Ok(()) + } + /// Obtains number of network monitor test runs that have occurred within the specified interval. /// /// # Arguments diff --git a/nym-api/src/support/storage/mod.rs b/nym-api/src/support/storage/mod.rs index 8bb14a697d..71e9fc3752 100644 --- a/nym-api/src/support/storage/mod.rs +++ b/nym-api/src/support/storage/mod.rs @@ -12,7 +12,8 @@ use crate::storage::manager::StorageManager; use crate::storage::models::TestingRoute; use crate::support::storage::models::{ GatewayDetails, HistoricalUptime, MixnodeDetails, MonitorRunReport, MonitorRunScore, - TestedGatewayStatus, TestedMixnodeStatus, + NymNodeStressTestingResult, RetrievedAverageStressTestResult, TestedGatewayStatus, + TestedMixnodeStatus, }; use dashmap::DashMap; use nym_mixnet_contract_common::NodeId; @@ -226,6 +227,18 @@ impl NymApiStorage { Ok(reliability) } + pub(crate) async fn get_average_node_stress_test_score( + &self, + node_id: NodeId, + start_ts: OffsetDateTime, + end_ts: OffsetDateTime, + ) -> Result, NymApiStorageError> { + Ok(self + .manager + .get_average_node_stress_test_score(node_id as i64, start_ts, end_ts) + .await?) + } + #[allow(unused)] pub(crate) async fn get_average_mixnode_uptime_in_the_last_24hrs( &self, @@ -692,6 +705,18 @@ impl NymApiStorage { Ok(()) } + /// Persist the given stress-testing results, produced by an authorised network monitor + /// orchestrator, into the database. + pub(crate) async fn insert_nym_node_stress_testing_results( + &self, + results: Vec, + ) -> Result<(), NymApiStorageError> { + self.manager + .insert_nym_node_stress_testing_results(results) + .await?; + Ok(()) + } + /// Obtains number of network monitor test runs that have occurred within the specified interval. /// /// # Arguments diff --git a/nym-api/src/support/storage/models.rs b/nym-api/src/support/storage/models.rs index e082961b01..c9fe22c440 100644 --- a/nym-api/src/support/storage/models.rs +++ b/nym-api/src/support/storage/models.rs @@ -1,7 +1,8 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_api_requests::models::TestNode; +use nym_api_requests::models::{StressTestResult, StressTestingScore, TestNode}; +use nym_crypto::asymmetric::ed25519; use nym_mixnet_contract_common::NodeId; use sqlx::FromRow; use time::Date; @@ -146,3 +147,48 @@ pub struct HistoricalUptime { pub date: Date, pub uptime: i64, } + +/// Row model for the `nym_node_stress_testing_result` table. +/// +/// Produced from the wire-level [`StressTestResult`] via [`Self::from_submission`], which also +/// renames `test_performance` to `result` to match the on-disk column name and attaches the +/// submitting orchestrator's identity key so that `(testrun_id, submitter_pubkey)` dedupes +/// retried at-least-once submissions. +#[derive(FromRow)] +pub struct NymNodeStressTestingResult { + pub testrun_id: i64, + pub submitter_pubkey: String, + pub node_id: NodeId, + pub result: f64, + pub was_reachable: bool, + pub test_timestamp: time::OffsetDateTime, +} + +impl NymNodeStressTestingResult { + pub fn from_submission(signer: &ed25519::PublicKey, value: StressTestResult) -> Self { + NymNodeStressTestingResult { + testrun_id: value.testrun_id, + submitter_pubkey: signer.to_base58_string(), + node_id: value.node_id, + result: value.test_performance, + was_reachable: value.was_reachable, + test_timestamp: value.test_timestamp, + } + } +} + +#[derive(FromRow)] +pub struct RetrievedAverageStressTestResult { + pub node_id: NodeId, + pub result: f64, + pub was_reachable: bool, +} + +impl From for StressTestingScore { + fn from(value: RetrievedAverageStressTestResult) -> Self { + StressTestingScore { + score: value.result, + was_reachable: value.was_reachable, + } + } +} diff --git a/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs b/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs index 20a31c5f74..e3cca6a660 100644 --- a/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs +++ b/nym-api/src/unstable_routes/v2/nym_nodes/semi_skimmed/mod.rs @@ -7,7 +7,7 @@ use crate::unstable_routes::helpers::refreshed_at; use crate::unstable_routes::v2::nym_nodes::helpers::NodesParamsWithRole; use axum::extract::{Query, State}; use nym_api_requests::models::{ - NodeAnnotation, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, + NodeAnnotationV1, NodeAnnotationV2, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, }; use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponseV2, SemiSkimmedNodeV1}; use nym_api_requests::pagination::PaginatedResponse; @@ -24,7 +24,7 @@ pub type PaginatedSemiSkimmedNodes = fn build_nym_nodes_response<'a, NI>( rewarded_set: &CachedEpochRewardedSet, nym_nodes_subset: NI, - annotations: &HashMap, + annotations: &HashMap, current_key_rotation: u32, active_only: bool, ) -> Vec @@ -44,7 +44,11 @@ where // honestly, not sure under what exact circumstances this value could be missing, // but in that case just use 0 performance - let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + let annotation: NodeAnnotationV1 = annotations + .get(&node_id) + .copied() + .unwrap_or_default() + .into(); nodes.push(nym_node.to_semi_skimmed_node( current_key_rotation, diff --git a/nym-api/src/unstable_routes/v2/nym_nodes/skimmed/helpers.rs b/nym-api/src/unstable_routes/v2/nym_nodes/skimmed/helpers.rs index 017c0e98ea..a82d4452dc 100644 --- a/nym-api/src/unstable_routes/v2/nym_nodes/skimmed/helpers.rs +++ b/nym-api/src/unstable_routes/v2/nym_nodes/skimmed/helpers.rs @@ -7,7 +7,7 @@ use crate::unstable_routes::v2::nym_nodes::helpers::NodesParams; use crate::unstable_routes::v2::nym_nodes::skimmed::PaginatedSkimmedNodes; use axum::extract::{Query, State}; use nym_api_requests::models::{ - NodeAnnotation, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, + NodeAnnotationV1, NodeAnnotationV2, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, }; use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponseV2, SkimmedNodeV1}; use nym_http_api_common::Output; @@ -20,7 +20,7 @@ use std::time::Duration; fn build_nym_nodes_response<'a, NI>( rewarded_set: &CachedEpochRewardedSet, nym_nodes_subset: NI, - annotations: &HashMap, + annotations: &HashMap, current_key_rotation: u32, active_only: bool, ) -> Vec @@ -40,7 +40,11 @@ where // honestly, not sure under what exact circumstances this value could be missing, // but in that case just use 0 performance - let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + let annotation: NodeAnnotationV1 = annotations + .get(&node_id) + .copied() + .unwrap_or_default() + .into(); nodes.push(nym_node.to_skimmed_node( current_key_rotation, diff --git a/nym-api/src/unstable_routes/v3/nym_nodes/semi_skimmed/mod.rs b/nym-api/src/unstable_routes/v3/nym_nodes/semi_skimmed/mod.rs index a189000077..b7b5ab8138 100644 --- a/nym-api/src/unstable_routes/v3/nym_nodes/semi_skimmed/mod.rs +++ b/nym-api/src/unstable_routes/v3/nym_nodes/semi_skimmed/mod.rs @@ -6,7 +6,7 @@ use crate::support::http::state::AppState; use crate::unstable_routes::helpers::refreshed_at; use axum::extract::{Query, State}; use nym_api_requests::models::{ - NodeAnnotation, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, + NodeAnnotationV1, NodeAnnotationV2, NymNodeDescriptionV2, OffsetDateTimeJsonSchemaWrapper, }; use nym_api_requests::nym_nodes::{NodeRole, PaginatedCachedNodesResponseV2, SemiSkimmedNodeV3}; use nym_api_requests::pagination::PaginatedResponse; @@ -22,7 +22,7 @@ pub type PaginatedSemiSkimmedNodes = fn build_response<'a>( rewarded_set: &CachedEpochRewardedSet, nym_nodes: impl Iterator, - annotations: &HashMap, + annotations: &HashMap, current_key_rotation: u32, ) -> Vec { let mut nodes = Vec::new(); @@ -33,7 +33,11 @@ fn build_response<'a>( // honestly, not sure under what exact circumstances this value could be missing, // but in that case just use 0 performance - let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + let annotation: NodeAnnotationV1 = annotations + .get(&node_id) + .copied() + .unwrap_or_default() + .into(); nodes.push(nym_node.to_semi_skimmed_node_v3( current_key_rotation, diff --git a/nym-data-observatory/src/chain_scraper/webhook.rs b/nym-data-observatory/src/chain_scraper/webhook.rs index 240bcbdccf..dd03806fb7 100644 --- a/nym-data-observatory/src/chain_scraper/webhook.rs +++ b/nym-data-observatory/src/chain_scraper/webhook.rs @@ -28,42 +28,40 @@ impl WebhookModule { #[async_trait] impl TxModule for WebhookModule { async fn handle_tx(&mut self, tx: &ParsedTransactionResponse) -> Result<(), ScraperError> { - for (index, msg) in &tx.parsed_messages { - if let Some(parsed_message_type_url) = tx.parsed_message_urls.get(index) { - let payload = WebhookPayload { - height: tx.height.value(), - message_index: *index as u64, - transaction_hash: tx.hash.to_string(), - message: Some(msg.clone()), - }; + for (index, msg) in &tx.decoded_messages { + let payload = WebhookPayload { + height: tx.tx_details.height().value(), + message_index: *index as u64, + transaction_hash: tx.tx_details.hash.to_string(), + message: Some(msg.decoded_content.clone()), + }; - // println!( - // "->>>>>>>>>>>>>>>>>>>>>>>>> {}", - // serde_json::to_string(&payload).unwrap() - // ); + // println!( + // "->>>>>>>>>>>>>>>>>>>>>>>>> {}", + // serde_json::to_string(&payload).unwrap() + // ); - for webhook in self.webhooks.clone() { - // if the webhook requires a type and the parsed message type doesn't match, skip - if !webhook.config.watch_for_chain_message_types.is_empty() - && !webhook - .config - .watch_for_chain_message_types - .contains(parsed_message_type_url) - { - continue; - } - - let payload = payload.clone(); - - // TODO: some excellent advice from Andrew, for another day: - // - pass a cancellation token for shutdown - // - use TaskManager and limit number of webhooks to spawn at once - tokio::spawn(async move { - if let Err(e) = webhook.invoke_webhook(&payload).await { - error!("webhook error: {}", e); - } - }); + for webhook in self.webhooks.clone() { + // if the webhook requires a type and the parsed message type doesn't match, skip + if !webhook.config.watch_for_chain_message_types.is_empty() + && !webhook + .config + .watch_for_chain_message_types + .contains(&msg.type_url) + { + continue; } + + let payload = payload.clone(); + + // TODO: some excellent advice from Andrew, for another day: + // - pass a cancellation token for shutdown + // - use TaskManager and limit number of webhooks to spawn at once + tokio::spawn(async move { + if let Err(e) = webhook.invoke_webhook(&payload).await { + error!("webhook error: {}", e); + } + }); } } Ok(()) diff --git a/nym-data-observatory/src/modules/wasm.rs b/nym-data-observatory/src/modules/wasm.rs index bbe90e2c37..3fc39afac3 100644 --- a/nym-data-observatory/src/modules/wasm.rs +++ b/nym-data-observatory/src/modules/wasm.rs @@ -8,7 +8,8 @@ use cosmrs::proto::cosmwasm::wasm::v1::MsgExecuteContract; use cosmrs::proto::prost::Message; use nym_validator_client::nyxd::{Any, Name}; use nyxd_scraper_psql::models::DbCoin; -use nyxd_scraper_psql::{MsgModule, ParsedTransactionResponse, ScraperError}; +use nyxd_scraper_psql::{MsgModule, ScraperError}; +use nyxd_scraper_shared::{DecodedMessage, ParsedTransactionDetails}; use serde_json::Value; use time::{OffsetDateTime, PrimitiveDateTime}; use tracing::{error, trace}; @@ -34,9 +35,10 @@ impl MsgModule for WasmModule { &mut self, index: usize, msg: &Any, - tx: &ParsedTransactionResponse, + decoded_msg: &DecodedMessage, + tx: &ParsedTransactionDetails, ) -> Result<(), ScraperError> { - let message = serde_json::to_value(tx.parsed_messages.get(&index)).unwrap_or_default(); + let message = serde_json::to_value(&decoded_msg.decoded_content).unwrap_or_default(); let value = serde_json::to_value(message.clone()).unwrap_or_default(); let wasm_message_type = get_wasm_message_type(&value); let fee: Vec = tx @@ -55,7 +57,7 @@ impl MsgModule for WasmModule { let offset_datetime: OffsetDateTime = tx.block.header.time.into(); let executed_at = PrimitiveDateTime::new(offset_datetime.date(), offset_datetime.time()); - let height = tx.height.value() as i64; + let height = tx.height().value() as i64; let hash = tx.hash.to_string(); let memo = tx.tx.body.memo.clone(); diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/Cargo.toml b/nym-network-monitor-v3/nym-network-monitor-agent/Cargo.toml new file mode 100644 index 0000000000..97d0adccef --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "nym-network-monitor-agent" +description = "Agent used for stress testing Nym mixnodes" +version = "1.0.2" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = false + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["cargo", "env"] } +futures = { workspace = true } +humantime = { workspace = true } +rand = { workspace = true } +nym-sphinx-types = { workspace = true } +nym-sphinx-params = { workspace = true } +nym-sphinx-framing = { workspace = true } +nym-sphinx-addressing = { workspace = true } +nym-noise = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = ["macros", "sync", "rt-multi-thread"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +zeroize = { workspace = true } + +# methods to recreate lioness +# we don't care about particular versions - just pull whatever is used by sphinx +lioness = { workspace = true } +arrayref = { workspace = true } +sha2 = { workspace = true } +hkdf = { workspace = true } +x25519-dalek = { workspace = true } + + +nym-bin-common = { workspace = true, features = [ + "basic_tracing", + "output_format", +] } +nym-crypto = { workspace = true, features = ["asymmetric", "rand", "hashing"] } +nym-pemstore = { workspace = true } +nym-task = { workspace = true } + +nym-network-monitor-orchestrator-requests = { path = "../nym-network-monitor-orchestrator-requests", features = ["client"] } + +[dev-dependencies] +nym-test-utils = { workspace = true } + +[lints] +workspace = true diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/Dockerfile b/nym-network-monitor-v3/nym-network-monitor-agent/Dockerfile new file mode 100644 index 0000000000..ac476573f0 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/Dockerfile @@ -0,0 +1,22 @@ +# this will only work with VPN, otherwise remove the harbor part +FROM harbor.nymte.ch/dockerhub/rust:latest AS builder + +RUN apt update && apt install -yy libdbus-1-dev pkg-config libclang-dev + +COPY ./ /usr/src/nym +WORKDIR /usr/src/nym + +RUN cargo build --bin nym-network-monitor-agent --release + +FROM harbor.nymte.ch/dockerhub/ubuntu:24.04 + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /nym + +COPY --from=builder /usr/src/nym/target/release/nym-network-monitor-agent ./ +COPY --from=builder /usr/src/nym/nym-network-monitor-v3/nym-network-monitor-agent/entrypoint.sh ./ +RUN chmod +x /nym/entrypoint.sh + +ENV SLEEP_TIME=5 +ENTRYPOINT [ "/nym/entrypoint.sh" ] diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/README.md b/nym-network-monitor-v3/nym-network-monitor-agent/README.md new file mode 100644 index 0000000000..e8df6d423d --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/README.md @@ -0,0 +1,3 @@ +# Network Monitor Agent + +An agent to run nym node stress tests and report results back to the Network Monitor orchestrator. diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/build-push-node-status-agent.sh b/nym-network-monitor-v3/nym-network-monitor-agent/build-push-node-status-agent.sh new file mode 100755 index 0000000000..721c996f94 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/build-push-node-status-agent.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Build and push Network Monitor Agent container to harbor.nymte.ch + +set -e + +# Configuration +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +WORKING_DIRECTORY="${SCRIPT_DIR}" +CONTAINER_NAME="network-monitor-agent" +REGISTRY="harbor.nymte.ch" +NAMESPACE="nym" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get version from Cargo.toml +VERSION=$(grep "^version = " "${WORKING_DIRECTORY}/Cargo.toml" | sed -E 's/version = "(.*)"/\1/') +if [ -z "$VERSION" ]; then + echo -e "${RED}Error: Could not extract version from Cargo.toml${NC}" + exit 1 +fi + +echo -e "${YELLOW}Building Network Monitor Agent${NC}" +echo -e "${YELLOW}Version: ${VERSION}${NC}" + +# Login to Harbor +echo -e "${GREEN}Logging into Harbor...${NC}" +docker login "${REGISTRY}" + +# Build the container +echo -e "${GREEN}Building the container...${NC}" +# Build from repository root (two levels up from script location) +docker build \ + --build-arg GIT_REF="${GATEWAY_PROBE_GIT_REF}" \ + -f "${WORKING_DIRECTORY}/Dockerfile" \ + "${SCRIPT_DIR}/../.." \ + -t "${REGISTRY}/${NAMESPACE}/${CONTAINER_NAME}:${VERSION}" \ + -t "${REGISTRY}/${NAMESPACE}/${CONTAINER_NAME}:latest" + +# Push to Harbor +echo -e "${GREEN}Pushing container to Harbor...${NC}" +docker push "${REGISTRY}/${NAMESPACE}/${CONTAINER_NAME}:${VERSION}" +docker push "${REGISTRY}/${NAMESPACE}/${CONTAINER_NAME}:latest" + +echo -e "${GREEN}Successfully built and pushed ${CONTAINER_NAME}:${VERSION}${NC}" \ No newline at end of file diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/entrypoint.sh b/nym-network-monitor-v3/nym-network-monitor-agent/entrypoint.sh new file mode 100755 index 0000000000..111e61ad0a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +echo "Starting agent loop with sleep interval: ${SLEEP_TIME}s" + +# Trap SIGTERM to allow graceful shutdown +trap "echo 'Stopping...'; exit 0" SIGTERM + +DEFAULT_ARGS="run-agent --orchestrator_address \"${NETWORK_MONITOR_AGENT_SERVER_ADDRESS}:${NETWORK_MONITOR_AGENT_SERVER_PORT}\" " +ARGS=${NETWORK_MONITOR_AGENT_ARGS:-${DEFAULT_ARGS}} +COMMAND="/nym/nym-network-monitor-agent ${ARGS}" + +echo "default_args = '${DEFAULT_ARGS}'" +echo "args = '${ARGS}'" +echo "command = '${COMMAND}'" + +# Run agent in an infinite loop +while true; do + eval "$COMMAND" + sleep "$SLEEP_TIME" +done diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/config.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/config.rs new file mode 100644 index 0000000000..4761b88c1f --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/config.rs @@ -0,0 +1,110 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::net::SocketAddr; +use std::time::Duration; + +/// Configuration for the [`NodeStressTester`], controlling packet sending behaviour during a test run. +#[derive(Debug, Clone, Copy)] +pub(crate) struct NodeTesterConfig { + /// How long the agent should be sending test packets with the specified rate. + pub(crate) sending_duration: Duration, + + /// How long the agent will wait to receive any leftover packets after finishing sending. + pub(crate) waiting_duration: Duration, + + /// How long the target node should delay the packet (i.e. the sphinx delay) + pub(crate) packet_delay: Duration, + + /// Timeout for establishing the egress connection to the node under test. + pub(crate) egress_connection_timeout: Duration, + + /// Timeout for the completing the noise handshake. + pub(crate) noise_handshake_timeout: Duration, + + /// Number of packets dispatched in a single batch. Together with `target_rate` this + /// determines the inter-batch interval: `sending_batch_size / target_rate` seconds. + pub(crate) sending_batch_size: usize, + + /// Target rate of packets (per second) to be sent. + pub(crate) target_rate: usize, + + /// Whether the agent should reuse the same header for all packets, and consequently replay them. + pub(crate) reuse_header: bool, + + /// Local socket address the agent binds its mixnet listener on to receive returning packets. + pub(crate) mixnet_bind_address: SocketAddr, + + /// The mixnet address announced in the contract, where the tested nodes will send their packets to. + pub(crate) external_mixnet_address: SocketAddr, +} + +impl NodeTesterConfig { + /// Total number of packets the agent intends to send: `floor(target_rate * sending_duration)`. + pub(crate) fn expected_packets(&self) -> usize { + (self.target_rate as f32 * self.sending_duration.as_secs_f32()).floor() as usize + } + + /// Time between consecutive batch dispatches needed to sustain `target_rate`: + /// `sending_batch_size / target_rate` seconds. + pub(crate) fn batch_interval(&self) -> Duration { + Duration::from_secs_f64(self.sending_batch_size as f64 / self.target_rate as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn config( + target_rate: usize, + sending_duration: Duration, + batch_size: usize, + ) -> NodeTesterConfig { + NodeTesterConfig { + sending_duration, + waiting_duration: Duration::from_secs(5), + packet_delay: Duration::from_millis(50), + egress_connection_timeout: Duration::from_secs(5), + noise_handshake_timeout: Duration::from_secs(3), + sending_batch_size: batch_size, + target_rate, + reuse_header: true, + mixnet_bind_address: "127.0.0.1:1789".parse().unwrap(), + external_mixnet_address: "127.0.0.1:1789".parse().unwrap(), + } + } + + #[test] + fn expected_packets_floors_fractional_result() { + // 1000 * 0.5s = 500.0 — exact, no rounding needed + assert_eq!( + config(1000, Duration::from_millis(500), 50).expected_packets(), + 500 + ); + } + + #[test] + fn expected_packets_floors_not_rounds() { + // 1000 * 1.9s = 1900.0 exactly + assert_eq!( + config(1000, Duration::from_millis(1900), 50).expected_packets(), + 1900 + ); + } + + #[test] + fn batch_interval_is_batch_size_over_rate() { + // 100 packets / 1000 pps = 100ms + let interval = config(1000, Duration::from_secs(30), 100).batch_interval(); + assert_eq!(interval, Duration::from_millis(100)); + } + + #[test] + fn batch_interval_smaller_than_one_ms() { + // 1 packet / 1000 pps = 1ms + let interval = config(1000, Duration::from_secs(30), 1).batch_interval(); + assert_eq!(interval, Duration::from_millis(1)); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/helpers.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/helpers.rs new file mode 100644 index 0000000000..6293361d6b --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/helpers.rs @@ -0,0 +1,19 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::{Context, bail}; +use nym_crypto::asymmetric::x25519; +use nym_pemstore::load_key; +use std::path::Path; +use std::sync::Arc; + +/// Loads an x25519 Noise private key from a PEM file and returns the full key pair +/// wrapped in an [`Arc`] for shared ownership. +pub(crate) fn load_noise_key>(path: P) -> anyhow::Result> { + let path = path.as_ref(); + if !path.exists() { + bail!("noise key file does not exist at: {}", path.display()); + } + let noise_key: x25519::PrivateKey = load_key(path).context("failed to load noise key")?; + Ok(Arc::new(noise_key.into())) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/mod.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/mod.rs new file mode 100644 index 0000000000..bc089f16f4 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/mod.rs @@ -0,0 +1,110 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::agent::config::NodeTesterConfig; +use crate::agent::tested_node::TestedNodeDetails; +use crate::agent::tester::NodeStressTester; +use anyhow::Context; +use nym_crypto::asymmetric::x25519; +use nym_network_monitor_orchestrator_requests::client::OrchestratorClient; +use nym_network_monitor_orchestrator_requests::models::{ + AgentAnnounceRequest, TestRunAssignmentRequest, TestRunResultSubmissionRequest, +}; +use nym_noise::LATEST_NOISE_VERSION; +use std::sync::Arc; +use tracing::info; + +pub(crate) mod config; +pub(crate) mod helpers; +pub(crate) mod result; +pub(crate) mod tested_node; +pub(crate) mod tester; + +/// A network monitor agent that receives test assignments from the orchestrator, +/// stress-tests individual nym-nodes, and reports results back. +pub(crate) struct NetworkMonitorAgent { + /// Tester configuration controlling rates, timeouts, and addressing. + tester_config: NodeTesterConfig, + + /// Client used to communicate with the orchestrator API (port requests, announcements, + /// work assignments, result submissions). + orchestrator_client: OrchestratorClient, + + /// The tester's own Noise key pair, used to authenticate the egress connection. + noise_key: Arc, +} + +impl NetworkMonitorAgent { + /// Creates a new agent with the given tester configuration, pre-loaded noise key, + /// and orchestrator client. + pub(crate) fn new( + tester_config: NodeTesterConfig, + noise_key: Arc, + orchestrator_client: OrchestratorClient, + ) -> Self { + NetworkMonitorAgent { + tester_config, + orchestrator_client, + noise_key, + } + } + + /// Announces this agent's details (mixnet address, noise key, protocol version) + /// to the orchestrator so they can be registered in the smart contract. + pub(crate) async fn announce_agent(&self) -> anyhow::Result<()> { + self.orchestrator_client + .announce_agent(&AgentAnnounceRequest { + agent_mix_socket_address: self.tester_config.external_mixnet_address, + x25519_noise_key: *self.noise_key.public_key(), + // we're always using the latest noise version available + noise_version: LATEST_NOISE_VERSION.into(), + }) + .await?; + Ok(()) + } + + /// Requests a work assignment from the orchestrator and, if one is available, + /// performs a stress test against the assigned node and submits the results. + pub(crate) async fn run_stress_test(&self) -> anyhow::Result<()> { + let request = TestRunAssignmentRequest { + agent_mix_socket_address: self.tester_config.external_mixnet_address, + x25519_noise_key: *self.noise_key.public_key(), + }; + + // 1. query the orchestrator for a work assignment + let Some(work_assignment) = self + .orchestrator_client + .request_work_assignment(&request) + .await? + .assignment + else { + // 2. if no work is available - exit immediately + info!("no work available, exiting..."); + return Ok(()); + }; + + info!("retrieved the following work assignment: {work_assignment:?}"); + let node_id = work_assignment.node_id; + + // 3. otherwise construct the tester and attempt to perform the measurements + let tested_node = TestedNodeDetails::from_testrun_assignment(work_assignment); + let mut stress_tester = + NodeStressTester::new(self.tester_config, self.noise_key.clone(), tested_node)?; + + // attempt to perform the measurements within the configured timeouts + // note: the only errors we're possibly exiting on are critical failures like + // theoretically impossible sphinx packet creations or failing to join on tasks. + // any sending/receiving errors are included as part of an `Ok(result)` response. + let result = stress_tester.run_stress_test().await?; + + // 4. after that has concluded - submit the results back to the orchestrator + self.orchestrator_client + .submit_test_run_result(&TestRunResultSubmissionRequest { + node_id, + result: result.into(), + }) + .await + .context("failed to submit test run result")?; + Ok(()) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/result.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/result.rs new file mode 100644 index 0000000000..5799ba432a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/result.rs @@ -0,0 +1,381 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::egress_connection::EgressConnectionStatistics; +use std::time::Duration; +use time::OffsetDateTime; + +// TODO: once created, move this struct to a shared models library +/// Captures the outcome of a single [`run_stress_test`](super::NodeStressTester::run_stress_test) run. +/// +/// Fields are populated incrementally as the test progresses; absent values (`None`) indicate +/// that the corresponding step was not reached or did not produce a result. +#[derive(Debug, Clone)] +pub(crate) struct TestRunResult { + /// The timestamp when the test run was initiated. + pub(crate) start_time: OffsetDateTime, + + /// Duration of the Noise handshake on the ingress (responder) side, if completed. + pub(crate) ingress_noise_handshake: Option, + + /// Duration of the Noise handshake on the egress (initiator) side, if completed. + pub(crate) egress_noise_handshake: Option, + + /// The (constant) delay of the sphinx packet set during the test run. + pub sphinx_packet_delay: Duration, + + /// Number of sphinx packets successfully sent to the node under test. + pub(crate) packets_sent: usize, + + /// Number of sphinx packets returned by the node and successfully received. + pub(crate) packets_received: usize, + + /// Round-trip time of the very first probe packet, sent in isolation before any load is applied. + /// Because the node is idle at this point, this value approximates the baseline network latency + /// to the node without any queuing or processing overhead from the stress test itself. + /// `None` if the initial probe did not complete successfully. + pub(crate) approximate_latency: Option, + + /// RTT statistics computed over all received packets, or `None` if no packets were received. + pub(crate) packets_statistics: Option, + + /// Latency distribution of individual batch send operations recorded during the load test. + /// Reflects how long each batch took to flush to the OS socket, giving a rough measure of + /// egress throughput. `None` if no batches were sent. + pub(crate) sending_statistics: Option, + + /// Whether any packet was received with an ID that had already been seen in this test run. + /// Duplicates should never occur under normal operation; their presence may indicate a + /// misbehaving or malicious node replaying packets. + pub(crate) received_duplicates: bool, + + /// Human-readable description of the first error that caused the test to abort if any. + pub(crate) error: Option, +} + +impl TestRunResult { + pub(crate) fn new(sphinx_packet_delay: Duration) -> Self { + TestRunResult { + start_time: OffsetDateTime::now_utc(), + ingress_noise_handshake: None, + egress_noise_handshake: None, + sphinx_packet_delay, + packets_sent: 0, + packets_received: 0, + approximate_latency: None, + packets_statistics: None, + sending_statistics: None, + received_duplicates: false, + error: None, + } + } + + /// Calculates the percentage of packets received out of the total sent. + pub(crate) fn received_percentage(&self) -> f64 { + if self.packets_sent > 0 { + (self.packets_received as f64 / self.packets_sent as f64) * 100.0 + } else { + 0.0 + } + } + + /// Records the duration of the ingress Noise handshake. + pub(crate) fn set_ingress_noise_handshake(&mut self, duration: Duration) { + self.ingress_noise_handshake = Some(duration); + } + + /// Records the duration of the egress Noise handshake. + pub(crate) fn set_egress_noise_handshake(&mut self, duration: Duration) { + self.egress_noise_handshake = Some(duration); + } + + /// Records the RTT of the initial probe packet as the baseline latency estimate. + pub(crate) fn set_approximate_latency(&mut self, rtt: Duration) { + self.approximate_latency = Some(rtt); + } + + /// Sets the number of packets that were sent during the stress test. + pub(crate) fn set_packets_sent(&mut self, count: usize) { + self.packets_sent = count; + } + + /// Sets the number of packets that were received back from the node under test. + pub(crate) fn set_packets_received(&mut self, count: usize) { + self.packets_received = count; + } + + /// Attaches pre-computed RTT statistics for the received packets. + pub(crate) fn set_packets_statistics(&mut self, stats: LatencyDistribution) { + self.packets_statistics = Some(stats); + } + + /// Marks that at least one duplicate packet ID was observed during the test run. + pub(crate) fn set_received_duplicates(&mut self) { + self.received_duplicates = true; + } + + /// Records an error message that caused the test run to abort. + pub(crate) fn set_error(&mut self, error: impl Into) { + self.error = Some(error.into()); + } + + /// Populates egress-side statistics from the finished [`EgressConnection`](crate::egress_connection::EgressConnection). + /// Sets the egress Noise handshake duration and, if any batches were sent, the batch send + /// latency distribution. + pub(crate) fn set_egress_connection_statistics(&mut self, stats: EgressConnectionStatistics) { + self.set_egress_noise_handshake(stats.noise_handshake_duration); + + if !stats.packet_batches_sending_duration.is_empty() { + self.sending_statistics = Some(LatencyDistribution::compute( + &stats.packet_batches_sending_duration, + )) + } + } +} + +/// Latency statistics computed over the set of test packets received or sent during a stress test. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct LatencyDistribution { + /// Minimum latency duration it took to send or receive a test packet. + pub minimum: Duration, + + /// Average latency duration it took to send or receive a test packet. + pub mean: Duration, + + /// Median latency duration it took to send or receive a test packet. + /// For an even number of samples, this is the arithmetic mean of the two middle values. + pub median: Duration, + + /// Maximum latency duration it took to send or receive a test packet. + pub maximum: Duration, + + /// The standard deviation of the latency duration it took to send or receive the test packets. + pub standard_deviation: Duration, +} + +impl LatencyDistribution { + /// Computes statistics from a slice of per-packet RTT durations. + /// Returns zeroed statistics if `raw_results` is empty. + pub fn compute(raw_results: &[Duration]) -> Self { + if raw_results.is_empty() { + return LatencyDistribution { + minimum: Duration::ZERO, + mean: Duration::ZERO, + median: Duration::ZERO, + maximum: Duration::ZERO, + standard_deviation: Duration::ZERO, + }; + } + + let mut sorted = raw_results.to_vec(); + sorted.sort(); + + let minimum = sorted[0]; + + // SAFETY: we have ensured our list is not empty + #[allow(clippy::unwrap_used)] + let maximum = *sorted.last().unwrap(); + let median = Self::duration_median(&sorted); + let mean = Self::duration_mean(&sorted); + let standard_deviation = Self::duration_standard_deviation(&sorted, mean); + + LatencyDistribution { + minimum, + mean, + median, + maximum, + standard_deviation, + } + } + + /// Computes the median of an already-sorted slice of durations. + /// For an even count, returns the arithmetic mean of the two middle elements. + /// Caller must ensure `sorted` is non-empty and ordered ascending. + fn duration_median(sorted: &[Duration]) -> Duration { + let len = sorted.len(); + let mid = len / 2; + if len % 2 == 1 { + sorted[mid] + } else { + (sorted[mid - 1] + sorted[mid]) / 2 + } + } + + /// Computes the arithmetic mean of a slice of durations. + /// Returns [`Duration::ZERO`] for an empty slice. + fn duration_mean(data: &[Duration]) -> Duration { + if data.is_empty() { + return Default::default(); + } + + let sum = data.iter().sum::(); + // packet counts realistically fit in a u32; a test sending 4 billion packets would + // have other problems first + let count = data.len() as u32; + + sum / count + } + + /// Computes the population standard deviation (divides by N, not N-1) of the RTT durations. + /// Precision is truncated to microseconds, which is sufficient for network latency. + fn duration_standard_deviation(data: &[Duration], mean: Duration) -> Duration { + if data.is_empty() { + return Default::default(); + } + + let variance_micros = data + .iter() + .map(|&value| { + let diff = mean.abs_diff(value); + // truncate to microseconds — nanosecond precision is noise for network RTTs + let diff_micros = diff.as_micros(); + diff_micros * diff_micros + }) + .sum::() + / data.len() as u128; + + // u128 easily holds squared microsecond values for any realistic RTT (< thousands of seconds) + let std_deviation_micros = (variance_micros as f64).sqrt() as u64; + Duration::from_micros(std_deviation_micros) + } +} + +impl From + for nym_network_monitor_orchestrator_requests::models::LatencyDistribution +{ + fn from(value: LatencyDistribution) -> Self { + Self { + minimum: value.minimum, + mean: value.mean, + median: value.median, + maximum: value.maximum, + standard_deviation: value.standard_deviation, + } + } +} + +impl From for nym_network_monitor_orchestrator_requests::models::TestRunResult { + fn from(value: TestRunResult) -> Self { + Self { + time_taken: (OffsetDateTime::now_utc() - value.start_time).unsigned_abs(), + ingress_noise_handshake: value.ingress_noise_handshake, + egress_noise_handshake: value.egress_noise_handshake, + sphinx_packet_delay: value.sphinx_packet_delay, + packets_sent: value.packets_sent, + packets_received: value.packets_received, + approximate_latency: value.approximate_latency, + packets_statistics: value.packets_statistics.map(Into::into), + sending_statistics: value.sending_statistics.map(Into::into), + received_duplicates: value.received_duplicates, + error: value.error, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn empty_slice_gives_zero_stats() { + let stats = LatencyDistribution::compute(&[]); + assert_eq!(stats.minimum, Duration::ZERO); + assert_eq!(stats.maximum, Duration::ZERO); + assert_eq!(stats.mean, Duration::ZERO); + assert_eq!(stats.median, Duration::ZERO); + assert_eq!(stats.standard_deviation, Duration::ZERO); + } + + #[test] + fn single_value_has_zero_deviation() { + let stats = LatencyDistribution::compute(&[ms(42)]); + assert_eq!(stats.minimum, ms(42)); + assert_eq!(stats.maximum, ms(42)); + assert_eq!(stats.mean, ms(42)); + assert_eq!(stats.median, ms(42)); + assert_eq!(stats.standard_deviation, Duration::ZERO); + } + + #[test] + fn two_equal_values_have_zero_deviation() { + let stats = LatencyDistribution::compute(&[ms(10), ms(10)]); + assert_eq!(stats.mean, ms(10)); + assert_eq!(stats.median, ms(10)); + assert_eq!(stats.standard_deviation, Duration::ZERO); + } + + #[test] + fn median_odd_count_picks_middle() { + // sorted: 10, 20, 30, 40, 50 -> median = 30 + let data = [ms(40), ms(10), ms(50), ms(20), ms(30)]; + let stats = LatencyDistribution::compute(&data); + assert_eq!(stats.median, ms(30)); + } + + #[test] + fn median_even_count_averages_two_middle() { + // sorted: 10, 20, 30, 40 -> median = (20 + 30) / 2 = 25 + let data = [ms(30), ms(10), ms(40), ms(20)]; + let stats = LatencyDistribution::compute(&data); + assert_eq!(stats.median, ms(25)); + } + + #[test] + fn min_max_are_correct() { + let data = [ms(30), ms(10), ms(50), ms(20)]; + let stats = LatencyDistribution::compute(&data); + assert_eq!(stats.minimum, ms(10)); + assert_eq!(stats.maximum, ms(50)); + } + + #[test] + fn mean_is_correct() { + // mean of 10, 20, 30, 40 = 25 ms + let data = [ms(10), ms(20), ms(30), ms(40)]; + let stats = LatencyDistribution::compute(&data); + assert_eq!(stats.mean, ms(25)); + } + + #[test] + fn standard_deviation_known_values() { + // population std-dev of {10, 20, 30, 40} ms: + // mean = 25, deviations = {-15, -5, 5, 15} + // variance = (225 + 25 + 25 + 225) / 4 = 125 + // std-dev = sqrt(125) ≈ 11.180 ms → truncated to microseconds = 11180 µs + let data = [ms(10), ms(20), ms(30), ms(40)]; + let stats = LatencyDistribution::compute(&data); + let expected = Duration::from_micros(11180); + // allow ±1 µs for floating-point rounding + let diff = stats.standard_deviation.abs_diff(expected); + assert!( + diff <= Duration::from_micros(1), + "std-dev {:.3?} not within 1µs of expected {:.3?}", + stats.standard_deviation, + expected + ); + } + + #[test] + fn result_setters_populate_fields() { + let mut result = TestRunResult::new(ms(2)); + result.set_ingress_noise_handshake(ms(5)); + result.set_egress_noise_handshake(ms(7)); + result.set_packets_sent(100); + result.set_packets_received(95); + result.set_error("timeout"); + + let stats = LatencyDistribution::compute(&[ms(10), ms(20)]); + result.set_packets_statistics(stats); + + assert_eq!(result.ingress_noise_handshake, Some(ms(5))); + assert_eq!(result.egress_noise_handshake, Some(ms(7))); + assert_eq!(result.packets_sent, 100); + assert_eq!(result.packets_received, 95); + assert_eq!(result.packets_statistics, Some(stats)); + assert_eq!(result.error.as_deref(), Some("timeout")); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tested_node.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tested_node.rs new file mode 100644 index 0000000000..5b4e16ed8a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tested_node.rs @@ -0,0 +1,53 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::sphinx_helpers::as_sphinx_node; +use nym_crypto::asymmetric::x25519; +use nym_network_monitor_orchestrator_requests::models::TestRunAssignment; +use nym_noise::config::{NoiseNode, NoiseVersion, VersionedNoiseKeyV1}; +use nym_sphinx_params::SphinxKeyRotation; +use std::net::SocketAddr; + +/// Identity and addressing information for the node being tested in a stress-test run. +#[derive(Debug)] +pub(crate) struct TestedNodeDetails { + pub(crate) node_id: Option, + + /// TCP socket address of the node's mixnet listener, used for the egress connection. + pub(crate) address: SocketAddr, + + /// Node's static Noise public key, used to authenticate and encrypt the egress connection. + pub(crate) noise_key: x25519::PublicKey, + + /// Key rotation associated with the current sphinx key of the node. + pub(crate) key_rotation: SphinxKeyRotation, + + /// Node's current sphinx public key, used to build the sphinx packet header. + pub(crate) sphinx_key: x25519::PublicKey, +} + +impl TestedNodeDetails { + pub(crate) fn from_testrun_assignment(assignment: TestRunAssignment) -> Self { + TestedNodeDetails { + node_id: Some(assignment.node_id), + address: assignment.node_address, + noise_key: assignment.noise_key, + key_rotation: SphinxKeyRotation::from_key_rotation_id(assignment.key_rotation_id), + sphinx_key: assignment.sphinx_key, + } + } + + /// Returns a sphinx [`Node`](nym_sphinx_types::Node) representation of this node, + /// suitable for use as a hop in a sphinx route. + pub(crate) fn as_sphinx_node(&self) -> nym_sphinx_types::Node { + as_sphinx_node(self.address, self.sphinx_key) + } + + /// Returns a [`NoiseNode`] representation of this node for use in the Noise network view. + pub(crate) fn as_noise_node(&self) -> NoiseNode { + NoiseNode::new_nym_node(VersionedNoiseKeyV1 { + supported_version: NoiseVersion::V1, + x25519_pubkey: self.noise_key, + }) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tester.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tester.rs new file mode 100644 index 0000000000..f4277ce9d4 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/agent/tester.rs @@ -0,0 +1,521 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::agent::config::NodeTesterConfig; +use crate::agent::result::{LatencyDistribution, TestRunResult}; +use crate::agent::tested_node::TestedNodeDetails; +use crate::egress_connection::EgressConnection; +use crate::listener::MixnetListener; +use crate::listener::received::MixnetPacketsSender; +use crate::processor::{MixnetPacketProcessor, ProcessedPacket}; +use crate::sphinx_helpers::{ + as_sphinx_node, build_test_sphinx_packet, create_test_sphinx_packet_header, +}; +use crate::test_packet::{TestPacketContent, TestPacketHeader}; +use anyhow::Context; +use humantime::format_duration; +use nym_crypto::asymmetric::x25519; +use nym_noise::config::{NoiseConfig, NoiseNetworkView}; +use nym_sphinx_types::SphinxPacket; +use nym_task::ShutdownToken; +use rand::rngs::OsRng; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::pin; +use tokio::sync::Notify; +use tokio::time::{Instant, sleep}; +use tracing::{debug, error, info, warn}; + +/// The core component responsible for executing a stress-test run against a single node. +/// +/// A test run proceeds in five ordered steps (see [`run_stress_test`](Self::run_stress_test)): +/// +/// 1. Establish an outbound (egress) Noise-encrypted TCP connection to the node. +/// 2. Bind a local TCP listener (ingress) that receives sphinx packets the node sends back. +/// 3. Send a single probe packet to verify basic connectivity and record baseline latency. +/// 4. Replay the same packet (when `reuse_header` is enabled) to confirm the node's +/// bloomfilter bypass is correctly configured. +/// 5. Send packets at the configured rate for the configured duration, then collect and +/// summarise the results. +/// +/// Only critical failures (e.g. failing to bind a port) are returned as +/// `Err`; node-level failures (e.g. the node not responding) are captured inside the +/// returned [`TestRunResult`] so the caller can still inspect partial data. +pub(crate) struct NodeStressTester { + /// Tester configuration controlling rates, timeouts, and addressing. + config: NodeTesterConfig, + + /// Monotonically increasing counter embedded in each outgoing packet as its ID. + packet_counter: u64, + + /// Pre-built sphinx packet header reused across all packets when `config.reuse_header` + /// is set. Allows the node's bloomfilter bypass to be exercised. `None` means a fresh + /// header is built for every packet. + reusable_test_header: Option, + + /// The tester's own Noise key pair, used to authenticate the egress connection. + noise_key: Arc, + + /// An ephemeral sphinx key pair generated at construction time. Used both to build the + /// return-route sphinx header (so packets come back to this tester) and to decrypt + /// returning packets when `reuse_header` is disabled. + sphinx_key: Arc, + + /// Identity and addressing information for the node being tested. + tested_node: TestedNodeDetails, +} + +impl NodeStressTester { + /// Creates a new tester, loading the Noise private key from `noise_key_path` and + /// generating a fresh ephemeral sphinx key. If `config.reuse_header` is set, the + /// sphinx packet header is pre-built here so it can be reused across all test packets. + pub(crate) fn new( + config: NodeTesterConfig, + noise_key: Arc, + tested_node: TestedNodeDetails, + ) -> anyhow::Result { + debug!("using the following tester config"); + debug!("{config:#?}"); + + debug!("testing the following node"); + debug!("{tested_node:#?}"); + + let sphinx_key = x25519::PrivateKey::new(&mut OsRng); + + let reusable_test_header = if config.reuse_header { + debug!("reusing sphinx header for tests"); + // Route: tested node → this agent (so packets come back to us). + let route = [ + tested_node.as_sphinx_node(), + as_sphinx_node(config.external_mixnet_address, sphinx_key.public_key()), + ]; + let delay = config.packet_delay; + Some(create_test_sphinx_packet_header(route, delay)?) + } else { + debug!("new sphinx header will be generated for each new test packet"); + None + }; + + Ok(Self { + config, + packet_counter: 0, + reusable_test_header, + noise_key, + sphinx_key: Arc::new(sphinx_key.into()), + tested_node, + }) + } + + /// Opens the outbound Noise-encrypted TCP connection to the node under test. + async fn establish_egress_connection(&self) -> anyhow::Result { + EgressConnection::establish( + self.tested_node.address, + self.config.egress_connection_timeout, + self.tested_node.key_rotation, + &self.noise_config(), + ) + .await + } + + /// Constructs the [`MixnetPacketProcessor`] used to decode and time-stamp returning packets. + /// When a reusable header is available it is used for decryption; otherwise the tester's + /// sphinx private key is used directly. + fn build_packet_processor(&self) -> MixnetPacketProcessor { + let packet_recovery = match &self.reusable_test_header { + Some(header) => header.clone().into(), + None => self.sphinx_key.clone().into(), + }; + MixnetPacketProcessor::new(packet_recovery, self.config.waiting_duration) + } + + /// Binds the local TCP listener and wraps it in a [`MixnetListener`] that will forward + /// decoded packets to `received_sender`. + async fn build_mixnet_listener( + &self, + received_sender: MixnetPacketsSender, + shutdown_token: ShutdownToken, + ) -> anyhow::Result { + MixnetListener::new( + self.config.mixnet_bind_address, + self.tested_node.address, + self.noise_config(), + received_sender, + shutdown_token.clone(), + ) + .await + } + + /// Builds a [`NoiseConfig`] that contains the default configuration for the protocol + /// and the key associated with the tested node to accept its connection. + fn noise_config(&self) -> NoiseConfig { + let mut nodes = HashMap::new(); + nodes.insert( + self.tested_node.address.ip(), + self.tested_node.as_noise_node(), + ); + let network = NoiseNetworkView::new(nodes); + + NoiseConfig::new( + self.noise_key.clone(), + network, + self.config.noise_handshake_timeout, + ) + } + + /// Returns a sphinx node representation of this tester's own mixnet listener address, + /// used as the final hop in the packet route so packets are delivered back here. + fn as_sphinx_node(&self) -> nym_sphinx_types::Node { + as_sphinx_node( + self.config.external_mixnet_address, + *self.sphinx_key.public_key(), + ) + } + + /// Builds the next test sphinx packet, incrementing the internal packet counter. + /// Reuses the pre-built header when available; otherwise builds a fresh header and + /// encrypts it with a new sphinx key each time. + fn create_test_sphinx_packet(&mut self) -> anyhow::Result { + let content = TestPacketContent::new(self.packet_counter); + self.packet_counter += 1; + + match &self.reusable_test_header { + Some(header) => header.create_test_packet(content), + None => { + let route = [self.tested_node.as_sphinx_node(), self.as_sphinx_node()]; + build_test_sphinx_packet( + &route, + self.config.packet_delay, + None, + &content.to_bytes(), + ) + } + } + } + + /// Builds a batch of `batch_size` test sphinx packets with consecutive IDs. + fn create_packet_batch(&mut self, batch_size: usize) -> anyhow::Result> { + let mut packets = Vec::with_capacity(batch_size); + for _ in 0..batch_size { + let packet = self.create_test_sphinx_packet()?; + packets.push(packet); + } + Ok(packets) + } + + /// Computes the network latency for a received packet by subtracting the configured + /// sphinx delay from its measured round-trip time. + fn packet_latency(&self, received: ProcessedPacket) -> Duration { + received.rtt - self.config.packet_delay + } + + /// Creates and sends a single test sphinx packet over `egress`. + /// On send failure, records an error on `result` and returns `false`. + async fn send_test_packet( + &mut self, + egress: &mut EgressConnection, + result: &mut TestRunResult, + ) -> anyhow::Result { + let packet = self + .create_test_sphinx_packet() + .context("sphinx packet creation failure!")?; + if let Err(err) = egress.send_packet(packet).await { + result.set_error(format!("{:#}", err.context("failed to send test packet"))); + return Ok(false); + }; + Ok(true) + } + + /// Creates and sends a batch of `batch_size` test packets over `egress`. + /// On send failure, records an error on `result` and returns `false`. + async fn send_test_packet_batch( + &mut self, + batch_size: usize, + egress: &mut EgressConnection, + result: &mut TestRunResult, + ) -> anyhow::Result { + let batch = self + .create_packet_batch(batch_size) + .context("sphinx packet batch creation failure!")?; + + if let Err(err) = egress.send_packet_batch(batch).await { + result.set_error(format!("{:#}", err.context("failed to send test packet"))); + return Ok(false); + }; + Ok(true) + } + + /// Sends a single packet and waits for it to come back. + /// On success, sets `approximate_latency` on the result and returns `true`. + /// On failure, sets an error on the result and returns `false` (caller should abort). + async fn send_connectivity_probe( + &mut self, + egress: &mut EgressConnection, + processor: &mut MixnetPacketProcessor, + result: &mut TestRunResult, + ) -> anyhow::Result { + if !self.send_test_packet(egress, result).await? { + return Ok(false); + } + + match processor.next_packet().await { + Ok(res) => { + result.set_approximate_latency(self.packet_latency(res)); + Ok(true) + } + Err(err) => { + result.set_error(format!( + "{:#}", + err.context("failed to receive a valid initial packet back") + )); + Ok(false) + } + } + } + + /// Replays a packet to verify that the node's bloomfilter bypass is correctly configured. + /// Returns `true` if the packet was returned, `false` if the node failed the check (caller should abort). + /// Should only be called when `config.reuse_header` is set. + async fn send_bloomfilter_probe( + &mut self, + egress: &mut EgressConnection, + processor: &mut MixnetPacketProcessor, + result: &mut TestRunResult, + ) -> anyhow::Result { + info!("repeating the packet to check bloomfilter bypass configuration"); + if !self.send_test_packet(egress, result).await? { + return Ok(false); + } + + match processor.next_packet().await { + Ok(res) => { + info!("received {res}"); + Ok(true) + } + Err(err) => { + result.set_error(format!( + "{:#}", + err.context("failed to receive a valid secondary packet back - the node might not have a working chain subscriber (or the agent might be misconfigured)")) + ); + Ok(false) + } + } + } + + /// Sends packets at the configured rate for the configured duration. + /// Dispatches one batch every `batch_interval` seconds; if the egress falls behind, + /// ticks are delayed rather than bunched up to avoid unintended bursts. + /// Updates `result.packets_sent` after every batch and returns `false` on send failure. + async fn send_load_test( + &mut self, + egress: &mut EgressConnection, + result: &mut TestRunResult, + ) -> anyhow::Result { + // one batch every (sending_batch_size / target_rate) seconds keeps us at the target rate + let batch_interval = self.config.batch_interval(); + let mut interval = tokio::time::interval(batch_interval); + // if we fall behind, don't try to catch up with burst sends + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + let start = Instant::now(); + let mut sent = 0; + let total_packets = self.config.expected_packets(); + + loop { + if start.elapsed() >= self.config.sending_duration { + break; + } + if sent >= total_packets { + break; + } + interval.tick().await; + + // the last batch may be smaller than other batches + let remaining = total_packets - sent; + let batch_size = self.config.sending_batch_size.min(remaining); + if !self + .send_test_packet_batch(batch_size, egress, result) + .await? + { + return Ok(false); + } + + sent += batch_size; + // update send count after each batch so partial results are visible on early exit + result.set_packets_sent(sent); + } + + if sent < total_packets { + warn!( + "did not manage to send all required packets within the sending window. sent {sent}/{total_packets}" + ); + } + Ok(true) + } + + /// Drains all received packets from `processor` (waiting up to `waiting_duration` for + /// stragglers), deduplicates by ID, computes RTT statistics, and populates `result`. + async fn collect_test_results( + &self, + processor: &mut MixnetPacketProcessor, + result: &mut TestRunResult, + ) { + // drain whatever arrived immediately, then wait for stragglers + let mut received = processor.all_available(); + if received.len() < result.packets_sent { + let deadline = sleep(self.config.waiting_duration); + pin!(deadline); + loop { + tokio::select! { + _ = &mut deadline => break, + next = processor.next_packet() => { + received.push(next); + if received.len() >= result.packets_sent { + break; + } + } + } + } + } + + // deduplicate by packet ID; duplicates indicate possible node misbehaviour + let mut valid_received = HashMap::new(); + for packet in received { + let Ok(packet) = packet else { + debug!("received packet was malformed"); + continue; + }; + if valid_received.insert(packet.id, packet).is_some() { + error!( + "‼️ received duplicate packet for id {} - something nasty is going on!", + packet.id + ); + result.set_received_duplicates(); + } + } + + let latencies = valid_received + .values() + .map(|p| self.packet_latency(*p)) + .collect::>(); + + let received_count = valid_received.len(); + result.set_packets_received(received_count); + result.set_packets_statistics(LatencyDistribution::compute(&latencies)); + + debug!( + sent = result.packets_sent, + received = received_count, + recv_pct = format!("{:.1}%", result.received_percentage()), + "load test complete" + ); + } + + /// Runs a full stress-test against the configured node and returns the collected results. + /// + /// Only returns `Err` for critical failures (e.g. unable to bind the listener + /// port). Node-level failures (no response, bloomfilter misconfiguration, etc.) are + /// recorded inside the returned [`TestRunResult`] so the caller always gets partial data. + pub(crate) async fn run_stress_test(&mut self) -> anyhow::Result { + let node_address = self.tested_node.address; + if let Some(node_id) = self.tested_node.node_id { + info!("beginning stress test of node {node_id} ({node_address})",); + } else { + info!("beginning stress test of node {node_address}",); + } + + let mut result = TestRunResult::new(self.config.packet_delay); + + // 1. establish the egress connection — abort immediately if it fails + debug!("attempting to establish egress connection to the tested node"); + let mut egress = match self.establish_egress_connection().await { + Ok(conn) => conn, + Err(err) => { + result.set_error(format!( + "{:#}", + err.context("failed to establish egress node connection") + )); + return Ok(result); + } + }; + + // 2. spawn the mixnet packet listener that forwards received packets to the processor + debug!( + "creating mixnet listener on {}", + self.config.mixnet_bind_address + ); + let mut processor = self.build_packet_processor(); + let shutdown_token = ShutdownToken::new(); + let listener = self + .build_mixnet_listener(processor.sender(), shutdown_token.clone()) + .await?; + let listener_on_start = Arc::new(Notify::new()); + let listener_on_start_clone = listener_on_start.clone(); + + let listener_join = + tokio::spawn(async move { listener.run(listener_on_start_clone).await }); + + // wait for the listener task to properly begin + listener_on_start.notified().await; + + // 3. probe: send a single packet to confirm the node responds + debug!("sending initial node connectivity probe"); + if !self + .send_connectivity_probe(&mut egress, &mut processor, &mut result) + .await? + { + shutdown_token.cancel(); + let _ = listener_join.await?; + return Ok(result); + } + + // 4. probe: replay the packet to verify bloomfilter bypass is configured + debug!("sending bloomfilter probe"); + if self.config.reuse_header + && !self + .send_bloomfilter_probe(&mut egress, &mut processor, &mut result) + .await? + { + shutdown_token.cancel(); + let mixnet_listener = listener_join.await?; + let ingress_noise = mixnet_listener + .last_noise_handshake_duration + .context("missing ingress noise duration after completing entire test run!")?; + + result.set_ingress_noise_handshake(ingress_noise); + result.set_egress_connection_statistics(egress.connection_statistics); + return Ok(result); + } + + // 5. stress test: send packets at the target rate for the configured duration + debug!( + "beginning the proper load testing. going to send at rate {}/s for {}", + self.config.target_rate, + format_duration(self.config.sending_duration) + ); + self.send_load_test(&mut egress, &mut result).await?; + + // 6. collect and summarise results + debug!("waiting for final packets to arrive"); + self.collect_test_results(&mut processor, &mut result).await; + + // 7. shut down the listener and harvest its stats + debug!("shutting down the mixnet listener and finishing the test"); + shutdown_token.cancel(); + let mixnet_listener = listener_join.await?; + let ingress_noise = mixnet_listener + .last_noise_handshake_duration + .context("missing ingress noise duration after completing entire test run!")?; + + result.set_ingress_noise_handshake(ingress_noise); + result.set_egress_connection_statistics(egress.connection_statistics); + + if let Some(node_id) = self.tested_node.node_id { + info!("finished stress test of node {node_id} ({node_address})",); + } else { + info!("finished stress test of node {node_address}",); + } + + Ok(result) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/build_info.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/build_info.rs new file mode 100644 index 0000000000..a5b80d67bf --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/build_info.rs @@ -0,0 +1,15 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_bin_common::bin_info_owned; +use nym_bin_common::output_format::OutputFormat; + +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + #[clap(short, long, default_value_t = OutputFormat::default())] + output: OutputFormat, +} + +pub(crate) fn execute(args: Args) { + println!("{}", args.output.format(&bin_info_owned!())) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/common.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/common.rs new file mode 100644 index 0000000000..94eed06d95 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/common.rs @@ -0,0 +1,88 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::env::vars::*; +use crate::agent::config::NodeTesterConfig; +use anyhow::bail; +use std::net::SocketAddr; +use std::num::NonZeroUsize; +use std::time::Duration; + +#[derive(clap::Args, Debug)] +pub(crate) struct CommonArgs { + /// Specifies for how long the agent should be sending test packets with the specified rate. + #[arg(long, value_parser = humantime::parse_duration, default_value = "30s", env = NYM_NETWORK_MONITOR_AGENT_SENDING_DURATION_ARG)] + sending_duration: Duration, + + /// Specifies how long the agent will wait to receive any leftover packets after finishing sending. + #[arg(long, value_parser = humantime::parse_duration, default_value = "5s", env = NYM_NETWORK_MONITOR_AGENT_WAITING_DURATION_ARG)] + waiting_duration: Duration, + + /// How long the node itself should delay the packet + /// It shouldn't be set to zero as otherwise the node will not put the packet through + /// its delay queue and we would not test the entire pipeline + #[arg(long, value_parser = humantime::parse_duration, default_value = "50ms", env = NYM_NETWORK_MONITOR_AGENT_PACKET_DELAY_ARG)] + packet_delay: Duration, + + /// Specifies the target rate of packets (per second) to be sent. + #[arg(long, default_value = "1000", env = NYM_NETWORK_MONITOR_AGENT_TARGET_RATE_ARG)] + target_rate: NonZeroUsize, + + /// Specifies whether the agent should reuse the same header for all packets. + /// And consequently replay them + #[arg(long, short, default_value = "true", env = NYM_NETWORK_MONITOR_AGENT_REUSE_HEADER_ARG)] + reuse_header: bool, + + /// Timeout for establishing the TCP connection to the node under test. + #[arg(long, value_parser = humantime::parse_duration, default_value = "5s", env = NYM_NETWORK_MONITOR_AGENT_EGRESS_CONNECTION_TIMEOUT_ARG)] + egress_connection_timeout: Duration, + + /// Timeout for completing the Noise handshake with the node under test. + #[arg(long, value_parser = humantime::parse_duration, default_value = "3s", env = NYM_NETWORK_MONITOR_AGENT_NOISE_HANDSHAKE_TIMEOUT_ARG)] + noise_handshake_timeout: Duration, + + /// Number of packets sent in a single batch. Together with `target_rate` this controls + /// how frequently batches are dispatched: one batch every `sending_batch_size / target_rate` seconds. + #[arg(long, default_value = "50", env = NYM_NETWORK_MONITOR_AGENT_SENDING_BATCH_SIZE_ARG)] + sending_batch_size: NonZeroUsize, + + /// Specifies the path to the noise key file used for establishing tunnel with the node being tested + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NOISE_KEY_PATH_ARG)] + pub(crate) noise_key_path: String, + + /// Specifies the socket address the agent will bind to for receiving mixnet traffic. + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_BIND_ADDRESS_ARG, default_value = "[::]:9000")] + bind_address: SocketAddr, +} + +impl CommonArgs { + /// Constructs a [`NodeTesterConfig`] from the common CLI arguments. + /// `mixnet_address` is provided separately as it is command-specific. + pub(crate) fn build_config( + &self, + external_address: SocketAddr, + ) -> anyhow::Result { + if self.sending_duration.is_zero() { + bail!("attempted to set sending duration to 0s") + } + if self.egress_connection_timeout.is_zero() { + bail!("attempted to set egress connection timeout to 0s") + } + if self.noise_handshake_timeout.is_zero() { + bail!("attempted to set noise handshake timeout to 0s") + } + + Ok(NodeTesterConfig { + sending_duration: self.sending_duration, + waiting_duration: self.waiting_duration, + packet_delay: self.packet_delay, + egress_connection_timeout: self.egress_connection_timeout, + noise_handshake_timeout: self.noise_handshake_timeout, + sending_batch_size: self.sending_batch_size.get(), + target_rate: self.target_rate.get(), + reuse_header: self.reuse_header, + mixnet_bind_address: self.bind_address, + external_mixnet_address: external_address, + }) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/env.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/env.rs new file mode 100644 index 0000000000..2a12a6fe67 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/env.rs @@ -0,0 +1,48 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Environment variable names used as fallbacks for CLI arguments. +/// Each constant matches the `env = ...` attribute on the corresponding clap field. +pub mod vars { + // common args + pub const NYM_NETWORK_MONITOR_AGENT_SENDING_DURATION_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_SENDING_DURATION"; + pub const NYM_NETWORK_MONITOR_AGENT_WAITING_DURATION_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_WAITING_DURATION"; + pub const NYM_NETWORK_MONITOR_AGENT_TARGET_RATE_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_TARGET_RATE"; + pub const NYM_NETWORK_MONITOR_AGENT_REUSE_HEADER_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_REUSE_HEADER"; + pub const NYM_NETWORK_MONITOR_AGENT_NOISE_KEY_PATH_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NOISE_KEY_PATH"; + pub const NYM_NETWORK_MONITOR_AGENT_PACKET_DELAY_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_PACKET_DELAY"; + pub const NYM_NETWORK_MONITOR_AGENT_EGRESS_CONNECTION_TIMEOUT_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_EGRESS_CONNECTION_TIMEOUT"; + pub const NYM_NETWORK_MONITOR_AGENT_NOISE_HANDSHAKE_TIMEOUT_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NOISE_HANDSHAKE_TIMEOUT"; + pub const NYM_NETWORK_MONITOR_AGENT_SENDING_BATCH_SIZE_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_SENDING_BATCH_SIZE"; + pub const NYM_NETWORK_MONITOR_AGENT_BIND_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_BIND_ADDRESS"; + + // run agent args + pub const NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_ADDRESS"; + pub const NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_TOKEN_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_TOKEN"; + pub const NYM_NETWORK_MONITOR_AGENT_HOST_IP_ARG: &str = "NYM_NETWORK_MONITOR_AGENT_HOST_IP"; + pub const NYM_NETWORK_MONITOR_AGENT_HOST_PORT_ARG: &str = "NYM_NETWORK_MONITOR_AGENT_HOST_PORT"; + + // test node args + pub const NYM_NETWORK_MONITOR_AGENT_MIXNET_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_MIXNET_ADDRESS"; + pub const NYM_NETWORK_MONITOR_AGENT_NODE_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NODE_ADDRESS"; + pub const NYM_NETWORK_MONITOR_AGENT_NODE_NOISE_KEY_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NODE_NOISE_KEY"; + pub const NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY"; + pub const NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY_ROTATION_ARG: &str = + "NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY_ROTATION"; +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/keygen.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/keygen.rs new file mode 100644 index 0000000000..a1771fc508 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/keygen.rs @@ -0,0 +1,24 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::env::vars::*; +use nym_crypto::asymmetric::x25519; +use tracing::info; + +/// Arguments for the `keygen` subcommand. +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + /// Specifies the path to the noise key file used for establishing tunnel with the node being tested + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NOISE_KEY_PATH_ARG)] + noise_key_path: String, +} + +/// Generates a fresh x25519 Noise private key and writes it to the path specified in `args`. +pub(crate) fn execute(args: Args) -> anyhow::Result<()> { + let mut rng = rand::thread_rng(); + let noise_key = x25519::PrivateKey::new(&mut rng); + + nym_pemstore::store_key(&noise_key, &args.noise_key_path)?; + info!("noise key written to '{}'", args.noise_key_path); + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/mod.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/mod.rs new file mode 100644 index 0000000000..d271f10b60 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/mod.rs @@ -0,0 +1,56 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use clap::{Parser, Subcommand}; +use nym_bin_common::bin_info; +use std::sync::OnceLock; + +mod build_info; +mod common; +mod env; +mod keygen; +mod run_agent; +mod test_node; + +// Helper for passing LONG_VERSION to clap +fn pretty_build_info_static() -> &'static str { + static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); + PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) +} + +/// Top-level CLI entry point for the network monitor agent. +#[derive(Parser, Debug)] +#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Command, +} + +impl Cli { + /// Dispatches execution to the subcommand selected by the user. + pub(crate) async fn execute(self) -> anyhow::Result<()> { + match self.command { + Command::BuildInfo(args) => build_info::execute(args), + Command::TestNode(args) => test_node::execute(args).await?, + Command::RunAgent(args) => run_agent::execute(args).await?, + Command::Keygen(args) => keygen::execute(args)?, + } + Ok(()) + } +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Show build information of this binary + BuildInfo(build_info::Args), + + /// One-shot manual testing of a specified node + /// without interacting with the orchestrator. + TestNode(test_node::Args), + + /// Test a node by contacting the orchestrator for the work assignment + RunAgent(run_agent::Args), + + /// Generate all required keys for the agent to work + Keygen(keygen::Args), +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/run_agent.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/run_agent.rs new file mode 100644 index 0000000000..eba620e58a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/run_agent.rs @@ -0,0 +1,63 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::env::vars::*; +use crate::agent::NetworkMonitorAgent; +use crate::agent::helpers::load_noise_key; +use crate::cli::common::CommonArgs; +use nym_network_monitor_orchestrator_requests::client::OrchestratorClient; +use std::net::{IpAddr, SocketAddr}; +use tracing::info; +use url::Url; + +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + #[clap(flatten)] + common_args: CommonArgs, + + /// Address of the orchestrator for requesting work assignments + #[clap(long, env = NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_ADDRESS_ARG)] + orchestrator_address: Url, + + /// Bearer token required for requesting work assignments + /// and submitting the results + #[clap(long, env = NYM_NETWORK_MONITOR_AGENT_ORCHESTRATOR_TOKEN_ARG)] + orchestrator_token: String, + + /// Egress IP address of this agent, retrieved from status.hostIP via the Downward API + #[clap(long, env = NYM_NETWORK_MONITOR_AGENT_HOST_IP_ARG)] + host_ip: IpAddr, + + /// Announced port of this agent, used alongside host_ip by nodes sending packets back to the agent + #[clap(long, env = NYM_NETWORK_MONITOR_AGENT_HOST_PORT_ARG)] + host_port: u16, +} + +pub(crate) async fn execute(args: Args) -> anyhow::Result<()> { + let orchestrator_client = + OrchestratorClient::new(args.orchestrator_address.into(), args.orchestrator_token)?; + + let noise_key = load_noise_key(&args.common_args.noise_key_path)?; + + let external_address = SocketAddr::new(args.host_ip, args.host_port); + + // 1. build instance of the agent (loads the noise keys) + let agent = NetworkMonitorAgent::new( + args.common_args.build_config(external_address)?, + noise_key, + orchestrator_client, + ); + + // 2. announce the agent to the orchestrator + // so that it would be registered in the smart contract + // (if it hasn't been announced before) + info!("announcing agent information to the orchestrator"); + agent.announce_agent().await?; + + // 3. query the orchestrator for work assignment and attempt to perform the stress test + // of the target node + info!("attempting to request test run assignment"); + agent.run_stress_test().await?; + + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/test_node.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/test_node.rs new file mode 100644 index 0000000000..1e6c7542fa --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/cli/test_node.rs @@ -0,0 +1,77 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::env::vars::*; +use crate::agent::config::NodeTesterConfig; +use crate::agent::helpers::load_noise_key; +use crate::agent::tested_node::TestedNodeDetails; +use crate::agent::tester::NodeStressTester; +use crate::cli::common::CommonArgs; +use nym_crypto::asymmetric::x25519; +use nym_sphinx_params::SphinxKeyRotation; +use std::net::SocketAddr; +use tracing::info; + +/// Arguments for the `test-node` subcommand. +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + #[clap(flatten)] + common_args: CommonArgs, + + /// The socket address of the agent to use for receiving test packets back + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_MIXNET_ADDRESS_ARG)] + agent_mixnet_listener: SocketAddr, + + /// The socket address of the node to test + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NODE_ADDRESS_ARG)] + tested_node_address: SocketAddr, + + /// Noise key of the node to test + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NODE_NOISE_KEY_ARG)] + tested_node_noise_key: x25519::PublicKey, + + /// Sphinx key of the node to test + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY_ARG)] + tested_node_sphinx_key: x25519::PublicKey, + + /// Current sphinx key rotation of the node to test + #[arg(long, env = NYM_NETWORK_MONITOR_AGENT_NODE_SPHINX_KEY_ROTATION_ARG)] + tested_node_sphinx_key_rotation: u32, +} + +impl Args { + /// Builds the agent [`NodeTesterConfig`] from the flattened common args and the local mixnet listener address. + pub(crate) fn build_tester_config(&self) -> anyhow::Result { + self.common_args.build_config(self.agent_mixnet_listener) + } + + /// Builds the [`TestedNodeDetails`] from the node address and key arguments. + pub(crate) fn build_tested_node_details(&self) -> TestedNodeDetails { + TestedNodeDetails { + node_id: None, + address: self.tested_node_address, + noise_key: self.tested_node_noise_key, + sphinx_key: self.tested_node_sphinx_key, + key_rotation: SphinxKeyRotation::from_key_rotation_id( + self.tested_node_sphinx_key_rotation, + ), + } + } + + /// Constructs a fully initialised [`NodeStressTester`] from the parsed arguments. + pub(crate) fn build_stress_tester(&self) -> anyhow::Result { + NodeStressTester::new( + self.build_tester_config()?, + load_noise_key(&self.common_args.noise_key_path)?, + self.build_tested_node_details(), + ) + } +} + +/// Runs a one-shot stress test against the specified node and logs the result. +pub(crate) async fn execute(args: Args) -> anyhow::Result<()> { + let result = args.build_stress_tester()?.run_stress_test().await?; + + info!("{result:#?}"); + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/egress_connection.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/egress_connection.rs new file mode 100644 index 0000000000..5eea7ceb15 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/egress_connection.rs @@ -0,0 +1,123 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::bail; +use futures::{SinkExt, stream}; +use humantime::format_duration; +use nym_noise::config::NoiseConfig; +use nym_noise::connection::Connection; +use nym_noise::upgrade_noise_initiator; +use nym_sphinx_framing::codec::NymCodec; +use nym_sphinx_framing::packet::FramedNymPacket; +use nym_sphinx_params::{PacketType, SphinxKeyRotation}; +use nym_sphinx_types::{NymPacket, SphinxPacket}; +use std::net::SocketAddr; +use tokio::net::TcpStream; +use tokio::time::{Instant, timeout}; +use tokio_util::codec::Framed; +use tracing::{error, info, trace}; + +/// Timing statistics collected over the lifetime of an [`EgressConnection`]. +pub(crate) struct EgressConnectionStatistics { + /// Duration of the Noise handshake performed when the connection was established. + pub(crate) noise_handshake_duration: std::time::Duration, + + /// Per-batch send durations, one entry for each call to [`send_packet_batch`](EgressConnection::send_packet_batch). + pub(crate) packet_batches_sending_duration: Vec, +} + +/// An outbound, noise-encrypted TCP connection to the node under test used for sending sphinx packets. +pub(crate) struct EgressConnection { + /// Timing statistics accumulated while the connection is active. + pub(crate) connection_statistics: EgressConnectionStatistics, + + /// The key rotation at the time of starting the agent. + key_rotation: SphinxKeyRotation, + + /// The noise-encrypted, framed TCP stream used to send sphinx packets. + mixnet_connection: Framed, NymCodec>, +} + +impl EgressConnection { + /// Opens a TCP connection to `address`, performs the Noise handshake as the initiator, + /// and returns a ready-to-use [`EgressConnection`]. + /// Fails if the TCP connect or Noise upgrade exceeds timeout. + pub(crate) async fn establish( + address: SocketAddr, + timeout_duration: std::time::Duration, + key_rotation: SphinxKeyRotation, + noise_config: &NoiseConfig, + ) -> anyhow::Result { + info!("attempting to establish connection to {address}"); + let stream = timeout(timeout_duration, TcpStream::connect(address)).await??; + + info!("beginning the noise handshake (initiator)"); + + let noise_handshake_start = Instant::now(); + let noise_stream = upgrade_noise_initiator(stream, noise_config).await?; + + if !noise_stream.is_noise() { + error!( + "failed to upgrade the connection to noise with {address}. does the node support the protocol?" + ); + bail!("egress connection failure"); + } + + let noise_handshake_duration = noise_handshake_start.elapsed(); + info!( + "noise handshake with {address} completed in {}", + format_duration(noise_handshake_duration) + ); + + Ok(Self { + connection_statistics: EgressConnectionStatistics { + noise_handshake_duration, + packet_batches_sending_duration: vec![], + }, + key_rotation, + mixnet_connection: Framed::new(noise_stream, NymCodec), + }) + } + + /// Sends a single sphinx packet and records the send duration in [`EgressConnectionStatistics`]. + pub(crate) async fn send_packet(&mut self, packet: SphinxPacket) -> anyhow::Result<()> { + self.mixnet_connection + .send(FramedNymPacket::new( + NymPacket::Sphinx(packet), + PacketType::Mix, + self.key_rotation, + false, + )) + .await?; + + Ok(()) + } + + /// Sends a batch of sphinx packets in one flushed write and records the total batch send duration. + pub(crate) async fn send_packet_batch( + &mut self, + packets: Vec, + ) -> anyhow::Result<()> { + let count = packets.len(); + let send_start = Instant::now(); + self.mixnet_connection + .send_all(&mut stream::iter(packets.into_iter().map(|p| { + Ok(FramedNymPacket::new( + NymPacket::Sphinx(p), + PacketType::Mix, + self.key_rotation, + false, + )) + }))) + .await?; + let elapsed = send_start.elapsed(); + self.connection_statistics + .packet_batches_sending_duration + .push(elapsed); + trace!( + "sent batch of {count} packets in {}", + format_duration(elapsed) + ); + Ok(()) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/mod.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/mod.rs new file mode 100644 index 0000000000..90817f277c --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/mod.rs @@ -0,0 +1,167 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::listener::received::{MixnetPacketsSender, ReceivedPacket}; +use futures::StreamExt; +use nym_noise::config::NoiseConfig; +use nym_noise::connection::Connection; +use nym_noise::upgrade_noise_responder; +use nym_sphinx_framing::codec::NymCodec; +use nym_task::ShutdownToken; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Notify; +use tokio::time::Instant; +use tokio_util::codec::Framed; +use tracing::{error, info, warn}; + +pub(crate) mod received; + +/// Listens for inbound sphinx packets returned by the node under test. +/// +/// Binds a TCP listener on `bind_address`, accepts a single connection at a time, +/// performs a Noise handshake as the responder, then forwards every decoded +/// [`NymPacket`] to the [`receiver`](received) via `received_packets_sender`. +/// Connections from any address other than `tested_node_address` are rejected. +pub(crate) struct MixnetListener { + /// Local TCP listener. + tcp_listener: tokio::net::TcpListener, + + /// Address of the node being tested; connections from any other source are rejected. + tested_node_address: SocketAddr, + + /// Noise protocol configuration used when upgrading incoming TCP connections. + noise_config: NoiseConfig, + + /// Channel used to forward received packets to the [`PacketReceiver`](received). + received_packets_sender: MixnetPacketsSender, + + /// Duration it took to complete the last Noise handshake as the responder. + pub(crate) last_noise_handshake_duration: Option, + + /// Global shutdown token + shutdown: ShutdownToken, +} + +impl MixnetListener { + /// Creates a new [`MixnetListener`] ready to be started with [`run`](Self::run). + pub(crate) async fn new( + bind_address: SocketAddr, + tested_node_address: SocketAddr, + noise_config: NoiseConfig, + received_packets_sender: MixnetPacketsSender, + shutdown: ShutdownToken, + ) -> anyhow::Result { + info!("attempting to run mixnet listener on {bind_address}"); + + let tcp_listener = tokio::net::TcpListener::bind(bind_address) + .await + .inspect_err(|err| { + error!("Failed to the mixnet listener bind to {bind_address}: {err}") + })?; + + Ok(Self { + tcp_listener, + tested_node_address, + noise_config, + received_packets_sender, + last_noise_handshake_duration: None, + shutdown, + }) + } + + /// Reads sphinx packets from an established, noise-encrypted stream and forwards + /// each one to the receiver until the connection is closed or an error occurs. + async fn handle_stream(&self, mut mixnet_connection: Framed, NymCodec>) { + loop { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => { + tracing::debug!("mixnet listener: received shutdown"); + return + } + next_packet = mixnet_connection.next() => { + let next_packet = match next_packet { + None => { + info!("mixnet connection closed"); + return; + } + Some(Ok(packet)) => packet, + Some(Err(err)) => { + error!("failed to read a packet from the mixnet connection: {err}"); + return; + } + }; + if self + .received_packets_sender + .unbounded_send(ReceivedPacket::new(next_packet)) + .is_err() + { + warn!("mixnet packet receiver has shut down - is the agent still running?"); + return; + } + } + } + } + } + + /// Validates the source address, performs the Noise handshake, then delegates to + /// [`handle_stream`](Self::handle_stream) for the lifetime of the connection. + async fn handle_connection(&mut self, (socket, source): (TcpStream, SocketAddr)) { + if source.ip() != self.tested_node_address.ip() { + warn!( + "received a connection from a source that's not the node being tested. Ignoring it. Source: {source}, tested node: {}", + self.tested_node_address + ); + return; + } + info!("accepted connection from {source}. beginning the noise handshake (responder)"); + + let noise_handshake_start = Instant::now(); + let noise_stream = match upgrade_noise_responder(socket, &self.noise_config).await { + Ok(noise_stream) => noise_stream, + Err(err) => { + error!("failed to upgrade the connection to noise with {source}: {err}"); + return; + } + }; + let noise_handshake_duration = noise_handshake_start.elapsed(); + + if !noise_stream.is_noise() { + error!( + "failed to upgrade the connection to noise with {source}. does the node support the protocol?" + ); + return; + } + self.last_noise_handshake_duration = Some(noise_handshake_duration); + + self.handle_stream(Framed::new(noise_stream, NymCodec)) + .await + } + + /// Processes one connection at a time until the shutdown token is cancelled. + /// Returns `self` so that the caller can inspect fields such as + /// [`last_noise_handshake_duration`](Self::last_noise_handshake_duration) after the run. + pub(crate) async fn run(mut self, on_start: Arc) -> Self { + on_start.notify_waiters(); + // only handle a single connection at once + // (we don't need more than that) + loop { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => { + tracing::debug!("mixnet listener: received shutdown"); + return self + } + connection = self.tcp_listener.accept() => { + if let Ok(connection) = connection { + self.handle_connection(connection).await; + } else { + error!("failed to accept a TCP connection from the mixnet listener"); + } + } + } + } + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/received.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/received.rs new file mode 100644 index 0000000000..8a9371c3d4 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/listener/received.rs @@ -0,0 +1,32 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; +use nym_sphinx_framing::packet::FramedNymPacket; +use time::OffsetDateTime; + +/// A sphinx packet received by the [`MixnetListener`](super::MixnetListener), bundled with its +/// wall-clock arrival time. +pub(crate) struct ReceivedPacket { + /// UTC timestamp at which the packet was pulled off the stream. + pub(crate) received_at: OffsetDateTime, + + /// The decoded sphinx packet as delivered by the framed codec. + pub(crate) received: FramedNymPacket, +} + +impl ReceivedPacket { + /// Wraps `received` and stamps it with the current UTC time. + pub(crate) fn new(received: FramedNymPacket) -> Self { + Self { + received_at: OffsetDateTime::now_utc(), + received, + } + } +} + +/// Sender half of the channel used to forward [`ReceivedPacket`]s from the listener to the processor. +pub(crate) type MixnetPacketsSender = UnboundedSender; + +/// Receiver half of the channel used to forward [`ReceivedPacket`]s from the listener to the processor. +pub(crate) type MixnetPacketsReceiver = UnboundedReceiver; diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/main.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/main.rs new file mode 100644 index 0000000000..bb002e0e48 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/main.rs @@ -0,0 +1,47 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::cli::Cli; +use clap::Parser; +use nym_bin_common::logging::tracing_subscriber::layer::SubscriberExt; +use nym_bin_common::logging::tracing_subscriber::util::SubscriberInitExt; +use nym_bin_common::logging::{ + default_tracing_env_filter, default_tracing_fmt_layer, tracing_subscriber, +}; +use tracing::info; + +mod agent; +pub(crate) mod cli; +mod egress_connection; +pub(crate) mod listener; +mod processor; +pub(crate) mod sphinx_helpers; +pub(crate) mod test_packet; + +fn setup_logger() -> anyhow::Result<()> { + // crates that are more granularly filtered, regardless of default `RUST_LOG` value + let filter_crates = ["reqwest", "hyper"]; + + let mut env_filter = default_tracing_env_filter(); + for crate_name in filter_crates { + env_filter = env_filter.add_directive(format!("{crate_name}=warn").parse()?); + } + + tracing_subscriber::registry() + .with(default_tracing_fmt_layer(std::io::stderr)) + .with(env_filter) + .init(); + + Ok(()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + setup_logger()?; + let cli = Cli::parse(); + + cli.execute().await?; + + info!("network monitor agent is done - quitting"); + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/processor.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/processor.rs new file mode 100644 index 0000000000..8b3a6812e5 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/processor.rs @@ -0,0 +1,165 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::listener::received::{MixnetPacketsReceiver, MixnetPacketsSender, ReceivedPacket}; +use crate::test_packet::{TestPacketContent, TestPacketHeader}; +use anyhow::{Context, bail}; +use futures::StreamExt; +use futures::channel::mpsc::unbounded; +use nym_crypto::asymmetric::x25519; +use nym_sphinx_types::{ProcessedPacketData, SphinxPacket}; +use std::fmt::Display; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; +use tracing::{debug, warn}; + +/// A decoded test packet together with its measured round-trip time. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProcessedPacket { + /// The packet ID copied from the embedded [`TestPacketContent`]. + pub(crate) id: u64, + + /// Round-trip time measured from when the packet was created to when it was received. + /// This includes both the sphinx delay and the network transit time; callers should + /// subtract `config.packet_delay` to obtain the network-only latency. + pub(crate) rtt: Duration, +} + +impl Display for ProcessedPacket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.id, humantime::format_duration(self.rtt)) + } +} + +/// Strategy used to decrypt a returning sphinx packet and extract its [`TestPacketContent`]. +/// +/// When the agent operates with a reusable header it already holds the payload key, so +/// only the payload needs unwrapping. When it builds a fresh header per-packet the full +/// sphinx processing path (DH + decryption) must be performed using the agent's private key. +pub(crate) enum PayloadRecovery { + /// The agent holds a pre-built [`TestPacketHeader`] whose payload key can be used to + /// unwrap the payload directly, skipping the full sphinx processing step. + ReusableHeader(TestPacketHeader), + + /// The agent must perform full sphinx processing using its private key to decrypt + /// the payload, as no pre-built header is available. + FullProcessing(Arc), +} + +impl From for PayloadRecovery { + fn from(header: TestPacketHeader) -> Self { + PayloadRecovery::ReusableHeader(header) + } +} + +impl From> for PayloadRecovery { + fn from(private_key: Arc) -> Self { + PayloadRecovery::FullProcessing(private_key) + } +} + +impl PayloadRecovery { + /// Decrypts `received` and deserialises its payload into a [`TestPacketContent`]. + /// Returns an error if decryption fails or the packet is not addressed to the final hop. + pub(crate) fn recover_test_payload( + &self, + received: SphinxPacket, + ) -> anyhow::Result { + match self { + PayloadRecovery::ReusableHeader(header) => header.recover_payload(received.payload), + PayloadRecovery::FullProcessing(private_key) => { + let ProcessedPacketData::FinalHop { payload, .. } = + received.process(private_key.private_key().inner())?.data + else { + bail!("received non final hop data") + }; + TestPacketContent::from_bytes(&payload.recover_plaintext()?) + } + } + } +} + +/// Receives raw sphinx packets forwarded by the [`MixnetListener`](crate::listener::MixnetListener), +/// decrypts them, and exposes them as [`ProcessedPacket`]s with RTT measurements. +/// +/// The processor owns one half of an unbounded channel; the sender half is cloned and handed +/// to the listener via [`sender`](Self::sender). Packets can be consumed one at a time with +/// [`next_packet`](Self::next_packet) or drained in bulk with [`all_available`](Self::all_available). +pub(crate) struct MixnetPacketProcessor { + /// Decryption strategy: either reuse a pre-built header or perform full sphinx processing. + payload_recovery: PayloadRecovery, + + /// How long [`next_packet`](Self::next_packet) will wait before returning a timeout error. + receive_timeout: Duration, + + /// Sender half kept alive so the channel stays open as long as the processor exists. + sender: MixnetPacketsSender, + + /// Receiver half polled by [`next_packet`](Self::next_packet) and [`all_available`](Self::all_available). + receiver: MixnetPacketsReceiver, +} + +impl MixnetPacketProcessor { + /// Creates a new processor along with an internal channel for receiving packets. + pub(crate) fn new(payload_recovery: PayloadRecovery, receive_timeout: Duration) -> Self { + let (sender, receiver) = unbounded(); + + Self { + payload_recovery, + receive_timeout, + sender, + receiver, + } + } + + /// Returns a clone of the sender half so the listener can forward packets to this processor. + pub(crate) fn sender(&self) -> MixnetPacketsSender { + self.sender.clone() + } + + /// Decrypts a [`ReceivedPacket`] and computes its RTT from the embedded send timestamp. + fn process_received(&self, packet: ReceivedPacket) -> anyhow::Result { + let sphinx_packet = packet + .received + .into_inner() + .to_sphinx_packet() + .context("the received packet was not a sphinx packet!")?; + let received_content = self.payload_recovery.recover_test_payload(sphinx_packet)?; + let latency = packet.received_at - received_content.sending_timestamp; + + Ok(ProcessedPacket { + id: received_content.id, + rtt: latency.unsigned_abs(), + }) + } + + /// Drains all packets currently available in the channel without blocking. + /// Returns a vec of results — decryption failures are included as `Err` entries rather + /// than causing the entire drain to abort. + pub(crate) fn all_available(&mut self) -> Vec> { + let mut packets = Vec::new(); + while let Ok(Some(pending)) = self.receiver.try_next() { + packets.push(self.process_received(pending)); + } + + debug!("drained {} immediately available packets", packets.len()); + packets + } + + /// Waits for the next packet, up to `receive_timeout`. + /// Returns `Err` on timeout, channel exhaustion, or decryption failure. + pub(crate) async fn next_packet(&mut self) -> anyhow::Result { + let packet = timeout(self.receive_timeout, self.receiver.next()) + .await + .inspect_err(|_| { + warn!( + "timed out waiting for next packet after {}", + humantime::format_duration(self.receive_timeout) + ) + })? + .context("stream has been exhausted")?; + + self.process_received(packet) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/sphinx_helpers.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/sphinx_helpers.rs new file mode 100644 index 0000000000..f1ba355097 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/sphinx_helpers.rs @@ -0,0 +1,264 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::test_packet::TestPacketHeader; +use arrayref::array_ref; +use hkdf::Hkdf; +use nym_crypto::aes::cipher::crypto_common::rand_core::OsRng; +use nym_crypto::asymmetric::x25519; +use nym_sphinx_addressing::nodes::NymNodeRoutingAddress; +use nym_sphinx_params::PacketSize; +use nym_sphinx_types::constants::{ + BLINDING_FACTOR_SIZE, EXPANDED_SHARED_SECRET_HKDF_INFO, EXPANDED_SHARED_SECRET_HKDF_SALT, + EXPANDED_SHARED_SECRET_LENGTH, INTEGRITY_MAC_KEY_SIZE, PAYLOAD_KEY_SEED_SIZE, +}; +use nym_sphinx_types::crypto::STREAM_CIPHER_KEY_SIZE; +use nym_sphinx_types::{ + DESTINATION_ADDRESS_LENGTH, Delay, Destination, DestinationAddressBytes, IDENTIFIER_LENGTH, + Node, PAYLOAD_KEY_SIZE, PayloadKey, SphinxPacket, SphinxPacketBuilder, derive_payload_key, +}; +use sha2::Sha256; +use std::net::SocketAddr; +use std::time::Duration; +use x25519_dalek::{PublicKey, StaticSecret}; + +/// Newtype wrapper around the HKDF-expanded shared secret used in the sphinx protocol +/// since the actual type within the sphinx library does not expose the required methods. +pub(crate) struct ExpandedSharedSecretWrapper(pub(crate) [u8; EXPANDED_SHARED_SECRET_LENGTH]); + +impl ExpandedSharedSecretWrapper { + /// Returns the blinding factor as an x25519 [`StaticSecret`], used to derive the + /// shared secret for the next hop when manually reconstructing payload keys. + pub(crate) fn blinding_factor(&self) -> StaticSecret { + StaticSecret::from(*self.blinding_factor_bytes()) + } + + /// Returns the raw blinding factor bytes. + pub(crate) fn blinding_factor_bytes(&self) -> &[u8; BLINDING_FACTOR_SIZE] { + array_ref!( + &self.0, + STREAM_CIPHER_KEY_SIZE + INTEGRITY_MAC_KEY_SIZE + PAYLOAD_KEY_SIZE, + BLINDING_FACTOR_SIZE + ) + } + + /// Returns the payload key seed, used as input to [`derive_payload_key`]. + pub(crate) fn payload_key_seed(&self) -> &[u8; PAYLOAD_KEY_SEED_SIZE] { + array_ref!( + &self.0, + STREAM_CIPHER_KEY_SIZE + INTEGRITY_MAC_KEY_SIZE, + PAYLOAD_KEY_SEED_SIZE + ) + } + + /// Derives the [`PayloadKey`] for this hop from the payload key seed. + pub(crate) fn derive_payload_key(&self) -> PayloadKey { + derive_payload_key(self.payload_key_seed()) + } +} + +/// Re-derives the expanded shared secret from a raw 32-byte DH shared secret using HKDF-SHA256 +/// with the sphinx protocol's standard salt and info strings. +/// +/// This mirrors the derivation performed inside the sphinx library, which is not publicly +/// exposed — hence the need to replicate it here when reconstructing payload keys for a +/// reusable header. +pub(crate) fn rederive_expanded_shared_secret( + shared_secret: &[u8; 32], +) -> ExpandedSharedSecretWrapper { + let hkdf = Hkdf::::new(Some(EXPANDED_SHARED_SECRET_HKDF_SALT), shared_secret); + + let mut output = [0u8; EXPANDED_SHARED_SECRET_LENGTH]; + // SAFETY: the length of the provided okm is within the allowed range + #[allow(clippy::unwrap_used)] + hkdf.expand(EXPANDED_SHARED_SECRET_HKDF_INFO, &mut output) + .unwrap(); + + ExpandedSharedSecretWrapper(output) +} + +/// Returns an all-zeroes [`Destination`] used as a placeholder for the final delivery address. +/// The sphinx protocol requires a destination, but for the agent's loopback packets the +/// address is irrelevant — the final hop (the agent itself) is already in the route. +fn dummy_destination() -> Destination { + Destination::new( + DestinationAddressBytes::from_bytes([0u8; DESTINATION_ADDRESS_LENGTH]), + [0u8; IDENTIFIER_LENGTH], + ) +} + +/// Builds a single test sphinx packet along `route` with the given per-hop `delay`. +/// +/// The packet uses [`PacketSize::AckPacket`] to keep its size as small as possible. If `initial_secret` +/// is provided it is used as the sender's ephemeral key, allowing the resulting header to +/// be deterministically reproduced (needed for `create_test_sphinx_packet_header`). +pub(crate) fn build_test_sphinx_packet( + route: &[Node; 2], + delay: Duration, + initial_secret: Option<&StaticSecret>, + message: &[u8], +) -> anyhow::Result { + let delays = [ + Delay::new_from_nanos(delay.as_nanos() as u64), + Delay::new_from_nanos(delay.as_nanos() as u64), + ]; + let destination = dummy_destination(); + let payload = PacketSize::AckPacket.payload_size(); + + let packet = match initial_secret { + None => SphinxPacketBuilder::new() + .with_payload_size(payload) + .build_packet(message, route, &destination, &delays), + Some(initial_secret) => SphinxPacketBuilder::new() + .with_payload_size(payload) + .with_initial_secret(initial_secret) + .build_packet(message, route, &destination, &delays), + }?; + + Ok(packet) +} + +/// Builds a [`TestPacketHeader`] that can be reused to send many packets with different +/// payloads but the same routing header. +/// +/// Internally this builds one full sphinx packet to capture the header, then manually +/// re-derives the per-hop payload keys by replaying the DH key-agreement steps along the +/// route. This is necessary because the sphinx library does not expose the payload keys +/// after packet construction. +/// +/// The derived `payload_key` vec has one entry per hop; the last entry (index 1) is the +/// key held by this agent as the final recipient and is used by [`TestPacketHeader::recover_payload`]. +pub(crate) fn create_test_sphinx_packet_header( + route: [Node; 2], + delay: Duration, +) -> anyhow::Result { + let initial_secret = StaticSecret::random_from_rng(OsRng); + + // Build a throwaway packet solely to capture the reusable header. + let packet = build_test_sphinx_packet(&route, delay, Some(&initial_secret), b"dummy-message")?; + + let header = packet.header; + + // Manually reconstruct the payload keys for each hop. + let mut expanded_shared_secrets = Vec::new(); + let mut blinding_factors = Vec::new(); + + // The sphinx library keeps these private, so we replicate the derivation: + // for each hop, apply all previous blinding factors to the node's public key + // via DH, then expand the result with HKDF to obtain the payload key. + for node in &route { + let mut acc = node.pub_key; + + for blinding_factor in std::iter::once(&initial_secret).chain(&blinding_factors) { + let shared_secret = blinding_factor.diffie_hellman(&acc); + acc = PublicKey::from(shared_secret.to_bytes()); + } + + let expanded_shared_secret = rederive_expanded_shared_secret(acc.as_bytes()); + blinding_factors.push(expanded_shared_secret.blinding_factor()); + expanded_shared_secrets.push(expanded_shared_secret); + } + + let payload_keys = expanded_shared_secrets + .iter() + .map(|s| s.derive_payload_key()) + .collect::>(); + assert_eq!(payload_keys.len(), 2); + + Ok(TestPacketHeader { + header, + payload_key: payload_keys, + }) +} + +/// Constructs a sphinx [`Node`](Node) from a socket address and public key. +/// Panics if the address cannot be converted to a routing address, which should never happen +/// for a valid `SocketAddr`. +pub(crate) fn as_sphinx_node(address: SocketAddr, pub_key: x25519::PublicKey) -> Node { + // SAFETY: we know that the address is valid, so we can safely unwrap it + #[allow(clippy::unwrap_used)] + Node::new( + NymNodeRoutingAddress::from(address).try_into().unwrap(), + pub_key.into(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_packet::TestPacketContent; + use nym_crypto::asymmetric::x25519; + use nym_sphinx_addressing::nodes::NymNodeRoutingAddress; + use nym_sphinx_types::ProcessedPacketData; + use nym_test_utils::helpers::deterministic_rng; + use std::net::SocketAddr; + + #[test] + fn creating_test_sphinx_packets() { + let mut rng = deterministic_rng(); + let remote_node_key = x25519::KeyPair::new(&mut rng); + let agent_key = x25519::KeyPair::new(&mut rng); + let node_addr: SocketAddr = "1.2.3.4:5677".parse().unwrap(); + let agent_addr: SocketAddr = "2.2.3.4:5678".parse().unwrap(); + + let remote_node = Node::new( + NymNodeRoutingAddress::from(node_addr).try_into().unwrap(), + (*remote_node_key.public_key()).into(), + ); + let agent_node = Node::new( + NymNodeRoutingAddress::from(agent_addr).try_into().unwrap(), + (*agent_key.public_key()).into(), + ); + + let delay = Duration::from_millis(1); + + let test_header = + create_test_sphinx_packet_header([remote_node, agent_node], delay).unwrap(); + + let payload1 = TestPacketContent::new(123); + let payload2 = TestPacketContent::new(456); + + let packet1 = test_header.create_test_packet(payload1).unwrap(); + let packet2 = test_header.create_test_packet(payload2).unwrap(); + + // simulate packet being received by remote node + let res1 = packet1 + .process(remote_node_key.private_key().inner()) + .unwrap(); + let ProcessedPacketData::ForwardHop { + next_hop_packet: res1_packet, + next_hop_address, + .. + } = res1.data + else { + panic!("bad data") + }; + assert_eq!( + next_hop_address, + NymNodeRoutingAddress::from(agent_addr).try_into().unwrap() + ); + + let res2 = packet2 + .process(remote_node_key.private_key().inner()) + .unwrap(); + let ProcessedPacketData::ForwardHop { + next_hop_packet: res2_packet, + next_hop_address, + .. + } = res2.data + else { + panic!("bad data") + }; + assert_eq!( + next_hop_address, + NymNodeRoutingAddress::from(agent_addr).try_into().unwrap() + ); + + // now getting back to us (no need for full unwrapping as we already have the payload key) + let received1 = test_header.recover_payload(res1_packet.payload).unwrap(); + assert_eq!(received1, payload1); + + let received2 = test_header.recover_payload(res2_packet.payload).unwrap(); + assert_eq!(received2, payload2); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-agent/src/test_packet.rs b/nym-network-monitor-v3/nym-network-monitor-agent/src/test_packet.rs new file mode 100644 index 0000000000..58603cfad3 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-agent/src/test_packet.rs @@ -0,0 +1,204 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::{Context, bail}; +use nym_sphinx_params::PacketSize; +use nym_sphinx_types::{Payload, PayloadKey, SphinxHeader, SphinxPacket}; +use time::OffsetDateTime; + +/// A pre-built sphinx packet header that can be reused across multiple test packets. +/// +/// When `config.reuse_header` is enabled the agent constructs one header for the entire +/// test run and stamps a fresh [`TestPacketContent`] (new ID + timestamp) into each +/// packet's payload. This lets the agent avoid performing expensive packet derivation +/// for each sent payload. +pub(crate) struct TestPacketHeader { + /// The immutable sphinx routing header shared across all replayed packets. + pub(crate) header: SphinxHeader, + + /// List of payload keys derived when the header was built + pub(crate) payload_key: Vec, +} + +impl Clone for TestPacketHeader { + fn clone(&self) -> Self { + TestPacketHeader { + header: SphinxHeader { + shared_secret: self.header.shared_secret, + routing_info: self.header.routing_info.clone(), + }, + payload_key: self.payload_key.clone(), + } + } +} + +impl TestPacketHeader { + /// Encapsulates `content` into a new [`SphinxPacket`] by reusing the pre-built header. + pub(crate) fn create_test_packet( + &self, + content: TestPacketContent, + ) -> anyhow::Result { + let payload = Payload::encapsulate_message( + &content.to_bytes(), + &self.payload_key, + PacketSize::AckPacket.payload_size(), + )?; + Ok(SphinxPacket { + header: SphinxHeader { + shared_secret: self.header.shared_secret, + routing_info: self.header.routing_info.clone(), + }, + payload, + }) + } + + /// Decrypts a received payload using the last payload key (the one belonging to this + /// agent as the final hop) and deserialises it into a [`TestPacketContent`]. + pub(crate) fn recover_payload(&self, received: Payload) -> anyhow::Result { + let key = self + .payload_key + .last() + .context("no payload keys generated")?; + + let payload = received.unwrap(key)?.recover_plaintext()?; + TestPacketContent::from_bytes(&payload) + } +} + +/// The payload embedded in every test sphinx packet. +/// +/// Serialises to exactly 16 bytes: 8 bytes for `id` (big-endian `u64`) followed by +/// 8 bytes for `sending_timestamp` (big-endian Unix timestamp in nanoseconds as `i64`). +/// Nanosecond precision is preserved for dates up to year 2262 (i64 max ≈ 9.2*10^18 ns). +/// The timestamp is used to compute the packet's round-trip time on receipt. +#[derive(Copy, Clone, PartialEq, Debug)] +pub(crate) struct TestPacketContent { + /// Monotonically increasing ID assigned by the agent; used to detect duplicates and + /// correlate sent packets with received ones. + pub(crate) id: u64, + + /// UTC wall-clock time at which the packet was created, used to compute RTT. + pub(crate) sending_timestamp: OffsetDateTime, +} + +impl TestPacketContent { + /// Creates a new content value with the given `id` and the current UTC time. + pub(crate) fn new(id: u64) -> Self { + Self { + id, + sending_timestamp: OffsetDateTime::now_utc(), + } + } + + /// Serialises the content to 16 bytes: `id` as big-endian u64, then + /// `sending_timestamp` as a big-endian i64 Unix timestamp in nanoseconds. + pub(crate) fn to_bytes(self) -> Vec { + let mut bytes = Vec::with_capacity(16); + bytes.extend_from_slice(&self.id.to_be_bytes()); + // unix_timestamp_nanos() returns i128, but the value fits in i64 for dates up to year 2262. + #[allow(clippy::cast_possible_truncation)] + bytes.extend_from_slice( + &(self.sending_timestamp.unix_timestamp_nanos() as i64).to_be_bytes(), + ); + bytes + } + + /// Deserialises content from a 16-byte slice produced by [`to_bytes`](Self::to_bytes). + /// Returns an error if the slice is not exactly 16 bytes or the timestamp is out of range. + pub(crate) fn from_bytes(bytes: &[u8]) -> anyhow::Result { + if bytes.len() != 16 { + bail!("malformed test packet received") + } + + let id = u64::from_be_bytes(bytes[0..8].try_into()?); + let nanos = i64::from_be_bytes(bytes[8..16].try_into()?); + Ok(Self { + id, + sending_timestamp: OffsetDateTime::from_unix_timestamp_nanos(nanos as i128)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + fn content_with_timestamp(id: u64, ts: OffsetDateTime) -> TestPacketContent { + TestPacketContent { + id, + sending_timestamp: ts, + } + } + + #[test] + fn serialised_length_is_always_16_bytes() { + let content = TestPacketContent::new(0); + assert_eq!(content.to_bytes().len(), 16); + } + + #[test] + fn roundtrip_preserves_all_fields() { + // Use a fixed timestamp to avoid sub-nanosecond clock jitter in the test. + let original = content_with_timestamp(42, datetime!(2025-06-01 12:00:00 UTC)); + let bytes = original.to_bytes(); + let recovered = TestPacketContent::from_bytes(&bytes).unwrap(); + assert_eq!(original, recovered); + } + + #[test] + fn roundtrip_preserves_nanosecond_precision() { + // Construct a timestamp with a sub-second component to verify nanos are not truncated. + let ts = datetime!(2025-06-01 12:00:00.123456789 UTC); + let original = content_with_timestamp(1, ts); + let recovered = TestPacketContent::from_bytes(&original.to_bytes()).unwrap(); + assert_eq!( + original.sending_timestamp.unix_timestamp_nanos(), + recovered.sending_timestamp.unix_timestamp_nanos() + ); + } + + #[test] + fn id_zero_and_max_roundtrip() { + for id in [0u64, u64::MAX] { + let original = content_with_timestamp(id, datetime!(2025-01-01 00:00:00 UTC)); + let recovered = TestPacketContent::from_bytes(&original.to_bytes()).unwrap(); + assert_eq!(recovered.id, id); + } + } + + #[test] + fn id_is_encoded_in_first_8_bytes_big_endian() { + let content = content_with_timestamp(1, datetime!(2025-01-01 00:00:00 UTC)); + let bytes = content.to_bytes(); + let id_bytes: [u8; 8] = bytes[0..8].try_into().unwrap(); + assert_eq!(u64::from_be_bytes(id_bytes), 1u64); + } + + #[test] + fn timestamp_is_encoded_in_last_8_bytes_big_endian() { + let ts = datetime!(2025-01-01 00:00:00 UTC); + let content = content_with_timestamp(0, ts); + let bytes = content.to_bytes(); + let ts_bytes: [u8; 8] = bytes[8..16].try_into().unwrap(); + assert_eq!( + i64::from_be_bytes(ts_bytes), + ts.unix_timestamp_nanos() as i64 + ); + } + + #[test] + fn from_bytes_rejects_too_short() { + assert!(TestPacketContent::from_bytes(&[0u8; 15]).is_err()); + } + + #[test] + fn from_bytes_rejects_too_long() { + assert!(TestPacketContent::from_bytes(&[0u8; 17]).is_err()); + } + + #[test] + fn from_bytes_rejects_empty() { + assert!(TestPacketContent::from_bytes(&[]).is_err()); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/Cargo.toml b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/Cargo.toml new file mode 100644 index 0000000000..9a738326f2 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "nym-network-monitor-orchestrator-requests" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true +version.workspace = true +publish = false + +[dependencies] +anyhow = { workspace = true } +humantime-serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } +time = { workspace = true, features = ["serde-well-known"] } +tracing = { workspace = true } +utoipa = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } + +nym-crypto = { workspace = true, features = ["asymmetric", "serde"] } +nym-http-api-client = { workspace = true, optional = true } + +[features] +client = ["nym-http-api-client", "zeroize"] +openapi = ["utoipa"] + +[lints] +workspace = true diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/api/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/api/mod.rs new file mode 100644 index 0000000000..bdc05451cb --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/api/mod.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/client.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/client.rs new file mode 100644 index 0000000000..6d840dc68b --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/client.rs @@ -0,0 +1,81 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::models::{ + AgentAnnounceRequest, AgentAnnounceResponse, TestRunAssignmentRequest, + TestRunAssignmentResponse, TestRunResultSubmissionRequest, TestRunSubmissionResponse, +}; +use crate::routes::v1::agent::{ + announce_absolute, request_testrun_absolute, submit_testrun_absolute, +}; +pub use nym_http_api_client::Client; +use nym_http_api_client::{ApiClient, HttpClientError, NO_PARAMS, Url, parse_response}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use zeroize::Zeroizing; + +/// HTTP client for communicating with the network monitor orchestrator API. +/// All requests are authenticated with a bearer token. +pub struct OrchestratorClient { + inner: Client, + bearer_token: Arc>, +} + +impl OrchestratorClient { + /// Creates a new client targeting `base_url`, storing the bearer token in a + /// zeroizing container. + pub fn new(base_url: Url, bearer_token: String) -> Result { + Ok(OrchestratorClient { + inner: Client::builder(base_url)? + .no_hickory_dns() + .with_user_agent(format!( + "nym-network-monitor-orchestrator-requests/{}", + env!("CARGO_PKG_VERSION") + )) + .build()?, + bearer_token: Arc::new(Zeroizing::new(bearer_token)), + }) + } + + /// Sends an authenticated POST request with a JSON body and deserialises the response. + async fn post_with_auth(&self, path: &str, json_body: &B) -> Result + where + B: Serialize + ?Sized + Sync, + for<'a> T: Deserialize<'a>, + { + let res = self + .inner + .create_post_request(path, NO_PARAMS, json_body)? + .bearer_auth(self.bearer_token.as_str()) + .send() + .await?; + + parse_response(res, false).await + } + + /// Announces this agent's details to the orchestrator, which forwards them + /// to the smart contract so network nodes can whitelist the agent. + pub async fn announce_agent( + &self, + body: &AgentAnnounceRequest, + ) -> Result { + self.post_with_auth(&announce_absolute(), body).await + } + + /// Asks the orchestrator for the next test run to execute. Returns `None` + /// inside the assignment if no work is currently available. + pub async fn request_work_assignment( + &self, + body: &TestRunAssignmentRequest, + ) -> Result { + self.post_with_auth(&request_testrun_absolute(), body).await + } + + /// Submits the result of a completed test run back to the orchestrator for storage. + pub async fn submit_test_run_result( + &self, + body: &TestRunResultSubmissionRequest, + ) -> Result { + self.post_with_auth(&submit_testrun_absolute(), body).await + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/lib.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/lib.rs new file mode 100644 index 0000000000..34b32f9c01 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/lib.rs @@ -0,0 +1,91 @@ +// Copyright 2026 Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +pub mod api; +pub mod models; + +#[cfg(feature = "client")] +pub mod client; + +/// Generates a function that returns the full absolute path for a route +/// by concatenating a parent prefix with a suffix. +macro_rules! absolute_route { + ( $name:ident, $parent:expr, $suffix:expr ) => { + pub fn $name() -> String { + format!("{}{}", $parent, $suffix) + } + }; +} + +/// Route constants and absolute-path helpers for the orchestrator HTTP API. +/// Used by both the orchestrator server (for route registration) and the agent +/// client (for constructing request URLs). +pub mod routes { + pub const ROOT: &str = "/"; + pub const V1: &str = "/v1"; + pub const SWAGGER: &str = "/swagger"; + + pub mod v1 { + pub const AGENT: &str = "/agent"; + pub const METRICS: &str = "/metrics"; + pub const RESULTS: &str = "/results"; + + absolute_route!(agent_absolute, super::V1, AGENT); + absolute_route!(metrics_absolute, super::V1, METRICS); + absolute_route!(results_absolute, super::V1, RESULTS); + + pub mod agent { + use super::*; + + pub const ANNOUNCE: &str = "/announce"; + pub const REQUEST_TESTRUN: &str = "/request-testrun"; + pub const SUBMIT_TESTRUN_RESULT: &str = "/submit-testrun-result"; + + absolute_route!(announce_absolute, agent_absolute(), ANNOUNCE); + absolute_route!(request_testrun_absolute, agent_absolute(), REQUEST_TESTRUN); + absolute_route!( + submit_testrun_absolute, + agent_absolute(), + SUBMIT_TESTRUN_RESULT + ); + } + + pub mod metrics { + use super::*; + + pub const PROMETHEUS: &str = "/prometheus"; + + absolute_route!(prometheus_absolute, metrics_absolute(), PROMETHEUS); + } + + pub mod results { + use super::*; + + pub const TESTRUN_BY_ID: &str = "/testrun/:id"; + pub const NYM_NODE_BY_NODE_ID: &str = "/nym-node/:node_id"; + pub const NYM_NODE_TESTRUNS: &str = "/nym-node/:node_id/testruns"; + pub const TESTRUNS_IN_PROGRESS: &str = "/testruns-in-progress"; + pub const TESTRUNS: &str = "/testruns"; + pub const NYM_NODES: &str = "/nym-nodes"; + + absolute_route!(testrun_by_id_absolute, results_absolute(), TESTRUN_BY_ID); + absolute_route!( + nym_node_by_node_id_absolute, + results_absolute(), + NYM_NODE_BY_NODE_ID + ); + absolute_route!( + nym_node_testruns_absolute, + results_absolute(), + NYM_NODE_TESTRUNS + ); + absolute_route!( + testruns_in_progress_absolute, + results_absolute(), + TESTRUNS_IN_PROGRESS + ); + absolute_route!(testruns_absolute, results_absolute(), TESTRUNS); + absolute_route!(nym_nodes_absolute, results_absolute(), NYM_NODES); + } + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/models.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/models.rs new file mode 100644 index 0000000000..da1bc36ff0 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src/models.rs @@ -0,0 +1,378 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey; +use nym_crypto::asymmetric::x25519; +use nym_crypto::asymmetric::x25519::serde_helpers::{ + bs58_x25519_pubkey, option_bs58_x25519_pubkey, +}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::time::Duration; +use time::OffsetDateTime; + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Body sent by an agent to announce its details to the orchestrator. +/// The orchestrator forwards this information to the smart contract so that +/// network nodes can whitelist connections from known agents. +pub struct AgentAnnounceRequest { + /// Egress address of the agent node combined with the previously + /// assigned mixnet socket address from the orchestrator + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub agent_mix_socket_address: SocketAddr, + + /// Base-58 encoded noise key of the agent. + #[serde(with = "bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub x25519_noise_key: x25519::PublicKey, + + /// Version of the noise protocol used by the agent. + pub noise_version: u8, +} + +/// Confirmation returned to an agent after a successful announcement. +/// Currently empty — exists to give the response an explicit type rather than +/// relying on `Json(())`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentAnnounceResponse {} + +/// Request sent by an agent to ask the orchestrator for a node to test. +/// Identifies the agent so the orchestrator can verify it has been announced. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunAssignmentRequest { + /// Egress address of the agent node combined with the previously + /// assigned mixnet socket address from the orchestrator + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub agent_mix_socket_address: SocketAddr, + + /// Base-58 encoded noise key of the agent. + #[serde(with = "bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub x25519_noise_key: x25519::PublicKey, +} + +/// Response from the orchestrator when an agent requests work. +/// `assignment` is `None` when no nodes are due for testing. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunAssignmentResponse { + pub assignment: Option, +} + +/// Details of a single node assigned to an agent for stress testing. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunAssignment { + pub node_id: u32, + + /// The address of the node that should be tested. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub node_address: SocketAddr, + + #[serde(with = "bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub noise_key: x25519::PublicKey, + + #[serde(with = "bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub sphinx_key: x25519::PublicKey, + + pub key_rotation_id: u32, +} + +/// Latency statistics computed over the set of test packets received or sent during a stress test. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatencyDistribution { + /// Minimum latency duration it took to send or receive a test packet. + #[serde(with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub minimum: Duration, + + /// Average latency duration it took to send or receive a test packet. + #[serde(with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub mean: Duration, + + /// Median latency duration it took to send or receive a test packet. + /// For an even number of samples, this is the arithmetic mean of the two middle values. + #[serde(with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub median: Duration, + + /// Maximum latency duration it took to send or receive a test packet. + #[serde(with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub maximum: Duration, + + /// The standard deviation of the latency duration it took to send or receive the test packets. + #[serde(with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub standard_deviation: Duration, +} + +/// Request sent by an agent to submit test results for a previously assigned node. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TestRunResultSubmissionRequest { + pub node_id: u32, + pub result: TestRunResult, +} + +/// Captures the outcome of a single test run against a nym node. +/// +/// Fields are populated incrementally as the test progresses; absent values (`None`) indicate +/// that the corresponding step was not reached or did not produce a result. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TestRunResult { + /// Total duration of the test run, including the time it took to establish the connections. + #[serde(default, with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub time_taken: Duration, + + /// Duration of the Noise handshake on the ingress (responder) side, if completed. + #[serde(default, with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub ingress_noise_handshake: Option, + + /// Duration of the Noise handshake on the egress (initiator) side, if completed. + #[serde(default, with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub egress_noise_handshake: Option, + + /// The (constant) delay of the sphinx packet set during the test run. + #[serde(default, with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub sphinx_packet_delay: Duration, + + /// Number of sphinx packets successfully sent to the node under test. + pub packets_sent: usize, + + /// Number of sphinx packets returned by the node and successfully received. + pub packets_received: usize, + + /// Round-trip time of the very first probe packet, sent in isolation before any load is applied. + /// Because the node is idle at this point, this value approximates the baseline network latency + /// to the node without any queuing or processing overhead from the stress test itself. + /// `None` if the initial probe did not complete successfully. + #[serde(default, with = "humantime_serde")] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub approximate_latency: Option, + + /// RTT statistics computed over all received packets, or `None` if no packets were received. + pub packets_statistics: Option, + + /// Latency distribution of individual batch send operations recorded during the load test. + /// Reflects how long each batch took to flush to the OS socket, giving a rough measure of + /// egress throughput. `None` if no batches were sent. + pub sending_statistics: Option, + + /// Whether any packet was received with an ID that had already been seen in this test run. + /// Duplicates should never occur under normal operation; their presence may indicate a + /// misbehaving or malicious node replaying packets. + pub received_duplicates: bool, + + /// Human-readable description of the first error that caused the test to abort if any. + pub error: Option, +} + +impl TestRunResult { + pub fn received_ratio(&self) -> f64 { + if self.packets_sent == 0 { + return 0.0; + } + let received = self.packets_received.min(self.packets_sent); + received as f64 / self.packets_sent as f64 + } +} + +/// Confirmation returned to an agent after a successful result submission. +/// Currently empty — exists to give the response an explicit type rather than +/// relying on `Json(())`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunSubmissionResponse {} + +// ------------------------------------------------------------------------ +// Response shapes for the read-only results API (`/v1/results/*`). These are +// the public, serialisation-stable types returned to callers; conversion from +// the storage layer's sqlx rows happens in `orchestrator/storage/models.rs`. +// ------------------------------------------------------------------------ + +pub const PAGINATION_SIZE_DEFAULT: usize = 50; +pub const PAGINATION_SIZE_MAX: usize = 200; +pub const PAGINATION_PAGE_DEFAULT: usize = 0; + +/// Query parameters for paginated endpoints. `size` defaults to +/// [`PAGINATION_SIZE_DEFAULT`] and is capped at [`PAGINATION_SIZE_MAX`]; +/// `page` defaults to [`PAGINATION_PAGE_DEFAULT`]. +#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "openapi", into_params(parameter_in = Query))] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub struct Pagination { + pub per_page: Option, + pub page: Option, +} + +impl Default for Pagination { + fn default() -> Self { + Self { + per_page: Some(PAGINATION_SIZE_DEFAULT), + page: Some(PAGINATION_PAGE_DEFAULT), + } + } +} + +impl Pagination { + pub fn new(per_page: Option, page: Option) -> Self { + Self { per_page, page } + } + + /// Resolved page size — defaults to [`PAGINATION_SIZE_DEFAULT`] when absent + /// and is capped at [`PAGINATION_SIZE_MAX`]. + pub fn per_page(&self) -> usize { + self.per_page + .unwrap_or(PAGINATION_SIZE_DEFAULT) + .min(PAGINATION_SIZE_MAX) + } + + /// Resolved page index — defaults to [`PAGINATION_PAGE_DEFAULT`] when absent. + pub fn page(&self) -> usize { + self.page.unwrap_or(PAGINATION_PAGE_DEFAULT) + } + + /// Value to bind to a SQL `LIMIT ?` clause. Equivalent to + /// [`Self::per_page`] cast to the `i64` sqlx bind type. + pub fn limit(&self) -> i64 { + self.per_page() as i64 + } + + /// Value to bind to a SQL `OFFSET ?` clause, i.e. `page * per_page`. + /// Saturating to avoid overflow on absurdly large `page` values from a client. + pub fn offset(&self) -> i64 { + (self.page() as i64).saturating_mul(self.limit()) + } +} + +/// Generic wrapper for a single page of results. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct PagedResult { + pub page: usize, + pub per_page: usize, + pub total: usize, + pub items: Vec, +} + +/// Discriminator for the type of node targeted by a test run. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TestType { + Mixnode, + Gateway, +} + +/// A completed test run as exposed by the results API. +/// +/// Unlike the agent-facing [`TestRunResult`], this carries the database id, +/// the node that was tested, and the timestamp at which the result was +/// recorded by the orchestrator. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunData { + /// Database-assigned identifier of the test run. + pub id: i64, + + /// Node that was tested. + pub node_id: u32, + + /// Kind of node that was tested. + pub test_type: TestType, + + /// When the test run completed and was recorded. + /// Serialised as an RFC 3339 timestamp string. + #[serde(with = "time::serde::rfc3339")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub test_timestamp: OffsetDateTime, + + /// The test run result itself. + pub result: TestRunResult, +} + +/// Public snapshot of a nym-node as tracked by the orchestrator. +/// +/// Built from the on-chain bond plus any details the orchestrator has managed +/// to retrieve directly from the node itself. The optional fields +/// (`mixnet_socket_address`, `noise_key`, `sphinx_key`, `key_rotation_id`) +/// are populated lazily by the node refresher and may be absent either because +/// the node is newly observed or because the refresher failed to reach it. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NymNodeData { + pub node_id: u32, + + /// Ed25519 identity key of the node, serialised as a base58 string. + #[serde(with = "bs58_ed25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub identity_key: ed25519::PublicKey, + + /// When this node was last observed as bonded in the contract. + #[serde(with = "time::serde::rfc3339")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub last_seen_bonded: OffsetDateTime, + + /// Mixnet socket address (host:port) at which the node accepts sphinx packets. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub mixnet_socket_address: Option, + + /// X25519 public key used for Noise handshakes. + /// `None` if retrieval from the node failed. + #[serde(with = "option_bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub noise_key: Option, + + /// Sphinx public key used for packet encryption. + /// `None` if retrieval from the node failed. + /// Always `None`/`Some` together with `key_rotation_id`. + #[serde(with = "option_bs58_x25519_pubkey")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub sphinx_key: Option, + + /// Key rotation epoch ID that `sphinx_key` belongs to. + /// `None` if retrieval from the node failed. + /// Always `None`/`Some` together with `sphinx_key`. + pub key_rotation_id: Option, +} + +/// Node snapshot paired with its most recent completed test run. +/// +/// `latest_test_run` is `None` when the node has never been tested or when its +/// most recent run has been evicted by the stale-result sweeper. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NymNodeWithTestRun { + pub node: NymNodeData, + + pub latest_test_run: Option, +} + +/// Marker for a test run that has been handed out to an agent but whose result +/// hasn't been submitted yet. Stripped of test-payload fields because by +/// definition none of them exist yet. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunInProgressData { + pub node_id: u32, + + /// When the test run was handed out to an agent. Serialised as an + /// RFC 3339 timestamp string. + #[serde(with = "time::serde::rfc3339")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub started_at: OffsetDateTime, +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/Cargo.toml b/nym-network-monitor-v3/nym-network-monitor-orchestrator/Cargo.toml new file mode 100644 index 0000000000..a93df645e7 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "nym-network-monitor-orchestrator" +description = "Orchestrator for performing Nym network stress testing" +version = "1.0.2" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = false + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["cargo", "env"] } +futures = { workspace = true } +humantime = { workspace = true } +rand = { workspace = true } +sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate", "time"] } +tokio = { workspace = true, features = ["macros", "sync", "rt-multi-thread"] } +strum = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +time = { workspace = true } +url = { workspace = true } +zeroize = { workspace = true } + +# http +axum = { workspace = true, features = ["tokio", "macros"] } +utoipa = { workspace = true, features = ["axum_extras", "time"] } +utoipa-swagger-ui = { workspace = true, features = ["axum"] } +utoipauto = { workspace = true } + +nym-validator-client = { workspace = true, features = ["http-client"] } +nym-bin-common = { workspace = true, features = ["basic_tracing", "output_format"] } +nym-crypto = { workspace = true, features = ["asymmetric"] } +nym-network-defaults = { workspace = true } +nym-task = { workspace = true } +nym-node-requests = { workspace = true, features = ["client"] } +nym-metrics = { workspace = true } +nym-http-api-common = { workspace = true, features = ["utoipa", "middleware"] } +nym-api-requests = { workspace = true } + +nym-network-monitor-orchestrator-requests = { path = "../nym-network-monitor-orchestrator-requests", features = ["openapi"] } + +[dev-dependencies] +nym-crypto = { workspace = true, features = ["asymmetric", "rand"] } +nym-test-utils = { workspace = true } + +[build-dependencies] +anyhow = { workspace = true } +sqlx = { workspace = true, features = [ + "runtime-tokio-rustls", + "sqlite", + "macros", + "migrate", +] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + + +[lints] +workspace = true diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/Dockerfile b/nym-network-monitor-v3/nym-network-monitor-orchestrator/Dockerfile new file mode 100644 index 0000000000..d7592eafcf --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/Dockerfile @@ -0,0 +1,19 @@ +# this will only work with VPN, otherwise remove the harbor part +FROM harbor.nymte.ch/dockerhub/rust:latest AS builder + +RUN apt update && apt install -yy libdbus-1-dev pkg-config libclang-dev + +COPY ./ /usr/src/nym +WORKDIR /usr/src/nym + +RUN cargo build --bin nym-network-monitor-orchestrator --release + +FROM harbor.nymte.ch/dockerhub/ubuntu:24.04 + +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /nym + +COPY --from=builder /usr/src/nym/target/release/nym-network-monitor-orchestrator ./ + +ENTRYPOINT ["/nym/nym-network-monitor-orchestrator", "run-orchestrator"] diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/build.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/build.rs new file mode 100644 index 0000000000..f08f638b03 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/build.rs @@ -0,0 +1,37 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::Context; +use sqlx::{Connection, SqliteConnection}; +use std::env; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let out_dir = env::var("OUT_DIR")?; + let database_path = format!("{out_dir}/orchestrator.sqlite"); + + // remove the db file if it already existed from previous build + // in case it was from a different branch + if std::fs::exists(&database_path)? { + std::fs::remove_file(&database_path)?; + } + + let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc")) + .await + .context("Failed to create SQLx database connection")?; + + sqlx::migrate!("./migrations") + .run(&mut conn) + .await + .context("Failed to perform SQLx migrations")?; + + #[cfg(target_family = "unix")] + println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path); + + #[cfg(target_family = "windows")] + // for some strange reason we need to add a leading `/` to the windows path even though it's + // not a valid windows path... but hey, it works... + println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path); + + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/migrations/01_initial_tables.sql b/nym-network-monitor-v3/nym-network-monitor-orchestrator/migrations/01_initial_tables.sql new file mode 100644 index 0000000000..499157a88a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/migrations/01_initial_tables.sql @@ -0,0 +1,139 @@ +/* + * Copyright 2026 - Nym Technologies SA + * SPDX-License-Identifier: GPL-3.0-only + */ + +CREATE TABLE metadata +( + id INTEGER PRIMARY KEY CHECK (id = 0), + last_submitted_testrun_id INTEGER +); + +CREATE TABLE testrun +( + -- Surrogate primary key. + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + + -- The node under test. References nym_node(node_id); forward reference is allowed in + -- SQLite since foreign keys are validated at INSERT time, not at CREATE TABLE time. + -- Kept as a full column (rather than relying on nym_node.last_testrun) so that the + -- node→testrun link survives once the node gets a newer run. + node_id INTEGER NOT NULL REFERENCES nym_node (node_id), + + -- Discriminator for the type of node under test; future-proofs the table for when we start testing gateways. + test_type TEXT CHECK ( test_type IN ('mixnode', 'gateway') ) NOT NULL, + + -- When this testrun has been performed. + test_timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + -- How long the test took to complete, in microseconds, from the point of view of an agent. + time_taken_us INTEGER NOT NULL, + + -- Duration of the Noise handshake on the ingress (responder) side, in microseconds. + -- NULL if the handshake did not complete. + ingress_noise_handshake_us INTEGER, + + -- Duration of the Noise handshake on the egress (initiator) side, in microseconds. + -- NULL if the handshake did not complete. + egress_noise_handshake_us INTEGER, + + -- The (constant) per-hop delay applied to sphinx packets during the test run, in microseconds. + sphinx_packet_delay_us INTEGER NOT NULL, + + -- Number of sphinx packets sent to the node under test. + packets_sent INTEGER NOT NULL DEFAULT 0, + + -- Number of sphinx packets received back from the node under test. + packets_received INTEGER NOT NULL DEFAULT 0, + + -- RTT of the initial probe packet in microseconds, approximating baseline latency. + -- NULL if the probe did not complete successfully. + approximate_latency_us INTEGER, + + -- RTT distribution (in microseconds) computed over all received packets. + -- All five columns are NULL together when no packets were received. + packets_rtt_min_us INTEGER, + packets_rtt_mean_us INTEGER, + packets_rtt_median_us INTEGER, + packets_rtt_max_us INTEGER, + packets_rtt_std_dev_us INTEGER, + + -- Batch send latency distribution (in microseconds) recorded during the load test. + -- All five columns are NULL together when no batches were sent. + sending_latency_min_us INTEGER, + sending_latency_mean_us INTEGER, + sending_latency_median_us INTEGER, + sending_latency_max_us INTEGER, + sending_latency_std_dev_us INTEGER, + + -- Whether any packet was received with a duplicate ID during this test run. + received_duplicates BOOLEAN NOT NULL, + + -- Human-readable description of the first error that caused the test to abort. + -- NULL if the test completed without error. + error TEXT + +); + +-- Supports efficient "all runs for node X, newest first" lookups. +CREATE INDEX idx_testrun_node_id_timestamp ON testrun (node_id, test_timestamp DESC); + +-- Supports efficient "all runs, newest first" lookups (the global testruns pagination endpoint). +-- The composite index above cannot serve this query because its leading column is node_id. +CREATE INDEX idx_testrun_test_timestamp ON testrun (test_timestamp DESC); + +CREATE TABLE nym_node +( + -- Node ID as assigned by the mixnet contract. + node_id INTEGER PRIMARY KEY NOT NULL, + + -- Ed25519 identity key of the node, base58-encoded. + -- A node_id always maps to exactly one identity_key and is never reassigned. + -- The inverse is not true: the same identity_key may appear under multiple node_ids + -- if the operator unbonds and rebonds, receiving a new contract-assigned node_id. + identity_key TEXT NOT NULL, + + -- When this node was last observed as bonded in the contract. + last_seen_bonded TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + -- Mixnet socket address (host:port) at which the node accepts sphinx packets. + mixnet_socket_address TEXT, + + -- X25519 public key used for Noise handshakes, base58-encoded. + -- NULL if retrieval from the node failed. + noise_key TEXT, + + -- Sphinx public key used for packet encryption, base58-encoded. + -- NULL if retrieval from the node failed. + -- Always NULL/non-NULL together with key_rotation_id. + sphinx_key TEXT, + + -- Key rotation epoch ID that the sphinx_key belongs to. + -- NULL if retrieval from the node failed. + -- Always NULL/non-NULL together with sphinx_key. + key_rotation_id INTEGER, + + -- Classification of the node based on the roles reported via its self-described endpoint. + -- 'unknown' is used both before the node has been successfully queried and when a queried + -- node reports no roles. Only nodes with node_type in ('mixnode', 'mixnode_and_gateway') + -- are eligible for testruns today. + node_type TEXT CHECK ( node_type IN ('unknown', 'mixnode', 'gateway', 'mixnode_and_gateway') ) NOT NULL DEFAULT 'unknown', + + -- The most recent test run performed against this node. NULL if never tested. + -- Set to NULL automatically when the referenced testrun row is evicted. + last_testrun INTEGER REFERENCES testrun (id) ON DELETE SET NULL, + + CHECK ((sphinx_key IS NULL) = (key_rotation_id IS NULL)) +); + +-- Tracks nodes that currently have a test run in progress. +-- At most one row per node (enforced by the PRIMARY KEY on node_id). +-- A row is inserted when a run is dispatched and deleted when it completes or is abandoned. +CREATE TABLE testrun_in_progress +( + -- The node currently being tested. + node_id INTEGER PRIMARY KEY REFERENCES nym_node (node_id) NOT NULL, + + -- When the in-progress run was started; used to detect stale/hung runs. + started_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +) \ No newline at end of file diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/build_info.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/build_info.rs new file mode 100644 index 0000000000..a5b80d67bf --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/build_info.rs @@ -0,0 +1,15 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_bin_common::bin_info_owned; +use nym_bin_common::output_format::OutputFormat; + +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + #[clap(short, long, default_value_t = OutputFormat::default())] + output: OutputFormat, +} + +pub(crate) fn execute(args: Args) { + println!("{}", args.output.format(&bin_info_owned!())) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/env.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/env.rs new file mode 100644 index 0000000000..d37292bc8f --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/env.rs @@ -0,0 +1,40 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Environment variable names used as fallbacks for CLI arguments. +/// Each constant matches the `env = ...` attribute on the corresponding clap field. +pub mod vars { + // run orchestrator args + pub const NYM_NETWORK_MONITOR_ORCHESTRATOR_AGENTS_TOKEN_ARG: &str = + "NYM_NETWORK_MONITOR_ORCHESTRATOR_AGENTS_TOKEN"; + pub const NYM_NETWORK_MONITOR_ORCHESTRATOR_METRICS_AND_RESULTS_TOKEN_ARG: &str = + "NYM_NETWORK_MONITOR_ORCHESTRATOR_METRICS_AND_RESULTS_TOKEN"; + pub const NYM_NETWORK_MONITOR_TEST_INTERVAL_ARG: &str = "NYM_NETWORK_MONITOR_TEST_INTERVAL"; + pub const NYM_NETWORK_MONITOR_TEST_TIMEOUT_ARG: &str = "NYM_NETWORK_MONITOR_TEST_TIMEOUT"; + pub const NYM_NETWORK_MONITOR_HTTP_SERVER_BIND_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_HTTP_SERVER_BIND_ADDRESS"; + pub const NYM_NETWORK_MONITOR_NYM_API_ENDPOINT_ARG: &str = + "NYM_NETWORK_MONITOR_NYM_API_ENDPOINT"; + pub const NYM_NETWORK_MONITOR_MNEMONIC_ARG: &str = "NYM_NETWORK_MONITOR_MNEMONIC"; + pub const NYM_NETWORK_MONITOR_RPC_URL_ARG: &str = "NYM_NETWORK_MONITOR_RPC_URL"; + pub const NYM_NETWORK_MONITOR_DATABASE_PATH_ARG: &str = "NYM_NETWORK_MONITOR_DATABASE_PATH"; + pub const NYM_NETWORK_MONITOR_PRIVATE_KEY_ARG: &str = "NYM_NETWORK_MONITOR_PRIVATE_KEY"; + pub const NYM_NETWORK_MONITOR_NODE_REFRESH_RATE_ARG: &str = + "NYM_NETWORK_MONITOR_NODE_REFRESH_RATE"; + pub const NYM_NETWORK_MONITOR_NODE_INFO_QUERY_TIMEOUT_ARG: &str = + "NYM_NETWORK_MONITOR_NODE_INFO_QUERY_TIMEOUT"; + pub const NYM_NETWORK_MONITOR_NETWORK_MONITORS_CONTRACT_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_NETWORK_MONITORS_CONTRACT_ADDRESS"; + pub const NYM_NETWORK_MONITOR_MIXNET_CONTRACT_ADDRESS_ARG: &str = + "NYM_NETWORK_MONITOR_MIXNET_CONTRACT_ADDRESS"; + pub const NYM_NETWORK_MONITOR_TESTRUN_EVICTION_AGE_ARG: &str = + "NYM_NETWORK_MONITOR_TESTRUN_EVICTION_AGE"; + pub const NYM_NETWORK_MONITOR_CONCURRENT_NODE_QUERIES_ARG: &str = + "NYM_NETWORK_MONITOR_CONCURRENT_NODE_QUERIES"; + pub const NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_MAX_ATTEMPTS_ARG: &str = + "NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_MAX_ATTEMPTS"; + pub const NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_RETRY_DELAY_ARG: &str = + "NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_RETRY_DELAY"; + pub const NYM_NETWORK_MONITOR_RESULT_SUBMISSION_INTERVAL_ARG: &str = + "NYM_NETWORK_MONITOR_RESULT_SUBMISSION_INTERVAL"; +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/mod.rs new file mode 100644 index 0000000000..955d9f1cfe --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/mod.rs @@ -0,0 +1,50 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use clap::{Parser, Subcommand}; +use nym_bin_common::bin_info; +use std::sync::OnceLock; + +mod build_info; +mod env; +mod run_orchestrator; + +// Helper for passing LONG_VERSION to clap +fn pretty_build_info_static() -> &'static str { + static PRETTY_BUILD_INFORMATION: OnceLock = OnceLock::new(); + PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print()) +} + +/// Top-level CLI entry point for the network monitor agent. +#[derive(Parser, Debug)] +#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)] +pub(crate) struct Cli { + /// Path pointing to an env file that configures the binary. + /// Useful in local testing setups against networks different from mainnet + #[clap(short, long)] + pub(crate) config_env_file: Option, + + #[command(subcommand)] + pub(crate) command: Command, +} + +impl Cli { + /// Dispatches execution to the subcommand selected by the user. + pub(crate) async fn execute(self) -> anyhow::Result<()> { + match self.command { + Command::BuildInfo(args) => build_info::execute(args), + Command::RunOrchestrator(args) => run_orchestrator::execute(*args).await?, + } + Ok(()) + } +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Show build information of this binary + BuildInfo(build_info::Args), + + /// Run the network monitor orchestrator which will periodically + /// issue work assignments for stress testing mixnodes + RunOrchestrator(Box), +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/run_orchestrator.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/run_orchestrator.rs new file mode 100644 index 0000000000..fa85d2aa62 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/cli/run_orchestrator.rs @@ -0,0 +1,214 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::env::vars::*; +use crate::orchestrator::NetworkMonitorOrchestrator; +use crate::orchestrator::config::Config; +use anyhow::{Context, anyhow, bail}; +use nym_crypto::asymmetric::ed25519; +use nym_validator_client::nyxd::bip39; +use std::mem; +use std::net::SocketAddr; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tracing::info; +use url::Url; +use zeroize::Zeroizing; + +#[derive(clap::Args, Debug)] +pub(crate) struct Args { + /// Bearer token required by the agents requesting work assignments and submitting results. + #[clap(long, env = NYM_NETWORK_MONITOR_ORCHESTRATOR_AGENTS_TOKEN_ARG)] + agents_token: String, + + /// Bearer token used for accessing the metrics and results endpoints. + #[clap(long, env = NYM_NETWORK_MONITOR_ORCHESTRATOR_METRICS_AND_RESULTS_TOKEN_ARG)] + metrics_and_results_token: String, + + /// How often each node should be stress-tested (e.g. `30m`, `1h`). + #[clap(long, env = NYM_NETWORK_MONITOR_TEST_INTERVAL_ARG, value_parser = humantime::parse_duration, default_value = "2h")] + test_interval: Duration, + + /// Maximum time a single test run is allowed to run before being considered timed out + /// (e.g. `5m`). + #[clap(long, env = NYM_NETWORK_MONITOR_TEST_TIMEOUT_ARG, value_parser = humantime::parse_duration, default_value = "5m")] + test_timeout: Duration, + + /// HTTP address to bind the HTTP server to (e.g. `0.0.0.0:8080`). + #[clap(long, env = NYM_NETWORK_MONITOR_HTTP_SERVER_BIND_ADDRESS_ARG, default_value = "0.0.0.0:8080")] + http_server_bind_address: SocketAddr, + + /// HTTP endpoint of the nym-api to which test results are submitted. + #[clap(long, env = NYM_NETWORK_MONITOR_NYM_API_ENDPOINT_ARG)] + nym_api_endpoint: Url, + + /// Mnemonic of the account used to authorise network monitor agents in the + /// network monitors contract. + #[clap(long, env = NYM_NETWORK_MONITOR_MNEMONIC_ARG)] + mnemonic: bip39::Mnemonic, + + /// HTTPS RPC URL of a Nyx node (e.g. `https://rpc.nymtech.net`). + /// If not provided, the default value from the environment will be retrieved (if available). + #[clap(long, env = NYM_NETWORK_MONITOR_RPC_URL_ARG)] + rpc_url: Option, + + /// Path to the SQLite database file. + #[clap(long, env = NYM_NETWORK_MONITOR_DATABASE_PATH_ARG)] + database_path: PathBuf, + + /// Base58-encoded Ed25519 private key used to authorise result submissions to the nym-api. + #[clap(long, env = NYM_NETWORK_MONITOR_PRIVATE_KEY_ARG)] + private_key: String, + + /// How often the list of bonded nym-nodes is refreshed from the mixnet contract + /// (e.g. `10m`, `1h`). + #[clap(long, env = NYM_NETWORK_MONITOR_NODE_REFRESH_RATE_ARG, value_parser = humantime::parse_duration, default_value = "2h")] + node_refresh_rate: Duration, + + /// Timeout for querying a single node for its detailed information (sphinx key, noise key, + /// etc.). Queries that exceed this budget leave the corresponding fields as `NULL` + /// (e.g. `10s`). + #[clap(long, env = NYM_NETWORK_MONITOR_NODE_INFO_QUERY_TIMEOUT_ARG, value_parser = humantime::parse_duration, default_value = "10s")] + node_info_query_timeout: Duration, + + /// Bech32 address of the networks monitors contract used to authorise agents + /// If not provided, the default value from the environment will be retrieved (if available). + #[clap(long, env = NYM_NETWORK_MONITOR_NETWORK_MONITORS_CONTRACT_ADDRESS_ARG)] + network_monitors_contract_address: Option, + + /// Bech32 address of the mixnet contract used to retrieve the list of bonded nodes. + /// If not provided, the default value from the environment will be retrieved (if available). + #[clap(long, env = NYM_NETWORK_MONITOR_MIXNET_CONTRACT_ADDRESS_ARG)] + mixnet_contract_address: Option, + + /// Maximum age of a completed test run row before it is evicted from the local database. + /// Rows older than this are assumed to have already been submitted to the nym-api + /// (e.g. `7d`, `24h`). + #[clap(long, env = NYM_NETWORK_MONITOR_TESTRUN_EVICTION_AGE_ARG, value_parser = humantime::parse_duration, default_value = "7d",)] + testrun_eviction_age: Duration, + + /// Maximum number of nodes queried concurrently during a node refresh cycle. + #[clap(long, env = NYM_NETWORK_MONITOR_CONCURRENT_NODE_QUERIES_ARG, default_value_t = 32)] + number_of_concurrent_node_queries: usize, + + /// Maximum number of attempts (including the initial one) made to verify that this + /// orchestrator's account is authorised in the network monitors contract before start-up. + /// The process exits with an error once the budget is exhausted. + #[clap(long, env = NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_MAX_ATTEMPTS_ARG, default_value = "10")] + chain_authorisation_check_max_attempts: NonZeroU32, + + /// Delay between consecutive chain authorisation checks during start-up (e.g. `1m`, `30s`). + /// Applied both when the query itself fails and when it succeeds but the orchestrator is not + /// (yet) listed. + #[clap(long, env = NYM_NETWORK_MONITOR_CHAIN_AUTH_CHECK_RETRY_DELAY_ARG, value_parser = humantime::parse_duration, default_value = "1m")] + chain_authorisation_check_retry_delay: Duration, + + /// How often the orchestrator flushes accumulated test results to the nym-api as a signed + /// batch submission (e.g. `15m`, `1h`). + #[clap(long, env = NYM_NETWORK_MONITOR_RESULT_SUBMISSION_INTERVAL_ARG, value_parser = humantime::parse_duration, default_value = "15m")] + result_submission_interval: Duration, +} + +impl Args { + /// Converts the parsed CLI arguments into a [`Config`]. + /// + /// Returns an error if `mixnet_contract_address` is not a valid bech32 account address. + /// + /// Note: `orchestrator_token`, `mnemonic`, and `private_key` are not part of [`Config`] + /// and must be handled separately by the caller. + pub(crate) fn build_orchestrator_config(&self) -> anyhow::Result { + Ok(Config { + nyxd_rpc_endpoint: self.rpc_url.clone(), + nym_api_endpoint: self.nym_api_endpoint.clone(), + http_server_bind_address: self.http_server_bind_address, + test_interval: self.test_interval, + test_timeout: self.test_timeout, + database_path: self.database_path.clone(), + node_refresh_rate: self.node_refresh_rate, + node_info_query_timeout: self.node_info_query_timeout, + network_monitors_contract_address: self + .network_monitors_contract_address + .as_ref() + .map(|addr| addr.parse()) + .transpose() + .map_err(|err| anyhow!("invalid network monitors contract address: {err}"))?, + mixnet_contract_address: self + .mixnet_contract_address + .as_ref() + .map(|addr| addr.parse()) + .transpose() + .map_err(|err| anyhow!("invalid mixnet contract address: {err}"))?, + testrun_eviction_age: self.testrun_eviction_age, + number_of_concurrent_node_queries: self.number_of_concurrent_node_queries, + chain_authorisation_check_max_attempts: self.chain_authorisation_check_max_attempts, + chain_authorisation_check_retry_delay: self.chain_authorisation_check_retry_delay, + result_submission_interval: self.result_submission_interval, + }) + } + + /// Moves the orchestrator agents token out of `self`, zeroizing the original. + /// + /// Returns an error if the token is empty. + pub(crate) fn take_agents_orchestrator_token(&mut self) -> anyhow::Result> { + // we must never accept empty tokens + if self.agents_token.is_empty() { + bail!("provided orchestrator token is empty, please provide a non-empty value") + } + let taken = mem::take(&mut self.agents_token); + Ok(Zeroizing::new(taken)) + } + + /// Moves the orchestrator metrics-and-results token out of `self`, zeroizing the original. + /// + /// Returns an error if the token is empty. + pub(crate) fn take_metrics_and_results_orchestrator_token( + &mut self, + ) -> anyhow::Result> { + // we must never accept empty tokens + if self.metrics_and_results_token.is_empty() { + bail!("provided orchestrator token is empty, please provide a non-empty value") + } + let taken = mem::take(&mut self.metrics_and_results_token); + Ok(Zeroizing::new(taken)) + } + + /// Moves the raw Base58-encoded private key out of `self`, parses it into an Ed25519 key pair, + /// and zeroizes the original string. + /// + /// Returns an error if the value is not a valid Base58-encoded Ed25519 private key. + pub(crate) fn take_identity_key(&mut self) -> anyhow::Result> { + // whatever happens, we'll zeroize the value + let taken = Zeroizing::new(mem::take(&mut self.private_key)); + + let private_key = ed25519::PrivateKey::from_base58_string(&taken) + .context("malformed identity key provided")?; + Ok(Arc::new(private_key.into())) + } + + /// Consumes `self` and returns the mnemonic. + pub(crate) fn into_mnemonic(self) -> bip39::Mnemonic { + self.mnemonic + } +} + +pub(crate) async fn execute(mut args: Args) -> anyhow::Result<()> { + info!("Starting network monitor orchestrator"); + let config = args.build_orchestrator_config()?; + let identity_keys = args.take_identity_key()?; + let agents_auth_token = args.take_agents_orchestrator_token()?; + let metrics_and_results_auth_token = args.take_metrics_and_results_orchestrator_token()?; + let mnemonic = args.into_mnemonic(); + + let mut orchestrator = NetworkMonitorOrchestrator::new( + config, + identity_keys, + agents_auth_token, + metrics_and_results_auth_token, + mnemonic, + ) + .await?; + orchestrator.run().await?; + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/api_docs.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/api_docs.rs new file mode 100644 index 0000000000..92bd3db53a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/api_docs.rs @@ -0,0 +1,52 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use axum::Router; +use nym_network_monitor_orchestrator_requests::routes; +use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; +use utoipa::{Modify, OpenApi}; +use utoipa_swagger_ui::SwaggerUi; +use utoipauto::utoipauto; + +// manually import external structs which are behind feature flags because they +// can't be automatically discovered +// https://github.com/ProbablyClem/utoipauto/issues/13#issuecomment-1974911829 +#[utoipauto( + paths = "./nym-network-monitor-v3/nym-network-monitor-orchestrator/src", + "./nym-network-monitor-v3/nym-network-monitor-orchestrator-requests/src from nym-network-monitor-orchestrator-requests" +)] +#[derive(OpenApi)] +#[openapi( + info(title = "Nym Network Monitor Orchestrator API"), + tags(), + modifiers(&SecurityAddon), +)] +pub(crate) struct ApiDoc; + +/// OpenAPI modifier that registers bearer-token security schemes for the API docs. +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + // token authorising access to prometheus metrics and test-run results + components.add_security_scheme( + "metrics_and_results_token", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + + // token authorising monitor agents + components.add_security_scheme( + "agents_token", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } + } +} + +/// Returns a router that serves the Swagger UI and the generated OpenAPI JSON spec. +pub(crate) fn route() -> Router { + SwaggerUi::new(routes::SWAGGER) + .url("/api-docs/openapi.json", ApiDoc::openapi()) + .into() +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/mod.rs new file mode 100644 index 0000000000..05ecd8d703 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/mod.rs @@ -0,0 +1,69 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::state::AppState; +use axum::Router; +use axum::response::Redirect; +use axum::routing::{MethodRouter, get}; +use nym_http_api_common::middleware::bearer_auth::AuthLayer; +use nym_http_api_common::middleware::logging::log_request_debug; +use nym_network_monitor_orchestrator_requests::routes; +use nym_task::ShutdownToken; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::{error, info}; +use zeroize::Zeroizing; + +pub(crate) mod api_docs; +pub(crate) mod v1; + +/// Returns a handler that issues a 303 redirect to the Swagger UI. +fn swagger_redirect() -> MethodRouter { + // redirects with 303 status code + get(|| async { Redirect::to(routes::SWAGGER) }) +} + +/// Assembles the full orchestrator HTTP router with Swagger UI, v1 API routes, +/// bearer-auth middleware, and request logging. +pub(crate) fn build_router( + state: AppState, + agents_auth_token: Arc>, + metrics_and_results_auth_token: Arc>, +) -> Router { + let agents_auth = AuthLayer::new(agents_auth_token); + let metrics_and_results_auth = AuthLayer::new(metrics_and_results_auth_token); + + Router::new() + .route(routes::ROOT, swagger_redirect()) + .merge(api_docs::route()) + .nest( + routes::V1, + v1::routes(agents_auth, metrics_and_results_auth), + ) + .layer(axum::middleware::from_fn(log_request_debug)) + .with_state(state) +} + +/// Binds to `bind_address` and serves the given router until the shutdown token is cancelled. +/// The listener is created with `into_make_service_with_connect_info` so handlers can +/// extract the peer [`SocketAddr`]. +pub(crate) async fn run_http_server( + router: Router, + bind_address: SocketAddr, + shutdown_token: ShutdownToken, +) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(bind_address) + .await + .inspect_err(|err| error!("couldn't bind to address {bind_address}: {err}"))?; + + info!("starting http api server on {bind_address}"); + + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { shutdown_token.cancelled().await }) + .await?; + + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/agent/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/agent/mod.rs new file mode 100644 index 0000000000..872ff05c1d --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/agent/mod.rs @@ -0,0 +1,243 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::api::v1::error::ApiError; +use crate::http::state::AppState; +use crate::orchestrator::prometheus::{PROMETHEUS_METRICS, PrometheusMetric}; +use axum::extract::{ConnectInfo, State}; +use axum::routing::post; +use axum::{Json, Router}; +use nym_http_api_common::middleware::bearer_auth::AuthLayer; +use nym_network_monitor_orchestrator_requests::models::{ + AgentAnnounceRequest, AgentAnnounceResponse, TestRunAssignmentRequest, + TestRunAssignmentResponse, TestRunResultSubmissionRequest, TestRunSubmissionResponse, +}; +use nym_network_monitor_orchestrator_requests::routes; +use nym_validator_client::nyxd::contract_traits::NetworkMonitorsSigningClient; +use std::net::SocketAddr; +use tracing::{error, info}; + +#[utoipa::path( + operation_id = "v1_agent_announce", + tag = "Network Monitor Agent", + post, + request_body = AgentAnnounceRequest, + path = "/announce", + context_path = "/v1/agent", + security(("agents_token" = [])), + responses( + (status = 200, content( + (AgentAnnounceResponse = "application/json"), + )), + (status = 500, description = "failed to announce agent to the network monitors contract"), + ) +)] +#[tracing::instrument( + level = "debug", + skip_all, + fields( + agent_pod = %addr + ) +)] +async fn announce_agent( + ConnectInfo(addr): ConnectInfo, + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + let pod_ip = addr.ip(); + info!("received announce request from pod at {pod_ip}: {body:?}"); + + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentAnnounceRequests); + + // 1. upsert the agent in the cache and learn whether it has already been announced + let already_announced = state + .agents + .try_announce_agent(body.agent_mix_socket_address, body.x25519_noise_key) + .await; + + // 2. if the agent was already announced, skip the contract tx + if already_announced { + info!("agent at {pod_ip} is already announced, skipping contract tx"); + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentDuplicateAnnouncementRequests); + return Ok(Json(AgentAnnounceResponse {})); + } + + // 3. attempt to announce the agent to the network monitors contract + state + .validator_client + .write() + .await + .nyxd + .authorise_network_monitor( + body.agent_mix_socket_address, + body.x25519_noise_key.to_base58_string(), + body.noise_version, + None, + ) + .await + .inspect_err(|err| { + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentContractAnnounceFailures); + + error!("failed to announce agent to the network monitors contract: {err}") + }) + .map_err(|_| ApiError::ContractFailure)?; + + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentContractAnnounceSuccesses); + + // 4. mark the agent as announced so subsequent calls are no-ops + state + .agents + .mark_announced(body.agent_mix_socket_address) + .await; + + Ok(Json(AgentAnnounceResponse {})) +} + +#[utoipa::path( + operation_id = "v1_agent_request_testrun", + tag = "Network Monitor Agent", + post, + request_body = TestRunAssignmentRequest, + path = "/request-testrun", + context_path = "/v1/agent", + security(("agents_token" = [])), + responses( + (status = 200, content( + (TestRunAssignmentResponse = "application/json"), + )), + (status = 400, description = "agent not found in cache, or agent has not yet been announced to the contract"), + (status = 500, description = "failed to read from storage, or a stored field could not be decoded"), + ) +)] +#[tracing::instrument( + level = "debug", + skip_all, + fields( + agent_pod = %addr + ) +)] +async fn request_testrun( + ConnectInfo(addr): ConnectInfo, + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + let pod_ip = addr.ip(); + info!("received testrun request from pod at {pod_ip}"); + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentTestrunRequests); + + // 1. ensure the agent still exists in our announced cache + // in case there was a weird network failure between the calls + let Some(agent) = state.agents.get_agent(body.agent_mix_socket_address).await else { + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentUnknownAgentTestrunRequests); + return Err(ApiError::AgentNotFound); + }; + + if !agent.announced { + PROMETHEUS_METRICS.inc(PrometheusMetric::AgentTestrunRequestsWithoutAnnouncement); + return Err(ApiError::AgentNotAnnounced); + } + + // 2. attempt to assign a testrun to the agent + let assignment = state.assign_next_mixnode_testrun().await?; + if assignment.is_none() { + PROMETHEUS_METRICS.inc(PrometheusMetric::EmptyTestrunAssignments); + } else { + PROMETHEUS_METRICS.inc(PrometheusMetric::NonEmptyTestrunAssignments); + } + + Ok(Json(TestRunAssignmentResponse { assignment })) +} + +fn emit_testrun_result_metrics(result: &TestRunResultSubmissionRequest) { + PROMETHEUS_METRICS.inc(PrometheusMetric::TestRunResultSubmissions); + + PROMETHEUS_METRICS.inc_by( + PrometheusMetric::TestPacketsSent, + result.result.packets_sent as i64, + ); + PROMETHEUS_METRICS.inc_by( + PrometheusMetric::TestPacketsReceived, + result.result.packets_received as i64, + ); + + PROMETHEUS_METRICS.observe_histogram( + PrometheusMetric::TestDurationSeconds, + result.result.time_taken.as_secs_f64(), + ); + if let Some(latency) = result.result.approximate_latency { + PROMETHEUS_METRICS.observe_histogram( + PrometheusMetric::ApproximateNodeLatencyMs, + latency.as_millis() as f64, + ); + } + PROMETHEUS_METRICS.observe_histogram( + PrometheusMetric::TestrunReceivedPacketsRatio, + result.result.received_ratio(), + ); + + if let Some(packets_stats) = result.result.packets_statistics { + PROMETHEUS_METRICS.observe_histogram( + PrometheusMetric::AverageTestPacketRTTMs, + packets_stats.mean.as_millis() as f64, + ); + } + + if result.result.error.is_some() { + PROMETHEUS_METRICS.inc(PrometheusMetric::TestrunsErrors) + } +} + +#[utoipa::path( + operation_id = "v1_agent_submit_testrun_result", + tag = "Network Monitor Agent", + post, + request_body = TestRunResultSubmissionRequest, + path = "/submit-testrun-result", + context_path = "/v1/agent", + security(("agents_token" = [])), + responses( + (status = 200, content( + (TestRunSubmissionResponse = "application/json"), + )), + (status = 500, description = "failed to persist the test run result to storage"), + ) +)] +#[tracing::instrument( + level = "debug", + skip_all, + fields( + agent_pod = %addr + ) +)] +async fn submit_testrun_result( + ConnectInfo(addr): ConnectInfo, + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + let pod_ip = addr.ip(); + + emit_testrun_result_metrics(&body); + + info!( + "received testrun result for node {} from pod at {pod_ip}", + body.node_id + ); + + state + .submit_testrun_result(body.result, body.node_id) + .await?; + + Ok(Json(TestRunSubmissionResponse {})) +} + +/// Builds the agent sub-router with all agent endpoints behind bearer-token auth. +pub(super) fn routes(auth_layer: AuthLayer) -> Router { + Router::new() + .route(routes::v1::agent::ANNOUNCE, post(announce_agent)) + .route(routes::v1::agent::REQUEST_TESTRUN, post(request_testrun)) + .route( + routes::v1::agent::SUBMIT_TESTRUN_RESULT, + post(submit_testrun_result), + ) + .route_layer(auth_layer) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/error.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/error.rs new file mode 100644 index 0000000000..16a4595681 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/error.rs @@ -0,0 +1,51 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +/// Unified error type for all v1 API endpoints. +/// The `Display` message from each variant is used as the HTTP response body. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ApiError { + #[error("agent information not found")] + AgentNotFound, + + #[error("failed to announce agent to the network monitors contract")] + ContractFailure, + + #[error("failed to read or write data from the database")] + StorageFailure, + + #[error("some of the stored data is malformed and could not be parsed")] + MalformedStoredData, + + #[error("agent hasn't been announced to the contract - can't assign testruns")] + AgentNotAnnounced, + + #[error("no test run found with the requested id")] + TestRunNotFound, + + #[error("no nym-node found with the requested node id")] + NymNodeNotFound, +} + +impl ApiError { + fn status_code(&self) -> StatusCode { + use ApiError::*; + + match self { + AgentNotFound | AgentNotAnnounced => StatusCode::BAD_REQUEST, + TestRunNotFound | NymNodeNotFound => StatusCode::NOT_FOUND, + ContractFailure | StorageFailure | MalformedStoredData => { + StatusCode::INTERNAL_SERVER_ERROR + } + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status_code(), self.to_string()).into_response() + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/metrics/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/metrics/mod.rs new file mode 100644 index 0000000000..984b0f0def --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/metrics/mod.rs @@ -0,0 +1,30 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::state::AppState; +use crate::orchestrator::prometheus::PROMETHEUS_METRICS; +use axum::Router; +use axum::routing::get; +use nym_network_monitor_orchestrator_requests::routes; + +/// Returns `prometheus` compatible metrics +#[utoipa::path( + get, + path = "/prometheus", + context_path = "/v1/metrics", + tag = "Metrics", + responses( + (status = 200, body = String), + (status = 400, description = "`Authorization` header was missing"), + (status = 401, description = "Access token is missing or invalid"), + ), + security(("metrics_and_results_token" = [])) +)] +// the AuthLayer is protecting access to this endpoint +pub(crate) async fn prometheus_metrics() -> String { + PROMETHEUS_METRICS.metrics() +} + +pub(super) fn routes() -> Router { + Router::new().route(routes::v1::metrics::PROMETHEUS, get(prometheus_metrics)) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/mod.rs new file mode 100644 index 0000000000..26b039adc3 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/mod.rs @@ -0,0 +1,29 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::state::AppState; +use axum::Router; +use nym_http_api_common::middleware::bearer_auth::AuthLayer; +use nym_network_monitor_orchestrator_requests::routes; + +pub(crate) mod agent; +pub(crate) mod error; +pub(crate) mod metrics; +pub(crate) mod results; + +/// Assembles the v1 API router, nesting agent, metrics, and results sub-routers +/// under their respective path prefixes. Metrics and results share the same +/// bearer-auth layer. +pub(crate) fn routes( + agents_auth: AuthLayer, + metrics_and_results_auth: AuthLayer, +) -> Router { + Router::new() + .nest(routes::v1::AGENT, agent::routes(agents_auth)) + .merge( + Router::new() + .nest(routes::v1::METRICS, metrics::routes()) + .nest(routes::v1::RESULTS, results::routes()) + .route_layer(metrics_and_results_auth), + ) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/results/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/results/mod.rs new file mode 100644 index 0000000000..bfbfd10d15 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/api/v1/results/mod.rs @@ -0,0 +1,230 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Read-only HTTP endpoints that expose the orchestrator's local database: +//! the set of nym-nodes it is tracking, the test runs it has recorded, and +//! the runs currently in flight. +//! +//! All handlers are thin wrappers that extract path/query parameters and +//! delegate the actual work to [`AppState`]; conversion from storage rows to +//! the public response shapes lives in [`crate::storage::models`]. Every +//! route in this module is protected by the shared `metrics_and_results` +//! bearer token applied one level up in [`crate::http::api::v1::routes`]. + +use crate::http::api::v1::error::ApiError; +use crate::http::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use nym_network_monitor_orchestrator_requests::models::{ + NymNodeData, NymNodeWithTestRun, PagedResult, Pagination, TestRunData, TestRunInProgressData, +}; +use nym_network_monitor_orchestrator_requests::routes; +use nym_validator_client::client::NodeId; + +/// Fetches a single completed test run by its database-assigned id. +/// Returns `404` with [`ApiError::TestRunNotFound`] if no such row exists — for +/// example because the run has already been evicted by the stale-result sweeper. +#[utoipa::path( + operation_id = "v1_results_testrun_by_id", + tag = "Network Monitor Results", + get, + params(("id" = i64, Path, description = "Database-assigned test-run id")), + path = "/testrun/{id}", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (TestRunData = "application/json"), + )), + (status = 404, description = "no test run found with the requested id"), + (status = 500, description = "failed to read the test run from storage"), + ) +)] +async fn get_testrun_by_id( + Path(id): Path, + State(state): State, +) -> Result, ApiError> { + state + .get_testrun_by_id(id) + .await? + .map(Json) + .ok_or(ApiError::TestRunNotFound) +} + +/// Fetches a single node along with its most recent completed test run. +/// +/// The `latest_test_run` field is `None` if the node has never been tested or +/// if its most recent run has been evicted. Returns `404` with +/// [`ApiError::NymNodeNotFound`] if the orchestrator has never observed a bond +/// for this `node_id`. +#[utoipa::path( + operation_id = "v1_results_nym_node_by_node_id", + tag = "Network Monitor Results", + get, + params(("node_id" = u32, Path, description = "Mixnet-contract node id")), + path = "/nym-node/{node_id}", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (NymNodeWithTestRun = "application/json"), + )), + (status = 404, description = "no nym-node found with the requested node id"), + (status = 500, description = "failed to read the node from storage, or a stored field could not be decoded"), + ) +)] +async fn get_nym_node_by_id( + Path(node_id): Path, + State(state): State, +) -> Result, ApiError> { + state + .get_nym_node_by_id(node_id) + .await? + .map(Json) + .ok_or(ApiError::NymNodeNotFound) +} + +/// Paginated list of test runs currently dispatched to agents and awaiting results. +/// +/// Ordered oldest-started first, so stale or hung runs surface at the top. In +/// normal operation the underlying table holds roughly one entry per active +/// agent. See [`Pagination`] for the page-size/page-number contract and default +/// caps. +#[utoipa::path( + operation_id = "v1_results_testruns_in_progress", + tag = "Network Monitor Results", + get, + params(Pagination), + path = "/testruns-in-progress", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (PagedResult = "application/json"), + )), + (status = 500, description = "failed to read in-progress test runs from storage"), + ) +)] +async fn get_testruns_in_progress( + Query(pagination): Query, + State(state): State, +) -> Result>, ApiError> { + state + .get_testruns_in_progress_paginated(pagination) + .await + .map(Json) +} + +/// Paginated list of all completed test runs, newest first. +/// +/// See [`Pagination`] for the page-size/page-number contract and default caps. +/// `total` reflects the row count at the moment the page was read; it is +/// fetched in the same transaction as the page itself to guarantee consistency. +#[utoipa::path( + operation_id = "v1_results_testruns", + tag = "Network Monitor Results", + get, + params(Pagination), + path = "/testruns", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (PagedResult = "application/json"), + )), + (status = 500, description = "failed to read test runs from storage"), + ) +)] +async fn get_testruns( + Query(pagination): Query, + State(state): State, +) -> Result>, ApiError> { + state.get_testruns_paginated(pagination).await.map(Json) +} + +/// Paginated list of every node the orchestrator has ever observed as bonded, +/// ordered by `node_id` ascending. +/// +/// Nodes are only removed from this table if they are explicitly deleted; a +/// node that has unbonded remains visible with its last-known `last_seen_bonded` +/// timestamp. +#[utoipa::path( + operation_id = "v1_results_nym_nodes", + tag = "Network Monitor Results", + get, + params(Pagination), + path = "/nym-nodes", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (PagedResult = "application/json"), + )), + (status = 500, description = "failed to read nodes from storage, or a stored field could not be decoded"), + ) +)] +async fn get_nym_nodes( + Query(pagination): Query, + State(state): State, +) -> Result>, ApiError> { + state.get_nym_nodes_paginated(pagination).await.map(Json) +} + +/// Paginated history of test runs for a single node, newest first. +/// +/// If `node_id` is unknown or has never been tested the response is a valid +/// empty page (`items: []`, `total: 0`) — there is no 404 here because the +/// orchestrator can't tell from a zero-row result whether the node simply has +/// no runs yet. Backed by the `idx_testrun_node_id_timestamp` index for +/// efficient per-node lookups. +#[utoipa::path( + operation_id = "v1_results_nym_node_testruns", + tag = "Network Monitor Results", + get, + params( + ("node_id" = u32, Path, description = "Mixnet-contract node id"), + Pagination, + ), + path = "/nym-node/{node_id}/testruns", + context_path = "/v1/results", + security(("metrics_and_results_token" = [])), + responses( + (status = 200, content( + (PagedResult = "application/json"), + )), + (status = 500, description = "failed to read test runs from storage"), + ) +)] +async fn get_nym_node_testruns( + Path(node_id): Path, + Query(pagination): Query, + State(state): State, +) -> Result>, ApiError> { + state + .get_testruns_for_node_paginated(node_id, pagination) + .await + .map(Json) +} + +/// Builds the router for the `/v1/results` sub-tree. The caller is expected to +/// nest this under [`routes::v1::RESULTS`] and to attach the shared +/// metrics-and-results bearer-auth layer at the parent level. +pub(super) fn routes() -> Router { + Router::new() + .route(routes::v1::results::TESTRUN_BY_ID, get(get_testrun_by_id)) + .route( + routes::v1::results::NYM_NODE_BY_NODE_ID, + get(get_nym_node_by_id), + ) + .route( + routes::v1::results::NYM_NODE_TESTRUNS, + get(get_nym_node_testruns), + ) + .route( + routes::v1::results::TESTRUNS_IN_PROGRESS, + get(get_testruns_in_progress), + ) + .route(routes::v1::results::TESTRUNS, get(get_testruns)) + .route(routes::v1::results::NYM_NODES, get(get_nym_nodes)) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/mod.rs new file mode 100644 index 0000000000..8ae6c1bd6c --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +pub(crate) mod api; +pub(crate) mod state; diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/state.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/state.rs new file mode 100644 index 0000000000..99776e382c --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/http/state.rs @@ -0,0 +1,471 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::api::v1::error::ApiError; +use crate::orchestrator::prometheus::{PROMETHEUS_METRICS, PrometheusMetric}; +use crate::storage::NetworkMonitorStorage; +use crate::storage::models::NewTestRun; +use axum::extract::FromRef; +use nym_crypto::asymmetric::x25519; +use nym_network_monitor_orchestrator_requests::models::{ + NymNodeData, NymNodeWithTestRun, PagedResult, Pagination, TestRunAssignment, TestRunData, + TestRunInProgressData, TestRunResult, +}; +use nym_validator_client::DirectSigningHttpRpcValidatorClient; +use nym_validator_client::client::NodeId; +use nym_validator_client::nyxd::nym_network_monitors_contract_common::AuthorisedNetworkMonitor; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::sync::{Mutex, RwLock}; +use tracing::error; + +/// Thread-safe cache of all agents known to this orchestrator, keyed by host IP. +/// Used to short-circuit the contract tx for already-announced agents. +#[derive(Clone, Default)] +pub(crate) struct KnownAgents { + inner: Arc>, +} + +impl KnownAgents { + /// Looks up an agent by its full mixnet socket address (host IP + port). + /// Returns `None` if no agent is registered at that address. + pub(crate) async fn get_agent(&self, address: SocketAddr) -> Option { + let guard = self.inner.lock().await; + let host_agents = guard.agents.get(&address.ip())?; + + host_agents + .iter() + .find(|a| a.mixnet_port == address.port()) + .copied() + } + + /// Records an announcement from the agent at `mix_listener`. The cache entry + /// is upserted: a missing entry is inserted, and if the cached noise key differs + /// from the announced one it is overwritten and the agent is treated as + /// not-yet-announced so the caller re-runs the contract tx with the new key. + /// + /// Returns the current `announced` flag: `true` means the agent was already + /// announced to the contract and the caller should skip the contract tx; + /// `false` means the caller should submit the tx and call [`Self::mark_announced`] + /// on success. + pub(crate) async fn try_announce_agent( + &self, + mix_listener: SocketAddr, + noise_key: x25519::PublicKey, + ) -> bool { + let mut guard = self.inner.lock().await; + let host_agents = guard.agents.entry(mix_listener.ip()).or_default(); + + if let Some(agent) = host_agents + .iter_mut() + .find(|agent| agent.mixnet_port == mix_listener.port()) + { + agent.last_active_at = OffsetDateTime::now_utc(); + if agent.noise_key == noise_key { + return agent.announced; + } + agent.noise_key = noise_key; + agent.announced = false; + guard.publish_gauges(); + return false; + } + + host_agents.push(KnownAgent { + mixnet_port: mix_listener.port(), + last_active_at: OffsetDateTime::now_utc(), + noise_key, + announced: false, + }); + guard.publish_gauges(); + false + } + + /// Marks the agent at `mix_listener` as announced. Should be called after a + /// successful contract transaction. + pub(crate) async fn mark_announced(&self, mix_listener: SocketAddr) { + let mut guard = self.inner.lock().await; + let Some(host_agents) = guard.agents.get_mut(&mix_listener.ip()) else { + return; + }; + if let Some(agent) = host_agents + .iter_mut() + .find(|a| a.mixnet_port == mix_listener.port()) + { + agent.announced = true; + } + guard.publish_gauges(); + } +} + +/// Rebuilds the agent cache from on-chain data. Used at orchestrator startup to +/// restore state for agents that were authorised before a restart. +impl TryFrom> for KnownAgents { + type Error = anyhow::Error; + + fn try_from(agents: Vec) -> Result { + let mut agents_map = HashMap::new(); + + for agent in agents { + let host_ip = agent.mixnet_address.ip(); + let noise_key = x25519::PublicKey::from_base58_string(&agent.bs58_x25519_noise)?; + agents_map + .entry(host_ip) + .or_insert_with(Vec::new) + .push(KnownAgent { + mixnet_port: agent.mixnet_address.port(), + // or should we use the authorisation ts? + last_active_at: OffsetDateTime::now_utc(), + noise_key, + announced: true, + }); + } + + let inner = KnownAgentsInner { agents: agents_map }; + inner.publish_gauges(); + Ok(KnownAgents { + inner: Arc::new(Mutex::new(inner)), + }) + } +} + +/// Inner state behind the [`KnownAgents`] mutex. +#[derive(Default)] +struct KnownAgentsInner { + /// Map from host IP to the list of agents running on that host. + agents: HashMap>, +} + +impl KnownAgentsInner { + /// Recomputes and publishes the `known_agents_*` gauges. Called from every mutation of + /// the inner map — we recount rather than incrementally adjust so the gauges stay correct + /// even if a future code path mutates state without going through a dedicated helper. + fn publish_gauges(&self) { + let (total, announced) = + self.agents + .values() + .fold((0i64, 0i64), |(total, announced), agents| { + let t = total + agents.len() as i64; + let a = announced + agents.iter().filter(|a| a.announced).count() as i64; + (t, a) + }); + PROMETHEUS_METRICS.set(PrometheusMetric::KnownAgentsTotal, total); + PROMETHEUS_METRICS.set(PrometheusMetric::KnownAgentsAnnounced, announced); + } +} + +/// Cached state of a single known agent on a particular host. +#[derive(Clone, Copy, Debug)] +pub(crate) struct KnownAgent { + pub(crate) mixnet_port: u16, + pub(crate) last_active_at: OffsetDateTime, + pub(crate) noise_key: x25519::PublicKey, + + /// Whether this agent has been successfully registered in the smart contract. + /// Set to `true` when restored from the chain at startup, or after a successful + /// `/announce` contract transaction. + pub(crate) announced: bool, +} + +/// Coordinates test run assignment and result storage. +/// +/// Wraps the underlying [`NetworkMonitorStorage`] and applies the configured +/// `testrun_staleness_age` when deciding which nodes are eligible for testing. +#[derive(Clone)] +pub(crate) struct TestrunManager { + /// Minimum time that must elapse after a node's last test before it becomes + /// eligible for another one. Passed to the storage layer as a staleness gate. + testrun_staleness_age: Duration, +} + +impl TestrunManager { + /// Selects the most stale idle mixnode and atomically marks it as having a test + /// in progress. Returns `None` if no mixnode is currently eligible. + async fn assign_next_mixnode_testrun( + &self, + storage: &NetworkMonitorStorage, + ) -> Result, ApiError> { + let node_to_test = match storage + .assign_next_mixnode_testrun(self.testrun_staleness_age) + .await + { + Ok(node) => node, + Err(err) => { + error!("testrun assignment storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + }; + + let Some(node) = node_to_test.map(|n| n.inner) else { + return Ok(None); + }; + + let (Some(address), Some(noise_key), Some(sphinx_key), Some(key_rotation)) = ( + node.mixnet_socket_address, + node.noise_key, + node.sphinx_key, + node.key_rotation_id, + ) else { + // this should never happen as the db query should ignore entries where those fields are set to NULL + error!( + "database inconsistency - attempted to assign node {} for stress testing, but we don't have its complete data", + node.node_id + ); + return Err(ApiError::StorageFailure); + }; + + let Ok(node_address) = address.parse() else { + return Err(ApiError::MalformedStoredData); + }; + + let Ok(noise_key) = noise_key.parse() else { + return Err(ApiError::MalformedStoredData); + }; + + let Ok(sphinx_key) = sphinx_key.parse() else { + return Err(ApiError::MalformedStoredData); + }; + + Ok(Some(TestRunAssignment { + node_id: node.node_id as u32, + node_address, + noise_key, + sphinx_key, + key_rotation_id: key_rotation as u32, + })) + } + + /// Persists a completed test run result to the database and updates the + /// node's `last_testrun` pointer. + async fn submit_testrun_result( + &self, + storage: &NetworkMonitorStorage, + result: TestRunResult, + node_id: NodeId, + ) -> Result<(), ApiError> { + // currently all testruns are mixnode results + let testrun = NewTestRun::from_mixnode_result(node_id, result); + if let Err(err) = storage.insert_test_run(&testrun).await { + error!("testrun result storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok(()) + } +} + +/// Shared application state available to all axum request handlers. +#[derive(Clone, FromRef)] +pub(crate) struct AppState { + pub(crate) agents: KnownAgents, + + pub(crate) testrun_manager: TestrunManager, + + pub(crate) storage: NetworkMonitorStorage, + + pub(crate) validator_client: Arc>, +} + +impl AppState { + pub(crate) fn new( + agents: KnownAgents, + storage: NetworkMonitorStorage, + testrun_staleness_age: Duration, + validator_client: Arc>, + ) -> Self { + AppState { + agents, + storage, + testrun_manager: TestrunManager { + testrun_staleness_age, + }, + validator_client, + } + } + + /// Selects the most stale idle mixnode and atomically marks it as having a test + /// in progress. Returns `None` if no mixnode is currently eligible. + pub(crate) async fn assign_next_mixnode_testrun( + &self, + ) -> Result, ApiError> { + self.testrun_manager + .assign_next_mixnode_testrun(&self.storage) + .await + } + + /// Persists a completed test run result to the database and updates the + /// node's `last_testrun` pointer. + pub(crate) async fn submit_testrun_result( + &self, + result: TestRunResult, + node_id: NodeId, + ) -> Result<(), ApiError> { + self.testrun_manager + .submit_testrun_result(&self.storage, result, node_id) + .await + } + + /// Backs `GET /v1/results/testrun/{id}`. `Ok(None)` means the row doesn't + /// exist (the handler maps this to a 404); storage errors are logged and + /// collapsed to [`ApiError::StorageFailure`]. + pub(crate) async fn get_testrun_by_id(&self, id: i64) -> Result, ApiError> { + let result = match self.storage.get_testrun_by_id(id).await { + Err(err) => { + error!("get_testrun_by_id storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok(None) => return Ok(None), + Ok(Some(testrun)) => testrun, + }; + + Ok(Some(result.into())) + } + + /// Backs `GET /v1/results/nym-node/{node_id}`. If the node is known, its + /// snapshot is returned along with the most recent completed test run + /// (fetched in a second query via [`Self::get_testrun_by_id`]); + /// `latest_test_run` is `None` when no such run exists. + /// + /// Malformed stored data (e.g. an unparsable base58 key) is surfaced as + /// [`ApiError::MalformedStoredData`]; this should never happen in practice + /// because the orchestrator writes these fields itself. + pub(crate) async fn get_nym_node_by_id( + &self, + node_id: NodeId, + ) -> Result, ApiError> { + let nym_node = match self.storage.get_nym_node_by_id(node_id).await { + Err(err) => { + error!("get_nym_node_by_id storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok(None) => return Ok(None), + Ok(Some(nym_node)) => nym_node, + }; + + let latest_test_run = match nym_node.last_testrun { + None => None, + Some(testrun_id) => self.get_testrun_by_id(testrun_id).await?, + }; + + Ok(Some(NymNodeWithTestRun { + node: nym_node.try_into().map_err(|err| { + error!("get_nym_node_by_id malformed stored data: {err}"); + ApiError::MalformedStoredData + })?, + latest_test_run, + })) + } + + /// Backs `GET /v1/results/testruns-in-progress`. Returns a page of rows + /// from `testrun_in_progress` ordered oldest `started_at` first so stale + /// runs surface at the top. + pub(crate) async fn get_testruns_in_progress_paginated( + &self, + pagination: Pagination, + ) -> Result, ApiError> { + let (in_progress, total) = match self + .storage + .get_testruns_in_progress_paginated(pagination) + .await + { + Err(err) => { + error!("get_testruns_in_progress_paginated storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok(result) => result, + }; + + Ok(PagedResult { + page: pagination.page(), + per_page: in_progress.len(), + total, + items: in_progress.into_iter().map(Into::into).collect(), + }) + } + + /// Backs `GET /v1/results/testruns`. Returns a single page of completed + /// runs ordered newest first, together with the total row count at the + /// time the page was read (fetched in the same transaction as the page + /// itself for consistency). + pub(crate) async fn get_testruns_paginated( + &self, + pagination: Pagination, + ) -> Result, ApiError> { + let (testruns, total) = match self.storage.get_testruns_paginated(pagination).await { + Err(err) => { + error!("get_testruns_paginated storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok(testruns) => testruns, + }; + + Ok(PagedResult { + page: pagination.page(), + per_page: testruns.len(), + total, + items: testruns.into_iter().map(Into::into).collect(), + }) + } + + /// Backs `GET /v1/results/nym-nodes`. Returns a page of nodes ordered by + /// `node_id` ascending. Each row is converted to [`NymNodeData`] via the + /// fallible `TryFrom` impl that decodes stored base58 keys; a failure + /// anywhere in the page produces [`ApiError::MalformedStoredData`]. + pub(crate) async fn get_nym_nodes_paginated( + &self, + pagination: Pagination, + ) -> Result, ApiError> { + let (nym_nodes, total) = match self.storage.get_nym_nodes_paginated(pagination).await { + Err(err) => { + error!("get_nym_nodes_paginated storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok((nym_nodes, total)) => (nym_nodes, total), + }; + + let mut items = Vec::with_capacity(nym_nodes.len()); + for node in nym_nodes { + items.push(node.try_into().map_err(|err| { + error!("get_nym_nodes_paginated malformed stored data: {err}"); + ApiError::MalformedStoredData + })?); + } + + Ok(PagedResult { + page: pagination.page(), + per_page: items.len(), + total, + items, + }) + } + + /// Backs `GET /v1/results/nym-node/{node_id}/testruns`. Returns a page of + /// completed runs for a single node ordered newest first. Unknown or + /// never-tested nodes produce a valid empty page (`total: 0`) rather than + /// a 404. + pub(crate) async fn get_testruns_for_node_paginated( + &self, + node_id: NodeId, + pagination: Pagination, + ) -> Result, ApiError> { + let (testruns, total) = match self + .storage + .get_testruns_for_node_paginated(node_id, pagination) + .await + { + Err(err) => { + error!("get_testruns_for_node_paginated storage failure: {err}"); + return Err(ApiError::StorageFailure); + } + Ok((testruns, total)) => (testruns, total), + }; + + Ok(PagedResult { + page: pagination.page(), + per_page: testruns.len(), + total, + items: testruns.into_iter().map(Into::into).collect(), + }) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/main.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/main.rs new file mode 100644 index 0000000000..f6a5d993a2 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/main.rs @@ -0,0 +1,50 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::cli::Cli; +use clap::Parser; +use nym_bin_common::bin_info_owned; +use nym_bin_common::logging::tracing_subscriber::layer::SubscriberExt; +use nym_bin_common::logging::tracing_subscriber::util::SubscriberInitExt; +use nym_bin_common::logging::{ + default_tracing_env_filter, default_tracing_fmt_layer, tracing_subscriber, +}; +use nym_network_defaults::setup_env; +use tracing::info; + +pub(crate) mod cli; +mod http; +pub(crate) mod orchestrator; +mod storage; + +fn setup_logger() -> anyhow::Result<()> { + // crates that are more granularly filtered, regardless of default `RUST_LOG` value + let filter_crates = ["reqwest", "hyper"]; + + let mut env_filter = default_tracing_env_filter(); + for crate_name in filter_crates { + env_filter = env_filter.add_directive(format!("{crate_name}=warn").parse()?); + } + + tracing_subscriber::registry() + .with(default_tracing_fmt_layer(std::io::stderr)) + .with(env_filter) + .init(); + + Ok(()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + setup_logger()?; + let cli = Cli::parse(); + setup_env(cli.config_env_file.as_ref()); + + let bin_info = bin_info_owned!(); + info!("using the following version: {bin_info}"); + + cli.execute().await?; + + info!("network monitor orchestrator is done - quitting"); + Ok(()) +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/config.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/config.rs new file mode 100644 index 0000000000..cad15f3692 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/config.rs @@ -0,0 +1,123 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::Context; +use nym_network_defaults::{NymNetworkDetails, env_configured}; +use nym_validator_client::nyxd::AccountId; +use nym_validator_client::{client, nyxd}; +use std::net::SocketAddr; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::time::Duration; +use tracing::info; +use url::Url; + +#[derive(Debug, Clone)] +pub(crate) struct Config { + /// HTTPS RPC URL of a Nyx node (e.g. `https://rpc.nymtech.net`). + /// If not provided, the default value from the environment will be retrieved (if available). + pub(crate) nyxd_rpc_endpoint: Option, + + /// HTTP endpoint of the nym-api to which test results are submitted. + pub(crate) nym_api_endpoint: Url, + + /// HTTP address to bind the HTTP server to (e.g. `0.0.0.0:8080`). + pub(crate) http_server_bind_address: SocketAddr, + + /// How often each node should be stress-tested (e.g. `30m`, `1h`). + pub(crate) test_interval: Duration, + + /// Maximum time a single test run is allowed to run before being considered timed out + /// (e.g. `5m`). + pub(crate) test_timeout: Duration, + + /// Path to the SQLite database file. + pub(crate) database_path: PathBuf, + + /// How often the list of bonded nym-nodes is refreshed from the mixnet contract + /// (e.g. `10m`, `1h`). + pub(crate) node_refresh_rate: Duration, + + /// Timeout for querying a single node for its detailed information (sphinx key, noise key, + /// etc.). Queries that exceed this budget leave the corresponding fields as `NULL` + /// (e.g. `10s`). + pub(crate) node_info_query_timeout: Duration, + + /// Bech32 address of the mixnet contract used to retrieve the list of bonded nodes. + /// If not provided, the default value from the environment will be retrieved (if available). + pub(crate) mixnet_contract_address: Option, + + /// Bech32 address of the networks monitors contract used to authorise agents + /// If not provided, the default value from the environment will be retrieved (if available). + pub(crate) network_monitors_contract_address: Option, + + /// Maximum age of a completed test run row before it is evicted from the local database. + /// Rows older than this are assumed to have already been submitted to the nym-api + /// (e.g. `7d`, `24h`). + pub(crate) testrun_eviction_age: Duration, + + /// Maximum number of nodes queried concurrently during a node refresh cycle. + pub(crate) number_of_concurrent_node_queries: usize, + + /// Maximum number of attempts (including the initial one) made to verify that the + /// orchestrator's account is authorised in the network monitors contract before start-up. + /// The process exits with an error once the budget is exhausted. + pub(crate) chain_authorisation_check_max_attempts: NonZeroU32, + + /// Delay between consecutive chain authorisation checks during start-up. Applied both when + /// the query itself fails and when it succeeds but the orchestrator is not (yet) listed. + pub(crate) chain_authorisation_check_retry_delay: Duration, + + /// How often the orchestrator flushes accumulated test results to the nym-api as a signed + /// batch submission (e.g. `15m`, `1h`). + pub(crate) result_submission_interval: Duration, +} + +impl Config { + /// Builds the validator client configuration from the orchestrator config. + /// Falls back to environment-provided network details when RPC endpoint or + /// contract addresses are not explicitly set. + pub(crate) fn try_build_validator_client_config(&self) -> anyhow::Result { + let mut base_network_details = if env_configured() { + info!("using base NymNetworkDetails from env vars"); + NymNetworkDetails::new_from_env() + } else { + info!("using mainnet as base for NymNetworkDetails"); + NymNetworkDetails::new_mainnet() + }; + + let nyxd_endpoint = if let Some(provided) = &self.nyxd_rpc_endpoint { + provided.clone() + } else { + base_network_details + .endpoints + .first() + .context("no nyxd endpoints provided")? + .nyxd_url + .parse()? + }; + + if let Some(mixnet_contract_address) = &self.mixnet_contract_address { + info!("overwriting mixnet contract address with {mixnet_contract_address}"); + base_network_details.contracts.mixnet_contract_address = + Some(mixnet_contract_address.to_string()); + } + + if let Some(network_monitors_contract_address) = &self.network_monitors_contract_address { + info!( + "overwriting network monitors contract address with {network_monitors_contract_address}" + ); + base_network_details + .contracts + .network_monitors_contract_address = + Some(network_monitors_contract_address.to_string()); + } + + let nyxd_config = nyxd::Config::try_from_nym_network_details(&base_network_details)?; + let client_config = + client::Config::new(nyxd_endpoint, self.nym_api_endpoint.clone(), nyxd_config); + + info!("using the following config: {client_config:#?}"); + Ok(client_config) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/mod.rs new file mode 100644 index 0000000000..fca8d4b62e --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/mod.rs @@ -0,0 +1,336 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::http::api::{build_router, run_http_server}; +use crate::http::state::{AppState, KnownAgents}; +use crate::orchestrator::config::Config; +use crate::orchestrator::node_refresher::NodeRefresher; +use crate::orchestrator::result_submitter::ResultSubmitter; +use crate::orchestrator::stale_results_eviction::StaleResultsEviction; +use crate::storage::NetworkMonitorStorage; +use anyhow::{Context, bail}; +use nym_crypto::asymmetric::ed25519; +use nym_task::ShutdownManager; +use nym_validator_client::DirectSigningHttpRpcValidatorClient; +use nym_validator_client::client::NymApiClientExt; +use nym_validator_client::nyxd::contract_traits::{ + NetworkMonitorsQueryClient, NetworkMonitorsSigningClient, PagedNetworkMonitorsQueryClient, +}; +use nym_validator_client::nyxd::{AccountId, CosmWasmClient, bip39}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tokio::time::sleep; +use tracing::{info, warn}; +use zeroize::Zeroizing; + +pub(crate) mod config; +mod node_refresher; +pub(crate) mod prometheus; +mod result_submitter; +mod stale_results_eviction; +pub(crate) mod testruns; + +pub(crate) struct NetworkMonitorOrchestrator { + /// Runtime configuration for the orchestrator. + pub(crate) config: Config, + + /// Validator client used to: + /// - submit test results to the nym-api + /// - query node information from the chain + /// - send authorisation transactions to the network monitors contract + pub(crate) client: Arc>, + + /// Ed25519 key pair used to sign result submissions to the nym-api. + pub(crate) identity_keys: Arc, + + /// Bearer token presented by agents when requesting work assignments and submitting results. + pub(crate) agents_http_auth_token: Arc>, + + /// Bearer token required when attempting to access the metrics or results endpoints. + pub(crate) metrics_and_results_http_auth_token: Arc>, + + /// Handle to the local SQLite database used to track nodes and test runs. + pub(crate) storage: NetworkMonitorStorage, + + /// Manages graceful shutdown signalling across all orchestrator tasks. + pub(crate) shutdown_manager: ShutdownManager, +} + +impl NetworkMonitorOrchestrator { + /// Initialises the orchestrator: connects to the database, builds the validator client, + /// and verifies that the orchestrator is authorised on both the chain and the nym-api. + pub(crate) async fn new( + config: Config, + identity_keys: Arc, + agents_http_auth_token: Zeroizing, + metrics_and_results_http_auth_token: Zeroizing, + mnemonic: bip39::Mnemonic, + ) -> anyhow::Result { + let storage = NetworkMonitorStorage::init(&config.database_path).await?; + + let client_config = config.try_build_validator_client_config()?; + let client = Arc::new(RwLock::new( + DirectSigningHttpRpcValidatorClient::new_signing(client_config, mnemonic)?, + )); + + let this = NetworkMonitorOrchestrator { + config, + client, + identity_keys, + agents_http_auth_token: Arc::new(agents_http_auth_token), + metrics_and_results_http_auth_token: Arc::new(metrics_and_results_http_auth_token), + storage, + shutdown_manager: ShutdownManager::build_new_default()?, + }; + this.verify_on_chain_balance().await?; + + let announced_identity_key = this.verify_orchestrator_chain_authorisation().await?; + this.reconcile_announced_identity_key(announced_identity_key) + .await?; + this.verify_orchestrator_nym_api_authorisation().await?; + + Ok(this) + } + + /// Returns the on-chain bech32 address of the orchestrator's signing account. + async fn address(&self) -> AccountId { + self.client.read().await.nyxd.address() + } + + /// Ensure the orchestrator has sufficient balance for transaction fees + async fn verify_on_chain_balance(&self) -> anyhow::Result<()> { + let address = self.address().await; + let Some(balance) = self + .client + .read() + .await + .nyxd + .get_balance(&address, "unym".to_string()) + .await? + else { + bail!("the orchestrator does not hold any unym balance"); + }; + if balance.amount < 1_000_000 { + bail!( + "the orchestrator does not hold sufficient amount of tokens. its current balance is {balance}" + ) + } + Ok(()) + } + + /// Verifies that the orchestrator's account is authorised to send transactions to the network + /// monitors contract (i.e. to authorise agents on-chain) and returns the identity key it has + /// previously announced on-chain, if any. + /// + /// Retries both on query failures (RPC flakiness) and on successful queries that don't list + /// this orchestrator - the latter happens routinely when the admin has scheduled an + /// authorisation transaction that hasn't landed yet, so giving it a bounded window to appear + /// avoids crash-looping the process in that race. The total budget is + /// `chain_authorisation_check_max_attempts` attempts spaced by + /// `chain_authorisation_check_retry_delay`; once exhausted the function returns an error and + /// `new()` aborts before any background tasks are spawned. + async fn verify_orchestrator_chain_authorisation(&self) -> anyhow::Result> { + let query_client = self.client.read().await.nyxd.clone_query_client(); + let address = self.address().await; + let max_attempts = self.config.chain_authorisation_check_max_attempts.get(); + let retry_delay = self.config.chain_authorisation_check_retry_delay; + + for attempt in 1..=max_attempts { + match query_client.get_network_monitor_orchestrators().await { + Ok(res) => { + if let Some(entry) = res + .authorised + .into_iter() + .find(|o| o.address.as_str() == address.as_ref()) + { + info!( + "orchestrator {address} is authorised in the network monitors contract" + ); + return Ok(entry.identity_key); + } + warn!( + attempt, + max_attempts, + "orchestrator {address} is not (yet) listed in the network monitors contract" + ); + } + Err(err) => { + warn!( + attempt, + max_attempts, + "failed to query network monitors contract for orchestrator authorisation: {err}" + ); + } + } + + if attempt < max_attempts { + sleep(retry_delay).await; + } + } + + Err(anyhow::anyhow!( + "orchestrator {address} failed to confirm its authorisation in the network monitors contract after {max_attempts} attempts" + )) + } + + /// Ensures the identity key announced on-chain matches the key the orchestrator is running + /// with. If the on-chain key is missing or stale, an update transaction is submitted so that + /// agents and the nym-api can verify signatures against the correct key. + async fn reconcile_announced_identity_key( + &self, + announced: Option, + ) -> anyhow::Result<()> { + let current = self.identity_keys.public_key().to_base58_string(); + + if announced.as_deref() == Some(current.as_str()) { + info!("on-chain announced identity key matches the local identity key"); + return Ok(()); + } + + match &announced { + Some(stale) => info!( + "on-chain announced identity key ({stale}) does not match the local identity key ({current}); submitting an update" + ), + None => info!( + "no identity key currently announced on-chain for this orchestrator; submitting the local one ({current})" + ), + } + + self.client + .write() + .await + .nyxd + .update_orchestrator_identity_key(current, None) + .await + .context( + "failed to announce the orchestrator identity key to the network monitors contract", + )?; + + info!("waiting for the key information to propagate"); + sleep(Duration::from_secs(30)).await; + + Ok(()) + } + + /// Verifies that the orchestrator's identity key is authorised to submit + /// test results to the nym-api. + async fn verify_orchestrator_nym_api_authorisation(&self) -> anyhow::Result<()> { + // ensure our key is authorised to submit test results to the nym-api + let auth_result = self + .client + .read() + .await + .nym_api + .get_known_network_monitor(&self.identity_keys.public_key().to_base58_string()) + .await?; + if !auth_result.authorised { + bail!( + "orchestrator identity key is not authorised to submit test results to the nym-api" + ); + } + Ok(()) + } + + /// Starts all orchestrator background tasks (HTTP server, node refresher, etc.) + /// and blocks until a shutdown signal is received. + pub(crate) async fn run(&mut self) -> anyhow::Result<()> { + // this shouldn't fail as we have no tasks using this client yet + let query_client = self + .client + .try_read() + .context("failed to acquire read lock on client")? + .nyxd + .clone_query_client(); + + // 1. build the shared state + // 1.1. retrieve all registered agents (by this orchestrator) from the contract + // (we assume the orchestrator has restarted and the agents are still out there as authorised) + let address = self.address().await; + let agents = query_client + .get_all_network_monitor_agents() + .await? + .into_iter() + .filter(|a| a.authorised_by.as_str() == address.as_ref()) + .collect::>(); + let agents_state = KnownAgents::try_from(agents)?; + let app_state = AppState::new( + agents_state, + self.storage.clone(), + self.config.test_interval, + self.client.clone(), + ); + + // 2. build node information refresher + let node_refresher = NodeRefresher::new( + &self.config, + query_client, + self.storage.clone(), + self.shutdown_manager.clone_shutdown_token(), + ); + + // 3. build the http server + let http_router = build_router( + app_state, + self.agents_http_auth_token.clone(), + self.metrics_and_results_http_auth_token.clone(), + ); + + // 4. build task for evicting stale test run results + let stale_results_eviction = StaleResultsEviction::new( + self.storage.clone(), + self.config.testrun_eviction_age, + self.config.test_timeout, + self.shutdown_manager.clone_shutdown_token(), + ); + + // 5. build task for submitting accumulated results to the nym-api + let result_submitter = ResultSubmitter::new( + &self.config, + self.client.read().await.nym_api.clone(), + self.storage.clone(), + self.identity_keys.clone(), + self.shutdown_manager.clone_shutdown_token(), + ); + + // 6. evict stale data before starting anything else so any test runs + // left "in progress" by a prior crashed/restarted orchestrator are + // freed up before agents start polling for work. Note: this is a + // blocking call — a hung DB at start-up will prevent the + // orchestrator from serving, which is the desired fail-fast here. + stale_results_eviction + .evict_stale_results() + .await + .context("failed to evict stale data")?; + + // 7. start all the tasks + // http server + let http_server_fut = run_http_server( + http_router, + self.config.http_server_bind_address, + self.shutdown_manager.clone_shutdown_token(), + ); + self.shutdown_manager + .try_spawn_named(http_server_fut, "http-server"); + // node refresher + self.shutdown_manager.try_spawn_named( + async move { + node_refresher.run().await; + }, + "node-refresher", + ); + // stale results eviction + self.shutdown_manager.try_spawn_named( + async move { stale_results_eviction.run().await }, + "stale-data-eviction", + ); + // nym-api result submitter + self.shutdown_manager.try_spawn_named( + async move { result_submitter.run().await }, + "result-submitter", + ); + + self.shutdown_manager.run_until_shutdown().await; + Ok(()) + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/node_refresher.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/node_refresher.rs new file mode 100644 index 0000000000..b11bfbdc4e --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/node_refresher.rs @@ -0,0 +1,243 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::orchestrator::config::Config; +use crate::orchestrator::prometheus::{PROMETHEUS_METRICS, PrometheusMetric}; +use crate::storage::NetworkMonitorStorage; +use crate::storage::models::{NewNymNode, NodeType}; +use anyhow::Context; +use futures::{StreamExt, stream}; +use nym_bin_common::bin_info; +use nym_crypto::asymmetric::x25519; +use nym_network_defaults::DEFAULT_MIX_LISTENING_PORT; +use nym_node_requests::api::client::NymNodeApiClientExt; +use nym_node_requests::api::helpers::NymNodeApiClientRetriever; +use nym_node_requests::api::v1::node::models::NodeRoles; +use nym_task::ShutdownToken; +use nym_validator_client::QueryHttpRpcNyxdClient; +use nym_validator_client::models::KeyRotationId; +use nym_validator_client::nyxd::contract_traits::PagedMixnetQueryClient; +use nym_validator_client::nyxd::nym_mixnet_contract_common::NymNodeBond; +use rand::prelude::SliceRandom; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::time::Duration; +use tokio::time::{Instant, interval}; +use tracing::{error, info, warn}; + +pub(crate) struct NodeRefresher { + pub(crate) client: QueryHttpRpcNyxdClient, + + pub(crate) storage: NetworkMonitorStorage, + + /// How often the list of bonded nym-nodes is refreshed from the mixnet contract + /// (e.g. `10m`, `1h`). + pub(crate) node_refresh_rate: Duration, + + /// Timeout for querying a single node for its detailed information (sphinx key, noise key, + /// etc.). Queries that exceed this budget leave the corresponding fields as `NULL` + /// (e.g. `10s`). + pub(crate) node_info_query_timeout: Duration, + + /// Maximum number of nodes queried concurrently during a node refresh cycle. + pub(crate) number_of_concurrent_node_queries: usize, + + pub(crate) shutdown_token: ShutdownToken, +} + +/// Information about the node retrieved from the node directly +struct SelfDescribedData { + /// Mixnet socket address (host:port) at which the node accepts sphinx packets. + mixnet_socket_address: SocketAddr, + + /// X25519 public key used for Noise handshakes + noise_key: x25519::PublicKey, + + /// Sphinx public key used for packet encryption + sphinx_key: x25519::PublicKey, + + /// Key rotation epoch ID that `sphinx_key` belongs to. + key_rotation_id: KeyRotationId, + + /// The supported roles of the node in the network. + roles: NodeRoles, +} + +impl NodeRefresher { + pub(crate) fn new( + config: &Config, + client: QueryHttpRpcNyxdClient, + storage: NetworkMonitorStorage, + shutdown_token: ShutdownToken, + ) -> Self { + NodeRefresher { + client, + storage, + node_refresh_rate: config.node_refresh_rate, + node_info_query_timeout: config.node_info_query_timeout, + number_of_concurrent_node_queries: config.number_of_concurrent_node_queries, + shutdown_token, + } + } + async fn get_node_details_inner(&self, bond: NymNodeBond) -> anyhow::Result { + let node_id = bond.node_id; + + let client = NymNodeApiClientRetriever::new(bin_info!()) + .with_expected_identity(Some(bond.node.identity_key)) + .with_verify_host_information() + .with_custom_port(bond.node.custom_http_port) + .get_client(&bond.node.host, node_id) + .await?; + + let api_client = client.client; + let host_info = client + .host_information + .context("failed to query node host information")?; + + // retrieve information on the announced ports in case a non-custom mixnet port + // is being used + let aux = api_client.get_auxiliary_details().await?; + + // if the noise key is missing, it means the node is outdated, + // so it does not support stress testing anyway + let noise_key = host_info + .keys + .x25519_versioned_noise + .context("missing noise key")? + .x25519_pubkey; + let sphinx_key = host_info.keys.primary_x25519_sphinx_key.public_key; + let key_rotation_id = host_info.keys.primary_x25519_sphinx_key.rotation_id; + + // pseudorandomly choose which ip address to use - each announced address should work! + let ip_address = host_info + .ip_address + .choose(&mut rand::thread_rng()) + .context("node hasn't announced any IPs")?; + let mix_port = aux + .announce_ports + .mix_port + .unwrap_or(DEFAULT_MIX_LISTENING_PORT); + + // retrieve information about the node roles so that we can classify the node + // (we're not testing gateways yet, but we still store them for completeness) + let roles = api_client + .get_roles() + .await + .context("failed to retrieve node roles")?; + + Ok(SelfDescribedData { + mixnet_socket_address: SocketAddr::new(*ip_address, mix_port), + noise_key, + sphinx_key, + key_rotation_id, + roles, + }) + } + + async fn get_node_details(&self, bond: NymNodeBond, timeout: Duration) -> NewNymNode { + let mut node_update = NewNymNode::from_bond(&bond); + + let node_id = bond.node_id; + let self_described = match tokio::time::timeout(timeout, self.get_node_details_inner(bond)) + .await + { + Err(_timeout) => { + warn!( + "timed out while attempting to retrieve self-described node details for node {node_id}" + ); + return node_update; + } + Ok(Err(err)) => { + error!("failed to retrieve self-described node details for node {node_id}: {err}"); + return node_update; + } + Ok(Ok(info)) => info, + }; + + node_update.mixnet_socket_address = Some(self_described.mixnet_socket_address.to_string()); + node_update.noise_key = Some(self_described.noise_key.to_base58_string()); + node_update.sphinx_key = Some(self_described.sphinx_key.to_base58_string()); + node_update.key_rotation_id = Some(self_described.key_rotation_id as i64); + node_update.node_type = NodeType::from_roles(&self_described.roles); + + node_update + } + + async fn refresh_bonded_nodes(&self) -> anyhow::Result<()> { + let start = Instant::now(); + + // 1. retrieve all nodes from the contract + let nodes = self.client.get_all_nymnode_bonds().await?; + let num_nodes = nodes.len(); + info!("retrieved {num_nodes} bonded nodes from the contract"); + + // 2. retrieve detailed information from the self-described endpoints + let timeout = self.node_info_query_timeout; + let refreshed_nodes: Vec<_> = stream::iter(nodes) + .map(|b| self.get_node_details(b, timeout)) + .buffer_unordered(self.number_of_concurrent_node_queries) + .collect() + .await; + + let mut per_type: HashMap = HashMap::new(); + for node in &refreshed_nodes { + *per_type.entry(node.node_type).or_insert(0) += 1; + } + let count_of = |t: NodeType| per_type.get(&t).copied().unwrap_or(0); + let unknown = count_of(NodeType::Unknown); + let successful = (refreshed_nodes.len() as i64) - unknown; + info!("managed to retrieve full node information on {successful} nodes ({unknown} failed)"); + + PROMETHEUS_METRICS.set( + PrometheusMetric::BondedMixnodeNymNodes, + count_of(NodeType::Mixnode), + ); + PROMETHEUS_METRICS.set( + PrometheusMetric::BondedGatewayNymNodes, + count_of(NodeType::Gateway), + ); + PROMETHEUS_METRICS.set( + PrometheusMetric::BondedMixnodeAndGatewayNymNodes, + count_of(NodeType::MixnodeAndGateway), + ); + PROMETHEUS_METRICS.set(PrometheusMetric::BondedUnknownNymNodes, unknown); + PROMETHEUS_METRICS.set(PrometheusMetric::SuccessfulNymNodeDataRetrieval, successful); + PROMETHEUS_METRICS.set(PrometheusMetric::FailedNymNodeDataRetrieval, unknown); + + // 3. persist every node (including unreachable ones so we keep their + // previously-learned keys around for the next refresh). The testrun + // assignment query filters out non-mixnode / unknown entries. + self.storage + .batch_insert_or_update_nym_nodes(&refreshed_nodes) + .await?; + + // Observe the cycle duration last so it reflects the full refresh path + // (contract query + per-node queries + storage write). + PROMETHEUS_METRICS.observe_histogram( + PrometheusMetric::NodeRefreshCycleSeconds, + start.elapsed().as_secs_f64(), + ); + Ok(()) + } + + pub(crate) async fn run(&self) { + let mut interval = interval(self.node_refresh_rate); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + biased; + _ = self.shutdown_token.cancelled() => { + break + } + _ = interval.tick() => { + if let Err(err) = self.refresh_bonded_nodes().await { + error!("failed to refresh bonded nodes: {err}"); + } + } + } + } + + info!("node refresher stopped"); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/prometheus.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/prometheus.rs new file mode 100644 index 0000000000..f48829f731 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/prometheus.rs @@ -0,0 +1,437 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Prometheus metrics exposed by the orchestrator. +//! +//! Every series this service emits is declared up-front as a variant of [`PrometheusMetric`]. +//! Each variant carries its Prometheus help string via strum's `EnumProperty` attribute and is +//! mapped to a concrete counter / gauge / histogram in [`PrometheusMetric::to_registrable_metric`]. +//! Call sites emit values through the process-wide [`PROMETHEUS_METRICS`] handle, which forwards +//! to the underlying [`nym_metrics`] registry. +//! +//! The registry is pre-populated in [`NetworkMonitorPrometheusMetrics::initialise`] so that every +//! series is present (with a zero value) from the very first scrape — this avoids dashboards and +//! alerts interpreting the first observation as a reset. + +use nym_metrics::{HistogramTimer, Metric, metrics_registry}; +use std::sync::LazyLock; +use strum::{Display, EnumCount, EnumIter, EnumProperty, IntoEnumIterator}; + +/// Process-wide handle to the orchestrator's Prometheus metrics. Lazily initialised on first +/// access; the initialisation pre-registers every [`PrometheusMetric`] variant so that scrapes +/// observe a complete set of zeroed series even before any event has fired. +pub static PROMETHEUS_METRICS: LazyLock = + LazyLock::new(NetworkMonitorPrometheusMetrics::initialise); + +/// Histogram buckets (upper bounds, in seconds) for [`PrometheusMetric::TestDurationSeconds`]. +/// Densely spaced in the 40–60 s range because most completed runs cluster there and small +/// shifts in that band are the most interesting signal. +const TESTRUN_DURATION: &[f64] = &[ + // sub 5s (implicitly) + 5., // 5s - 15s + 15., // 15s - 30s + 30., // 30s - 40s + 40., // 40s - 45s + 45., // 45s - 50s + 50., // 50s - 55s + 55., // 55s - 60s + 60., // 1min - 2min + 120., // 2min - 5min + 300., // 5min+ (implicitly) +]; + +/// Histogram buckets (upper bounds, in milliseconds) for +/// [`PrometheusMetric::ApproximateNodeLatencyMs`]. Log-ish spacing from 1 ms up to 1 s — typical +/// mixnet latencies are well under 500 ms and anything past 1 s lands in the overflow bucket. +const NODE_LATENCY: &[f64] = &[ + // sub 1ms (implicitly) + 1., // 1ms - 5ms + 5., // 5ms - 10ms + 10., // 10ms - 20ms + 20., // 20ms - 50ms + 50., // 50ms - 100ms + 100., // 100ms - 200ms + 200., // 200ms - 500ms + 500., // 500ms - 1s + 1000., // 1s+ (implicitly) +]; + +/// Histogram buckets for [`PrometheusMetric::TestrunReceivedPacketsRatio`] — `received / sent`, +/// so values live in `[0, 1]`. The dedicated `<= 0.0` bucket isolates the "got nothing" case from +/// "got a few", which otherwise would all collapse into a single low bucket; upper buckets are +/// dense near 1.0 because the difference between 99% and 95% delivery is operationally significant. +const RECEIVED_PACKETS_RATIO: &[f64] = &[ + 0., // 0 - 0.1 + 0.1, // 0.1 - 0.2 + 0.2, // 0.2 - 0.3 + 0.3, // 0.3 - 0.4 + 0.4, // 0.4 - 0.5 + 0.5, // 0.5 - 0.6 + 0.6, // 0.6 - 0.7 + 0.7, // 0.7 - 0.8 + 0.8, // 0.8 - 0.9 + 0.9, // 0.9 - 0.95 + 0.95, // 0.95 - 0.98 + 0.98, // 0.98 - 0.99 + 0.99, // 0.99+ (implicitly) +]; + +/// Histogram buckets (upper bounds, in seconds) for +/// [`PrometheusMetric::NodeRefreshCycleSeconds`]. Shape targets the expected range of a single +/// refresh sweep: under a minute when everything's healthy, up to ~10 min in degraded cases +/// (large topology × per-node timeouts × limited concurrency). +const NODE_REFRESH_CYCLE: &[f64] = &[ + // sub 1s (implicitly) + 1., // 1s - 5s + 5., // 5s - 10s + 10., // 10s - 30s + 30., // 30s - 60s + 60., // 1min - 2min + 120., // 2min - 5min + 300., // 5min - 10min + 600., // 10min+ (implicitly) +]; + +/// Histogram buckets (upper bounds, in milliseconds) for +/// [`PrometheusMetric::AverageTestPacketRTTMs`]. Same shape as [`NODE_LATENCY`] — this is the +/// mean per-packet round trip over a single testrun, not the approximation used for node latency. +const AVG_PACKET_RTT: &[f64] = &[ + // sub 1ms (implicitly) + 1., // 1ms - 5ms + 5., // 5ms - 10ms + 10., // 10ms - 20ms + 20., // 20ms - 50ms + 50., // 50ms - 100ms + 100., // 100ms - 200ms + 200., // 200ms - 500ms + 500., // 500ms - 1s + 1000., // 1s+ (implicitly) +]; + +/// Every Prometheus series emitted by the orchestrator. Each variant maps to exactly one metric +/// and must carry a `help` strum property — this is verified by the `every_variant_has_help_property` +/// test. +/// +/// The Prometheus metric name is derived from the variant name via strum: `serialize_all = +/// "snake_case"` + the `nym_network_monitor_` prefix. So `MixPortRequests` becomes +/// `nym_network_monitor_mix_port_requests`. The concrete metric kind (counter / gauge / histogram, +/// plus bucket bounds) is chosen in [`PrometheusMetric::to_registrable_metric`]. +#[derive(Clone, Debug, EnumIter, Display, EnumProperty, EnumCount, Eq, Hash, PartialEq)] +#[strum(serialize_all = "snake_case", prefix = "nym_network_monitor_")] +pub enum PrometheusMetric { + #[strum(props( + help = "The number of requests to announce an agent to the network monitors contract" + ))] + AgentAnnounceRequests, + + #[strum(props( + help = "The number of duplicate requests to announce an agent to the network monitors contract (agent has already been announced before)" + ))] + AgentDuplicateAnnouncementRequests, + + #[strum(props( + help = "The number of successful announcements of an agent to the network monitors contract" + ))] + AgentContractAnnounceSuccesses, + + #[strum(props( + help = "The number of failed announcements of an agent to the network monitors contract" + ))] + AgentContractAnnounceFailures, + + #[strum(props(help = "The number of requests to assign a test run to an agent"))] + AgentTestrunRequests, + + #[strum(props( + help = "The number of requests to assign a test run to an agent that was not known to the orchestrator" + ))] + AgentUnknownAgentTestrunRequests, + + #[strum(props( + help = "The number of requests to assign a test run to an agent that was not announced to the network monitors contract" + ))] + AgentTestrunRequestsWithoutAnnouncement, + + #[strum(props( + help = "The number of testrun requests that resulted in no work being assigned" + ))] + EmptyTestrunAssignments, + + #[strum(props(help = "The number of testrun requests that resulted in work being assigned"))] + NonEmptyTestrunAssignments, + + #[strum(props(help = "The number of testrun results that were submitted by agents"))] + TestRunResultSubmissions, + + #[strum(props(help = "The number of stale testruns that were evicted from the storage"))] + StaleTestrunsEvicted, + + #[strum(props( + help = "The number of testruns in progress that timed out and were evicted from the queue and the storage" + ))] + TimedOutTestrunsEvicted, + + #[strum(props(help = "The duration of a test run, in seconds"))] + TestDurationSeconds, + + #[strum(props(help = "The number of testruns that resulted in errors"))] + TestrunsErrors, + + #[strum(props(help = "The approximate latency to a node in milliseconds"))] + ApproximateNodeLatencyMs, + + #[strum(props( + help = "Ratio of packets sent to packets received in a testrun (received / sent)" + ))] + TestrunReceivedPacketsRatio, + + #[strum(props( + help = "The average time it took to receive a test packet back from a node under test" + ))] + AverageTestPacketRTTMs, + + #[strum(props( + help = "The number of bonded nodes classified as mixnode-only from their self-described roles" + ))] + BondedMixnodeNymNodes, + + #[strum(props( + help = "The number of bonded nodes classified as gateway-only from their self-described roles" + ))] + BondedGatewayNymNodes, + + #[strum(props(help = "The number of bonded nodes advertising both mixnode and gateway roles"))] + BondedMixnodeAndGatewayNymNodes, + + #[strum(props( + help = "The number of bonded nodes whose self-described role could not be determined (unreachable or no roles reported)" + ))] + BondedUnknownNymNodes, + + #[strum(props( + help = "The number of successful Nym node data retrievals from self-described endpoints" + ))] + SuccessfulNymNodeDataRetrieval, + + #[strum(props( + help = "The number of failed Nym node data retrievals from self-described endpoints" + ))] + FailedNymNodeDataRetrieval, + + #[strum(props(help = "The duration of a full bonded-node refresh cycle, in seconds"))] + NodeRefreshCycleSeconds, + + #[strum(props( + help = "The number of test runs currently in progress (rows in testrun_in_progress)" + ))] + TestrunsInProgress, + + #[strum(props(help = "The total number of agents known to the orchestrator"))] + KnownAgentsTotal, + + #[strum(props( + help = "The number of known agents that have been announced to the network monitors contract" + ))] + KnownAgentsAnnounced, + + #[strum(props( + help = "The total number of test packets dispatched across all submitted testruns" + ))] + TestPacketsSent, + + #[strum(props( + help = "The total number of test packets received back across all submitted testruns" + ))] + TestPacketsReceived, +} + +impl PrometheusMetric { + fn name(&self) -> String { + self.to_string() + } + + fn help(&self) -> &'static str { + // SAFETY: every variant has a `help` prop defined (and there's a unit test is checking for that) + #[allow(clippy::unwrap_used)] + self.get_str("help").unwrap() + } + + /// Builds the concrete [`Metric`] this variant should register as (counter / gauge / histogram + /// with the right bucket bounds). Called from [`NetworkMonitorPrometheusMetrics::initialise`] + /// to pre-populate the registry, and from the `set` / `observe_histogram` fallback paths to + /// lazily register a metric that somehow wasn't set up yet. + fn to_registrable_metric(&self) -> Option { + let name = self.name(); + let help = self.help(); + + match self { + PrometheusMetric::AgentAnnounceRequests => Metric::new_int_counter(&name, help), + PrometheusMetric::AgentDuplicateAnnouncementRequests => { + Metric::new_int_counter(&name, help) + } + PrometheusMetric::AgentContractAnnounceSuccesses => { + Metric::new_int_counter(&name, help) + } + PrometheusMetric::AgentContractAnnounceFailures => Metric::new_int_counter(&name, help), + PrometheusMetric::AgentTestrunRequests => Metric::new_int_counter(&name, help), + PrometheusMetric::AgentUnknownAgentTestrunRequests => { + Metric::new_int_counter(&name, help) + } + PrometheusMetric::AgentTestrunRequestsWithoutAnnouncement => { + Metric::new_int_counter(&name, help) + } + PrometheusMetric::EmptyTestrunAssignments => Metric::new_int_counter(&name, help), + PrometheusMetric::NonEmptyTestrunAssignments => Metric::new_int_counter(&name, help), + PrometheusMetric::TestRunResultSubmissions => Metric::new_int_counter(&name, help), + PrometheusMetric::StaleTestrunsEvicted => Metric::new_int_counter(&name, help), + PrometheusMetric::TimedOutTestrunsEvicted => Metric::new_int_counter(&name, help), + PrometheusMetric::TestDurationSeconds => { + Metric::new_histogram(&name, help, Some(TESTRUN_DURATION)) + } + PrometheusMetric::TestrunsErrors => Metric::new_int_counter(&name, help), + PrometheusMetric::ApproximateNodeLatencyMs => { + Metric::new_histogram(&name, help, Some(NODE_LATENCY)) + } + PrometheusMetric::TestrunReceivedPacketsRatio => { + Metric::new_histogram(&name, help, Some(RECEIVED_PACKETS_RATIO)) + } + PrometheusMetric::AverageTestPacketRTTMs => { + Metric::new_histogram(&name, help, Some(AVG_PACKET_RTT)) + } + PrometheusMetric::BondedMixnodeNymNodes => Metric::new_int_gauge(&name, help), + PrometheusMetric::BondedGatewayNymNodes => Metric::new_int_gauge(&name, help), + PrometheusMetric::BondedMixnodeAndGatewayNymNodes => Metric::new_int_gauge(&name, help), + PrometheusMetric::BondedUnknownNymNodes => Metric::new_int_gauge(&name, help), + PrometheusMetric::SuccessfulNymNodeDataRetrieval => Metric::new_int_gauge(&name, help), + PrometheusMetric::FailedNymNodeDataRetrieval => Metric::new_int_gauge(&name, help), + PrometheusMetric::NodeRefreshCycleSeconds => { + Metric::new_histogram(&name, help, Some(NODE_REFRESH_CYCLE)) + } + PrometheusMetric::TestrunsInProgress => Metric::new_int_gauge(&name, help), + PrometheusMetric::KnownAgentsTotal => Metric::new_int_gauge(&name, help), + PrometheusMetric::KnownAgentsAnnounced => Metric::new_int_gauge(&name, help), + PrometheusMetric::TestPacketsSent => Metric::new_int_counter(&name, help), + PrometheusMetric::TestPacketsReceived => Metric::new_int_counter(&name, help), + } + } + + /// Sets the gauge to `value`. If the metric has not yet been registered (shouldn't happen after + /// `initialise`, but we're defensive), falls back to registering it first and retrying. + fn set(&self, value: i64) { + let reg = metrics_registry(); + if !reg.set(&self.name(), value) + && let Some(registrable) = self.to_registrable_metric() + { + reg.register_metric(registrable); + reg.set(&self.name(), value); + } + } + + fn set_float(&self, value: f64) { + metrics_registry().set_float(&self.name(), value); + } + + fn inc(&self) { + metrics_registry().inc(&self.name()); + } + + fn inc_by(&self, value: i64) { + metrics_registry().inc_by(&self.name(), value); + } + + /// Records `value` into the histogram. Same register-on-miss fallback as [`Self::set`]. + fn observe_histogram(&self, value: f64) { + let reg = metrics_registry(); + if !reg.add_to_histogram(&self.name(), value) + && let Some(registrable) = self.to_registrable_metric() + { + reg.register_metric(registrable); + reg.add_to_histogram(&self.name(), value); + } + } + + fn start_timer(&self) -> Option { + metrics_registry().start_timer(&self.name()) + } +} + +/// Orchestrator-side handle to the process-wide Prometheus registry. Constructed once via +/// [`Self::initialise`] (held in the [`PROMETHEUS_METRICS`] static) and used from call sites to +/// emit values against the [`PrometheusMetric`] enum. All mutating methods are thin wrappers +/// around the corresponding methods on [`PrometheusMetric`]. +#[non_exhaustive] +pub struct NetworkMonitorPrometheusMetrics { + _private: (), +} + +impl NetworkMonitorPrometheusMetrics { + /// Pre-registers every [`PrometheusMetric`] variant in the shared registry so that the very + /// first scrape after startup already returns the full set of series with zero values. + /// Without this, series only appear after their first observation, which can make dashboards + /// and alerting rules misbehave (missing series vs. zeroed series are not the same signal). + pub(crate) fn initialise() -> Self { + let registry = metrics_registry(); + + // we can't initialise complex metrics as their names will only be fully known at runtime + for kind in PrometheusMetric::iter() { + if let Some(metric) = kind.to_registrable_metric() { + registry.register_metric(metric); + } + } + + NetworkMonitorPrometheusMetrics { _private: () } + } + + /// Renders the full registry in the Prometheus text exposition format — this is what the + /// `/v1/metrics/prometheus` scrape endpoint returns. + pub fn metrics(&self) -> String { + metrics_registry().to_string() + } + + pub fn set(&self, metric: PrometheusMetric, value: i64) { + metric.set(value) + } + + #[allow(dead_code)] + pub fn set_float(&self, metric: PrometheusMetric, value: f64) { + metric.set_float(value) + } + + pub fn inc(&self, metric: PrometheusMetric) { + metric.inc() + } + + pub fn inc_by(&self, metric: PrometheusMetric, value: i64) { + metric.inc_by(value) + } + + pub fn observe_histogram(&self, metric: PrometheusMetric, value: f64) { + metric.observe_histogram(value) + } + + #[allow(dead_code)] + pub fn start_timer(&self, metric: PrometheusMetric) -> Option { + metric.start_timer() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn prometheus_metrics() { + // a sanity check for anyone adding new metrics. if this test fails, + // make sure any methods on `PrometheusMetric` enum don't need updating + // or require custom Display impl + assert_eq!(29, PrometheusMetric::COUNT) + } + + #[test] + fn every_variant_has_help_property() { + for variant in PrometheusMetric::iter() { + assert!(variant.get_str("help").is_some()) + } + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/result_submitter.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/result_submitter.rs new file mode 100644 index 0000000000..fbb97de233 --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/result_submitter.rs @@ -0,0 +1,141 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::orchestrator::config::Config; +use crate::storage::NetworkMonitorStorage; +use anyhow::Context; +use nym_crypto::asymmetric::ed25519; +use nym_node_requests::api::Client; +use nym_task::ShutdownToken; +use nym_validator_client::models::{StressTestBatchSubmissionContent, StressTestResult}; +use nym_validator_client::nym_api::NymApiClientExt; +use nym_validator_client::signable::SignableMessageBody; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::{Instant, MissedTickBehavior, interval_at}; +use tracing::{debug, info}; + +/// Background task that periodically drains freshly-completed test run results from the local +/// storage, wraps them into a signed [`StressTestBatchSubmission`][batch], and POSTs the batch to +/// the nym-api. +/// +/// Results are kept in local storage (and subject to the `testrun_eviction_age` retention window) +/// so that a transient nym-api outage or a crashed orchestrator doesn't silently lose +/// measurements - the next successful submission sweep will pick up anything that was missed. +/// +/// [batch]: nym_api_requests::models::network_monitor::StressTestBatchSubmission +pub(crate) struct ResultSubmitter { + /// Nym-api client used to reach the api endpoint that accepts stress-test batches. + client: Client, + + /// Handle to the local SQLite database from which pending results are drained. + storage: NetworkMonitorStorage, + + /// Ed25519 key pair whose private half signs each batch submission and whose public half + /// is the `signer` nym-api validates against the authorised-monitors set. + identity_keys: Arc, + + /// Cadence at which [`Self::run`] attempts a submission sweep. + submission_interval: Duration, + + shutdown_token: ShutdownToken, +} + +impl ResultSubmitter { + pub(crate) fn new( + config: &Config, + client: Client, + storage: NetworkMonitorStorage, + identity_keys: Arc, + shutdown_token: ShutdownToken, + ) -> Self { + ResultSubmitter { + client, + storage, + identity_keys, + submission_interval: config.result_submission_interval, + shutdown_token, + } + } + + /// Perform a single submission sweep: read every `testrun` row produced since the last + /// acknowledged batch, wrap them into a signed [`StressTestBatchSubmission`][batch], POST the + /// batch to the nym-api, and - only on success - advance the `last_submitted_testrun_id` + /// watermark. + /// + /// No-ops silently when there is nothing new to submit. + /// + /// The watermark is intentionally advanced **after** the POST returns `Ok`. A crash or + /// network failure between these two steps re-sends the same rows under a fresh batch + /// timestamp on the next sweep - harmless because nym-api's replay protection is batch-level + /// (it rejects stale/duplicate batches, not re-seen row contents) and duplicate inserts at + /// the row level are rare and tolerable. This bias towards at-least-once delivery is + /// deliberate: losing measurements is worse than occasionally duplicating them. + /// + /// [batch]: nym_api_requests::models::network_monitor::StressTestBatchSubmission + async fn submit_pending_results(&self) -> anyhow::Result<()> { + info!("submitting stress-test results to nym-api"); + let last_submitted = self.storage.get_last_submitted_testrun_id().await?; + // `None` means "never submitted" - treat as 0, which pulls everything currently in the + // table (testrun.id is AUTOINCREMENT, so always >= 1). + let after_id = last_submitted.unwrap_or(0); + + let pending = self.storage.get_testruns_after(after_id).await?; + if pending.is_empty() { + debug!("stress-test result submission sweep: no new results"); + return Ok(()); + } + + // `get_testruns_after` returns rows ordered by id ASC, so the last row carries the + // highest id and is what we advance the watermark to once the batch is accepted. + #[allow(clippy::expect_used)] + let max_id = pending.last().expect("pending is non-empty").id; + let batch_size = pending.len(); + + let results: Vec = pending.into_iter().map(Into::into).collect(); + + let signer = *self.identity_keys.public_key(); + let body = StressTestBatchSubmissionContent::new(signer, results); + let signed = body.sign(self.identity_keys.private_key()); + + self.client + .submit_stress_testing_results(&signed) + .await + .context("failed to POST stress-test batch submission to nym-api")?; + + self.storage.set_last_submitted_testrun_id(max_id).await?; + info!("submitted {batch_size} stress-test results to nym-api (testrun ids up to {max_id})"); + Ok(()) + } + + /// Run the submission loop until the shutdown token is cancelled. + /// + /// The first tick is deliberately offset by `submission_interval` so the orchestrator has + /// time to finish start-up reconciliation (chain authorisation check, etc.) before the first + /// submission is attempted. `MissedTickBehavior::Delay` avoids burst catch-up ticks if a + /// sweep runs long under DB or network pressure. + pub(crate) async fn run(&self) { + let mut interval = interval_at( + Instant::now() + self.submission_interval, + self.submission_interval, + ); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + biased; + _ = self.shutdown_token.cancelled() => break, + _ = interval.tick() => { + if let Err(err) = self.submit_pending_results().await { + // Submission errors shouldn't kill the task - local storage retains the + // pending rows until the retention window expires, so the next tick will + // retry and eventually catch up once the nym-api is reachable again. + tracing::error!("failed to submit stress-test results: {err}"); + } + } + } + } + + info!("stress-test result submitter stopped"); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/stale_results_eviction.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/stale_results_eviction.rs new file mode 100644 index 0000000000..eebd7a6b7f --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/stale_results_eviction.rs @@ -0,0 +1,146 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::orchestrator::prometheus::{PROMETHEUS_METRICS, PrometheusMetric}; +use crate::storage::NetworkMonitorStorage; +use nym_task::ShutdownToken; +use std::time::Duration; +use tokio::time::{Instant, MissedTickBehavior, interval_at}; +use tracing::{debug, error, info}; + +/// Background task that periodically purges stale data from the storage. +/// +/// Two distinct kinds of staleness are handled: +/// - in-progress test runs whose assigned agent has gone silent past +/// `test_timeout` (freed so they can be reassigned), +/// - finalised test runs older than `testrun_eviction_age` (dropped to keep +/// the results table bounded). +/// +/// The two deletions are deliberately issued as separate statements rather +/// than wrapped in a transaction: they touch disjoint tables, a partial +/// failure is self-healing on the next tick, and keeping them independent +/// avoids holding a write lock across both for the whole sweep. +pub(crate) struct StaleResultsEviction { + storage: NetworkMonitorStorage, + + /// Age past which a finalised test run is considered stale and removed. + /// Mirrors `Config::testrun_eviction_age`. + testrun_eviction_age: Duration, + + /// Maximum time a test run may remain "in progress" before we assume the + /// assigned agent has died and free the slot for reassignment. + /// Mirrors `Config::test_timeout`. + test_timeout: Duration, + + /// Cadence at which [`Self::run`] performs an eviction sweep. + check_interval: Duration, + + shutdown_token: ShutdownToken, +} + +/// Lower bound on the sweep cadence to avoid hammering the DB (or panicking +/// `interval_at`) when either timeout is configured to an unrealistically +/// small value. +const MIN_CHECK_INTERVAL: Duration = Duration::from_secs(60); + +impl StaleResultsEviction { + pub(crate) fn new( + storage: NetworkMonitorStorage, + testrun_eviction_age: Duration, + test_timeout: Duration, + shutdown_token: ShutdownToken, + ) -> Self { + // Sweep at least twice per shortest timeout window so the worst-case + // lag between an item going stale and being evicted is bounded by + // roughly 1.5x that timeout rather than 2x. Floored at + // `MIN_CHECK_INTERVAL` to stay safe under degenerate configs. + let check_interval = Duration::max( + MIN_CHECK_INTERVAL, + Duration::min(testrun_eviction_age, test_timeout) / 2, + ); + + Self { + storage, + testrun_eviction_age, + test_timeout, + check_interval, + shutdown_token, + } + } + + /// Performs a single eviction sweep: clears timed-out in-progress test + /// runs and deletes results older than the configured retention window. + /// Logs how many rows were affected so ops can confirm the task is doing + /// real work (and spot unexpected spikes). + pub(crate) async fn evict_stale_results(&self) -> anyhow::Result<()> { + let cleared_in_progress = self + .storage + .clear_timed_out_testruns_in_progress(self.test_timeout) + .await?; + let evicted_old = self + .storage + .evict_old_testruns(self.testrun_eviction_age) + .await?; + + if cleared_in_progress > 0 || evicted_old > 0 { + PROMETHEUS_METRICS.inc_by( + PrometheusMetric::TimedOutTestrunsEvicted, + cleared_in_progress as i64, + ); + PROMETHEUS_METRICS.inc_by(PrometheusMetric::StaleTestrunsEvicted, evicted_old as i64); + + info!( + cleared_in_progress, + evicted_old, "stale data eviction sweep completed" + ); + } else { + debug!("stale data eviction sweep completed: nothing to evict"); + } + + // Reconcile the in-flight gauge against the authoritative row count. The gauge is + // primarily maintained live via inc/dec at assign/submit/timeout paths; this sweep is + // a safety net that corrects any drift (e.g. from a future code path that forgets to + // update the gauge) and bounds the worst-case staleness to one sweep interval. + match self.storage.count_testruns_in_progress().await { + Ok(count) => PROMETHEUS_METRICS.set(PrometheusMetric::TestrunsInProgress, count), + Err(err) => error!("failed to count in-flight testruns for metric: {err}"), + } + Ok(()) + } + + /// Runs the eviction loop until the shutdown token is cancelled. + /// + /// Cancellation is cooperative: it is only observed between sweeps, so a + /// sweep already in flight is allowed to finish. This keeps partial + /// deletions from being left on the floor at shutdown. + /// + /// The first tick is deliberately offset by `check_interval` because the + /// orchestrator invokes [`Self::evict_stale_results`] once during start-up + /// (to reap anything left behind by a prior crash or restart), so an + /// immediate tick here would redo that work. + /// + /// `MissedTickBehavior::Delay` prevents burst catch-up ticks when a sweep + /// runs long under DB load — otherwise a slow sweep would queue multiple + /// back-to-back ticks and amplify the pressure that made it slow in the + /// first place. + pub(crate) async fn run(&self) { + let mut interval = interval_at(Instant::now() + self.check_interval, self.check_interval); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + biased; + _ = self.shutdown_token.cancelled() => break, + _ = interval.tick() => { + if let Err(err) = self.evict_stale_results().await { + // Transient storage errors shouldn't kill the task — the next + // tick will retry and any missed items simply age a bit longer. + error!("failed to evict stale results: {err}"); + } + } + } + } + + info!("stale results eviction stopped"); + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/testruns.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/testruns.rs new file mode 100644 index 0000000000..bdc05451cb --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/orchestrator/testruns.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/manager.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/manager.rs new file mode 100644 index 0000000000..bcb479e1fd --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/manager.rs @@ -0,0 +1,1540 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::storage::models::{NewNymNode, NewTestRun, NymNode, TestRun, TestRunInProgress}; +use time::OffsetDateTime; + +#[derive(Clone)] +pub(crate) struct StorageManager { + pub(crate) connection_pool: sqlx::SqlitePool, +} + +impl StorageManager { + /// Inserts or updates multiple node records in a single transaction. + /// + /// For each node, if a row with the same `node_id` already exists, all fields except + /// `identity_key` are updated — `identity_key` is intentionally left unchanged because + /// a given `node_id` always corresponds to exactly one identity key and is never reassigned. + /// + /// Wrapping the entire batch in one transaction means SQLite performs a single WAL sync + /// rather than one per row. + pub(crate) async fn batch_insert_or_update_nym_nodes( + &self, + nodes: &[NewNymNode], + ) -> anyhow::Result<()> { + let mut tx = self.connection_pool.begin().await?; + + for node in nodes { + sqlx::query!( + r#" + INSERT INTO nym_node ( + node_id, + identity_key, + last_seen_bonded, + mixnet_socket_address, + noise_key, + sphinx_key, + key_rotation_id, + node_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (node_id) DO UPDATE SET + last_seen_bonded = excluded.last_seen_bonded, + mixnet_socket_address = excluded.mixnet_socket_address, + noise_key = excluded.noise_key, + sphinx_key = excluded.sphinx_key, + key_rotation_id = excluded.key_rotation_id, + node_type = excluded.node_type + "#, + node.node_id, + node.identity_key, + node.last_seen_bonded, + node.mixnet_socket_address, + node.noise_key, + node.sphinx_key, + node.key_rotation_id, + node.node_type, + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + /// Inserts a completed test run and returns the auto-assigned row ID. + pub(crate) async fn insert_test_run(&self, run: &NewTestRun) -> anyhow::Result { + let id = sqlx::query!( + r#" + INSERT INTO testrun ( + node_id, + test_type, + test_timestamp, + time_taken_us, + ingress_noise_handshake_us, + egress_noise_handshake_us, + sphinx_packet_delay_us, + packets_sent, + packets_received, + approximate_latency_us, + packets_rtt_min_us, + packets_rtt_mean_us, + packets_rtt_median_us, + packets_rtt_max_us, + packets_rtt_std_dev_us, + sending_latency_min_us, + sending_latency_mean_us, + sending_latency_median_us, + sending_latency_max_us, + sending_latency_std_dev_us, + received_duplicates, + error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + run.node_id, + run.test_type, + run.test_timestamp, + run.time_taken_us, + run.ingress_noise_handshake_us, + run.egress_noise_handshake_us, + run.sphinx_packet_delay_us, + run.packets_sent, + run.packets_received, + run.approximate_latency_us, + run.packets_rtt_min_us, + run.packets_rtt_mean_us, + run.packets_rtt_median_us, + run.packets_rtt_max_us, + run.packets_rtt_std_dev_us, + run.sending_latency_min_us, + run.sending_latency_mean_us, + run.sending_latency_median_us, + run.sending_latency_max_us, + run.sending_latency_std_dev_us, + run.received_duplicates, + run.error, + ) + .execute(&self.connection_pool) + .await? + .last_insert_rowid(); + Ok(id) + } + + /// Marks a node as having a test run in progress by inserting into `testrun_in_progress`. + /// Returns an error if the node already has a run in progress (PRIMARY KEY conflict). + #[cfg(test)] + pub(crate) async fn mark_testrun_in_progress( + &self, + node_id: i64, + started_at: OffsetDateTime, + ) -> anyhow::Result<()> { + sqlx::query!( + "INSERT INTO testrun_in_progress (node_id, started_at) VALUES (?, ?)", + node_id, + started_at, + ) + .execute(&self.connection_pool) + .await?; + Ok(()) + } + + /// Removes all in-progress markers with a `started_at` older than `cutoff`, on the + /// assumption that those runs have timed out and will never complete. + pub(crate) async fn clear_timed_out_testruns_in_progress( + &self, + cutoff: OffsetDateTime, + ) -> anyhow::Result { + let res = sqlx::query!( + "DELETE FROM testrun_in_progress WHERE started_at < ?", + cutoff, + ) + .execute(&self.connection_pool) + .await?; + Ok(res.rows_affected()) + } + + /// Returns the number of rows currently in `testrun_in_progress` — i.e. the number of + /// test runs that have been assigned to an agent but not yet submitted back. + pub(crate) async fn count_testruns_in_progress(&self) -> anyhow::Result { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM testrun_in_progress") + .fetch_one(&self.connection_pool) + .await?; + Ok(total) + } + + /// Removes the in-progress marker for a node once its test run has completed or been abandoned. + /// Returns the number of rows actually deleted — callers use this to avoid decrementing the + /// `TestrunsInProgress` gauge when the row had already been cleared out by eviction (e.g. a + /// result submission that arrives after the agent's slot timed out). + pub(crate) async fn clear_testrun_in_progress(&self, node_id: i64) -> anyhow::Result { + let res = sqlx::query!("DELETE FROM testrun_in_progress WHERE node_id = ?", node_id,) + .execute(&self.connection_pool) + .await?; + Ok(res.rows_affected()) + } + + /// Atomically selects the most stale idle mixnode and marks it as having a test run in + /// progress. + /// + /// "Most stale" is defined as: nodes that have never been tested come first, followed by + /// nodes whose last test run has the oldest timestamp. + /// + /// `last_tested_before` acts as a minimum-staleness gate: a node that has already been + /// tested is only eligible if its last test run completed before this timestamp. Nodes + /// that have never been tested are always eligible regardless of this value. The caller + /// is expected to pass `now - min_test_interval`. + /// + /// `now` is recorded as the `started_at` timestamp on the resulting `testrun_in_progress` + /// row. It is accepted as an argument rather than read from the clock internally so that + /// callers can use a consistent timestamp across related operations. + /// + /// Nodes with a row in `testrun_in_progress` are excluded entirely. + /// Nodes where `mixnet_socket_address`, `noise_key`, or `sphinx_key` is NULL are also + /// excluded, as they lack the information required to perform a test. + /// Only nodes whose `node_type` is `mixnode` or `mixnode_and_gateway` are eligible; + /// gateway-only and unclassified (`unknown`) nodes are excluded. + /// + /// Returns `None` if no eligible idle mixnode exists. + pub(crate) async fn assign_next_mixnode_testrun( + &self, + now: OffsetDateTime, + last_tested_before: OffsetDateTime, + ) -> anyhow::Result> { + // Starts a write (IMMEDIATE) transaction, to prevent issue when upgrading from a read one to a write one + let mut tx = self.connection_pool.begin_with("BEGIN IMMEDIATE").await?; + + let node = sqlx::query_as::<_, NymNode>( + r#" + SELECT + n.node_id, + n.identity_key, + n.last_seen_bonded, + n.mixnet_socket_address, + n.noise_key, + n.sphinx_key, + n.key_rotation_id, + n.node_type, + n.last_testrun + FROM nym_node n + LEFT JOIN testrun_in_progress tip ON tip.node_id = n.node_id + LEFT JOIN testrun tr ON tr.id = n.last_testrun + WHERE tip.node_id IS NULL + AND n.mixnet_socket_address IS NOT NULL + AND n.noise_key IS NOT NULL + AND n.sphinx_key IS NOT NULL + AND n.node_type IN ('mixnode', 'mixnode_and_gateway') + AND (n.last_testrun IS NULL OR tr.test_timestamp < ?) + ORDER BY tr.test_timestamp ASC NULLS FIRST + LIMIT 1 + "#, + ) + .bind(last_tested_before) + .fetch_optional(&mut *tx) + .await?; + + if let Some(ref node) = node { + sqlx::query!( + "INSERT INTO testrun_in_progress (node_id, started_at) VALUES (?, ?)", + node.inner.node_id, + now, + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(node) + } + + /// Fetches a single `testrun` row by its primary key. + /// + /// Returns `None` if no row with that id exists. + pub(crate) async fn get_testrun_by_id(&self, id: i64) -> anyhow::Result> { + let row = sqlx::query_as::<_, TestRun>("SELECT * FROM testrun WHERE id = ?") + .bind(id) + .fetch_optional(&self.connection_pool) + .await?; + Ok(row) + } + + /// Fetches a single `nym_node` row by its `node_id`. + /// + /// Returns `None` if the orchestrator has never seen a bond for this node. + pub(crate) async fn get_nym_node_by_id(&self, node_id: i64) -> anyhow::Result> { + let row = sqlx::query_as::<_, NymNode>("SELECT * FROM nym_node WHERE node_id = ?") + .bind(node_id) + .fetch_optional(&self.connection_pool) + .await?; + Ok(row) + } + + /// Fetches a page of `testrun` rows filtered to a single `node_id`, ordered by + /// `test_timestamp` descending (newest first), together with the total number of rows + /// for that node (used to populate `PagedResult::total`). + /// + /// Backed by the `idx_testrun_node_id_timestamp` index. + /// + /// `limit` and `offset` translate directly to SQL `LIMIT` / `OFFSET`; the caller is + /// expected to derive them from the public pagination contract as + /// `limit = size` and `offset = page * size`. + /// + /// The page and total count are fetched inside a single transaction so that the `total` + /// is consistent with the rows returned. + pub(crate) async fn get_testruns_for_node_paginated( + &self, + node_id: i64, + limit: i64, + offset: i64, + ) -> anyhow::Result<(Vec, i64)> { + let mut tx = self.connection_pool.begin().await?; + + let rows = sqlx::query_as::<_, TestRun>( + "SELECT * FROM testrun WHERE node_id = ? ORDER BY test_timestamp DESC LIMIT ? OFFSET ?", + ) + .bind(node_id) + .bind(limit) + .bind(offset) + .fetch_all(&mut *tx) + .await?; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM testrun WHERE node_id = ?") + .bind(node_id) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok((rows, total)) + } + + /// Fetches a page of `testrun` rows, ordered by `test_timestamp` descending (newest first), + /// together with the total number of rows in the table (used to populate + /// `PagedResult::total`). + /// + /// `limit` and `offset` translate directly to SQL `LIMIT` / `OFFSET`; the caller is + /// expected to derive them from the public pagination contract as + /// `limit = size` and `offset = page * size`. + /// + /// The page and total count are fetched inside a single transaction so that the `total` + /// is consistent with the rows returned (no tearing if another writer commits in between). + pub(crate) async fn get_testruns_paginated( + &self, + limit: i64, + offset: i64, + ) -> anyhow::Result<(Vec, i64)> { + let mut tx = self.connection_pool.begin().await?; + + let rows = sqlx::query_as::<_, TestRun>( + "SELECT * FROM testrun ORDER BY test_timestamp DESC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&mut *tx) + .await?; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM testrun") + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok((rows, total)) + } + + /// Fetches a page of `nym_node` rows, ordered by `node_id` ascending, together with the + /// total number of rows in the table (used to populate `PagedResult::total`). + /// + /// `limit` and `offset` translate directly to SQL `LIMIT` / `OFFSET`; the caller is + /// expected to derive them from the public pagination contract as + /// `limit = size` and `offset = page * size`. + /// + /// The page and total count are fetched inside a single transaction so that the `total` + /// is consistent with the rows returned (no tearing if another writer commits in between). + pub(crate) async fn get_nym_nodes_paginated( + &self, + limit: i64, + offset: i64, + ) -> anyhow::Result<(Vec, i64)> { + let mut tx = self.connection_pool.begin().await?; + + let rows = sqlx::query_as::<_, NymNode>( + "SELECT * FROM nym_node ORDER BY node_id ASC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&mut *tx) + .await?; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nym_node") + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok((rows, total)) + } + + /// Fetches a page of `testrun_in_progress` rows, ordered from oldest `started_at` to + /// newest (so stale/hung runs surface first), together with the total number of rows in + /// the table (used to populate `PagedResult::total`). + /// + /// `limit` and `offset` translate directly to SQL `LIMIT` / `OFFSET`; the caller is + /// expected to derive them from the public pagination contract as + /// `limit = size` and `offset = page * size`. + /// + /// The page and total count are fetched inside a single transaction so that the `total` + /// is consistent with the rows returned (no tearing if another writer commits in between). + /// + /// At steady state this table holds roughly one row per concurrently-testing agent, so + /// the ordinary page-size cap from [`Pagination`] is more than enough headroom. + pub(crate) async fn get_testruns_in_progress_paginated( + &self, + limit: i64, + offset: i64, + ) -> anyhow::Result<(Vec, i64)> { + let mut tx = self.connection_pool.begin().await?; + + let rows = sqlx::query_as::<_, TestRunInProgress>( + "SELECT * FROM testrun_in_progress ORDER BY started_at ASC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&mut *tx) + .await?; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM testrun_in_progress") + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok((rows, total)) + } + + /// Deletes all `testrun` rows whose `test_timestamp` is older than `cutoff`. + /// + /// Intended to be called periodically with `now - eviction_age` as the cutoff to keep + /// the local database from growing unboundedly. Rows that are evicted are assumed to + /// have already been submitted to the nym-api for persistent storage. + /// + /// Any `nym_node.last_testrun` foreign key that pointed at an evicted row is automatically + /// set to `NULL` by the database (`ON DELETE SET NULL`). + pub(crate) async fn evict_old_testruns(&self, cutoff: OffsetDateTime) -> anyhow::Result { + let res = sqlx::query!("DELETE FROM testrun WHERE test_timestamp < ?", cutoff) + .execute(&self.connection_pool) + .await?; + Ok(res.rows_affected()) + } + + /// Returns the id of the most recent `testrun` that has been successfully submitted to the + /// nym-api, or `None` if no batch has been submitted yet (either because the orchestrator has + /// only just been started, or because no testruns have been produced at all). + /// + /// The underlying `metadata` row is allowed to not exist and is also allowed to exist with a + /// `NULL` column — both cases map to `None` here. + pub(crate) async fn get_last_submitted_testrun_id(&self) -> anyhow::Result> { + let id = sqlx::query_scalar!("SELECT last_submitted_testrun_id FROM metadata WHERE id = 0") + .fetch_optional(&self.connection_pool) + .await? + .flatten(); + Ok(id) + } + + /// Records that all testruns with `id <= testrun_id` have been successfully submitted to the + /// nym-api. Inserts the singleton `metadata` row if it does not yet exist. + pub(crate) async fn set_last_submitted_testrun_id( + &self, + testrun_id: i64, + ) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO metadata (id, last_submitted_testrun_id) VALUES (0, ?) + ON CONFLICT (id) DO UPDATE SET last_submitted_testrun_id = excluded.last_submitted_testrun_id + "#, + testrun_id, + ) + .execute(&self.connection_pool) + .await?; + Ok(()) + } + + /// Fetches every `testrun` row with an id strictly greater than `after_id`, ordered by id + /// ascending so the caller can pick the highest-id submitted row deterministically. + /// + /// `after_id = 0` (the default used before any batch has been submitted) returns every row in + /// the table, since `testrun.id` is `AUTOINCREMENT` and therefore always `>= 1`. + pub(crate) async fn get_testruns_after(&self, after_id: i64) -> anyhow::Result> { + let rows = + sqlx::query_as::<_, TestRun>("SELECT * FROM testrun WHERE id > ? ORDER BY id ASC") + .bind(after_id) + .fetch_all(&self.connection_pool) + .await?; + Ok(rows) + } + + /// Updates `nym_node.last_testrun` to point at the given test run ID. + pub(crate) async fn set_node_last_testrun( + &self, + node_id: i64, + testrun_id: i64, + ) -> anyhow::Result<()> { + sqlx::query!( + "UPDATE nym_node SET last_testrun = ? WHERE node_id = ?", + testrun_id, + node_id, + ) + .execute(&self.connection_pool) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::models::{NewNymNode, NewTestRun, NodeType, TestType}; + use std::path::Path; + use time::macros::datetime; + + async fn setup() -> StorageManager { + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("failed to create in-memory SQLite pool"); + let migrations_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); + sqlx::migrate::Migrator::new(migrations_path.as_path()) + .await + .expect("failed to find migrations") + .run(&pool) + .await + .expect("failed to run migrations"); + StorageManager { + connection_pool: pool, + } + } + + fn node(id: i64, identity_key: &str) -> NewNymNode { + NewNymNode { + node_id: id, + identity_key: identity_key.to_string(), + last_seen_bonded: datetime!(2025-01-01 00:00:00 UTC), + mixnet_socket_address: Some("1.2.3.4:1789".to_string()), + noise_key: Some("placeholder_noise_key".to_string()), + sphinx_key: Some("placeholder_sphinx_key".to_string()), + key_rotation_id: Some(0), + node_type: NodeType::Mixnode, + } + } + + fn minimal_test_run(node_id: i64) -> NewTestRun { + NewTestRun { + node_id, + test_type: TestType::Mixnode, + test_timestamp: datetime!(2025-06-01 12:00:00 UTC), + time_taken_us: 0, + ingress_noise_handshake_us: None, + egress_noise_handshake_us: None, + sphinx_packet_delay_us: 0, + packets_sent: 0, + packets_received: 0, + approximate_latency_us: None, + packets_rtt_min_us: None, + packets_rtt_mean_us: None, + packets_rtt_median_us: None, + packets_rtt_max_us: None, + packets_rtt_std_dev_us: None, + sending_latency_min_us: None, + sending_latency_mean_us: None, + sending_latency_median_us: None, + sending_latency_max_us: None, + sending_latency_std_dev_us: None, + received_duplicates: false, + error: None, + } + } + + /// Seeds a single nym_node row so that testruns referencing `node_id` satisfy the FK. + async fn seed_node(db: &StorageManager, node_id: i64) { + db.batch_insert_or_update_nym_nodes(&[node(node_id, &format!("key_{node_id}"))]) + .await + .unwrap(); + } + + mod batch_insert_or_update_nym_nodes { + use super::*; + + #[tokio::test] + async fn inserts_multiple_nodes() { + let db = setup().await; + let nodes = vec![node(1, "key_a"), node(2, "key_b"), node(3, "key_c")]; + db.batch_insert_or_update_nym_nodes(&nodes).await.unwrap(); + + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM nym_node") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 3); + } + + #[tokio::test] + async fn updates_existing_nodes_in_batch() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + + let mut updated = node(1, "key_a"); + updated.mixnet_socket_address = Some("9.9.9.9:1789".to_string()); + updated.noise_key = Some("new_noise".to_string()); + + let nodes = vec![updated, node(2, "key_b")]; + db.batch_insert_or_update_nym_nodes(&nodes).await.unwrap(); + + let row = sqlx::query!( + "SELECT mixnet_socket_address, noise_key FROM nym_node WHERE node_id = 1" + ) + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(row.mixnet_socket_address.as_deref(), Some("9.9.9.9:1789")); + assert_eq!(row.noise_key.as_deref(), Some("new_noise")); + + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM nym_node") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 2); + } + + #[tokio::test] + async fn empty_batch_is_noop() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[]).await.unwrap(); + + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM nym_node") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + } + + mod insert_test_run { + use super::*; + + #[tokio::test] + async fn returns_sequential_ids() { + let db = setup().await; + seed_node(&db, 1).await; + let id1 = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + let id2 = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + assert!(id2 > id1); + } + + #[tokio::test] + async fn persists_fields() { + let db = setup().await; + seed_node(&db, 1).await; + let mut run = minimal_test_run(1); + run.packets_sent = 100; + run.packets_received = 95; + run.received_duplicates = true; + run.error = Some("timeout".to_string()); + let id = db.insert_test_run(&run).await.unwrap(); + + let row = sqlx::query!( + "SELECT packets_sent, packets_received, received_duplicates, error + FROM testrun WHERE id = ?", + id + ) + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(row.packets_sent, 100); + assert_eq!(row.packets_received, 95); + assert!(row.received_duplicates); + assert_eq!(row.error.as_deref(), Some("timeout")); + } + } + + mod set_node_last_testrun { + use super::*; + + #[tokio::test] + async fn links_run_to_node() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + let run_id = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + let row = sqlx::query!("SELECT last_testrun FROM nym_node WHERE node_id = 1") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(row.last_testrun, Some(run_id)); + } + } + + mod mark_testrun_in_progress { + use super::*; + + #[tokio::test] + async fn inserts_row() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + + let count = + sqlx::query_scalar!("SELECT COUNT(*) FROM testrun_in_progress WHERE node_id = 1") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn rejects_duplicate() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + let result = db + .mark_testrun_in_progress(1, datetime!(2025-06-01 11:00:00 UTC)) + .await; + assert!(result.is_err()); + } + } + + mod clear_testrun_in_progress { + use super::*; + + #[tokio::test] + async fn removes_row() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + db.clear_testrun_in_progress(1).await.unwrap(); + + let count = + sqlx::query_scalar!("SELECT COUNT(*) FROM testrun_in_progress WHERE node_id = 1") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + } + + mod clear_timed_out_testruns_in_progress { + use super::*; + + #[tokio::test] + async fn removes_only_old_entries() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.batch_insert_or_update_nym_nodes(&[node(2, "key_b")]) + .await + .unwrap(); + db.mark_testrun_in_progress(1, datetime!(2025-06-01 08:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(2, datetime!(2025-06-01 12:00:00 UTC)) + .await + .unwrap(); + + // cutoff between the two timestamps + db.clear_timed_out_testruns_in_progress(datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + + let remaining: Vec = + sqlx::query_scalar!("SELECT node_id FROM testrun_in_progress ORDER BY node_id") + .fetch_all(&db.connection_pool) + .await + .unwrap(); + assert_eq!(remaining, vec![2]); + } + } + + mod evict_old_testruns { + use super::*; + + #[tokio::test] + async fn evicts_runs_older_than_cutoff() { + let db = setup().await; + seed_node(&db, 1).await; + let mut old_run = minimal_test_run(1); + old_run.test_timestamp = datetime!(2025-01-01 00:00:00 UTC); + let old_id = db.insert_test_run(&old_run).await.unwrap(); + + let mut recent_run = minimal_test_run(1); + recent_run.test_timestamp = datetime!(2025-06-01 12:00:00 UTC); + let recent_id = db.insert_test_run(&recent_run).await.unwrap(); + + db.evict_old_testruns(datetime!(2025-03-01 00:00:00 UTC)) + .await + .unwrap(); + + let ids: Vec = sqlx::query_scalar!("SELECT id FROM testrun ORDER BY id") + .fetch_all(&db.connection_pool) + .await + .unwrap(); + assert!(!ids.contains(&old_id)); + assert!(ids.contains(&recent_id)); + } + + #[tokio::test] + async fn preserves_runs_at_or_after_cutoff() { + let db = setup().await; + seed_node(&db, 1).await; + let mut run = minimal_test_run(1); + run.test_timestamp = datetime!(2025-03-01 00:00:00 UTC); + let id = db.insert_test_run(&run).await.unwrap(); + + // cutoff is exactly at the run's timestamp — should NOT be evicted (strict <) + db.evict_old_testruns(datetime!(2025-03-01 00:00:00 UTC)) + .await + .unwrap(); + + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM testrun WHERE id = ?", id) + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn nullifies_node_last_testrun_on_eviction() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + + let mut run = minimal_test_run(1); + run.test_timestamp = datetime!(2025-01-01 00:00:00 UTC); + let run_id = db.insert_test_run(&run).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + db.evict_old_testruns(datetime!(2025-06-01 00:00:00 UTC)) + .await + .unwrap(); + + let row = sqlx::query!("SELECT last_testrun FROM nym_node WHERE node_id = 1") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert!(row.last_testrun.is_none()); + } + + #[tokio::test] + async fn does_nothing_when_no_old_runs() { + let db = setup().await; + seed_node(&db, 1).await; + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + + // cutoff is well in the past — nothing should be evicted + let result = db + .evict_old_testruns(datetime!(2000-01-01 00:00:00 UTC)) + .await; + assert!(result.is_ok()); + + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM testrun") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + } + + mod assign_next_mixnode_testrun { + use super::*; + + // A far-future cutoff that effectively disables the staleness gate, + // used in tests that are not concerned with that behaviour. + fn no_staleness_gate() -> OffsetDateTime { + datetime!(9999-12-31 23:59:59 UTC) + } + + #[tokio::test] + async fn returns_none_when_no_nodes() { + let db = setup().await; + let result = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn returns_none_when_all_nodes_in_progress() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.assign_next_mixnode_testrun(datetime!(2025-06-01 12:00:00 UTC), no_staleness_gate()) + .await + .unwrap(); + + let result = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn inserts_in_progress_row() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap(); + assert!(assigned.is_some()); + + let count = + sqlx::query_scalar!("SELECT COUNT(*) FROM testrun_in_progress WHERE node_id = 1") + .fetch_one(&db.connection_pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn prefers_never_tested_node_over_stale_one() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.batch_insert_or_update_nym_nodes(&[node(2, "key_b")]) + .await + .unwrap(); + + // give node 1 a completed test run + let run_id = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + // node 2 has never been tested — it should be picked first + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(assigned.inner.node_id, 2); + } + + #[tokio::test] + async fn prefers_older_testrun_over_newer_one() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.batch_insert_or_update_nym_nodes(&[node(2, "key_b")]) + .await + .unwrap(); + + let mut old_run = minimal_test_run(1); + old_run.test_timestamp = datetime!(2025-01-01 00:00:00 UTC); + let old_id = db.insert_test_run(&old_run).await.unwrap(); + db.set_node_last_testrun(1, old_id).await.unwrap(); + + let mut new_run = minimal_test_run(2); + new_run.test_timestamp = datetime!(2025-06-01 12:00:00 UTC); + let new_id = db.insert_test_run(&new_run).await.unwrap(); + db.set_node_last_testrun(2, new_id).await.unwrap(); + + // node 1 has the older run — it should be picked + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(assigned.inner.node_id, 1); + } + + #[tokio::test] + async fn skips_node_already_in_progress() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.batch_insert_or_update_nym_nodes(&[node(2, "key_b")]) + .await + .unwrap(); + + // both have no test run; node 1 is manually put in progress + db.mark_testrun_in_progress(1, datetime!(2025-06-01 11:00:00 UTC)) + .await + .unwrap(); + + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 12:00:00 UTC), + no_staleness_gate(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(assigned.inner.node_id, 2); + } + + #[tokio::test] + async fn skips_node_tested_too_recently() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + + let mut run = minimal_test_run(1); + run.test_timestamp = datetime!(2025-06-01 12:00:00 UTC); + let run_id = db.insert_test_run(&run).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + // cutoff is before the last test — node is not stale enough + let result = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 13:00:00 UTC), + datetime!(2025-06-01 11:00:00 UTC), + ) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn returns_node_tested_sufficiently_long_ago() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + + let mut run = minimal_test_run(1); + run.test_timestamp = datetime!(2025-06-01 12:00:00 UTC); + let run_id = db.insert_test_run(&run).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + // cutoff is after the last test — node is eligible + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 14:00:00 UTC), + datetime!(2025-06-01 13:00:00 UTC), + ) + .await + .unwrap(); + assert!(assigned.is_some()); + } + + #[tokio::test] + async fn never_tested_node_bypasses_staleness_gate() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + db.batch_insert_or_update_nym_nodes(&[node(2, "key_b")]) + .await + .unwrap(); + + // node 1 was tested very recently + let mut run = minimal_test_run(1); + run.test_timestamp = datetime!(2025-06-01 12:00:00 UTC); + let run_id = db.insert_test_run(&run).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + // cutoff is before node 1's last test — it is filtered out + // node 2 has never been tested and must still be returned + let assigned = db + .assign_next_mixnode_testrun( + datetime!(2025-06-01 13:00:00 UTC), + datetime!(2025-06-01 11:00:00 UTC), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(assigned.inner.node_id, 2); + } + } + + mod get_testrun_by_id { + use super::*; + + #[tokio::test] + async fn returns_none_when_missing() { + let db = setup().await; + let result = db.get_testrun_by_id(123).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn returns_inserted_run() { + let db = setup().await; + seed_node(&db, 1).await; + let mut run = minimal_test_run(1); + run.packets_sent = 42; + run.packets_received = 41; + run.error = Some("boom".to_string()); + let id = db.insert_test_run(&run).await.unwrap(); + + let fetched = db.get_testrun_by_id(id).await.unwrap().unwrap(); + assert_eq!(fetched.id, id); + assert_eq!(fetched.inner.node_id, 1); + assert_eq!(fetched.inner.packets_sent, 42); + assert_eq!(fetched.inner.packets_received, 41); + assert_eq!(fetched.inner.error.as_deref(), Some("boom")); + } + + #[tokio::test] + async fn returns_the_right_row_when_multiple_exist() { + let db = setup().await; + seed_node(&db, 1).await; + let _ = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + let mut other = minimal_test_run(1); + other.packets_sent = 7; + let target_id = db.insert_test_run(&other).await.unwrap(); + let _ = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + + let fetched = db.get_testrun_by_id(target_id).await.unwrap().unwrap(); + assert_eq!(fetched.id, target_id); + assert_eq!(fetched.inner.packets_sent, 7); + } + } + + mod get_nym_node_by_id { + use super::*; + + #[tokio::test] + async fn returns_none_when_missing() { + let db = setup().await; + let result = db.get_nym_node_by_id(1).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn returns_inserted_node() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(42, "key_a")]) + .await + .unwrap(); + + let fetched = db.get_nym_node_by_id(42).await.unwrap().unwrap(); + assert_eq!(fetched.inner.node_id, 42); + assert_eq!(fetched.inner.identity_key, "key_a"); + assert!(fetched.last_testrun.is_none()); + } + + #[tokio::test] + async fn reflects_last_testrun_when_linked() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a")]) + .await + .unwrap(); + let run_id = db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.set_node_last_testrun(1, run_id).await.unwrap(); + + let fetched = db.get_nym_node_by_id(1).await.unwrap().unwrap(); + assert_eq!(fetched.last_testrun, Some(run_id)); + } + } + + mod get_testruns_in_progress_paginated { + use super::*; + + #[tokio::test] + async fn empty_when_table_empty() { + let db = setup().await; + let (rows, total) = db.get_testruns_in_progress_paginated(50, 0).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 0); + } + + #[tokio::test] + async fn ordering_is_started_at_ascending() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[ + node(1, "key_a"), + node(2, "key_b"), + node(3, "key_c"), + ]) + .await + .unwrap(); + + db.mark_testrun_in_progress(2, datetime!(2025-06-01 12:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(3, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(1, datetime!(2025-06-01 11:00:00 UTC)) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_in_progress_paginated(50, 0).await.unwrap(); + assert_eq!(total, 3); + let ordered_node_ids: Vec = rows.iter().map(|r| r.node_id).collect(); + assert_eq!(ordered_node_ids, vec![3, 1, 2]); + } + + #[tokio::test] + async fn limit_truncates_page_but_preserves_total() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[ + node(1, "key_a"), + node(2, "key_b"), + node(3, "key_c"), + ]) + .await + .unwrap(); + + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(2, datetime!(2025-06-01 11:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(3, datetime!(2025-06-01 12:00:00 UTC)) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_in_progress_paginated(2, 0).await.unwrap(); + assert_eq!(total, 3); + let ordered_node_ids: Vec = rows.iter().map(|r| r.node_id).collect(); + assert_eq!(ordered_node_ids, vec![1, 2]); + } + + #[tokio::test] + async fn offset_skips_oldest_rows() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[ + node(1, "key_a"), + node(2, "key_b"), + node(3, "key_c"), + ]) + .await + .unwrap(); + + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(2, datetime!(2025-06-01 11:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(3, datetime!(2025-06-01 12:00:00 UTC)) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_in_progress_paginated(2, 1).await.unwrap(); + assert_eq!(total, 3); + let ordered_node_ids: Vec = rows.iter().map(|r| r.node_id).collect(); + assert_eq!(ordered_node_ids, vec![2, 3]); + } + + #[tokio::test] + async fn offset_past_end_returns_empty_but_accurate_total() { + let db = setup().await; + db.batch_insert_or_update_nym_nodes(&[node(1, "key_a"), node(2, "key_b")]) + .await + .unwrap(); + + db.mark_testrun_in_progress(1, datetime!(2025-06-01 10:00:00 UTC)) + .await + .unwrap(); + db.mark_testrun_in_progress(2, datetime!(2025-06-01 11:00:00 UTC)) + .await + .unwrap(); + + let (rows, total) = db + .get_testruns_in_progress_paginated(10, 100) + .await + .unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 2); + } + } + + mod get_nym_nodes_paginated { + use super::*; + + #[tokio::test] + async fn empty_when_table_empty() { + let db = setup().await; + let (rows, total) = db.get_nym_nodes_paginated(50, 0).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 0); + } + + #[tokio::test] + async fn returns_first_page_and_correct_total() { + let db = setup().await; + let nodes: Vec = (1..=5).map(|i| node(i, &format!("key_{i}"))).collect(); + db.batch_insert_or_update_nym_nodes(&nodes).await.unwrap(); + + let (rows, total) = db.get_nym_nodes_paginated(2, 0).await.unwrap(); + assert_eq!(total, 5); + let ids: Vec = rows.iter().map(|r| r.inner.node_id).collect(); + assert_eq!(ids, vec![1, 2]); + } + + #[tokio::test] + async fn offset_skips_earlier_rows() { + let db = setup().await; + let nodes: Vec = (1..=5).map(|i| node(i, &format!("key_{i}"))).collect(); + db.batch_insert_or_update_nym_nodes(&nodes).await.unwrap(); + + let (rows, total) = db.get_nym_nodes_paginated(2, 2).await.unwrap(); + assert_eq!(total, 5); + let ids: Vec = rows.iter().map(|r| r.inner.node_id).collect(); + assert_eq!(ids, vec![3, 4]); + } + + #[tokio::test] + async fn offset_past_end_returns_empty_but_accurate_total() { + let db = setup().await; + let nodes: Vec = (1..=3).map(|i| node(i, &format!("key_{i}"))).collect(); + db.batch_insert_or_update_nym_nodes(&nodes).await.unwrap(); + + let (rows, total) = db.get_nym_nodes_paginated(10, 100).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 3); + } + + #[tokio::test] + async fn ordering_is_node_id_ascending() { + let db = setup().await; + // insert in non-ascending order to confirm ORDER BY actually sorts + db.batch_insert_or_update_nym_nodes(&[ + node(3, "key_c"), + node(1, "key_a"), + node(2, "key_b"), + ]) + .await + .unwrap(); + + let (rows, _) = db.get_nym_nodes_paginated(10, 0).await.unwrap(); + let ids: Vec = rows.iter().map(|r| r.inner.node_id).collect(); + assert_eq!(ids, vec![1, 2, 3]); + } + } + + mod get_testruns_paginated { + use super::*; + + fn run_at(node_id: i64, ts: OffsetDateTime) -> NewTestRun { + let mut r = minimal_test_run(node_id); + r.test_timestamp = ts; + r + } + + #[tokio::test] + async fn empty_when_table_empty() { + let db = setup().await; + let (rows, total) = db.get_testruns_paginated(50, 0).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 0); + } + + #[tokio::test] + async fn ordering_is_test_timestamp_descending() { + let db = setup().await; + seed_node(&db, 1).await; + // insert in mixed order; ensure query returns newest first + let _ = db + .insert_test_run(&run_at(1, datetime!(2025-03-01 00:00:00 UTC))) + .await + .unwrap(); + let _ = db + .insert_test_run(&run_at(1, datetime!(2025-01-01 00:00:00 UTC))) + .await + .unwrap(); + let _ = db + .insert_test_run(&run_at(1, datetime!(2025-02-01 00:00:00 UTC))) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_paginated(10, 0).await.unwrap(); + assert_eq!(total, 3); + let timestamps: Vec = + rows.iter().map(|r| r.inner.test_timestamp).collect(); + assert_eq!( + timestamps, + vec![ + datetime!(2025-03-01 00:00:00 UTC), + datetime!(2025-02-01 00:00:00 UTC), + datetime!(2025-01-01 00:00:00 UTC), + ] + ); + } + + #[tokio::test] + async fn offset_skips_newest_rows() { + let db = setup().await; + seed_node(&db, 1).await; + db.insert_test_run(&run_at(1, datetime!(2025-03-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-02-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-01-01 00:00:00 UTC))) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_paginated(2, 1).await.unwrap(); + assert_eq!(total, 3); + let timestamps: Vec = + rows.iter().map(|r| r.inner.test_timestamp).collect(); + assert_eq!( + timestamps, + vec![ + datetime!(2025-02-01 00:00:00 UTC), + datetime!(2025-01-01 00:00:00 UTC), + ] + ); + } + + #[tokio::test] + async fn offset_past_end_returns_empty_but_accurate_total() { + let db = setup().await; + seed_node(&db, 1).await; + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + + let (rows, total) = db.get_testruns_paginated(10, 50).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 2); + } + } + + mod get_testruns_for_node_paginated { + use super::*; + + fn run_at(node_id: i64, ts: OffsetDateTime) -> NewTestRun { + let mut r = minimal_test_run(node_id); + r.test_timestamp = ts; + r + } + + #[tokio::test] + async fn empty_when_node_has_no_runs() { + let db = setup().await; + seed_node(&db, 1).await; + + let (rows, total) = db.get_testruns_for_node_paginated(1, 50, 0).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 0); + } + + #[tokio::test] + async fn returns_only_runs_for_requested_node() { + let db = setup().await; + seed_node(&db, 1).await; + seed_node(&db, 2).await; + + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + db.insert_test_run(&minimal_test_run(2)).await.unwrap(); + + let (rows, total) = db.get_testruns_for_node_paginated(1, 50, 0).await.unwrap(); + assert_eq!(total, 2); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r.inner.node_id == 1)); + + let (rows, total) = db.get_testruns_for_node_paginated(2, 50, 0).await.unwrap(); + assert_eq!(total, 1); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].inner.node_id, 2); + } + + #[tokio::test] + async fn ordering_is_test_timestamp_descending() { + let db = setup().await; + seed_node(&db, 1).await; + + db.insert_test_run(&run_at(1, datetime!(2025-02-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-03-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-01-01 00:00:00 UTC))) + .await + .unwrap(); + + let (rows, _) = db.get_testruns_for_node_paginated(1, 10, 0).await.unwrap(); + let timestamps: Vec = + rows.iter().map(|r| r.inner.test_timestamp).collect(); + assert_eq!( + timestamps, + vec![ + datetime!(2025-03-01 00:00:00 UTC), + datetime!(2025-02-01 00:00:00 UTC), + datetime!(2025-01-01 00:00:00 UTC), + ] + ); + } + + #[tokio::test] + async fn offset_skips_newest_rows() { + let db = setup().await; + seed_node(&db, 1).await; + + db.insert_test_run(&run_at(1, datetime!(2025-03-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-02-01 00:00:00 UTC))) + .await + .unwrap(); + db.insert_test_run(&run_at(1, datetime!(2025-01-01 00:00:00 UTC))) + .await + .unwrap(); + + let (rows, total) = db.get_testruns_for_node_paginated(1, 2, 1).await.unwrap(); + assert_eq!(total, 3); + let timestamps: Vec = + rows.iter().map(|r| r.inner.test_timestamp).collect(); + assert_eq!( + timestamps, + vec![ + datetime!(2025-02-01 00:00:00 UTC), + datetime!(2025-01-01 00:00:00 UTC), + ] + ); + } + + #[tokio::test] + async fn unknown_node_returns_empty_with_zero_total() { + let db = setup().await; + seed_node(&db, 1).await; + db.insert_test_run(&minimal_test_run(1)).await.unwrap(); + + // node 99 was never seeded, so it has no runs and total is 0. + let (rows, total) = db.get_testruns_for_node_paginated(99, 50, 0).await.unwrap(); + assert!(rows.is_empty()); + assert_eq!(total, 0); + } + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/mod.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/mod.rs new file mode 100644 index 0000000000..a0ec448bdb --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/mod.rs @@ -0,0 +1,279 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::orchestrator::prometheus::{PROMETHEUS_METRICS, PrometheusMetric}; +use crate::storage::manager::StorageManager; +use crate::storage::models::{NewNymNode, NewTestRun, NymNode, TestRun, TestRunInProgress}; +use anyhow::Context; +use nym_network_monitor_orchestrator_requests::models::Pagination; +use nym_validator_client::client::NodeId; +use sqlx::ConnectOptions; +use sqlx::sqlite::{SqliteAutoVacuum, SqliteSynchronous}; +use std::path::Path; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::log::{LevelFilter, debug}; + +mod manager; +pub(crate) mod models; + +/// High-level handle to the orchestrator's local SQLite database. +/// +/// Wraps a [`StorageManager`] and translates between the orchestrator-level +/// types (e.g. [`NodeId`], [`Pagination`], [`Duration`]) used by callers and +/// the raw SQL-friendly primitives (`i64` ids, `limit`/`offset`, absolute +/// timestamps) understood by the manager. All public methods are +/// [`Clone`]-safe because [`sqlx::SqlitePool`] is internally reference-counted. +#[derive(Clone)] +pub(crate) struct NetworkMonitorStorage { + pub(crate) storage_manager: StorageManager, +} + +impl NetworkMonitorStorage { + /// Opens (or creates) the SQLite database at `database_path`, configures + /// WAL journaling and incremental auto-vacuum, and runs the embedded + /// migrations. Slow statements (>50ms) are logged at `WARN`. + pub(crate) async fn init>(database_path: P) -> anyhow::Result { + debug!( + "attempting to connect to database {}", + database_path.as_ref().display() + ); + + let connect_opts = sqlx::sqlite::SqliteConnectOptions::new() + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .auto_vacuum(SqliteAutoVacuum::Incremental) + .filename(database_path) + .create_if_missing(true) + .log_statements(LevelFilter::Trace) + .log_slow_statements(LevelFilter::Warn, Duration::from_millis(50)); + + let connection_pool = sqlx::SqlitePool::connect_with(connect_opts) + .await + .context("Failed to connect to SQLx database")?; + + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .context("Failed to run database migrations")?; + + Ok(Self { + storage_manager: StorageManager { connection_pool }, + }) + } + + /// Inserts or updates multiple node records in a single transaction. + /// + /// For each node, if a row with the same `node_id` already exists, all fields except + /// `identity_key` are updated. The entire batch shares one transaction for efficiency. + pub(crate) async fn batch_insert_or_update_nym_nodes( + &self, + nodes: &[NewNymNode], + ) -> anyhow::Result<()> { + self.storage_manager + .batch_insert_or_update_nym_nodes(nodes) + .await + } + + /// Inserts a completed test run, updates the node's `last_testrun` pointer and + /// clears the corresponding `testrun_in_progress` marker. The target node is + /// taken from [`NewTestRun::node_id`]. + /// + /// Decrements the `TestrunsInProgress` gauge iff a row was actually cleared — a late + /// submission whose in-progress row was already reaped by the timeout sweep must not + /// double-decrement the gauge. + pub(crate) async fn insert_test_run(&self, run: &NewTestRun) -> anyhow::Result<()> { + let node_id = run.node_id; + let run_id = self.storage_manager.insert_test_run(run).await?; + self.storage_manager + .set_node_last_testrun(node_id, run_id) + .await?; + let cleared = self + .storage_manager + .clear_testrun_in_progress(node_id) + .await?; + if cleared > 0 { + PROMETHEUS_METRICS.inc_by(PrometheusMetric::TestrunsInProgress, -(cleared as i64)); + } + Ok(()) + } + + /// Returns the number of rows currently in `testrun_in_progress`. + pub(crate) async fn count_testruns_in_progress(&self) -> anyhow::Result { + self.storage_manager.count_testruns_in_progress().await + } + + /// Removes all in-progress markers whose `started_at` is older than `timeout`, on the + /// assumption that those runs have timed out and will never complete. Decrements the + /// `TestrunsInProgress` gauge by the number of rows actually cleared. + pub(crate) async fn clear_timed_out_testruns_in_progress( + &self, + timeout: Duration, + ) -> anyhow::Result { + let cutoff = OffsetDateTime::now_utc() - timeout; + let cleared = self + .storage_manager + .clear_timed_out_testruns_in_progress(cutoff) + .await?; + if cleared > 0 { + PROMETHEUS_METRICS.inc_by(PrometheusMetric::TestrunsInProgress, -(cleared as i64)); + } + Ok(cleared) + } + + /// Atomically selects the most stale idle mixnode and marks it as having a test run in + /// progress. + /// + /// "Most stale" is defined as: nodes that have never been tested come first, followed by + /// nodes whose last test run has the oldest timestamp. + /// + /// `staleness_age` acts as a minimum-staleness gate: a node that has already been tested + /// is only eligible if its last test run completed more than `staleness_age` ago. Nodes + /// that have never been tested are always eligible regardless of this value. + /// + /// The current time is used as the `started_at` timestamp on the resulting + /// `testrun_in_progress` row. + /// + /// Nodes with a row in `testrun_in_progress` are excluded entirely. Only nodes classified + /// as `mixnode` or `mixnode_and_gateway` are eligible. + /// + /// Returns `None` if no eligible idle mixnode exists. + pub(crate) async fn assign_next_mixnode_testrun( + &self, + staleness_age: Duration, + ) -> anyhow::Result> { + let now = OffsetDateTime::now_utc(); + let last_tested_before = now - staleness_age; + let assigned = self + .storage_manager + .assign_next_mixnode_testrun(now, last_tested_before) + .await?; + if assigned.is_some() { + PROMETHEUS_METRICS.inc(PrometheusMetric::TestrunsInProgress); + } + Ok(assigned) + } + + /// Fetches a single completed test run by its row id, or `None` if it has + /// been evicted or never existed. + pub(crate) async fn get_testrun_by_id(&self, id: i64) -> anyhow::Result> { + self.storage_manager.get_testrun_by_id(id).await + } + + /// Fetches a node by its contract-assigned `node_id`, or `None` if the + /// orchestrator has never observed a bond for it. + pub(crate) async fn get_nym_node_by_id( + &self, + node_id: NodeId, + ) -> anyhow::Result> { + self.storage_manager + .get_nym_node_by_id(node_id as i64) + .await + } + + /// Paginated list of outstanding `testrun_in_progress` rows, oldest `started_at` + /// first so stale/hung runs surface at the top, with the snapshot-consistent + /// total row count. + pub(crate) async fn get_testruns_in_progress_paginated( + &self, + pagination: Pagination, + ) -> anyhow::Result<(Vec, usize)> { + let (rows, total) = self + .storage_manager + .get_testruns_in_progress_paginated(pagination.limit(), pagination.offset()) + .await?; + + Ok((rows, total as usize)) + } + + /// Paginated list of nodes ordered by `node_id` ascending, with the + /// snapshot-consistent total row count. [`Pagination`] is resolved to + /// `limit`/`offset` here so the manager never sees the public contract. + pub(crate) async fn get_nym_nodes_paginated( + &self, + pagination: Pagination, + ) -> anyhow::Result<(Vec, usize)> { + let (nodes, total) = self + .storage_manager + .get_nym_nodes_paginated(pagination.limit(), pagination.offset()) + .await?; + + Ok((nodes, total as usize)) + } + + /// Paginated list of completed test runs ordered by `test_timestamp` + /// descending (newest first), with the snapshot-consistent total row count. + pub(crate) async fn get_testruns_paginated( + &self, + pagination: Pagination, + ) -> anyhow::Result<(Vec, usize)> { + let (test_results, total) = self + .storage_manager + .get_testruns_paginated(pagination.limit(), pagination.offset()) + .await?; + + Ok((test_results, total as usize)) + } + + /// Paginated list of completed test runs for a single node, ordered newest + /// first, with the snapshot-consistent total row count. Backed by the + /// `idx_testrun_node_id_timestamp` index. An unknown or never-tested + /// `node_id` produces `(vec![], 0)` rather than an error. + pub(crate) async fn get_testruns_for_node_paginated( + &self, + node_id: NodeId, + pagination: Pagination, + ) -> anyhow::Result<(Vec, usize)> { + let (test_results, total) = self + .storage_manager + .get_testruns_for_node_paginated( + node_id as i64, + pagination.limit(), + pagination.offset(), + ) + .await?; + + Ok((test_results, total as usize)) + } + + /// Returns the id of the newest `testrun` already submitted to the nym-api, or `None` if no + /// batch has been submitted yet. Callers treat `None` as "submit everything currently in + /// storage". + pub(crate) async fn get_last_submitted_testrun_id(&self) -> anyhow::Result> { + self.storage_manager.get_last_submitted_testrun_id().await + } + + /// Persists the id of the newest `testrun` whose batch submission to the nym-api has + /// succeeded. Subsequent [`Self::get_testruns_after`] calls use this value to avoid + /// resubmitting already-acknowledged rows. + pub(crate) async fn set_last_submitted_testrun_id( + &self, + testrun_id: i64, + ) -> anyhow::Result<()> { + self.storage_manager + .set_last_submitted_testrun_id(testrun_id) + .await + } + + /// Fetches every `testrun` row with `id > after_id`, ordered by id ascending. + /// + /// Used by the nym-api submission task to build the next batch of pending results. Ascending + /// ordering lets the caller record the highest-id row as the new submission watermark once + /// the batch is acknowledged. + pub(crate) async fn get_testruns_after(&self, after_id: i64) -> anyhow::Result> { + self.storage_manager.get_testruns_after(after_id).await + } + + /// Deletes all `testrun` rows older than `eviction_age` relative to the current time. + /// + /// Intended to be called periodically to keep the local database from growing unboundedly. + /// Rows that are evicted are assumed to have already been submitted to the nym-api for + /// persistent storage. + /// + /// Any `nym_node.last_testrun` foreign key that pointed at an evicted row is automatically + /// set to `NULL` by the database (`ON DELETE SET NULL`). + pub(crate) async fn evict_old_testruns(&self, eviction_age: Duration) -> anyhow::Result { + let cutoff = OffsetDateTime::now_utc() - eviction_age; + self.storage_manager.evict_old_testruns(cutoff).await + } +} diff --git a/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/models.rs b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/models.rs new file mode 100644 index 0000000000..9da074588a --- /dev/null +++ b/nym-network-monitor-v3/nym-network-monitor-orchestrator/src/storage/models.rs @@ -0,0 +1,406 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use anyhow::Context; +use nym_api_requests::models::network_monitor::StressTestResult; +use nym_crypto::asymmetric::{ed25519, x25519}; +use nym_network_monitor_orchestrator_requests::models::{ + self as api, LatencyDistribution, NymNodeData, TestRunData, TestRunInProgressData, + TestRunResult, +}; +use nym_node_requests::api::v1::node::models::NodeRoles; +use nym_validator_client::client::NodeId; +use nym_validator_client::nyxd::nym_mixnet_contract_common::NymNodeBond; +use std::time::Duration; +use time::OffsetDateTime; + +/// Discriminator for the type of node targeted by a [`TestRun`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +pub(crate) enum TestType { + #[default] + Mixnode, + Gateway, +} + +/// Classification of a node based on the roles reported via its self-described endpoint. +/// [`NodeType::Unknown`] is used both as the initial value before the node is successfully +/// queried and when a queried node reports no roles at all. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "snake_case")] +pub(crate) enum NodeType { + #[default] + Unknown, + Mixnode, + Gateway, + MixnodeAndGateway, +} + +impl NodeType { + /// Classifies a node from the `NodeRoles` reported by its self-described endpoint. + /// We key off `gateway_enabled` (entry-gateway capability) only — the `exit` property is + /// not a useful distinction for test-target selection. A node reporting neither role maps + /// to [`NodeType::Unknown`] and will be ignored by the mixnode testrun assignment query. + pub(crate) fn from_roles(roles: &NodeRoles) -> Self { + match (roles.mixnode_enabled, roles.gateway_enabled) { + (true, true) => NodeType::MixnodeAndGateway, + (true, false) => NodeType::Mixnode, + (false, true) => NodeType::Gateway, + (false, false) => NodeType::Unknown, + } + } +} + +/// The data required to insert a new row into `testrun`. Does not carry an `id` since that +/// is assigned by the database on insertion. +#[derive(Debug, Clone, sqlx::FromRow)] +pub(crate) struct NewTestRun { + /// Contract-assigned node id of the node under test. + pub(crate) node_id: i64, + + pub(crate) test_type: TestType, + pub(crate) test_timestamp: OffsetDateTime, + + /// How long the test took, in microseconds. + pub(crate) time_taken_us: i64, + + /// Noise handshake duration on the ingress (responder) side, in microseconds. + pub(crate) ingress_noise_handshake_us: Option, + + /// Noise handshake duration on the egress (initiator) side, in microseconds. + pub(crate) egress_noise_handshake_us: Option, + + /// Constant per-hop sphinx packet delay used during the test run, in microseconds. + pub(crate) sphinx_packet_delay_us: i64, + + pub(crate) packets_sent: i64, + pub(crate) packets_received: i64, + + /// RTT of the initial probe packet in microseconds. `None` if the probe did not complete. + pub(crate) approximate_latency_us: Option, + + // RTT distribution over received packets (all NULL when no packets were received). + pub(crate) packets_rtt_min_us: Option, + pub(crate) packets_rtt_mean_us: Option, + pub(crate) packets_rtt_median_us: Option, + pub(crate) packets_rtt_max_us: Option, + pub(crate) packets_rtt_std_dev_us: Option, + + // Batch send latency distribution (all NULL when no batches were sent). + pub(crate) sending_latency_min_us: Option, + pub(crate) sending_latency_mean_us: Option, + pub(crate) sending_latency_median_us: Option, + pub(crate) sending_latency_max_us: Option, + pub(crate) sending_latency_std_dev_us: Option, + + pub(crate) received_duplicates: bool, + + /// First error that caused the test to abort. `None` if the run completed without error. + pub(crate) error: Option, +} + +fn duration_to_us(d: std::time::Duration) -> i64 { + d.as_micros() as i64 +} + +impl NewTestRun { + /// Converts an API-level [`TestRunResult`] into a database-ready row, + /// flattening [`LatencyDistribution`](nym_network_monitor_orchestrator_requests::models::LatencyDistribution) + /// fields into individual microsecond columns and recording the current UTC time as the test timestamp. + fn from_result(test_type: TestType, node_id: NodeId, result: TestRunResult) -> Self { + NewTestRun { + node_id: node_id as i64, + test_type, + test_timestamp: OffsetDateTime::now_utc(), + time_taken_us: result.time_taken.as_micros() as i64, + ingress_noise_handshake_us: result.ingress_noise_handshake.map(duration_to_us), + egress_noise_handshake_us: result.egress_noise_handshake.map(duration_to_us), + sphinx_packet_delay_us: duration_to_us(result.sphinx_packet_delay), + packets_sent: result.packets_sent as i64, + packets_received: result.packets_received as i64, + approximate_latency_us: result.approximate_latency.map(duration_to_us), + packets_rtt_min_us: result.packets_statistics.map(|s| duration_to_us(s.minimum)), + packets_rtt_mean_us: result.packets_statistics.map(|s| duration_to_us(s.mean)), + packets_rtt_median_us: result.packets_statistics.map(|s| duration_to_us(s.median)), + packets_rtt_max_us: result.packets_statistics.map(|s| duration_to_us(s.maximum)), + packets_rtt_std_dev_us: result + .packets_statistics + .map(|s| duration_to_us(s.standard_deviation)), + sending_latency_min_us: result.sending_statistics.map(|s| duration_to_us(s.minimum)), + sending_latency_mean_us: result.sending_statistics.map(|s| duration_to_us(s.mean)), + sending_latency_median_us: result.sending_statistics.map(|s| duration_to_us(s.median)), + sending_latency_max_us: result.sending_statistics.map(|s| duration_to_us(s.maximum)), + sending_latency_std_dev_us: result + .sending_statistics + .map(|s| duration_to_us(s.standard_deviation)), + received_duplicates: result.received_duplicates, + error: result.error, + } + } + + /// Creates a new test run row for a mixnode stress test result. + pub(crate) fn from_mixnode_result(node_id: NodeId, result: TestRunResult) -> Self { + Self::from_result(TestType::Mixnode, node_id, result) + } + + /// Creates a new test run row for a gateway stress test result. + #[allow(dead_code)] + pub(crate) fn from_gateway_result(node_id: NodeId, result: TestRunResult) -> Self { + Self::from_result(TestType::Gateway, node_id, result) + } +} + +/// A row from the `testrun` table, as returned by a SELECT. +#[derive(Debug, Clone, sqlx::FromRow)] +pub(crate) struct TestRun { + pub(crate) id: i64, + + #[sqlx(flatten)] + pub(crate) inner: NewTestRun, +} + +fn us_to_duration(us: i64) -> Duration { + Duration::from_micros(us as u64) +} + +/// Reassembles a [`LatencyDistribution`] from its four flattened microsecond columns. +/// Returns `None` if any column is `NULL`; the four columns are always all-set or all-NULL +/// together (see [`NewTestRun::from_result`]). +fn latency_distribution( + min_us: Option, + mean_us: Option, + median_us: Option, + max_us: Option, + std_dev_us: Option, +) -> Option { + match (min_us, mean_us, median_us, max_us, std_dev_us) { + (Some(min), Some(mean), Some(median), Some(max), Some(std_dev)) => { + Some(LatencyDistribution { + minimum: us_to_duration(min), + mean: us_to_duration(mean), + median: us_to_duration(median), + maximum: us_to_duration(max), + standard_deviation: us_to_duration(std_dev), + }) + } + _ => None, + } +} + +/// Maps the internal enum onto its public API counterpart. Kept as a separate +/// type so `sqlx::Type` can be derived on the internal side without leaking +/// sqlx into the public request crate. +impl From for api::TestType { + fn from(t: TestType) -> Self { + match t { + TestType::Mixnode => api::TestType::Mixnode, + TestType::Gateway => api::TestType::Gateway, + } + } +} + +/// Lifts a `testrun` row into the public [`TestRunData`] shape: widens `i64` +/// ids/counters to the API's `u32`/`usize`, reconstitutes each +/// `LatencyDistribution` from its four microsecond columns, and converts +/// microsecond integers back into `std::time::Duration`. +impl From for TestRunData { + fn from(run: TestRun) -> Self { + let inner = run.inner; + TestRunData { + id: run.id, + node_id: inner.node_id as u32, + test_type: inner.test_type.into(), + test_timestamp: inner.test_timestamp, + result: TestRunResult { + time_taken: Duration::from_micros(inner.time_taken_us as u64), + ingress_noise_handshake: inner.ingress_noise_handshake_us.map(us_to_duration), + egress_noise_handshake: inner.egress_noise_handshake_us.map(us_to_duration), + sphinx_packet_delay: us_to_duration(inner.sphinx_packet_delay_us), + packets_sent: inner.packets_sent as usize, + packets_received: inner.packets_received as usize, + approximate_latency: inner.approximate_latency_us.map(us_to_duration), + packets_statistics: latency_distribution( + inner.packets_rtt_min_us, + inner.packets_rtt_mean_us, + inner.packets_rtt_median_us, + inner.packets_rtt_max_us, + inner.packets_rtt_std_dev_us, + ), + sending_statistics: latency_distribution( + inner.sending_latency_min_us, + inner.sending_latency_mean_us, + inner.sending_latency_median_us, + inner.sending_latency_max_us, + inner.sending_latency_std_dev_us, + ), + received_duplicates: inner.received_duplicates, + error: inner.error, + }, + } + } +} + +/// Projects a completed `testrun` row onto the nym-api's `StressTestResult` shape used by the +/// stress-test batch submission endpoint. +/// +/// Two fields are synthesised here rather than stored directly: +/// +/// - `test_performance` is `packets_received / packets_sent` clamped to `[0.0, 1.0]`. A run that +/// sent no packets collapses to `0.0`; `was_reachable` is the signal that lets the server tell +/// that case apart from a genuine zero score. +/// - `was_reachable` is `error.is_none()` — i.e. the test completed without an abort error. A run +/// that aborted before the node responded sets `error` to the first failure, so the inverse is +/// an accurate "did we reach the node at all" signal. +impl From for StressTestResult { + fn from(run: TestRun) -> Self { + let id = run.id; + let inner = run.inner; + + // if we have received any duplicate packets, we have to discard the entire result, + // as an honest node would never replay a packet + let test_performance = if inner.packets_sent > 0 && !inner.received_duplicates { + inner.packets_received as f64 / inner.packets_sent as f64 + } else { + 0.0 + }; + StressTestResult { + testrun_id: id, + node_id: inner.node_id as u32, + is_mixnode: matches!(inner.test_type, TestType::Mixnode), + test_timestamp: inner.test_timestamp, + test_performance, + was_reachable: inner.error.is_none(), + } + } +} + +/// The data required to insert or update a row in `nym_node`. Does not carry `last_testrun` +/// since that is managed separately via [`StorageManager::set_node_last_testrun`]. +#[derive(Debug, Clone, sqlx::FromRow)] +pub(crate) struct NewNymNode { + /// Node ID as assigned by the mixnet contract. + pub(crate) node_id: i64, + + /// Ed25519 identity key, base58-encoded. + /// A node_id always maps to exactly one identity_key and is never reassigned. + pub(crate) identity_key: String, + + /// When this node was last observed as bonded in the contract. + pub(crate) last_seen_bonded: OffsetDateTime, + + /// Mixnet socket address (host:port) at which the node accepts sphinx packets. + /// Stored as a string; parse with `str::parse::()` when needed. + pub(crate) mixnet_socket_address: Option, + + /// X25519 public key used for Noise handshakes, base58-encoded. + /// `None` if retrieval from the node failed. + pub(crate) noise_key: Option, + + /// Sphinx public key used for packet encryption, base58-encoded. + /// `None` if retrieval from the node failed. + /// Always `None`/`Some` together with `key_rotation_id`. + pub(crate) sphinx_key: Option, + + /// Key rotation epoch ID that `sphinx_key` belongs to. + /// `None` if retrieval from the node failed. + /// Always `None`/`Some` together with `sphinx_key`. + pub(crate) key_rotation_id: Option, + + /// Classification of the node based on the roles reported via its self-described endpoint. + /// [`NodeType::Unknown`] if the self-described retrieval failed. + pub(crate) node_type: NodeType, +} + +impl NewNymNode { + pub(crate) fn from_bond(bond: &NymNodeBond) -> Self { + NewNymNode { + node_id: bond.node_id as i64, + identity_key: bond.identity().to_string(), + last_seen_bonded: OffsetDateTime::now_utc(), + mixnet_socket_address: None, + noise_key: None, + sphinx_key: None, + key_rotation_id: None, + node_type: NodeType::Unknown, + } + } +} + +/// A row from the `testrun_in_progress` table. +#[derive(Debug, Clone, sqlx::FromRow)] +pub(crate) struct TestRunInProgress { + pub(crate) node_id: i64, + pub(crate) started_at: OffsetDateTime, +} + +/// Lifts a `testrun_in_progress` row into the public shape, narrowing `node_id` +/// from the sqlx-native `i64` to the API's `u32`. +impl From for TestRunInProgressData { + fn from(row: TestRunInProgress) -> Self { + TestRunInProgressData { + node_id: row.node_id as u32, + started_at: row.started_at, + } + } +} + +/// A row from the `nym_node` table, as returned by a SELECT. +#[derive(Debug, Clone, sqlx::FromRow)] +pub(crate) struct NymNode { + #[sqlx(flatten)] + pub(crate) inner: NewNymNode, + + /// ID of the most recent test run against this node. `None` if never tested. + pub(crate) last_testrun: Option, +} + +/// Decodes a node's stored base58 key strings and parses the socket address +/// into typed counterparts for the public API. Fails (with context) when any +/// stored value is malformed — this should not happen in practice because the +/// orchestrator writes these fields itself, so a failure here indicates +/// corruption or a schema regression and is surfaced as +/// [`crate::http::api::v1::error::ApiError::MalformedStoredData`] by callers. +impl TryFrom for NymNodeData { + type Error = anyhow::Error; + + fn try_from(node: NewNymNode) -> anyhow::Result { + let identity_key = ed25519::PublicKey::from_base58_string(&node.identity_key) + .context("invalid identity_key")?; + + let mixnet_socket_address = node + .mixnet_socket_address + .map(|s| s.parse().context("invalid mixnet_socket_address")) + .transpose()?; + + let noise_key = node + .noise_key + .map(|s| x25519::PublicKey::from_base58_string(&s).context("invalid noise_key")) + .transpose()?; + + let sphinx_key = node + .sphinx_key + .map(|s| x25519::PublicKey::from_base58_string(&s).context("invalid sphinx_key")) + .transpose()?; + + Ok(NymNodeData { + node_id: node.node_id as u32, + identity_key, + last_seen_bonded: node.last_seen_bonded, + mixnet_socket_address, + noise_key, + sphinx_key, + key_rotation_id: node.key_rotation_id, + }) + } +} + +/// Convenience pass-through that drops `last_testrun` (callers that need the +/// latest run fetch it explicitly via [`TestRun`]) and delegates to the +/// [`NewNymNode`] conversion for the rest of the fields. +impl TryFrom for NymNodeData { + type Error = anyhow::Error; + + fn try_from(node: NymNode) -> anyhow::Result { + node.inner.try_into() + } +} diff --git a/nym-node-status-api/nym-node-status-api/src/metrics_scraper/error.rs b/nym-node-status-api/nym-node-status-api/src/metrics_scraper/error.rs deleted file mode 100644 index 65004fbeaf..0000000000 --- a/nym-node-status-api/nym-node-status-api/src/metrics_scraper/error.rs +++ /dev/null @@ -1,155 +0,0 @@ -use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; -use nym_node_requests::api::client::NymNodeApiClientError; -use nym_validator_client::client::NodeId; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum NodeScraperError { - #[error("node {node_id} has provided malformed host information ({host}: {source}")] - MalformedHost { - host: String, - node_id: NodeId, - #[source] - source: Box, - }, - - #[error( - "node {node_id} with host '{host}' doesn't seem to expose its declared http port nor any of the standard API ports, i.e.: 80, 443 or {}", - DEFAULT_NYM_NODE_HTTP_PORT - )] - NoHttpPortsAvailable { host: String, node_id: NodeId }, -} - -#[cfg(test)] -mod tests { - use super::*; - use std::error::Error; - - fn dummy_url() -> reqwest::Url { - "http://nym.com".parse().unwrap() - } - - #[test] - #[allow(deprecated)] - fn test_malformed_host_error() { - // Create a generic error to test with - let source_error = NymNodeApiClientError::GenericRequestFailure("Invalid URL".to_string()); - let error = NodeScraperError::MalformedHost { - host: "invalid-host:abc".to_string(), - node_id: 42, - source: Box::new(source_error), - }; - - // Test error message formatting - let error_msg = error.to_string(); - assert!(error_msg.contains("node 42")); - assert!(error_msg.contains("invalid-host:abc")); - assert!(error_msg.contains("malformed host information")); - - // Test that source error is accessible - assert!(error.source().is_some()); - } - - #[test] - #[allow(deprecated)] - fn test_malformed_host_error_edge_cases() { - // Test with empty host - let error = NodeScraperError::MalformedHost { - host: "".to_string(), - node_id: 0, - source: Box::new(NymNodeApiClientError::NotFound { - url: Box::new(dummy_url()), - }), - }; - - let error_msg = error.to_string(); - assert!(error_msg.contains("node 0")); - - // Test with very long host - let long_host = "x".repeat(1000); - let error = NodeScraperError::MalformedHost { - host: long_host.clone(), - node_id: u32::MAX, - source: Box::new(NymNodeApiClientError::GenericRequestFailure( - "Too long".to_string(), - )), - }; - - let error_msg = error.to_string(); - assert!(error_msg.contains(&format!("node {}", u32::MAX))); - assert!(error_msg.contains(&long_host)); - } - - #[test] - fn test_no_http_ports_available_error() { - let error = NodeScraperError::NoHttpPortsAvailable { - host: "example.com".to_string(), - node_id: 123, - }; - - let error_msg = error.to_string(); - assert!(error_msg.contains("node 123")); - assert!(error_msg.contains("example.com")); - assert!(error_msg.contains("doesn't seem to expose its declared http port")); - assert!(error_msg.contains("80, 443 or")); - assert!(error_msg.contains(&DEFAULT_NYM_NODE_HTTP_PORT.to_string())); - - // This error type has no source - assert!(error.source().is_none()); - } - - #[test] - fn test_no_http_ports_special_characters() { - // Test with host containing special characters - let error = NodeScraperError::NoHttpPortsAvailable { - host: "test-node_123.example.com:8080".to_string(), - node_id: 999, - }; - - let error_msg = error.to_string(); - assert!(error_msg.contains("test-node_123.example.com:8080")); - } - - #[test] - #[allow(deprecated)] - fn test_error_different_sources() { - // Test with different NymNodeApiClientError variants - let not_found_error = NymNodeApiClientError::NotFound { - url: Box::new(dummy_url()), - }; - let error1 = NodeScraperError::MalformedHost { - host: "host1".to_string(), - node_id: 1, - source: Box::new(not_found_error), - }; - - let generic_error = NymNodeApiClientError::GenericRequestFailure("404 error".to_string()); - let error2 = NodeScraperError::MalformedHost { - host: "host2".to_string(), - node_id: 2, - source: Box::new(generic_error), - }; - - // Both should format differently based on their source - assert!(error1.to_string().contains("host1")); - assert!(error2.to_string().contains("host2")); - } - - #[test] - fn test_error_trait_implementation() { - // Test that NodeScraperError implements std::error::Error properly - let error = NodeScraperError::NoHttpPortsAvailable { - host: "test.com".to_string(), - node_id: 42, - }; - - // Can be used as dyn Error - let _error_ref: &dyn std::error::Error = &error; - - // Display trait is implemented - let _display = format!("{error}"); - - // Debug trait is implemented - let _debug = format!("{error:?}"); - } -} diff --git a/nym-node-status-api/nym-node-status-api/src/metrics_scraper/mod.rs b/nym-node-status-api/nym-node-status-api/src/metrics_scraper/mod.rs index ab843af8e5..797ba8384f 100644 --- a/nym-node-status-api/nym-node-status-api/src/metrics_scraper/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/metrics_scraper/mod.rs @@ -1,6 +1,5 @@ use crate::db::{DbPool, models::GatewaySessionsRecord, queries}; -use error::NodeScraperError; -use nym_network_defaults::{DEFAULT_NYM_NODE_HTTP_PORT, NymNetworkDetails}; +use nym_network_defaults::NymNetworkDetails; use nym_node_requests::api::{client::NymNodeApiClientExt, v1::metrics::models::SessionStats}; use nym_validator_client::{ client::{NodeId, NymNodeDetails}, @@ -8,14 +7,14 @@ use nym_validator_client::{ }; use time::OffsetDateTime; +use nym_bin_common::bin_info; +use nym_node_requests::try_get_valid_nym_node_api_client; use nym_statistics_common::types::SessionType; use nym_validator_client::client::NymApiClientExt; use std::collections::HashMap; use tokio::time::Duration; use tracing::instrument; -mod error; - const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60); const REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6); const STALE_DURATION: Duration = Duration::from_secs(86400 * 365); //one year @@ -131,7 +130,10 @@ impl MetricsScrapingData { #[instrument(level = "info", name = "metrics_scraper", skip_all)] async fn try_scrape_metrics(&self) -> Option { - match self.try_get_client().await { + let client_fut = + try_get_valid_nym_node_api_client(&self.host, self.node_id, self.port, bin_info!()); + + match client_fut.await { Ok(client) => { match client.get_sessions_metrics().await { Ok(session_stats) => { @@ -154,54 +156,6 @@ impl MetricsScrapingData { } } } - - async fn try_get_client(&self) -> Result { - // first try the standard port in case the operator didn't put the node behind the proxy, - // then default https (443) - // finally default http (80) - let mut addresses_to_try = vec![ - format!("http://{0}:{DEFAULT_NYM_NODE_HTTP_PORT}", self.host), // 'standard' nym-node - format!("https://{0}", self.host), // node behind https proxy (443) - format!("http://{0}", self.host), // node behind http proxy (80) - ]; - - // note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via - // the 'custom_port' since it should have been present in the contract. - - if let Some(port) = self.port { - addresses_to_try.insert(0, format!("http://{0}:{port}", self.host)); - } - - for address in addresses_to_try { - // if provided host was malformed, no point in continuing - let client = match nym_node_requests::api::Client::builder(address).and_then(|b| { - b.with_timeout(Duration::from_secs(5)) - .with_user_agent("node-status-api-metrics-scraper") - .no_hickory_dns() - .build() - }) { - Ok(client) => client, - Err(err) => { - return Err(NodeScraperError::MalformedHost { - host: self.host.to_string(), - node_id: self.node_id, - source: Box::new(err), - }); - } - }; - - if let Ok(health) = client.get_health().await { - if health.status.is_up() { - return Ok(client); - } - } - } - - Err(NodeScraperError::NoHttpPortsAvailable { - host: self.host.to_string(), - node_id: self.node_id, - }) - } } impl From for MetricsScrapingData { diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index c3f8aa0489..1a2e0b9d38 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -109,6 +109,7 @@ nym-node-requests = { workspace = true, default-features = false, features = [ "openapi", ] } nym-node-metrics = { workspace = true } +nyxd-scraper-shared = { workspace = true } # nodes: nym-gateway = { path = "../gateway" } @@ -123,9 +124,9 @@ bincode = { workspace = true } # throughput tester to recreate lioness # we don't care about particular versions - just pull whatever is used by sphinx -lioness = "*" +lioness = { workspace = true } chacha = "0.3.0" -arrayref = "*" +arrayref = { workspace = true } blake2 = "=0.8.1" sha2 = { workspace = true } hkdf = { workspace = true } diff --git a/nym-node/nym-node-metrics/src/mixnet.rs b/nym-node/nym-node-metrics/src/mixnet.rs index 75695e2cd4..2f9d6a456b 100644 --- a/nym-node/nym-node-metrics/src/mixnet.rs +++ b/nym-node/nym-node-metrics/src/mixnet.rs @@ -46,6 +46,7 @@ impl MixingStats { .store(update_timestamp, Ordering::Release); } + /// Record a packet that was dropped due to replay detection. pub fn ingress_replayed_packet(&self, source: IpAddr) { self.ingress .replayed_packets_received @@ -53,6 +54,20 @@ impl MixingStats { self.ingress.senders.entry(source).or_default().replayed += 1; } + /// Record a packet received from an authorised Network Monitor agent. + /// + /// # Purpose + /// + /// This metric tracks test traffic from network monitors, which is useful for: + /// - Distinguishing legitimate traffic from test traffic in dashboards + /// - Monitoring network monitor activity levels + /// - Debugging issues with network monitor authorisation + pub fn ingress_network_monitor_packet(&self) { + self.ingress + .test_packets_received + .fetch_add(1, Ordering::Relaxed); + } + pub fn ingress_malformed_packet(&self, source: IpAddr) { self.ingress .malformed_packets_received @@ -243,6 +258,9 @@ pub struct IngressMixingStats { // packets that were already received and processed before replayed_packets_received: AtomicUsize, + // packets that were received from a globally known network monitor agent + test_packets_received: AtomicUsize, + // (forward) packets that had invalid, i.e. too large, delays excessive_delay_packets: AtomicUsize, @@ -268,6 +286,10 @@ impl IngressMixingStats { self.replayed_packets_received.load(Ordering::Relaxed) } + pub fn test_packets_received(&self) -> usize { + self.test_packets_received.load(Ordering::Relaxed) + } + pub fn malformed_packets_received(&self) -> usize { self.malformed_packets_received.load(Ordering::Relaxed) } diff --git a/nym-node/nym-node-metrics/src/prometheus_wrapper.rs b/nym-node/nym-node-metrics/src/prometheus_wrapper.rs index ffdb66e5c0..72c4b2f4d0 100644 --- a/nym-node/nym-node-metrics/src/prometheus_wrapper.rs +++ b/nym-node/nym-node-metrics/src/prometheus_wrapper.rs @@ -54,6 +54,9 @@ pub enum PrometheusMetric { #[strum(props(help = "The number of ingress replayed sphinx packets received"))] MixnetIngressReplayedPacketsReceived, + #[strum(props(help = "The number of ingress test sphinx packets received"))] + MixnetIngressTestPacketsReceived, + #[strum(props(help = "The number of ingress malformed sphinx packets received"))] MixnetIngressMalformedPacketsReceived, @@ -254,6 +257,9 @@ impl PrometheusMetric { PrometheusMetric::MixnetIngressReplayedPacketsReceived => { Metric::new_int_gauge(&name, help) } + PrometheusMetric::MixnetIngressTestPacketsReceived => { + Metric::new_int_gauge(&name, help) + } PrometheusMetric::MixnetIngressMalformedPacketsReceived => { Metric::new_int_gauge(&name, help) } @@ -446,7 +452,7 @@ mod tests { // a sanity check for anyone adding new metrics. if this test fails, // make sure any methods on `PrometheusMetric` enum don't need updating // or require custom Display impl - assert_eq!(45, PrometheusMetric::COUNT) + assert_eq!(46, PrometheusMetric::COUNT) } #[test] diff --git a/nym-node/nym-node-requests/Cargo.toml b/nym-node/nym-node-requests/Cargo.toml index bd78701287..39d8fec616 100644 --- a/nym-node/nym-node-requests/Cargo.toml +++ b/nym-node/nym-node-requests/Cargo.toml @@ -42,6 +42,7 @@ nym-kkt-ciphersuite = { workspace = true } ## client: async-trait = { workspace = true, optional = true } nym-http-api-client = { workspace = true, optional = true } +nym-network-defaults = { workspace = true, optional = true } ## openapi: utoipa = { workspace = true, features = ["time"], optional = true } @@ -57,5 +58,8 @@ nym-crypto = { workspace = true, features = ["rand"] } [features] default = ["client"] -client = ["nym-http-api-client", "async-trait"] +client = ["nym-http-api-client", "async-trait", "nym-network-defaults"] openapi = ["utoipa", "nym-bin-common/openapi", "nym-exit-policy/openapi"] + +[lints] +workspace = true diff --git a/nym-node/nym-node-requests/src/api/helpers.rs b/nym-node/nym-node-requests/src/api/helpers.rs new file mode 100644 index 0000000000..8fe72eb548 --- /dev/null +++ b/nym-node/nym-node-requests/src/api/helpers.rs @@ -0,0 +1,255 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "client")] +pub use client_helpers::*; + +#[cfg(feature = "client")] +mod client_helpers { + use crate::api::SignedHostInformation; + use crate::api::client::NymNodeApiClientExt; + use nym_http_api_client::UserAgent; + use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; + use std::time::Duration; + + /// Builder-style helper for obtaining a validated [`crate::api::Client`] for a nym-node. + /// + /// On top of the basic port-probing performed by [`try_get_valid_nym_node_api_client`], + /// this struct optionally: + /// - verifies that the node's self-reported ed25519 identity matches an expected value + /// (e.g. the identity committed on-chain during bonding), and + /// - checks the cryptographic signature on the node's host information. + /// + /// Both checks require an extra HTTP round-trip to the node's `/host-information` endpoint + /// and are skipped when neither option is enabled. + #[derive(Debug)] + pub struct NymNodeApiClientRetriever { + /// Expected (base58-encoded) ed25519 identity of the node. + /// used to check against data retrieved from the host information + expected_identity: Option, + + /// Custom port to use when attempting to query the node. + custom_port: Option, + + /// User agent to use when attempting to query the node. + user_agent: UserAgent, + + /// Specify whether the signature on the host information should be verified. + verify_host_information: bool, + } + + impl NymNodeApiClientRetriever { + /// Creates a new retriever with the given user agent. + /// All optional checks (identity verification, host information signature) + /// are disabled by default — use the builder methods to enable them. + pub fn new(user_agent: impl Into) -> Self { + Self { + expected_identity: None, + custom_port: None, + user_agent: user_agent.into(), + verify_host_information: false, + } + } + + /// If set, the node's self-reported ed25519 identity (from its `/host-information` + /// endpoint) will be compared against this value. A mismatch produces + /// [`crate::error::Error::MismatchedIdentity`]. + #[must_use] + pub fn with_expected_identity(mut self, expected_identity: Option) -> Self { + self.expected_identity = expected_identity; + self + } + + /// Prepend `http://:` to the list of addresses probed during + /// [`get_client`](Self::get_client), so it is tried before the standard ports. + #[must_use] + pub fn with_custom_port(mut self, port: Option) -> Self { + self.custom_port = port; + self + } + + /// Enable cryptographic verification of the node's host information signature. + /// When enabled, [`get_client`](Self::get_client) will return + /// [`crate::error::Error::MissignedHostInformation`] if the signature is invalid. + #[must_use] + pub fn with_verify_host_information(mut self) -> Self { + self.verify_host_information = true; + self + } + + /// Probe the node's HTTP API, perform any configured verification, and return the + /// client together with the [`SignedHostInformation`] if it was fetched. + /// + /// The host information is only retrieved when identity verification or signature + /// checking is enabled. When neither is active, the returned + /// [`ApiClientWithHostInformation::host_information`] will be `None`. + pub async fn get_client( + self, + base_host: &str, + node_id: u32, + ) -> Result { + let base_client = try_get_valid_nym_node_api_client( + base_host, + node_id, + self.custom_port, + self.user_agent, + ) + .await?; + + // no need to retrieve host information if we don't have to perform any verification + if !self.verify_host_information && self.expected_identity.is_none() { + return Ok(base_client.into()); + } + + let host_info = retrieve_validated_host_information( + &base_client, + node_id, + &self.expected_identity, + self.verify_host_information, + ) + .await?; + + Ok(ApiClientWithHostInformation::from(base_client).with_host_information(host_info)) + } + } + + /// Fetch a node's [`SignedHostInformation`] and optionally validate it. + /// + /// This is the standalone equivalent of the checks performed inside + /// [`NymNodeApiClientRetriever::get_client`], useful when the caller already + /// holds a [`crate::api::Client`] and only needs the host information. + /// + /// When `expected_ed25519_identity` is `Some`, the node's self-reported identity + /// is compared against it — a mismatch produces [`crate::error::Error::MismatchedIdentity`]. + /// When `verify_host_information` is `true`, the cryptographic signature on the + /// host information is checked — an invalid signature produces + /// [`crate::error::Error::MissignedHostInformation`]. + pub async fn retrieve_validated_host_information( + client: &crate::api::Client, + node_id: u32, + expected_ed25519_identity: &Option, + verify_host_information: bool, + ) -> Result { + let host_info = match client.get_host_information().await { + Ok(info) => info, + Err(err) => { + return Err(crate::error::Error::QueryFailure { + host: client.current_url().to_string(), + node_id, + source: Box::new(err), + }); + } + }; + + if let Some(expected_identity) = expected_ed25519_identity { + // check if the identity key matches the information provided during bonding + if expected_identity.as_str() != host_info.keys.ed25519_identity.to_base58_string() { + return Err(crate::error::Error::MismatchedIdentity { + node_id, + expected: expected_identity.clone(), + got: host_info.keys.ed25519_identity.to_base58_string(), + }); + } + } + + // check if the host information has been signed with the node's key + if verify_host_information && !host_info.verify_host_information() { + return Err(crate::error::Error::MissignedHostInformation { node_id }); + } + + Ok(host_info) + } + + /// A nym-node API client bundled with the node's [`SignedHostInformation`], + /// if it was retrieved during the connection/verification phase. + /// + /// This avoids a redundant second call to the `/host-information` endpoint + /// when the caller also needs the host information after obtaining the client. + pub struct ApiClientWithHostInformation { + pub client: crate::api::Client, + pub host_information: Option, + } + + impl ApiClientWithHostInformation { + fn with_host_information(self, host_information: SignedHostInformation) -> Self { + Self { + host_information: Some(host_information), + ..self + } + } + } + + impl From for ApiClientWithHostInformation { + fn from(client: crate::api::Client) -> Self { + Self { + client, + host_information: None, + } + } + } + + /// Probe a nym-node's HTTP API and return a connected [`crate::api::Client`]. + /// + /// `base_host` is a hostname (e.g. `nymtech.net`) or IP address (e.g. `127.0.0.1`). + /// The function tries the following addresses in order, returning the first one whose + /// `/health` endpoint reports an "up" status: + /// + /// 1. `http://:` (only when `custom_port` is `Some`) + /// 2. `http://:8080` — the standard nym-node API port + /// 3. `https://` — node behind an HTTPS reverse proxy (port 443) + /// 4. `http://` — node behind an HTTP reverse proxy (port 80) + /// + /// This function is intended for infrastructure binaries (nym-api, network monitor, etc.), + /// not regular clients, which is why hickory DNS is explicitly disabled. + pub async fn try_get_valid_nym_node_api_client( + base_host: &str, + node_id: u32, + custom_port: Option, + user_agent: impl Into, + ) -> Result { + // first try the standard port in case the operator didn't put the node behind the proxy, + // then default https (443) + // finally default http (80) + let mut addresses_to_try = vec![ + format!("http://{base_host}:{DEFAULT_NYM_NODE_HTTP_PORT}"), // 'standard' nym-node + format!("https://{base_host}"), // node behind https proxy (443) + format!("http://{base_host}"), // node behind http proxy (80) + ]; + + // if a custom port was provided, try to connect to it first + if let Some(port) = custom_port { + addresses_to_try.insert(0, format!("http://{base_host}:{port}")); + } + + let user_agent = user_agent.into(); + for address in addresses_to_try { + // if provided base_host was malformed, there's no point in continuing + let client = match crate::api::Client::builder(address).and_then(|b| { + b.with_timeout(Duration::from_secs(5)) + .no_hickory_dns() + .with_user_agent(user_agent.clone()) + .build() + }) { + Ok(client) => client, + Err(err) => { + return Err(crate::error::Error::MalformedHost { + host: base_host.to_string(), + node_id, + source: Box::new(err), + }); + } + }; + + if let Ok(health) = client.get_health().await + && health.status.is_up() + { + return Ok(client); + } + } + + Err(crate::error::Error::NoHttpPortsAvailable { + host: base_host.to_string(), + node_id, + }) + } +} diff --git a/nym-node/nym-node-requests/src/api/mod.rs b/nym-node/nym-node-requests/src/api/mod.rs index d9c9835af0..ea539a82f7 100644 --- a/nym-node/nym-node-requests/src/api/mod.rs +++ b/nym-node/nym-node-requests/src/api/mod.rs @@ -13,6 +13,7 @@ use std::ops::Deref; #[cfg(feature = "client")] pub mod client; +pub mod helpers; pub mod v1; #[cfg(feature = "client")] diff --git a/nym-node/nym-node-requests/src/api/v1/metrics/models.rs b/nym-node/nym-node-requests/src/api/v1/metrics/models.rs index 2d0eddfda4..6113d6e4be 100644 --- a/nym-node/nym-node-requests/src/api/v1/metrics/models.rs +++ b/nym-node/nym-node-requests/src/api/v1/metrics/models.rs @@ -43,6 +43,9 @@ pub mod packets { // packets that were already received and processed before pub replayed_packets_received: usize, + // packets that were received from a globally known network monitor agent + pub test_packets_received: usize, + // (forward) packets that had invalid, i.e. too large, delays pub excessive_delay_packets: usize, diff --git a/nym-node/nym-node-requests/src/error.rs b/nym-node/nym-node-requests/src/error.rs index d14e252270..57b63e0beb 100644 --- a/nym-node/nym-node-requests/src/error.rs +++ b/nym-node/nym-node-requests/src/error.rs @@ -16,4 +16,45 @@ pub enum Error { #[from] source: nym_wireguard_types::Error, }, + + #[cfg(feature = "client")] + #[error("node {node_id} has provided malformed host information ({host}: {source})")] + MalformedHost { + host: String, + + node_id: u32, + + #[source] + source: Box, + }, + + #[cfg(feature = "client")] + #[error("failed to query node {node_id} at host {host}: {source}")] + QueryFailure { + host: String, + + node_id: u32, + + #[source] + source: Box, + }, + + #[cfg(feature = "client")] + #[error( + "node {node_id} with host '{host}' doesn't seem to expose its declared http port nor any of the standard API ports, i.e.: 80, 443 or {}", + nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT + )] + NoHttpPortsAvailable { host: String, node_id: u32 }, + + #[cfg(feature = "client")] + #[error("could not verify signed host information for node {node_id}")] + MissignedHostInformation { node_id: u32 }, + + #[cfg(feature = "client")] + #[error("identity of node {node_id} does not match. expected {expected} but got {got}")] + MismatchedIdentity { + node_id: u32, + expected: String, + got: String, + }, } diff --git a/nym-node/nym-node-requests/src/lib.rs b/nym-node/nym-node-requests/src/lib.rs index e237ba7bd1..f6b72055c7 100644 --- a/nym-node/nym-node-requests/src/lib.rs +++ b/nym-node/nym-node-requests/src/lib.rs @@ -1,11 +1,12 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -#![warn(clippy::expect_used)] -#![warn(clippy::unwrap_used)] - pub mod api; pub mod error; + +#[cfg(feature = "client")] +pub use crate::api::helpers::try_get_valid_nym_node_api_client; + macro_rules! absolute_route { ( $name:ident, $parent:expr, $suffix:expr ) => { pub fn $name() -> String { diff --git a/nym-node/src/cli/commands/reset_sphinx_keys.rs b/nym-node/src/cli/commands/reset_sphinx_keys.rs index 379196b934..804d4b3e96 100644 --- a/nym-node/src/cli/commands/reset_sphinx_keys.rs +++ b/nym-node/src/cli/commands/reset_sphinx_keys.rs @@ -47,7 +47,7 @@ pub async fn execute(args: Args) -> anyhow::Result<()> { // 1. attempt to retrieve current rotation id let current_rotation_id = - get_current_rotation_id(&config.mixnet.nym_api_urls, &config.mixnet.nyxd_urls).await?; + get_current_rotation_id(&config.mixnet.nym_api_urls, &config.nyx.nyxd_urls).await?; // 2. remove all bloomfilters info!("clearing old replay protection bloomfilters..."); diff --git a/nym-node/src/cli/commands/run/args.rs b/nym-node/src/cli/commands/run/args.rs index 2fbc4d3623..6647155438 100644 --- a/nym-node/src/cli/commands/run/args.rs +++ b/nym-node/src/cli/commands/run/args.rs @@ -3,7 +3,7 @@ use crate::cli::helpers::{ ConfigArgs, EntryGatewayArgs, ExitGatewayArgs, HostArgs, HttpArgs, LpArgs, MetricsArgs, - MixnetArgs, VerlocArgs, WireguardArgs, + MixnetArgs, NyxArgs, VerlocArgs, WireguardArgs, }; use crate::config::persistence::NymNodePaths; use crate::config::{Config, ConfigBuilder, NodeMode, NodeModes}; @@ -108,6 +108,9 @@ pub(crate) struct Args { #[clap(flatten)] http: HttpArgs, + #[clap(flatten)] + nyx: NyxArgs, + #[clap(flatten)] mixnet: MixnetArgs, @@ -170,6 +173,7 @@ impl Args { ) .with_host(self.host.build_config_section()) .with_http(self.http.build_config_section()) + .with_nyx(self.nyx.build_config_section()) .with_mixnet(self.mixnet.build_config_section(&data_dir)) .with_wireguard(self.wireguard.build_config_section(&data_dir)) .with_storage_paths(NymNodePaths::new(&data_dir)) @@ -189,6 +193,7 @@ impl Args { config.host = self.host.override_config_section(config.host); config.http = self.http.override_config_section(config.http); config.mixnet = self.mixnet.override_config_section(config.mixnet); + config.nyx = self.nyx.override_config_section(config.nyx); config.wireguard = self.wireguard.override_config_section(config.wireguard); config.metrics = self.metrics.override_config_section(config.metrics); config.gateway_tasks = self diff --git a/nym-node/src/cli/helpers.rs b/nym-node/src/cli/helpers.rs index 85e0df1b7e..970c537fa0 100644 --- a/nym-node/src/cli/helpers.rs +++ b/nym-node/src/cli/helpers.rs @@ -180,6 +180,44 @@ impl HttpArgs { } } +#[derive(clap::Args, Debug)] +pub(crate) struct NyxArgs { + /// Addresses to nyxd chain endpoint which the node will use for chain interactions. + #[clap( + long, + value_delimiter = ',', + env = NYMNODE_NYXD_URLS_ARG + )] + pub(crate) nyxd_urls: Option>, + + #[clap( + long, + env = NYMNODE_NYXD_WEBSOCKET_URL_ARG + )] + /// Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. + /// It is used for subscribing to new block events. + pub(crate) nyxd_websocket_url: Option, +} + +impl NyxArgs { + // TODO: could we perhaps make a clap error here and call `safe_exit` instead? + pub(crate) fn build_config_section(self) -> config::Nyx { + self.override_config_section(config::Nyx::default()) + } + + pub(crate) fn override_config_section(self, mut section: config::Nyx) -> config::Nyx { + if let Some(nyxd_urls) = self.nyxd_urls { + section.nyxd_urls = nyxd_urls + } + + if let Some(websocket) = self.nyxd_websocket_url { + section.nyxd_websocket_url = websocket + } + + section + } +} + #[derive(clap::Args, Debug)] pub(crate) struct MixnetArgs { /// Address this node will bind to for listening for mixnet packets @@ -207,14 +245,6 @@ pub(crate) struct MixnetArgs { )] pub(crate) nym_api_urls: Option>, - /// Addresses to nyxd chain endpoint which the node will use for chain interactions. - #[clap( - long, - value_delimiter = ',', - env = NYMNODE_NYXD_URLS_ARG - )] - pub(crate) nyxd_urls: Option>, - /// Specifies whether this node should **NOT** use noise protocol in the connections (currently not implemented) #[clap( hide = true, @@ -248,9 +278,6 @@ impl MixnetArgs { if let Some(nym_api_urls) = self.nym_api_urls { section.nym_api_urls = nym_api_urls } - if let Some(nyxd_urls) = self.nyxd_urls { - section.nyxd_urls = nyxd_urls - } if self.unsafe_disable_noise { section.debug.unsafe_disable_noise = true } diff --git a/nym-node/src/config/helpers.rs b/nym-node/src/config/helpers.rs index 8d43a10f3d..9d2bd9996b 100644 --- a/nym-node/src/config/helpers.rs +++ b/nym-node/src/config/helpers.rs @@ -18,7 +18,7 @@ fn ephemeral_gateway_config(config: &Config) -> nym_gateway::config::Config { enforce_zk_nyms: config.gateway_tasks.enforce_zk_nyms, websocket_bind_address: config.gateway_tasks.ws_bind_address, nym_api_urls: config.mixnet.nym_api_urls.clone(), - nyxd_urls: config.mixnet.nyxd_urls.clone(), + nyxd_urls: config.nyx.nyxd_urls.clone(), }, nym_gateway::config::NetworkRequester { enabled: config.service_providers.ip_packet_router.debug.enabled, @@ -79,7 +79,7 @@ pub fn base_client_config(config: &Config) -> nym_client_core_config_types::Clie id: config.id.clone(), // irrelevant field - no need for credentials in embedded mode disabled_credentials_mode: true, - nyxd_urls: config.mixnet.nyxd_urls.clone(), + nyxd_urls: config.nyx.nyxd_urls.clone(), nym_api_urls: config.mixnet.nym_api_urls.clone(), } } diff --git a/nym-node/src/config/mod.rs b/nym-node/src/config/mod.rs index 14b00b7f5e..963f1bf5dd 100644 --- a/nym-node/src/config/mod.rs +++ b/nym-node/src/config/mod.rs @@ -174,6 +174,8 @@ pub struct ConfigBuilder { pub host: Option, + pub nyx: Option, + pub http: Option, pub verloc: Option, @@ -200,6 +202,7 @@ impl ConfigBuilder { config_path, data_dir, host: None, + nyx: None, http: None, mixnet: None, verloc: None, @@ -214,56 +217,73 @@ impl ConfigBuilder { } } + #[must_use] pub fn with_modes(mut self, mode: impl Into) -> Self { self.modes = mode.into(); self } + #[must_use] + pub fn with_nyx(mut self, section: impl Into>) -> Self { + self.nyx = section.into(); + self + } + + #[must_use] pub fn with_host(mut self, section: impl Into>) -> Self { self.host = section.into(); self } + #[must_use] pub fn with_http(mut self, section: impl Into>) -> Self { self.http = section.into(); self } + #[must_use] pub fn with_verloc(mut self, section: impl Into>) -> Self { self.verloc = section.into(); self } + #[must_use] pub fn with_mixnet(mut self, section: impl Into>) -> Self { self.mixnet = section.into(); self } + #[must_use] pub fn with_lp(mut self, section: impl Into>) -> Self { self.lp = section.into(); self } + #[must_use] pub fn with_wireguard(mut self, section: impl Into>) -> Self { self.wireguard = section.into(); self } + #[must_use] pub fn with_storage_paths(mut self, section: impl Into>) -> Self { self.storage_paths = section.into(); self } + #[must_use] pub fn with_metrics(mut self, section: impl Into>) -> Self { self.metrics = section.into(); self } + #[must_use] pub fn with_gateway_tasks(mut self, section: impl Into>) -> Self { self.gateway_tasks = section.into(); self } + #[must_use] pub fn with_service_providers( mut self, section: impl Into>, @@ -302,6 +322,7 @@ impl ConfigBuilder { logging: self.logging.unwrap_or_default(), save_path: Some(self.config_path), debug: Default::default(), + nyx: self.nyx.unwrap_or_default(), }) } } @@ -326,6 +347,9 @@ pub struct Config { /// Storage paths to persistent nym-node data, such as its long term keys. pub storage_paths: NymNodePaths, + #[serde(default)] + pub nyx: Nyx, + #[serde(default)] pub http: Http, @@ -443,6 +467,7 @@ impl Config { } pub fn validate(&self) -> Result<(), NymNodeError> { + self.nyx.validate()?; self.mixnet.validate()?; // it's not allowed to run mixnode mode alongside entry mode @@ -540,6 +565,72 @@ impl Default for Http { } } +/// Configuration relevant to any interaction involving nyx chain +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct Nyx { + /// Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. + /// It is used for subscribing to new block events. + pub nyxd_websocket_url: Url, + + /// Addresses to nyxd which the node uses to interact with the nyx chain. + pub nyxd_urls: Vec, + // pub watcher: NyxdWatcher, +} + +impl Nyx { + pub fn validate(&self) -> Result<(), NymNodeError> { + if self.nyxd_urls.is_empty() { + return Err(NymNodeError::config_validation_failure( + "no nyxd urls provided", + )); + } + Ok(()) + } +} + +impl Default for Nyx { + fn default() -> Self { + // SAFETY: + // our hardcoded values should always be valid + // is if there's anything set in the environment, otherwise fallback to mainnet + #[allow(clippy::expect_used)] + let nyxd_urls = if let Ok(env_value) = env::var(var_names::NYXD_QUERY_LITE) { + // 1. try the lite node if available + parse_urls(&env_value) + } else if let Ok(env_value) = env::var(var_names::NYXD) { + // 2. then fallback to the main rpc node + parse_urls(&env_value) + } else { + // finally fallback to mainnet nodes + vec![ + mainnet::NYXD_QUERY_LITE + .parse() + .expect("invalid default nyxd lite URL"), + mainnet::NYXD_URL.parse().expect("invalid default nyxd URL"), + ] + }; + + #[allow(clippy::expect_used)] + let nyxd_websocket_url = if let Ok(env_value) = env::var(var_names::NYXD_WS_LITE) { + env_value + .parse() + .expect("malformed default nyxd lite websocket URL") + } else if let Ok(env_value) = env::var(var_names::NYXD_WEBSOCKET) { + env_value + .parse() + .expect("malformed default nyxd websocket URL") + } else { + mainnet::NYXD_WS_LITE + .parse() + .expect("invalid default mainnet nyxd websocket URL") + }; + Nyx { + nyxd_websocket_url, + nyxd_urls, + } + } +} + #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Mixnet { @@ -557,9 +648,6 @@ pub struct Mixnet { /// Addresses to nym APIs from which the node gets the view of the network. pub nym_api_urls: Vec, - /// Addresses to nyxd which the node uses to interact with the nyx chain. - pub nyxd_urls: Vec, - /// Settings for controlling replay detection pub replay_protection: ReplayProtection, @@ -578,12 +666,6 @@ impl Mixnet { )); } - if self.nyxd_urls.is_empty() { - return Err(NymNodeError::config_validation_failure( - "no nyxd urls provided", - )); - } - self.replay_protection.validate()?; Ok(()) @@ -821,29 +903,21 @@ impl Default for MixnetDebug { } impl Mixnet { + // SAFETY: + // our hardcoded values should always be valid + #[allow(clippy::expect_used)] pub fn new_default>(data_dir: P) -> Self { - // SAFETY: - // our hardcoded values should always be valid - #[allow(clippy::expect_used)] // is if there's anything set in the environment, otherwise fallback to mainnet let nym_api_urls = if let Ok(env_value) = env::var(var_names::NYM_API) { parse_urls(&env_value) } else { - vec![mainnet::NYM_API.parse().expect("Invalid default API URL")] - }; - - #[allow(clippy::expect_used)] - let nyxd_urls = if let Ok(env_value) = env::var(var_names::NYXD) { - parse_urls(&env_value) - } else { - vec![mainnet::NYXD_URL.parse().expect("Invalid default nyxd URL")] + vec![mainnet::NYM_API.parse().expect("invalid default API URL")] }; Mixnet { bind_address: SocketAddr::new(in6addr_any_init(), DEFAULT_MIXNET_PORT), announce_port: None, nym_api_urls, - nyxd_urls, replay_protection: ReplayProtection::new_default(data_dir), key_rotation: Default::default(), debug: Default::default(), diff --git a/nym-node/src/config/old_configs/mod.rs b/nym-node/src/config/old_configs/mod.rs index 2116f94ec8..6e9fa3ea28 100644 --- a/nym-node/src/config/old_configs/mod.rs +++ b/nym-node/src/config/old_configs/mod.rs @@ -5,6 +5,7 @@ mod old_config_v1; mod old_config_v10; mod old_config_v11; mod old_config_v12; +mod old_config_v13; mod old_config_v2; mod old_config_v3; mod old_config_v4; @@ -26,3 +27,4 @@ pub use old_config_v9::try_upgrade_config_v9; pub use old_config_v10::try_upgrade_config_v10; pub use old_config_v11::try_upgrade_config_v11; pub use old_config_v12::try_upgrade_config_v12; +pub use old_config_v13::try_upgrade_config_v13; diff --git a/nym-node/src/config/old_configs/old_config_v12.rs b/nym-node/src/config/old_configs/old_config_v12.rs index ce0b381e3f..170b5946ef 100644 --- a/nym-node/src/config/old_configs/old_config_v12.rs +++ b/nym-node/src/config/old_configs/old_config_v12.rs @@ -1,34 +1,15 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use crate::config::authenticator::{Authenticator, AuthenticatorDebug}; -use crate::config::gateway_tasks::{ - ClientBandwidthDebug, StaleMessageDebug, UpgradeModeWatcher, UpgradeModeWatcherDebug, - ZkNymTicketHandlerDebug, -}; use crate::config::helpers::log_error_and_return; -use crate::config::persistence::{ - AuthenticatorPaths, DEFAULT_MCELIECE_PRIVATE_KEY_FILENAME, - DEFAULT_MCELIECE_PUBLIC_KEY_FILENAME, DEFAULT_MLKEM768_PRIVATE_KEY_FILENAME, - DEFAULT_MLKEM768_PUBLIC_KEY_FILENAME, DEFAULT_X25519_PRIVATE_LP_KEY_FILENAME, - DEFAULT_X25519_PUBLIC_LP_KEY_FILENAME, GatewayTasksPaths, IpPacketRouterPaths, KeysPaths, - NetworkRequesterPaths, NymNodePaths, ReplayProtectionPaths, ServiceProvidersPaths, - WireguardPaths, -}; -use crate::config::service_providers::{ - IpPacketRouter, IpPacketRouterDebug, NetworkRequester, NetworkRequesterDebug, -}; -use crate::config::{ - Config, Debug, GatewayTasksConfig, Host, Http, KeyRotation, KeyRotationDebug, MetricsConfig, - Mixnet, MixnetDebug, NodeModes, ReplayProtection, ReplayProtectionDebug, - ServiceProvidersConfig, Verloc, VerlocDebug, Wireguard, gateway_tasks, metrics, - service_providers, +use crate::config::old_configs::old_config_v13::{ + ConfigV13, GatewayTasksConfigDebugV13, GatewayTasksConfigV13, KeysPathsV13, LpConfigV13, + LpDebugV13, NymNodePathsV13, }; use crate::error::NymNodeError; use crate::node::helpers::{ store_mceliece_keypair, store_mlkem768_keypair, store_x25519_lp_keypair, }; -use nym_bin_common::logging::LoggingSettings; use nym_config::defaults::{mainnet, var_names}; use nym_config::read_config_from_toml_file; use nym_config::serde_helpers::de_maybe_port; @@ -46,7 +27,11 @@ use std::time::Duration; use tracing::{debug, info, instrument}; use url::Url; -use crate::config::lp::{LpConfig, LpDebug}; +use crate::config::persistence::{ + DEFAULT_MCELIECE_PRIVATE_KEY_FILENAME, DEFAULT_MCELIECE_PUBLIC_KEY_FILENAME, + DEFAULT_MLKEM768_PRIVATE_KEY_FILENAME, DEFAULT_MLKEM768_PUBLIC_KEY_FILENAME, + DEFAULT_X25519_PRIVATE_LP_KEY_FILENAME, DEFAULT_X25519_PUBLIC_LP_KEY_FILENAME, +}; pub use unchanged_v12_types::*; // (while some of those are technically unused, they might be needed in future migrations, @@ -526,7 +511,7 @@ impl ConfigV12 { pub async fn try_upgrade_config_v12>( path: P, prev_config: Option, -) -> Result { +) -> Result { debug!("attempting to load v12 config..."); let old_cfg = if let Some(prev_config) = prev_config { @@ -544,7 +529,7 @@ pub async fn try_upgrade_config_v12>( .ok_or(NymNodeError::DataDirDerivationFailure)? .to_path_buf(); - let updated_keys = KeysPaths { + let updated_keys = KeysPathsV13 { private_ed25519_identity_key_file: old_cfg .storage_paths .keys @@ -586,145 +571,27 @@ pub async fn try_upgrade_config_v12>( let paths = updated_keys.mceliece_key_paths(); store_mceliece_keypair(&mceliece, &paths)?; - let cfg = Config { + let cfg = ConfigV13 { save_path: old_cfg.save_path, id: old_cfg.id, - modes: NodeModes { - mixnode: old_cfg.modes.mixnode, - entry: old_cfg.modes.entry, - exit: old_cfg.modes.exit, - }, - host: Host { - public_ips: old_cfg.host.public_ips, - hostname: old_cfg.host.hostname, - location: old_cfg.host.location, - }, - mixnet: Mixnet { - bind_address: old_cfg.mixnet.bind_address, - announce_port: old_cfg.mixnet.announce_port, - nym_api_urls: old_cfg.mixnet.nym_api_urls, - nyxd_urls: old_cfg.mixnet.nyxd_urls, - replay_protection: ReplayProtection { - storage_paths: ReplayProtectionPaths { - current_bloomfilters_directory: old_cfg - .mixnet - .replay_protection - .storage_paths - .current_bloomfilters_directory, - }, - debug: ReplayProtectionDebug { - unsafe_disabled: old_cfg.mixnet.replay_protection.debug.unsafe_disabled, - maximum_replay_detection_deferral: old_cfg - .mixnet - .replay_protection - .debug - .maximum_replay_detection_deferral, - maximum_replay_detection_pending_packets: old_cfg - .mixnet - .replay_protection - .debug - .maximum_replay_detection_pending_packets, - false_positive_rate: old_cfg.mixnet.replay_protection.debug.false_positive_rate, - initial_expected_packets_per_second: old_cfg - .mixnet - .replay_protection - .debug - .initial_expected_packets_per_second, - bloomfilter_minimum_packets_per_second_size: old_cfg - .mixnet - .replay_protection - .debug - .bloomfilter_minimum_packets_per_second_size, - bloomfilter_size_multiplier: old_cfg - .mixnet - .replay_protection - .debug - .bloomfilter_size_multiplier, - bloomfilter_disk_flushing_rate: old_cfg - .mixnet - .replay_protection - .debug - .bloomfilter_disk_flushing_rate, - }, - }, - key_rotation: KeyRotation { - debug: KeyRotationDebug { - rotation_state_poling_interval: old_cfg - .mixnet - .key_rotation - .debug - .rotation_state_poling_interval, - }, - }, - debug: MixnetDebug { - maximum_forward_packet_delay: old_cfg.mixnet.debug.maximum_forward_packet_delay, - packet_forwarding_initial_backoff: old_cfg - .mixnet - .debug - .packet_forwarding_initial_backoff, - packet_forwarding_maximum_backoff: old_cfg - .mixnet - .debug - .packet_forwarding_maximum_backoff, - initial_connection_timeout: old_cfg.mixnet.debug.initial_connection_timeout, - maximum_connection_buffer_size: old_cfg.mixnet.debug.maximum_connection_buffer_size, - unsafe_disable_noise: old_cfg.mixnet.debug.unsafe_disable_noise, - use_legacy_packet_encoding: old_cfg.mixnet.debug.use_legacy_packet_encoding, - }, - }, - storage_paths: NymNodePaths { + modes: old_cfg.modes, + host: old_cfg.host, + mixnet: old_cfg.mixnet, + // \/ UPDATED + storage_paths: NymNodePathsV13 { keys: updated_keys, description: old_cfg.storage_paths.description, }, - http: Http { - bind_address: old_cfg.http.bind_address, - landing_page_assets_path: old_cfg.http.landing_page_assets_path, - access_token: old_cfg.http.access_token, - expose_system_info: old_cfg.http.expose_system_info, - expose_system_hardware: old_cfg.http.expose_system_hardware, - expose_crypto_hardware: old_cfg.http.expose_crypto_hardware, - node_load_cache_ttl: old_cfg.http.node_load_cache_ttl, - }, - verloc: Verloc { - bind_address: old_cfg.verloc.bind_address, - announce_port: old_cfg.verloc.announce_port, - debug: VerlocDebug { - packets_per_node: old_cfg.verloc.debug.packets_per_node, - connection_timeout: old_cfg.verloc.debug.connection_timeout, - packet_timeout: old_cfg.verloc.debug.packet_timeout, - delay_between_packets: old_cfg.verloc.debug.delay_between_packets, - tested_nodes_batch_size: old_cfg.verloc.debug.tested_nodes_batch_size, - testing_interval: old_cfg.verloc.debug.testing_interval, - retry_timeout: old_cfg.verloc.debug.retry_timeout, - }, - }, - wireguard: Wireguard { - enabled: old_cfg.wireguard.enabled, - bind_address: old_cfg.wireguard.bind_address, - private_ipv4: old_cfg.wireguard.private_ipv4, - private_ipv6: old_cfg.wireguard.private_ipv6, - announced_tunnel_port: old_cfg.wireguard.announced_tunnel_port, - announced_metadata_port: old_cfg.wireguard.announced_metadata_port, - private_network_prefix_v4: old_cfg.wireguard.private_network_prefix_v4, - private_network_prefix_v6: old_cfg.wireguard.private_network_prefix_v6, - use_userspace: old_cfg.wireguard.use_userspace, - storage_paths: WireguardPaths { - private_diffie_hellman_key_file: old_cfg - .wireguard - .storage_paths - .private_diffie_hellman_key_file, - public_diffie_hellman_key_file: old_cfg - .wireguard - .storage_paths - .public_diffie_hellman_key_file, - }, - }, - lp: LpConfig { + // /\ UPDATED + http: old_cfg.http, + verloc: old_cfg.verloc, + wireguard: old_cfg.wireguard, + lp: LpConfigV13 { control_bind_address: old_cfg.gateway_tasks.lp.control_bind_address, data_bind_address: old_cfg.gateway_tasks.lp.data_bind_address, announce_control_port: old_cfg.gateway_tasks.lp.announce_control_port, announce_data_port: old_cfg.gateway_tasks.lp.announce_data_port, - debug: LpDebug { + debug: LpDebugV13 { max_connections: old_cfg.gateway_tasks.lp.debug.max_connections, use_mock_ecash: old_cfg.gateway_tasks.lp.debug.use_mock_ecash, handshake_ttl: old_cfg.gateway_tasks.lp.debug.handshake_ttl, @@ -733,290 +600,35 @@ pub async fn try_upgrade_config_v12>( max_concurrent_forwards: old_cfg.gateway_tasks.lp.debug.max_concurrent_forwards, }, }, - gateway_tasks: GatewayTasksConfig { - storage_paths: GatewayTasksPaths { - clients_storage: old_cfg.gateway_tasks.storage_paths.clients_storage, - stats_storage: old_cfg.gateway_tasks.storage_paths.stats_storage, - cosmos_mnemonic: old_cfg.gateway_tasks.storage_paths.cosmos_mnemonic, - bridge_client_params: old_cfg.gateway_tasks.storage_paths.bridge_client_params, - }, + gateway_tasks: GatewayTasksConfigV13 { + storage_paths: old_cfg.gateway_tasks.storage_paths, enforce_zk_nyms: old_cfg.gateway_tasks.enforce_zk_nyms, ws_bind_address: old_cfg.gateway_tasks.ws_bind_address, announce_ws_port: old_cfg.gateway_tasks.announce_ws_port, announce_wss_port: old_cfg.gateway_tasks.announce_wss_port, - upgrade_mode: UpgradeModeWatcher { - enabled: old_cfg.gateway_tasks.upgrade_mode.enabled, - attestation_url: old_cfg.gateway_tasks.upgrade_mode.attestation_url, - attester_public_key: old_cfg.gateway_tasks.upgrade_mode.attester_public_key, - debug: UpgradeModeWatcherDebug { - regular_polling_interval: old_cfg - .gateway_tasks - .upgrade_mode - .debug - .regular_polling_interval, - expedited_poll_interval: old_cfg - .gateway_tasks - .upgrade_mode - .debug - .expedited_poll_interval, - }, - }, - debug: gateway_tasks::Debug { + upgrade_mode: old_cfg.gateway_tasks.upgrade_mode, + debug: GatewayTasksConfigDebugV13 { message_retrieval_limit: old_cfg.gateway_tasks.debug.message_retrieval_limit, maximum_open_connections: old_cfg.gateway_tasks.debug.maximum_open_connections, minimum_mix_performance: old_cfg.gateway_tasks.debug.minimum_mix_performance, // \/ ADDED - maximum_initial_topology_waiting_time: gateway_tasks::Debug::default() + maximum_initial_topology_waiting_time: GatewayTasksConfigDebugV13::default() .maximum_initial_topology_waiting_time, // /\ ADDED max_request_timestamp_skew: old_cfg.gateway_tasks.debug.max_request_timestamp_skew, - stale_messages: StaleMessageDebug { - cleaner_run_interval: old_cfg - .gateway_tasks - .debug - .stale_messages - .cleaner_run_interval, - max_age: old_cfg.gateway_tasks.debug.stale_messages.max_age, - }, - client_bandwidth: ClientBandwidthDebug { - max_flushing_rate: old_cfg - .gateway_tasks - .debug - .client_bandwidth - .max_flushing_rate, - max_delta_flushing_amount: old_cfg - .gateway_tasks - .debug - .client_bandwidth - .max_delta_flushing_amount, - }, - zk_nym_tickets: ZkNymTicketHandlerDebug { - revocation_bandwidth_penalty: old_cfg - .gateway_tasks - .debug - .zk_nym_tickets - .revocation_bandwidth_penalty, - pending_poller: old_cfg.gateway_tasks.debug.zk_nym_tickets.pending_poller, - minimum_api_quorum: old_cfg - .gateway_tasks - .debug - .zk_nym_tickets - .minimum_api_quorum, - minimum_redemption_tickets: old_cfg - .gateway_tasks - .debug - .zk_nym_tickets - .minimum_redemption_tickets, - maximum_time_between_redemption: old_cfg - .gateway_tasks - .debug - .zk_nym_tickets - .maximum_time_between_redemption, - }, + stale_messages: old_cfg.gateway_tasks.debug.stale_messages, + client_bandwidth: old_cfg.gateway_tasks.debug.client_bandwidth, + zk_nym_tickets: old_cfg.gateway_tasks.debug.zk_nym_tickets, upgrade_mode_min_staleness_recheck: old_cfg .gateway_tasks .debug .upgrade_mode_min_staleness_recheck, }, }, - service_providers: ServiceProvidersConfig { - storage_paths: ServiceProvidersPaths { - clients_storage: old_cfg.service_providers.storage_paths.clients_storage, - stats_storage: old_cfg.service_providers.storage_paths.stats_storage, - network_requester: NetworkRequesterPaths { - private_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .network_requester - .private_ed25519_identity_key_file, - public_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .network_requester - .public_ed25519_identity_key_file, - private_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .network_requester - .private_x25519_diffie_hellman_key_file, - public_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .network_requester - .public_x25519_diffie_hellman_key_file, - ack_key_file: old_cfg - .service_providers - .storage_paths - .network_requester - .ack_key_file, - reply_surb_database: old_cfg - .service_providers - .storage_paths - .network_requester - .reply_surb_database, - gateway_registrations: old_cfg - .service_providers - .storage_paths - .network_requester - .gateway_registrations, - }, - ip_packet_router: IpPacketRouterPaths { - private_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .private_ed25519_identity_key_file, - public_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .public_ed25519_identity_key_file, - private_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .private_x25519_diffie_hellman_key_file, - public_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .public_x25519_diffie_hellman_key_file, - ack_key_file: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .ack_key_file, - reply_surb_database: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .reply_surb_database, - gateway_registrations: old_cfg - .service_providers - .storage_paths - .ip_packet_router - .gateway_registrations, - }, - authenticator: AuthenticatorPaths { - private_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .authenticator - .private_ed25519_identity_key_file, - public_ed25519_identity_key_file: old_cfg - .service_providers - .storage_paths - .authenticator - .public_ed25519_identity_key_file, - private_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .authenticator - .private_x25519_diffie_hellman_key_file, - public_x25519_diffie_hellman_key_file: old_cfg - .service_providers - .storage_paths - .authenticator - .public_x25519_diffie_hellman_key_file, - ack_key_file: old_cfg - .service_providers - .storage_paths - .authenticator - .ack_key_file, - reply_surb_database: old_cfg - .service_providers - .storage_paths - .authenticator - .reply_surb_database, - gateway_registrations: old_cfg - .service_providers - .storage_paths - .authenticator - .gateway_registrations, - }, - }, - open_proxy: old_cfg.service_providers.open_proxy, - upstream_exit_policy_url: old_cfg.service_providers.upstream_exit_policy_url, - network_requester: NetworkRequester { - allow_local_ips: false, - debug: NetworkRequesterDebug { - enabled: old_cfg.service_providers.network_requester.debug.enabled, - disable_poisson_rate: old_cfg - .service_providers - .network_requester - .debug - .disable_poisson_rate, - client_debug: old_cfg - .service_providers - .network_requester - .debug - .client_debug, - }, - }, - ip_packet_router: IpPacketRouter { - allow_local_ips: false, - debug: IpPacketRouterDebug { - enabled: old_cfg.service_providers.ip_packet_router.debug.enabled, - disable_poisson_rate: old_cfg - .service_providers - .ip_packet_router - .debug - .disable_poisson_rate, - client_debug: old_cfg - .service_providers - .ip_packet_router - .debug - .client_debug, - }, - }, - authenticator: Authenticator { - debug: AuthenticatorDebug { - enabled: old_cfg.service_providers.authenticator.debug.enabled, - disable_poisson_rate: old_cfg - .service_providers - .authenticator - .debug - .disable_poisson_rate, - client_debug: old_cfg.service_providers.authenticator.debug.client_debug, - }, - }, - debug: service_providers::Debug { - message_retrieval_limit: old_cfg.service_providers.debug.message_retrieval_limit, - }, - }, - metrics: MetricsConfig { - debug: metrics::Debug { - log_stats_to_console: old_cfg.metrics.debug.log_stats_to_console, - aggregator_update_rate: old_cfg.metrics.debug.aggregator_update_rate, - stale_mixnet_metrics_cleaner_rate: old_cfg - .metrics - .debug - .stale_mixnet_metrics_cleaner_rate, - global_prometheus_counters_update_rate: old_cfg - .metrics - .debug - .global_prometheus_counters_update_rate, - pending_egress_packets_update_rate: old_cfg - .metrics - .debug - .pending_egress_packets_update_rate, - clients_sessions_update_rate: old_cfg.metrics.debug.clients_sessions_update_rate, - console_logging_update_interval: old_cfg - .metrics - .debug - .console_logging_update_interval, - legacy_mixing_metrics_update_rate: old_cfg - .metrics - .debug - .legacy_mixing_metrics_update_rate, - }, - }, - logging: LoggingSettings {}, - debug: Debug { - topology_cache_ttl: old_cfg.debug.topology_cache_ttl, - routing_nodes_check_interval: old_cfg.debug.routing_nodes_check_interval, - testnet: old_cfg.debug.testnet, - }, + service_providers: old_cfg.service_providers, + metrics: old_cfg.metrics, + logging: old_cfg.logging, + debug: old_cfg.debug, }; Ok(cfg) } diff --git a/nym-node/src/config/old_configs/old_config_v13.rs b/nym-node/src/config/old_configs/old_config_v13.rs new file mode 100644 index 0000000000..b62320a7e7 --- /dev/null +++ b/nym-node/src/config/old_configs/old_config_v13.rs @@ -0,0 +1,923 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::config::authenticator::{Authenticator, AuthenticatorDebug}; +use crate::config::gateway_tasks::{ + ClientBandwidthDebug, StaleMessageDebug, UpgradeModeWatcher, UpgradeModeWatcherDebug, + ZkNymTicketHandlerDebug, +}; +use crate::config::old_configs::old_config_v13::unchanged_v13_types::{ + ClientBandwidthDebugV13, DebugV13, GatewayTasksPathsV13, HostV13, HttpV13, LoggingSettingsV13, + MetricsConfigV13, MixnetV13, NodeModesV13, ServiceProvidersConfigV13, StaleMessageDebugV13, + UpgradeModeWatcherV13, VerlocV13, WireguardV13, ZkNymTicketHandlerDebugV13, +}; +use crate::config::persistence::{ + AuthenticatorPaths, GatewayTasksPaths, IpPacketRouterPaths, KeysPaths, NetworkRequesterPaths, + NymNodePaths, ReplayProtectionPaths, ServiceProvidersPaths, WireguardPaths, +}; +use crate::config::service_providers::{ + IpPacketRouter, IpPacketRouterDebug, NetworkRequester, NetworkRequesterDebug, +}; +use crate::config::{ + Config, Debug, GatewayTasksConfig, Host, Http, KeyRotation, KeyRotationDebug, LpConfig, + LpDebug, MetricsConfig, Mixnet, MixnetDebug, NodeModes, Nyx, ReplayProtection, + ReplayProtectionDebug, ServiceProvidersConfig, Verloc, VerlocDebug, Wireguard, gateway_tasks, + metrics, service_providers, +}; +use crate::error::NymNodeError; +use nym_bin_common::logging::LoggingSettings; +use nym_config::read_config_from_toml_file; +use nym_config::serde_helpers::de_maybe_port; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tracing::{debug, info, instrument}; + +#[allow(unused_imports)] +pub use unchanged_v13_types::*; + +// (while some of those are technically unused, they might be needed in future migrations, +// thus allow them to exist) +#[allow(dead_code)] +pub mod unchanged_v13_types { + use crate::config::old_configs::old_config_v12::{ + AuthenticatorDebugV12, AuthenticatorPathsV12, AuthenticatorV12, ClientBandwidthDebugV12, + DebugV12, GatewayTasksPathsV12, HostV12, HttpV12, IpPacketRouterDebugV12, + IpPacketRouterPathsV12, IpPacketRouterV12, KeyRotationDebugV12, KeyRotationV12, + KeysPathsV12, LoggingSettingsV12, MetricsConfigV12, MetricsDebugV12, MixnetDebugV12, + MixnetV12, NetworkRequesterDebugV12, NetworkRequesterPathsV12, NetworkRequesterV12, + NodeModeV12, NodeModesV12, ReplayProtectionDebugV12, ReplayProtectionPathsV12, + ReplayProtectionV12, ServiceProvidersConfigDebugV12, ServiceProvidersConfigV12, + ServiceProvidersPathsV12, StaleMessageDebugV12, UpgradeModeWatcherV12, VerlocDebugV12, + VerlocV12, WireguardPathsV12, WireguardV12, ZkNymTicketHandlerDebugV12, + }; + + pub type WireguardPathsV13 = WireguardPathsV12; + pub type NodeModeV13 = NodeModeV12; + pub type NodeModesV13 = NodeModesV12; + pub type HostV13 = HostV12; + pub type KeyRotationDebugV13 = KeyRotationDebugV12; + pub type KeyRotationV13 = KeyRotationV12; + pub type MixnetDebugV13 = MixnetDebugV12; + pub type MixnetV13 = MixnetV12; + pub type ReplayProtectionV13 = ReplayProtectionV12; + pub type ReplayProtectionPathsV13 = ReplayProtectionPathsV12; + pub type ReplayProtectionDebugV13 = ReplayProtectionDebugV12; + pub type KeysPathsV13 = KeysPathsV12; + pub type HttpV13 = HttpV12; + pub type VerlocDebugV13 = VerlocDebugV12; + pub type VerlocV13 = VerlocV12; + pub type ZkNymTicketHandlerDebugV13 = ZkNymTicketHandlerDebugV12; + pub type NetworkRequesterPathsV13 = NetworkRequesterPathsV12; + pub type IpPacketRouterPathsV13 = IpPacketRouterPathsV12; + pub type AuthenticatorPathsV13 = AuthenticatorPathsV12; + pub type AuthenticatorV13 = AuthenticatorV12; + pub type AuthenticatorDebugV13 = AuthenticatorDebugV12; + pub type IpPacketRouterDebugV13 = IpPacketRouterDebugV12; + pub type IpPacketRouterV13 = IpPacketRouterV12; + pub type NetworkRequesterDebugV13 = NetworkRequesterDebugV12; + pub type NetworkRequesterV13 = NetworkRequesterV12; + pub type GatewayTasksPathsV13 = GatewayTasksPathsV12; + pub type StaleMessageDebugV13 = StaleMessageDebugV12; + pub type ClientBandwidthDebugV13 = ClientBandwidthDebugV12; + pub type ServiceProvidersPathsV13 = ServiceProvidersPathsV12; + pub type ServiceProvidersConfigDebugV13 = ServiceProvidersConfigDebugV12; + pub type ServiceProvidersConfigV13 = ServiceProvidersConfigV12; + pub type MetricsConfigV13 = MetricsConfigV12; + pub type MetricsDebugV13 = MetricsDebugV12; + pub type LoggingSettingsV13 = LoggingSettingsV12; + pub type WireguardV13 = WireguardV12; + pub type DebugV13 = DebugV12; + pub type UpgradeModeWatcherV13 = UpgradeModeWatcherV12; +} + +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct LpConfigV13 { + /// Bind address for the TCP LP control traffic. + /// default: `[::]:41264` + pub control_bind_address: SocketAddr, + + /// Bind address for the UDP LP data traffic. + /// default: `[::]:51264` + pub data_bind_address: SocketAddr, + + /// Custom announced port for listening for the TCP LP control traffic. + /// If unspecified, the value from the `control_bind_address` will be used instead + /// (default: None) + #[serde(deserialize_with = "de_maybe_port")] + pub announce_control_port: Option, + + /// Custom announced port for listening for the UDP LP data traffic. + /// If unspecified, the value from the `data_bind_address` will be used instead + /// (default: None) + #[serde(deserialize_with = "de_maybe_port")] + pub announce_data_port: Option, + + /// Auxiliary configuration + #[serde(default)] + pub debug: LpDebugV13, +} + +impl Default for LpConfigV13 { + fn default() -> Self { + LpConfigV13 { + control_bind_address: SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 41264), + data_bind_address: SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 51264), + announce_control_port: None, + announce_data_port: None, + debug: Default::default(), + } + } +} + +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct LpDebugV13 { + /// Maximum concurrent connections + pub max_connections: usize, + + /// Use mock ecash manager for testing (default: false) + /// + /// When enabled, the LP listener will use a mock ecash verifier that + /// accepts any credential without blockchain verification. This is + /// useful for testing the LP protocol implementation without requiring + /// a full blockchain/contract setup. + /// + /// WARNING: Only use this for local testing! Never enable in production. + pub use_mock_ecash: bool, + + /// Maximum age of in-progress handshakes before cleanup (default: 90s) + /// + /// Handshakes should complete quickly (3-5 packets). This TTL accounts for: + /// - Network latency and retransmits + /// - Slow clients + /// - Clock skew tolerance + /// + /// Stale handshakes are removed by the cleanup task to prevent memory leaks. + #[serde(with = "humantime_serde")] + pub handshake_ttl: Duration, + + /// Maximum age of established sessions before cleanup (default: 24h) + /// + /// Sessions can be long-lived for dVPN tunnels. This TTL should be set + /// high enough to accommodate expected usage patterns: + /// - dVPN sessions: hours to days + /// - Registration: minutes + /// + /// Sessions with no activity for this duration are removed by the cleanup task. + #[serde(with = "humantime_serde")] + pub session_ttl: Duration, + + /// How often to run the state cleanup task (default: 5 minutes) + /// + /// The cleanup task scans for and removes stale handshakes and sessions. + /// Lower values = more frequent cleanup but higher overhead. + /// Higher values = less overhead but slower memory reclamation. + #[serde(with = "humantime_serde")] + pub state_cleanup_interval: Duration, + + /// Maximum concurrent forward connections (default: 1000) + /// + /// Limits simultaneous outbound connections when forwarding LP packets to other gateways + /// during telescope setup. This prevents file descriptor exhaustion under high load. + /// + /// When at capacity, new forward requests return an error, signaling the client + /// to choose a different gateway. + pub max_concurrent_forwards: usize, +} + +impl LpDebugV13 { + pub const DEFAULT_MAX_CONNECTIONS: usize = 10000; + pub const DEFAULT_HANDSHAKE_TTL: Duration = Duration::from_secs(90); + pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(86400); + pub const DEFAULT_STATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(300); + pub const DEFAULT_MAX_CONCURRENT_FORWARDS: usize = 1000; +} + +impl Default for LpDebugV13 { + fn default() -> Self { + LpDebugV13 { + max_connections: Self::DEFAULT_MAX_CONNECTIONS, + use_mock_ecash: false, + handshake_ttl: Self::DEFAULT_HANDSHAKE_TTL, + session_ttl: Self::DEFAULT_SESSION_TTL, + state_cleanup_interval: Self::DEFAULT_STATE_CLEANUP_INTERVAL, + max_concurrent_forwards: Self::DEFAULT_MAX_CONCURRENT_FORWARDS, + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] +pub struct KeysPathsV13 { + /// Path to file containing ed25519 identity private key. + pub private_ed25519_identity_key_file: PathBuf, + + /// Path to file containing ed25519 identity public key. + pub public_ed25519_identity_key_file: PathBuf, + + /// Path to file containing the primary x25519 sphinx private key. + pub primary_x25519_sphinx_key_file: PathBuf, + + /// Path to file containing the secondary x25519 sphinx private key. + pub secondary_x25519_sphinx_key_file: PathBuf, + + /// Path to file containing x25519 noise private key. + pub private_x25519_noise_key_file: PathBuf, + + /// Path to file containing x25519 noise public key. + pub public_x25519_noise_key_file: PathBuf, + + // >> LP KEYS START: + /// Path to file containing x25519 lp private key. + pub private_x25519_lp_key_file: PathBuf, + + /// Path to file containing x25519 lp public key. + pub public_x25519_lp_key_file: PathBuf, + + /// Path to file containing mlkem768 lp private key. + pub private_mlkem768_lp_key_file: PathBuf, + + /// Path to file containing mlkem768 lp public key. + pub public_mlkem768_lp_key_file: PathBuf, + + /// Path to file containing mceliece lp private key. + pub private_mceliece_lp_key_file: PathBuf, + + /// Path to file containing mceliece lp public key. + pub public_mceliece_lp_key_file: PathBuf, + // >> LP KEYS END +} + +impl KeysPathsV13 { + pub fn x25519_lp_key_paths(&self) -> nym_pemstore::KeyPairPath { + nym_pemstore::KeyPairPath::new( + &self.private_x25519_lp_key_file, + &self.public_x25519_lp_key_file, + ) + } + + pub fn mlkem768_key_paths(&self) -> nym_pemstore::KeyPairPath { + nym_pemstore::KeyPairPath::new( + &self.private_mlkem768_lp_key_file, + &self.public_mlkem768_lp_key_file, + ) + } + + pub fn mceliece_key_paths(&self) -> nym_pemstore::KeyPairPath { + nym_pemstore::KeyPairPath::new( + &self.private_mceliece_lp_key_file, + &self.public_mceliece_lp_key_file, + ) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct NymNodePathsV13 { + pub keys: KeysPathsV13, + pub description: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub struct GatewayTasksConfigDebugV13 { + /// Number of messages from offline client that can be pulled at once (i.e. with a single SQL query) from the storage. + pub message_retrieval_limit: i64, + + /// The maximum number of client connections the gateway will keep open at once. + pub maximum_open_connections: usize, + + /// Specifies the minimum performance of mixnodes in the network that are to be used in internal topologies + /// of the services providers + pub minimum_mix_performance: u8, + + /// Specifies the maximum time this node will wait for its initial valid topology + #[serde(with = "humantime_serde")] + pub maximum_initial_topology_waiting_time: Duration, + + /// Defines the timestamp skew of a signed authentication request before it's deemed too excessive to process. + #[serde(alias = "maximum_auth_request_age")] + pub max_request_timestamp_skew: Duration, + + /// The minimum duration since the last explicit check for the upgrade mode to allow creation of new requests. + #[serde(with = "humantime_serde")] + pub upgrade_mode_min_staleness_recheck: Duration, + + pub stale_messages: StaleMessageDebugV13, + + pub client_bandwidth: ClientBandwidthDebugV13, + + pub zk_nym_tickets: ZkNymTicketHandlerDebugV13, +} + +impl GatewayTasksConfigDebugV13 { + pub const DEFAULT_MESSAGE_RETRIEVAL_LIMIT: i64 = 100; + pub const DEFAULT_MINIMUM_MIX_PERFORMANCE: u8 = 50; + pub const DEFAULT_MAXIMUM_AUTH_REQUEST_TIMESTAMP_SKEW: Duration = Duration::from_secs(120); + pub const DEFAULT_MAXIMUM_OPEN_CONNECTIONS: usize = 8192; + pub const DEFAULT_UPGRADE_MODE_MIN_STALENESS_RECHECK: Duration = Duration::from_secs(30); + pub const DEFAULT_MAXIMUM_INITIAL_TOPOLOGY_WAITING_TIME: Duration = + Duration::from_secs(15 * 60); +} + +impl Default for GatewayTasksConfigDebugV13 { + fn default() -> Self { + GatewayTasksConfigDebugV13 { + message_retrieval_limit: Self::DEFAULT_MESSAGE_RETRIEVAL_LIMIT, + maximum_open_connections: Self::DEFAULT_MAXIMUM_OPEN_CONNECTIONS, + max_request_timestamp_skew: Self::DEFAULT_MAXIMUM_AUTH_REQUEST_TIMESTAMP_SKEW, + minimum_mix_performance: Self::DEFAULT_MINIMUM_MIX_PERFORMANCE, + stale_messages: Default::default(), + client_bandwidth: Default::default(), + zk_nym_tickets: Default::default(), + upgrade_mode_min_staleness_recheck: Self::DEFAULT_UPGRADE_MODE_MIN_STALENESS_RECHECK, + maximum_initial_topology_waiting_time: + Self::DEFAULT_MAXIMUM_INITIAL_TOPOLOGY_WAITING_TIME, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GatewayTasksConfigV13 { + pub storage_paths: GatewayTasksPathsV13, + + /// Indicates whether this gateway is accepting only zk-nym credentials for accessing the mixnet + /// or if it also accepts non-paying clients + pub enforce_zk_nyms: bool, + + /// Socket address this node will use for binding its client websocket API. + /// default: `[::]:9000` + pub ws_bind_address: SocketAddr, + + /// Custom announced port for listening for websocket client traffic. + /// If unspecified, the value from the `bind_address` will be used instead + /// default: None + #[serde(deserialize_with = "de_maybe_port")] + pub announce_ws_port: Option, + + /// If applicable, announced port for listening for secure websocket client traffic. + /// (default: None) + #[serde(deserialize_with = "de_maybe_port")] + pub announce_wss_port: Option, + + pub upgrade_mode: UpgradeModeWatcherV13, + + #[serde(default)] + pub debug: GatewayTasksConfigDebugV13, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigV13 { + // additional metadata holding on-disk location of this config file + #[serde(skip)] + pub(crate) save_path: Option, + + /// Human-readable ID of this particular node. + pub id: String, + + /// Current modes of this nym-node. + pub modes: NodeModesV13, + + pub host: HostV13, + + pub mixnet: MixnetV13, + + /// Storage paths to persistent nym-node data, such as its long term keys. + pub storage_paths: NymNodePathsV13, + + #[serde(default)] + pub http: HttpV13, + + #[serde(default)] + pub verloc: VerlocV13, + + pub wireguard: WireguardV13, + + #[serde(default)] + pub lp: LpConfigV13, + + #[serde(alias = "entry_gateway")] + pub gateway_tasks: GatewayTasksConfigV13, + + #[serde(alias = "exit_gateway")] + pub service_providers: ServiceProvidersConfigV13, + + #[serde(default)] + pub metrics: MetricsConfigV13, + + #[serde(default)] + pub logging: LoggingSettingsV13, + + #[serde(default)] + pub debug: DebugV13, +} + +impl ConfigV13 { + // simple wrapper that reads config file and assigns path location + fn read_from_path>(path: P) -> Result { + let path = path.as_ref(); + let mut loaded: ConfigV13 = + read_config_from_toml_file(path).map_err(|source| NymNodeError::ConfigLoadFailure { + path: path.to_path_buf(), + source, + })?; + loaded.save_path = Some(path.to_path_buf()); + debug!("loaded config file from {}", path.display()); + Ok(loaded) + } +} + +#[instrument(skip_all)] +pub async fn try_upgrade_config_v13>( + path: P, + prev_config: Option, +) -> Result { + debug!("attempting to load v13 config..."); + + let old_cfg = if let Some(prev_config) = prev_config { + prev_config + } else { + ConfigV13::read_from_path(&path)? + }; + + info!("migrating the old config (v13)..."); + + let cfg = Config { + save_path: old_cfg.save_path, + id: old_cfg.id, + modes: NodeModes { + mixnode: old_cfg.modes.mixnode, + entry: old_cfg.modes.entry, + exit: old_cfg.modes.exit, + }, + host: Host { + public_ips: old_cfg.host.public_ips, + hostname: old_cfg.host.hostname, + location: old_cfg.host.location, + }, + // \/ ADDED + // use default to switch to the lite query node + nyx: Nyx::default(), + // /\ ADDED + mixnet: Mixnet { + bind_address: old_cfg.mixnet.bind_address, + announce_port: old_cfg.mixnet.announce_port, + nym_api_urls: old_cfg.mixnet.nym_api_urls, + replay_protection: ReplayProtection { + storage_paths: ReplayProtectionPaths { + current_bloomfilters_directory: old_cfg + .mixnet + .replay_protection + .storage_paths + .current_bloomfilters_directory, + }, + debug: ReplayProtectionDebug { + unsafe_disabled: old_cfg.mixnet.replay_protection.debug.unsafe_disabled, + maximum_replay_detection_deferral: old_cfg + .mixnet + .replay_protection + .debug + .maximum_replay_detection_deferral, + maximum_replay_detection_pending_packets: old_cfg + .mixnet + .replay_protection + .debug + .maximum_replay_detection_pending_packets, + false_positive_rate: old_cfg.mixnet.replay_protection.debug.false_positive_rate, + initial_expected_packets_per_second: old_cfg + .mixnet + .replay_protection + .debug + .initial_expected_packets_per_second, + bloomfilter_minimum_packets_per_second_size: old_cfg + .mixnet + .replay_protection + .debug + .bloomfilter_minimum_packets_per_second_size, + bloomfilter_size_multiplier: old_cfg + .mixnet + .replay_protection + .debug + .bloomfilter_size_multiplier, + bloomfilter_disk_flushing_rate: old_cfg + .mixnet + .replay_protection + .debug + .bloomfilter_disk_flushing_rate, + }, + }, + key_rotation: KeyRotation { + debug: KeyRotationDebug { + rotation_state_poling_interval: old_cfg + .mixnet + .key_rotation + .debug + .rotation_state_poling_interval, + }, + }, + debug: MixnetDebug { + maximum_forward_packet_delay: old_cfg.mixnet.debug.maximum_forward_packet_delay, + packet_forwarding_initial_backoff: old_cfg + .mixnet + .debug + .packet_forwarding_initial_backoff, + packet_forwarding_maximum_backoff: old_cfg + .mixnet + .debug + .packet_forwarding_maximum_backoff, + initial_connection_timeout: old_cfg.mixnet.debug.initial_connection_timeout, + maximum_connection_buffer_size: old_cfg.mixnet.debug.maximum_connection_buffer_size, + unsafe_disable_noise: old_cfg.mixnet.debug.unsafe_disable_noise, + use_legacy_packet_encoding: old_cfg.mixnet.debug.use_legacy_packet_encoding, + }, + }, + storage_paths: NymNodePaths { + keys: KeysPaths { + private_ed25519_identity_key_file: old_cfg + .storage_paths + .keys + .private_ed25519_identity_key_file, + public_ed25519_identity_key_file: old_cfg + .storage_paths + .keys + .public_ed25519_identity_key_file, + primary_x25519_sphinx_key_file: old_cfg + .storage_paths + .keys + .primary_x25519_sphinx_key_file, + secondary_x25519_sphinx_key_file: old_cfg + .storage_paths + .keys + .secondary_x25519_sphinx_key_file, + private_x25519_noise_key_file: old_cfg + .storage_paths + .keys + .private_x25519_noise_key_file, + public_x25519_noise_key_file: old_cfg + .storage_paths + .keys + .public_x25519_noise_key_file, + private_x25519_lp_key_file: old_cfg.storage_paths.keys.private_x25519_lp_key_file, + public_x25519_lp_key_file: old_cfg.storage_paths.keys.public_x25519_lp_key_file, + private_mlkem768_lp_key_file: old_cfg + .storage_paths + .keys + .private_mlkem768_lp_key_file, + public_mlkem768_lp_key_file: old_cfg.storage_paths.keys.public_mlkem768_lp_key_file, + private_mceliece_lp_key_file: old_cfg + .storage_paths + .keys + .private_mceliece_lp_key_file, + public_mceliece_lp_key_file: old_cfg.storage_paths.keys.public_mceliece_lp_key_file, + }, + description: old_cfg.storage_paths.description, + }, + http: Http { + bind_address: old_cfg.http.bind_address, + landing_page_assets_path: old_cfg.http.landing_page_assets_path, + access_token: old_cfg.http.access_token, + expose_system_info: old_cfg.http.expose_system_info, + expose_system_hardware: old_cfg.http.expose_system_hardware, + expose_crypto_hardware: old_cfg.http.expose_crypto_hardware, + node_load_cache_ttl: old_cfg.http.node_load_cache_ttl, + }, + verloc: Verloc { + bind_address: old_cfg.verloc.bind_address, + announce_port: old_cfg.verloc.announce_port, + debug: VerlocDebug { + packets_per_node: old_cfg.verloc.debug.packets_per_node, + connection_timeout: old_cfg.verloc.debug.connection_timeout, + packet_timeout: old_cfg.verloc.debug.packet_timeout, + delay_between_packets: old_cfg.verloc.debug.delay_between_packets, + tested_nodes_batch_size: old_cfg.verloc.debug.tested_nodes_batch_size, + testing_interval: old_cfg.verloc.debug.testing_interval, + retry_timeout: old_cfg.verloc.debug.retry_timeout, + }, + }, + wireguard: Wireguard { + enabled: old_cfg.wireguard.enabled, + bind_address: old_cfg.wireguard.bind_address, + private_ipv4: old_cfg.wireguard.private_ipv4, + private_ipv6: old_cfg.wireguard.private_ipv6, + announced_tunnel_port: old_cfg.wireguard.announced_tunnel_port, + announced_metadata_port: old_cfg.wireguard.announced_metadata_port, + private_network_prefix_v4: old_cfg.wireguard.private_network_prefix_v4, + private_network_prefix_v6: old_cfg.wireguard.private_network_prefix_v6, + use_userspace: old_cfg.wireguard.use_userspace, + storage_paths: WireguardPaths { + private_diffie_hellman_key_file: old_cfg + .wireguard + .storage_paths + .private_diffie_hellman_key_file, + public_diffie_hellman_key_file: old_cfg + .wireguard + .storage_paths + .public_diffie_hellman_key_file, + }, + }, + lp: LpConfig { + control_bind_address: old_cfg.lp.control_bind_address, + data_bind_address: old_cfg.lp.data_bind_address, + announce_control_port: old_cfg.lp.announce_control_port, + announce_data_port: old_cfg.lp.announce_data_port, + debug: LpDebug { + max_connections: old_cfg.lp.debug.max_connections, + use_mock_ecash: old_cfg.lp.debug.use_mock_ecash, + handshake_ttl: old_cfg.lp.debug.handshake_ttl, + session_ttl: old_cfg.lp.debug.session_ttl, + state_cleanup_interval: old_cfg.lp.debug.state_cleanup_interval, + max_concurrent_forwards: old_cfg.lp.debug.max_concurrent_forwards, + }, + }, + gateway_tasks: GatewayTasksConfig { + storage_paths: GatewayTasksPaths { + clients_storage: old_cfg.gateway_tasks.storage_paths.clients_storage, + stats_storage: old_cfg.gateway_tasks.storage_paths.stats_storage, + cosmos_mnemonic: old_cfg.gateway_tasks.storage_paths.cosmos_mnemonic, + bridge_client_params: old_cfg.gateway_tasks.storage_paths.bridge_client_params, + }, + enforce_zk_nyms: old_cfg.gateway_tasks.enforce_zk_nyms, + ws_bind_address: old_cfg.gateway_tasks.ws_bind_address, + announce_ws_port: old_cfg.gateway_tasks.announce_ws_port, + announce_wss_port: old_cfg.gateway_tasks.announce_wss_port, + upgrade_mode: UpgradeModeWatcher { + enabled: old_cfg.gateway_tasks.upgrade_mode.enabled, + attestation_url: old_cfg.gateway_tasks.upgrade_mode.attestation_url, + attester_public_key: old_cfg.gateway_tasks.upgrade_mode.attester_public_key, + debug: UpgradeModeWatcherDebug { + regular_polling_interval: old_cfg + .gateway_tasks + .upgrade_mode + .debug + .regular_polling_interval, + expedited_poll_interval: old_cfg + .gateway_tasks + .upgrade_mode + .debug + .expedited_poll_interval, + }, + }, + debug: gateway_tasks::Debug { + message_retrieval_limit: old_cfg.gateway_tasks.debug.message_retrieval_limit, + maximum_open_connections: old_cfg.gateway_tasks.debug.maximum_open_connections, + minimum_mix_performance: old_cfg.gateway_tasks.debug.minimum_mix_performance, + maximum_initial_topology_waiting_time: old_cfg + .gateway_tasks + .debug + .maximum_initial_topology_waiting_time, + max_request_timestamp_skew: old_cfg.gateway_tasks.debug.max_request_timestamp_skew, + stale_messages: StaleMessageDebug { + cleaner_run_interval: old_cfg + .gateway_tasks + .debug + .stale_messages + .cleaner_run_interval, + max_age: old_cfg.gateway_tasks.debug.stale_messages.max_age, + }, + client_bandwidth: ClientBandwidthDebug { + max_flushing_rate: old_cfg + .gateway_tasks + .debug + .client_bandwidth + .max_flushing_rate, + max_delta_flushing_amount: old_cfg + .gateway_tasks + .debug + .client_bandwidth + .max_delta_flushing_amount, + }, + zk_nym_tickets: ZkNymTicketHandlerDebug { + revocation_bandwidth_penalty: old_cfg + .gateway_tasks + .debug + .zk_nym_tickets + .revocation_bandwidth_penalty, + pending_poller: old_cfg.gateway_tasks.debug.zk_nym_tickets.pending_poller, + minimum_api_quorum: old_cfg + .gateway_tasks + .debug + .zk_nym_tickets + .minimum_api_quorum, + minimum_redemption_tickets: old_cfg + .gateway_tasks + .debug + .zk_nym_tickets + .minimum_redemption_tickets, + maximum_time_between_redemption: old_cfg + .gateway_tasks + .debug + .zk_nym_tickets + .maximum_time_between_redemption, + }, + upgrade_mode_min_staleness_recheck: old_cfg + .gateway_tasks + .debug + .upgrade_mode_min_staleness_recheck, + }, + }, + service_providers: ServiceProvidersConfig { + storage_paths: ServiceProvidersPaths { + clients_storage: old_cfg.service_providers.storage_paths.clients_storage, + stats_storage: old_cfg.service_providers.storage_paths.stats_storage, + network_requester: NetworkRequesterPaths { + private_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .network_requester + .private_ed25519_identity_key_file, + public_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .network_requester + .public_ed25519_identity_key_file, + private_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .network_requester + .private_x25519_diffie_hellman_key_file, + public_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .network_requester + .public_x25519_diffie_hellman_key_file, + ack_key_file: old_cfg + .service_providers + .storage_paths + .network_requester + .ack_key_file, + reply_surb_database: old_cfg + .service_providers + .storage_paths + .network_requester + .reply_surb_database, + gateway_registrations: old_cfg + .service_providers + .storage_paths + .network_requester + .gateway_registrations, + }, + ip_packet_router: IpPacketRouterPaths { + private_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .private_ed25519_identity_key_file, + public_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .public_ed25519_identity_key_file, + private_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .private_x25519_diffie_hellman_key_file, + public_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .public_x25519_diffie_hellman_key_file, + ack_key_file: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .ack_key_file, + reply_surb_database: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .reply_surb_database, + gateway_registrations: old_cfg + .service_providers + .storage_paths + .ip_packet_router + .gateway_registrations, + }, + authenticator: AuthenticatorPaths { + private_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .authenticator + .private_ed25519_identity_key_file, + public_ed25519_identity_key_file: old_cfg + .service_providers + .storage_paths + .authenticator + .public_ed25519_identity_key_file, + private_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .authenticator + .private_x25519_diffie_hellman_key_file, + public_x25519_diffie_hellman_key_file: old_cfg + .service_providers + .storage_paths + .authenticator + .public_x25519_diffie_hellman_key_file, + ack_key_file: old_cfg + .service_providers + .storage_paths + .authenticator + .ack_key_file, + reply_surb_database: old_cfg + .service_providers + .storage_paths + .authenticator + .reply_surb_database, + gateway_registrations: old_cfg + .service_providers + .storage_paths + .authenticator + .gateway_registrations, + }, + }, + open_proxy: old_cfg.service_providers.open_proxy, + upstream_exit_policy_url: old_cfg.service_providers.upstream_exit_policy_url, + network_requester: NetworkRequester { + allow_local_ips: false, + debug: NetworkRequesterDebug { + enabled: old_cfg.service_providers.network_requester.debug.enabled, + disable_poisson_rate: old_cfg + .service_providers + .network_requester + .debug + .disable_poisson_rate, + client_debug: old_cfg + .service_providers + .network_requester + .debug + .client_debug, + }, + }, + ip_packet_router: IpPacketRouter { + allow_local_ips: false, + debug: IpPacketRouterDebug { + enabled: old_cfg.service_providers.ip_packet_router.debug.enabled, + disable_poisson_rate: old_cfg + .service_providers + .ip_packet_router + .debug + .disable_poisson_rate, + client_debug: old_cfg + .service_providers + .ip_packet_router + .debug + .client_debug, + }, + }, + authenticator: Authenticator { + debug: AuthenticatorDebug { + enabled: old_cfg.service_providers.authenticator.debug.enabled, + disable_poisson_rate: old_cfg + .service_providers + .authenticator + .debug + .disable_poisson_rate, + client_debug: old_cfg.service_providers.authenticator.debug.client_debug, + }, + }, + debug: service_providers::Debug { + message_retrieval_limit: old_cfg.service_providers.debug.message_retrieval_limit, + }, + }, + metrics: MetricsConfig { + debug: metrics::Debug { + log_stats_to_console: old_cfg.metrics.debug.log_stats_to_console, + aggregator_update_rate: old_cfg.metrics.debug.aggregator_update_rate, + stale_mixnet_metrics_cleaner_rate: old_cfg + .metrics + .debug + .stale_mixnet_metrics_cleaner_rate, + global_prometheus_counters_update_rate: old_cfg + .metrics + .debug + .global_prometheus_counters_update_rate, + pending_egress_packets_update_rate: old_cfg + .metrics + .debug + .pending_egress_packets_update_rate, + clients_sessions_update_rate: old_cfg.metrics.debug.clients_sessions_update_rate, + console_logging_update_interval: old_cfg + .metrics + .debug + .console_logging_update_interval, + legacy_mixing_metrics_update_rate: old_cfg + .metrics + .debug + .legacy_mixing_metrics_update_rate, + }, + }, + logging: LoggingSettings {}, + debug: Debug { + topology_cache_ttl: old_cfg.debug.topology_cache_ttl, + routing_nodes_check_interval: old_cfg.debug.routing_nodes_check_interval, + testnet: old_cfg.debug.testnet, + }, + }; + Ok(cfg) +} diff --git a/nym-node/src/config/template.rs b/nym-node/src/config/template.rs index 6b5c1e39b7..52f4d4d178 100644 --- a/nym-node/src/config/template.rs +++ b/nym-node/src/config/template.rs @@ -60,10 +60,7 @@ nym_api_urls = [ {{#each mixnet.nym_api_urls }}'{{this}}',{{/each}} ] -# Addresses to nyxd which the node uses to interact with the nyx chain. -nyxd_urls = [ - {{#each mixnet.nyxd_urls }}'{{this}}',{{/each}} -] + [mixnet.replay_protection] @@ -77,6 +74,18 @@ current_bloomfilters_directory = '{{ mixnet.replay_protection.storage_paths.curr # Path to a file containing basic node description: human-readable name, website, details, etc. description = '{{ storage_paths.description }}' +[nyx] + +# Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. +# It is used for subscribing to new block events. +nyxd_websocket_url = '{{ nyx.nyxd_websocket_url }}' + +# Addresses to nyxd which the node uses to interact with the nyx chain. +nyxd_urls = [ + {{#each nyx.nyxd_urls }}'{{this}}',{{/each}} +] + + [storage_paths.keys] # Path to file containing ed25519 identity private key. private_ed25519_identity_key_file = '{{ storage_paths.keys.private_ed25519_identity_key_file }}' diff --git a/nym-node/src/config/upgrade_helpers.rs b/nym-node/src/config/upgrade_helpers.rs index 8c556b409d..b00534fc3c 100644 --- a/nym-node/src/config/upgrade_helpers.rs +++ b/nym-node/src/config/upgrade_helpers.rs @@ -19,7 +19,8 @@ async fn try_upgrade_config(path: &Path) -> Result<(), NymNodeError> { let cfg = try_upgrade_config_v9(path, cfg).await.ok(); let cfg = try_upgrade_config_v10(path, cfg).await.ok(); let cfg = try_upgrade_config_v11(path, cfg).await.ok(); - match try_upgrade_config_v12(path, cfg).await { + let cfg = try_upgrade_config_v12(path, cfg).await.ok(); + match try_upgrade_config_v13(path, cfg).await { Ok(cfg) => cfg.save(), Err(e) => { tracing::error!("Failed to finish upgrade: {e}"); diff --git a/nym-node/src/env.rs b/nym-node/src/env.rs index 196c5dcc2f..3b553d28b5 100644 --- a/nym-node/src/env.rs +++ b/nym-node/src/env.rs @@ -38,10 +38,13 @@ pub mod vars { pub const NYMNODE_MIXNET_BIND_ADDRESS_ARG: &str = "NYMNODE_MIXNET_BIND_ADDRESS"; pub const NYMNODE_MIXNET_ANNOUNCE_PORT_ARG: &str = "NYMNODE_MIXNET_ANNOUNCE_PORT"; pub const NYMNODE_NYM_APIS_ARG: &str = "NYMNODE_NYM_APIS"; - pub const NYMNODE_NYXD_URLS_ARG: &str = "NYMNODE_NYXD"; pub const NYMNODE_UNSAFE_DISABLE_NOISE: &str = "UNSAFE_DISABLE_NOISE"; pub const NYMNODE_UNSAFE_DISABLE_REPLAY_PROTECTION: &str = "UNSAFE_DISABLE_REPLAY_PROTECTION"; + // nyxd: + pub const NYMNODE_NYXD_URLS_ARG: &str = "NYMNODE_NYXD"; + pub const NYMNODE_NYXD_WEBSOCKET_URL_ARG: &str = "NYMNODE_NYXD_WEBSOCKET"; + // wireguard: pub const NYMNODE_WG_ENABLED_ARG: &str = "NYMNODE_WG_ENABLED"; pub const NYMNODE_WG_BIND_ADDRESS_ARG: &str = "NYMNODE_WG_BIND_ADDRESS"; diff --git a/nym-node/src/error.rs b/nym-node/src/error.rs index 582cf321a4..37f91729c5 100644 --- a/nym-node/src/error.rs +++ b/nym-node/src/error.rs @@ -9,6 +9,7 @@ use nym_ip_packet_router::error::ClientCoreError; use nym_kkt::keys::storage_wrappers::MalformedStoredKeyError; use nym_validator_client::ValidatorClientError; use nym_validator_client::nyxd::error::NyxdError; +use nyxd_scraper_shared::error::ScraperError; use std::io; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -248,6 +249,12 @@ pub enum NymNodeError { #[error(transparent)] LpFailure(#[from] LpHandlerError), + + #[error("no valid network monitors contract address configured")] + MissingNetworkMonitorsContractAddress, + + #[error(transparent)] + ChainWatcherFailure(#[from] ScraperError), } impl From for NymNodeError { diff --git a/nym-node/src/node/http/router/api/v1/metrics/packets_stats.rs b/nym-node/src/node/http/router/api/v1/metrics/packets_stats.rs index aa2816637d..347a767789 100644 --- a/nym-node/src/node/http/router/api/v1/metrics/packets_stats.rs +++ b/nym-node/src/node/http/router/api/v1/metrics/packets_stats.rs @@ -39,6 +39,7 @@ fn build_response(metrics: &NymNodeMetrics) -> PacketsStats { final_hop_packets_received: metrics.mixnet.ingress.final_hop_packets_received(), malformed_packets_received: metrics.mixnet.ingress.malformed_packets_received(), replayed_packets_received: metrics.mixnet.ingress.replayed_packets_received(), + test_packets_received: metrics.mixnet.ingress.test_packets_received(), excessive_delay_packets: metrics.mixnet.ingress.excessive_delay_packets(), forward_hop_packets_dropped: metrics.mixnet.ingress.forward_hop_packets_dropped(), final_hop_packets_dropped: metrics.mixnet.ingress.final_hop_packets_dropped(), diff --git a/nym-node/src/node/metrics/handler/global_prometheus_updater/mod.rs b/nym-node/src/node/metrics/handler/global_prometheus_updater/mod.rs index 4578f6bd60..219adba8d1 100644 --- a/nym-node/src/node/metrics/handler/global_prometheus_updater/mod.rs +++ b/nym-node/src/node/metrics/handler/global_prometheus_updater/mod.rs @@ -68,6 +68,10 @@ impl OnUpdateMetricsHandler for PrometheusGlobalNodeMetricsRegistryUpdater { MixnetIngressReplayedPacketsReceived, self.metrics.mixnet.ingress.replayed_packets_received() as i64, ); + self.prometheus_wrapper.set( + MixnetIngressTestPacketsReceived, + self.metrics.mixnet.ingress.test_packets_received() as i64, + ); self.prometheus_wrapper.set( MixnetIngressExcessiveDelayPacketsReceived, self.metrics.mixnet.ingress.excessive_delay_packets() as i64, diff --git a/nym-node/src/node/mixnet/handler.rs b/nym-node/src/node/mixnet/handler.rs index 4b193bfe87..bd70892686 100644 --- a/nym-node/src/node/mixnet/handler.rs +++ b/nym-node/src/node/mixnet/handler.rs @@ -110,6 +110,7 @@ impl ConnectionHandler { final_hop: shared.final_hop.clone(), noise_config: shared.noise_config.clone(), metrics: shared.metrics.clone(), + authorised_network_monitor_agents: shared.authorised_network_monitor_agents.clone(), shutdown_token: shared.shutdown_token.child_token(), }, remote_address, @@ -117,8 +118,35 @@ impl ConnectionHandler { } } + /// Check if the current connection is from an authorised Network Monitor agent. + /// + /// # Replay Protection Bypass + /// + /// Network Monitor agents are granted special privileges to bypass replay protection. + /// This allows them to intentionally send replayed packets for testing purposes to reduce + /// the processing required to generate enough packets required for stress testing. + /// + /// # Security + /// + /// - Authorisation is controlled on-chain via the Network Monitors smart contract + /// - Only specific IP addresses can bypass replay protection (not public keys or other identifiers) + /// - All bypass events are logged and tracked via Prometheus metrics + /// - Regular nodes cannot bypass replay protection under any circumstances + /// + /// # Authorisation Source + /// + /// The list of authorised IPs is: + /// 1. Initially loaded from the contract at node startup + /// 2. Updated in real-time via blockchain subscription (see `NetworkMonitorAgentsModule`) + /// 3. Shared across all connection handlers via lock-free `ArcSwap` + fn is_from_authorised_network_monitor_agent(&self) -> bool { + self.shared + .authorised_network_monitor_agents + .is_known(&self.remote_address.ip()) + } + /// Determine instant at which packet should get forwarded to the next hop. - /// By using [`Instant`] rather than explicit [`Duration`] we minimise effects of + /// By using [`Instant`] rather than explicit [`Duration`], we minimise the effects of /// the skew caused by being stuck in the channel queue. /// This method also clamps the maximum allowed delay so that nobody could send a bunch of packets /// with, for example, delays of 1 year thus causing denial of service @@ -147,7 +175,13 @@ impl ConnectionHandler { delay_ms = tracing::field::Empty, ) )] - fn handle_forward_packet(&self, now: Instant, mix_packet: MixPacket, delay: Option) { + fn handle_forward_packet( + &self, + now: Instant, + mix_packet: MixPacket, + delay: Option, + network_monitor_packet: bool, + ) { if !self.shared.processing_config.forward_hop_processing_enabled { warn!( event = "packet.dropped.forward_disabled", @@ -165,7 +199,8 @@ impl ConnectionHandler { target.saturating_duration_since(now).as_millis() as u64, ); } - self.shared.forward_mix_packet(mix_packet, forward_instant); + self.shared + .forward_mix_packet(mix_packet, forward_instant, network_monitor_packet); } #[instrument( @@ -179,7 +214,11 @@ impl ConnectionHandler { ack_forwarded = false, ) )] - async fn handle_final_hop(&self, final_hop_data: ProcessedFinalHop) { + async fn handle_final_hop( + &self, + final_hop_data: ProcessedFinalHop, + network_monitor_packet: bool, + ) { if !self.shared.processing_config.final_hop_processing_enabled { warn!( event = "packet.dropped.final_hop_disabled", @@ -191,6 +230,17 @@ impl ConnectionHandler { return; } + if network_monitor_packet { + warn!( + event = "packet.dropped.network_monitor_final_hop", + remote_addr = %self.remote_address, + "dropping packet: unsupported network monitor final hop packets" + ); + self.shared + .dropped_final_hop_packet(self.remote_address.ip()); + return; + } + let client = final_hop_data.destination; let message = final_hop_data.message; let has_ack = final_hop_data.forward_ack.is_some(); @@ -411,6 +461,7 @@ impl ConnectionHandler { &self, now: Instant, unwrapped_packet: Result, + network_monitor_packet: bool, ) { // 2. increment our favourite metrics stats self.shared @@ -423,10 +474,11 @@ impl ConnectionHandler { } Ok(processed_packet) => match processed_packet.processing_data { MixProcessingResultData::ForwardHop { packet, delay } => { - self.handle_forward_packet(now, packet, delay); + self.handle_forward_packet(now, packet, delay, network_monitor_packet); } MixProcessingResultData::FinalHop { final_hop_data } => { - self.handle_final_hop(final_hop_data).await; + self.handle_final_hop(final_hop_data, network_monitor_packet) + .await; } }, } @@ -446,7 +498,26 @@ impl ConnectionHandler { continue; }; for (packet, &replayed) in packets.into_iter().zip(replay_checks) { - let unwrapped_packet = if replayed { + // CRITICAL SECURITY DECISION POINT: Replay Protection Bypass for Network Monitors + // + // This is where we decide whether to enforce replay protection for this packet. + // The decision tree is: + // + // 1. Is packet replayed? (bloomfilter check already completed) + // NO → Process normally (finalise_unwrapping) + // YES → Go to step 2 + // + // 2. Is source IP an authorised network monitor? + // YES → BYPASS replay protection, process packet normally + // NO → DROP packet, increment metrics, log warning + // + // Why we allow network monitors to replay: + // - They need to be able to generate high volumes of packets in short bursts + // - Authorisation is on-chain and strictly controlled + // + // All bypass activity is tracked via `ingress_network_monitor_packet` metric. + let network_monitor_packet = self.is_from_authorised_network_monitor_agent(); + if replayed && !network_monitor_packet { replays_detected += 1; warn!( event = "packet.dropped.replay", @@ -454,12 +525,18 @@ impl ConnectionHandler { rotation_id, "dropping replayed packet" ); - Err(PacketProcessingError::PacketReplay) - } else { - packet.finalise_unwrapping() - }; + self.handle_unwrapped_packet( + now, + Err(PacketProcessingError::PacketReplay), + network_monitor_packet, + ) + .await; + continue; + } - self.handle_unwrapped_packet(now, unwrapped_packet).await; + let unwrapped_packet = packet.finalise_unwrapping(); + self.handle_unwrapped_packet(now, unwrapped_packet, network_monitor_packet) + .await; } } if replays_detected > 0 { @@ -551,7 +628,10 @@ impl ConnectionHandler { packet: FramedNymPacket, ) { let unwrapped_packet = self.try_full_unwrap_packet(packet); - self.handle_unwrapped_packet(now, unwrapped_packet).await; + + let is_network_monitor_packet = self.is_from_authorised_network_monitor_agent(); + self.handle_unwrapped_packet(now, unwrapped_packet, is_network_monitor_packet) + .await; } #[instrument(skip(self, packet), level = "debug")] diff --git a/nym-node/src/node/mixnet/packet_forwarding/mod.rs b/nym-node/src/node/mixnet/packet_forwarding/mod.rs index dc8d4af7d4..7b87e546ba 100644 --- a/nym-node/src/node/mixnet/packet_forwarding/mod.rs +++ b/nym-node/src/node/mixnet/packet_forwarding/mod.rs @@ -89,7 +89,10 @@ impl PacketForwarder { { let next_hop = new_packet.packet.next_hop(); - if !self.routing_filter.should_route(next_hop.as_ref().ip()) { + if !self + .routing_filter + .should_route(next_hop.as_ref().ip(), new_packet.network_monitor_packet) + { warn!( event = "packet.dropped.routing_filter", next_hop = %next_hop, diff --git a/nym-node/src/node/mixnet/shared/mod.rs b/nym-node/src/node/mixnet/shared/mod.rs index 19b15d9116..cbe3418d6a 100644 --- a/nym-node/src/node/mixnet/shared/mod.rs +++ b/nym-node/src/node/mixnet/shared/mod.rs @@ -6,6 +6,7 @@ use crate::node::key_rotation::active_keys::ActiveSphinxKeys; use crate::node::mixnet::SharedFinalHopData; use crate::node::mixnet::handler::ConnectionHandler; use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilters; +use crate::node::routing_filter::network_filter::RoutableNetworkMonitors; use nym_gateway::node::GatewayStorageError; use nym_mixnet_client::forwarder::{MixForwardingSender, PacketToForward}; use nym_node_metrics::NymNodeMetrics; @@ -66,7 +67,9 @@ impl ProcessingConfig { // explicitly do NOT derive clone as we want the childs to use CHILD shutdown tokens pub(crate) struct SharedData { pub(super) processing_config: ProcessingConfig, + pub(super) sphinx_keys: ActiveSphinxKeys, + pub(super) replay_protection_filter: ReplayProtectionBloomfilters, // used for FORWARD mix packets and FINAL ack packets @@ -80,6 +83,10 @@ pub(crate) struct SharedData { pub(super) metrics: NymNodeMetrics, + // list of all known network monitor agents that are permitted + // to forward packets even if they replay them + pub(super) authorised_network_monitor_agents: RoutableNetworkMonitors, + pub(super) shutdown_token: ShutdownToken, } @@ -100,6 +107,7 @@ impl SharedData { final_hop: SharedFinalHopData, noise_config: NoiseConfig, metrics: NymNodeMetrics, + authorised_network_monitor_agents: RoutableNetworkMonitors, shutdown_token: ShutdownToken, ) -> Self { SharedData { @@ -110,6 +118,7 @@ impl SharedData { final_hop, noise_config, metrics, + authorised_network_monitor_agents, shutdown_token, } } @@ -136,11 +145,23 @@ impl SharedData { processing_result: &Result, source: IpAddr, ) { - let Ok(processing_result) = processing_result else { - self.metrics.mixnet.ingress_malformed_packet(source); - return; + let processing_result = match processing_result { + Ok(processing_result) => processing_result, + Err(err) => { + if err.is_replay() { + self.metrics.mixnet.ingress_replayed_packet(source); + } else { + self.metrics.mixnet.ingress_malformed_packet(source); + } + + return; + } }; + if self.authorised_network_monitor_agents.is_known(&source) { + self.metrics.mixnet.ingress_network_monitor_packet(); + } + let packet_version = convert_to_metrics_version(processing_result.packet_version); match processing_result.processing_data { @@ -184,11 +205,20 @@ impl SharedData { } } - pub(super) fn forward_mix_packet(&self, packet: MixPacket, delay_until: Option) { + pub(super) fn forward_mix_packet( + &self, + packet: MixPacket, + delay_until: Option, + network_monitor_packet: bool, + ) { let has_delay = delay_until.is_some(); if self .mixnet_forwarder - .forward_packet(PacketToForward::new(packet, delay_until)) + .forward_packet(PacketToForward::new( + packet, + delay_until, + network_monitor_packet, + )) .is_err() && !self.shutdown_token.is_cancelled() { @@ -203,7 +233,7 @@ impl SharedData { pub(super) fn forward_ack_packet(&self, forward_ack: Option) { if let Some(forward_ack) = forward_ack { - self.forward_mix_packet(forward_ack, None); + self.forward_mix_packet(forward_ack, None, false); self.metrics.mixnet.egress_sent_ack(); } } diff --git a/nym-node/src/node/mod.rs b/nym-node/src/node/mod.rs index 7f857f3a5b..af1a511c96 100644 --- a/nym-node/src/node/mod.rs +++ b/nym-node/src/node/mod.rs @@ -23,6 +23,7 @@ use crate::node::key_rotation::active_keys::ActiveSphinxKeys; use crate::node::key_rotation::controller::KeyRotationController; use crate::node::key_rotation::manager::SphinxKeyManager; use crate::node::lp::LpSetup; +use crate::node::lp::directory::LpNodes; use crate::node::metrics::aggregator::MetricsAggregator; use crate::node::metrics::console_logger::ConsoleLogger; use crate::node::metrics::handler::client_sessions::GatewaySessionStatsHandler; @@ -34,16 +35,21 @@ use crate::node::mixnet::SharedFinalHopData; use crate::node::mixnet::packet_forwarding::PacketForwarder; use crate::node::mixnet::shared::ProcessingConfig; use crate::node::nym_apis_client::NymApisClient; +use crate::node::nyxd_watcher::network_monitor_agents::NetworkMonitorAgentsModule; use crate::node::replay_protection::background_task::ReplayProtectionDiskFlush; use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilters; use crate::node::replay_protection::manager::ReplayProtectionBloomfiltersManager; +use crate::node::routing_filter::network_filter::{NetworkRoutingFilter, RoutableNetworkMonitors}; use crate::node::routing_filter::{OpenFilter, RoutingFilter}; -use crate::node::shared_network::{ - CachedNetwork, CachedTopologyProvider, LocalGatewayNode, NetworkRefresher, -}; +use crate::node::shared_network::CachedNetwork; +use crate::node::shared_network::refresher::{NetworkRefresher, NetworkRefresherConfig}; +use crate::node::shared_network::topology_provider::{CachedTopologyProvider, LocalGatewayNode}; use nym_bin_common::bin_info; +use nym_config::defaults::NymNetworkDetails; use nym_credential_verification::UpgradeModeState; use nym_crypto::asymmetric::{ed25519, x25519}; +pub use nym_gateway::node::ActiveClientsStore; +pub use nym_gateway::node::GatewayStorage; use nym_gateway::node::wireguard::PeerRegistrator; use nym_gateway::node::{GatewayTasksBuilder, UpgradeModeCheckRequestSender}; use nym_kkt::key_utils::{ @@ -63,31 +69,30 @@ use nym_node_metrics::events::MetricEventsSender; use nym_node_requests::api::SignedData; use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM, LewesProtocol}; use nym_node_requests::api::v1::node::models::{AnnouncePorts, NodeDescription}; -use nym_noise::config::{NoiseConfig, NoiseNetworkView}; +use nym_noise::config::{NetworkMonitorAgentNode, NoiseConfig, NoiseNetworkView}; use nym_noise_keys::VersionedNoiseKeyV1; use nym_sphinx_acknowledgements::AckKey; use nym_sphinx_addressing::Recipient; use nym_task::{ShutdownManager, ShutdownToken, ShutdownTracker}; -use nym_validator_client::UserAgent; +use nym_validator_client::nyxd::contract_traits::PagedNetworkMonitorsQueryClient; +use nym_validator_client::nyxd::nym_network_monitors_contract_common::AuthorisedNetworkMonitor; +use nym_validator_client::{QueryHttpRpcNyxdClient, UserAgent}; use nym_verloc::measurements::SharedVerlocStats; use nym_verloc::{self, measurements::VerlocMeasurer}; use nym_wireguard::{WireguardGatewayData, peer_controller::PeerControlRequest}; +use nyxd_scraper_shared::watcher::{NyxdWatcher, WatcherConfig}; use rand::rngs::OsRng; use rand::{CryptoRng, RngCore}; use rand09::SeedableRng; -use std::collections::BTreeMap; -use std::net::SocketAddr; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; use std::ops::Deref; use std::path::Path; use std::sync::Arc; use tokio::sync::mpsc; -use tracing::{debug, info, trace}; +use tracing::{debug, error, info, trace}; use zeroize::Zeroizing; -use crate::node::lp::directory::LpNodes; -pub use nym_gateway::node::ActiveClientsStore; -pub use nym_gateway::node::GatewayStorage; - pub mod bonding_information; pub mod description; pub mod helpers; @@ -97,6 +102,7 @@ pub mod lp; pub(crate) mod metrics; pub(crate) mod mixnet; mod nym_apis_client; +mod nyxd_watcher; pub(crate) mod replay_protection; mod routing_filter; mod shared_network; @@ -391,6 +397,8 @@ pub struct NymNode { accepted_operator_terms_and_conditions: bool, shutdown_manager: ShutdownManager, + network: NymNetworkDetails, + description: NodeDescription, metrics: NymNodeMetrics, @@ -434,7 +442,7 @@ impl NymNode { let mceliece = generate_keypair_mceliece(&mut rng09); let current_rotation_id = - get_current_rotation_id(&config.mixnet.nym_api_urls, &config.mixnet.nyxd_urls).await?; + get_current_rotation_id(&config.mixnet.nym_api_urls, &config.nyx.nyxd_urls).await?; let _ = SphinxKeyManager::initialise_new( &mut rng, current_rotation_id, @@ -512,7 +520,7 @@ impl NymNode { pub(crate) async fn new(config: Config) -> Result { let wireguard_data = WireguardData::new(&config.wireguard)?; let current_rotation_id = - get_current_rotation_id(&config.mixnet.nym_api_urls, &config.mixnet.nyxd_urls).await?; + get_current_rotation_id(&config.mixnet.nym_api_urls, &config.nyx.nyxd_urls).await?; let ed25519_identity_keys = load_ed25519_identity_keypair( &config.storage_paths.keys.ed25519_identity_storage_paths(), @@ -549,6 +557,7 @@ impl NymNode { shutdown_manager: ShutdownManager::build_new_default() .map_err(|source| NymNodeError::ShutdownSignalFailure { source })?, x25519_lp_keys: Arc::new(x25519_lp_keys), + network: NymNetworkDetails::new_from_env(), }) } @@ -638,11 +647,13 @@ impl NymNode { Ok(self.sphinx_keys()?.keys.clone()) } - async fn build_network_refresher(&self) -> Result { - NetworkRefresher::initialise_new( - self.config.debug.testnet, - Self::user_agent(), - self.config.mixnet.nym_api_urls.clone(), + async fn build_network_refresher( + &self, + routing_filter: NetworkRoutingFilter, + client: NymApisClient, + noise_view: NoiseNetworkView, + ) -> Result { + let config = NetworkRefresherConfig::new( self.config.debug.topology_cache_ttl, self.config.debug.routing_nodes_check_interval, self.config @@ -650,6 +661,12 @@ impl NymNode { .debug .maximum_initial_topology_waiting_time, self.config.gateway_tasks.debug.minimum_mix_performance, + ); + NetworkRefresher::initialise_new( + config, + client, + routing_filter, + noise_view, self.shutdown_manager.clone_shutdown_token(), ) .await @@ -706,6 +723,7 @@ impl NymNode { let mut gateway_tasks_builder = GatewayTasksBuilder::new( config.gateway, + self.network.clone(), self.ed25519_identity_keys.clone(), self.entry_gateway.client_storage.clone(), mix_packet_sender.clone(), @@ -1240,6 +1258,7 @@ impl NymNode { active_clients_store: &ActiveClientsStore, replay_protection_bloomfilter: ReplayProtectionBloomfilters, routing_filter: F, + authorised_network_monitor_agents: RoutableNetworkMonitors, noise_config: NoiseConfig, ) -> Result<(MixForwardingSender, ActiveConnections), NymNodeError> where @@ -1295,6 +1314,7 @@ impl NymNode { final_hop_data, noise_config, self.metrics.clone(), + authorised_network_monitor_agents, self.shutdown_token(), ); @@ -1321,6 +1341,7 @@ impl NymNode { &ActiveClientsStore::new(), ReplayProtectionBloomfilters::new_disabled(), OpenFilter, + RoutableNetworkMonitors::default(), noise_config, ) .await?; @@ -1331,6 +1352,56 @@ impl NymNode { Ok(()) } + async fn known_network_monitors(&self) -> Result, NymNodeError> { + // 1. create a nyx rpc client + // (TODO: we should have unified client later on for all chain interactions) + let client = QueryHttpRpcNyxdClient::connect_with_network_details( + self.config.nyx.nyxd_urls[0].as_str(), + self.network.clone(), + )?; + + // 2. run the queries to retrieve all agents + Ok(client.get_all_network_monitor_agents().await?) + } + + async fn setup_nyx_chain_watcher( + &self, + network_monitors_handle: RoutableNetworkMonitors, + noise_network_view: NoiseNetworkView, + ) -> Result<(), NymNodeError> { + // START: module creation + let Some(Ok(contract_address)) = self + .network + .contracts + .network_monitors_contract_address + .as_ref() + .map(|addr| addr.parse()) + else { + // **THEORETICALLY** this should be impossible, for we have already created a nyxd client and + // queried this very contract before + return Err(NymNodeError::MissingNetworkMonitorsContractAddress); + }; + let nm_agents = NetworkMonitorAgentsModule::new( + contract_address, + network_monitors_handle, + noise_network_view, + ); + + // END: module creation + let cancellation = self.shutdown_manager.clone_shutdown_token(); + + let config = WatcherConfig { + websocket_url: self.config.nyx.nyxd_websocket_url.clone(), + rpc_url: self.config.nyx.nyxd_urls[0].clone(), + }; + let watcher = NyxdWatcher::builder(config) + .with_msg_module(nm_agents) + .with_custom_shutdown(cancellation.to_cancellation_token()); + + watcher.build_and_start().await?; + Ok(()) + } + async fn start_nym_node_tasks(mut self) -> Result { info!( "starting Nym Node {} with the following modes: mixnode: {}, entry: {}, exit: {}, wireguard: {}", @@ -1342,6 +1413,7 @@ impl NymNode { ); debug!("config: {:#?}", self.config); + // ##### START HTTP SERVER ##### let http_server = self.build_http_server().await?; let bind_address = self.config.http.bind_address; let server_shutdown = self.shutdown_manager.clone_shutdown_token(); @@ -1355,31 +1427,87 @@ impl NymNode { }, "HttpApi", ); + // ##### END HTTP SERVER ##### + // shared client for querying nym-apis let nym_apis_client = self.setup_nym_apis_client()?; + // announce current sphinx key to all nym apis self.try_refresh_remote_nym_api_cache(&nym_apis_client) .await?; + + // start verloc self.start_verloc_measurements(); - let network_refresher = self.build_network_refresher().await?; + // obtain the initial list of known network monitors + let known_network_monitors = self.known_network_monitors().await?; + + let mut known_network_monitor_ips = HashSet::new(); + let mut known_network_monitor_nodes: HashMap> = + HashMap::new(); + for agent in known_network_monitors { + let Ok(x25519_pubkey) = x25519::PublicKey::from_base58_string(&agent.bs58_x25519_noise) + else { + error!( + "network monitor agent {} has announced an invalid noise key - ignoring", + agent.mixnet_address + ); + continue; + }; + + let ip = agent.mixnet_address.ip(); + known_network_monitor_ips.insert(ip); + + let entry = known_network_monitor_nodes.entry(ip).or_default(); + entry.push(NetworkMonitorAgentNode { + port: agent.mixnet_address.port(), + key: VersionedNoiseKeyV1 { + supported_version: agent.noise_version.into(), + x25519_pubkey, + }, + }) + } + + // build routing filter + let routing_filter = NetworkRoutingFilter::new_empty(self.config.debug.testnet) + .with_known_network_monitors(known_network_monitor_ips); + let network_monitors_ref = routing_filter.known_network_monitors_handle(); + + let noise_view = NoiseNetworkView::new_with_agents(known_network_monitor_nodes); + // retrieve the initial view of the network and update the known set of nym nodes in the routing filter + let network_refresher = self + .build_network_refresher( + routing_filter.clone(), + nym_apis_client.clone(), + noise_view.clone(), + ) + .await?; + + // setup nyx chain watcher (currently only used for updating the network monitors view) + self.setup_nyx_chain_watcher(network_monitors_ref, noise_view.clone()) + .await?; + let active_clients_store = ActiveClientsStore::new(); let lp_nodes = network_refresher.lp_nodes(); + // start building a replay detection manager (bloomfilters, etc.) let bloomfilters_manager = self.setup_replay_detection().await?; - let noise_config = nym_noise::config::NoiseConfig::new( + let noise_config = NoiseConfig::new( self.x25519_noise_keys.clone(), - network_refresher.noise_view(), + noise_view, self.config.mixnet.debug.initial_connection_timeout, ) .with_unsafe_disabled(self.config.mixnet.debug.unsafe_disable_noise); + // start the listener for the mixnet packet(s) + let authorised_network_monitor_agents = routing_filter.known_network_monitors_handle(); let (mix_packet_sender, active_egress_mixnet_connections) = self .start_mixnet_listener( &active_clients_store, bloomfilters_manager.bloomfilters(), - network_refresher.routing_filter(), + routing_filter, + authorised_network_monitor_agents, noise_config, ) .await?; @@ -1392,6 +1520,7 @@ impl NymNode { let network = network_refresher.cached_network(); network_refresher.start(); + // setup all gateway-related tasks (client websocket, wireguard, lp, etc.) self.start_gateway_tasks( network, lp_nodes, @@ -1401,6 +1530,7 @@ impl NymNode { ) .await?; + // start watching for key rotation and update the keys accordingly self.setup_key_rotation(nym_apis_client, bloomfilters_manager) .await?; diff --git a/nym-node/src/node/nym_apis_client.rs b/nym-node/src/node/nym_apis_client.rs index f9d97a93d9..92d0e4dfcf 100644 --- a/nym-node/src/node/nym_apis_client.rs +++ b/nym-node/src/node/nym_apis_client.rs @@ -1,4 +1,4 @@ -// Copyright 2025 - Nym Technologies SA +// Copyright 2025-2026 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only use crate::error::NymNodeError; @@ -7,16 +7,19 @@ use futures::{StreamExt, stream}; use nym_crypto::asymmetric::ed25519; use nym_http_api_client::Client; use nym_task::ShutdownToken; +use nym_topology::EpochRewardedSet; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::{KeyRotationInfoResponse, NodeRefreshBody}; use nym_validator_client::nym_api::error::NymAPIError; +use nym_validator_client::nym_api::{NodesByAddressesResponse, SemiSkimmedNodesWithMetadata}; use rand::prelude::SliceRandom; use rand::thread_rng; +use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockReadGuard}; use tokio::time::sleep; -use tracing::{debug, warn}; +use tracing::{debug, error, warn}; use url::Url; #[derive(Clone)] @@ -60,17 +63,9 @@ impl NymApisClient { }) } - // async fn use_next_endpoint(&self) { - // let mut guard = self.inner.write().await; - // if guard.available_urls.len() == 1 { - // return; - // } - // - // let next_index = (guard.currently_used_api + 1) % guard.available_urls.len(); - // let next = guard.available_urls[next_index].clone(); - // guard.currently_used_api = next_index; - // guard.active_client.change_nym_api(next) - // } + async fn active_client(&self) -> RwLockReadGuard<'_, Client> { + RwLockReadGuard::map(self.inner.read().await, |inner| &inner.active_client) + } pub(crate) async fn query_exhaustively( &self, @@ -122,6 +117,38 @@ impl NymApisClient { ) .await } + + // non-critical best-effort queries to the current available endpoint: + + pub(crate) async fn rewarded_set(&mut self) -> Result { + self.active_client() + .await + .get_current_rewarded_set() + .await + .inspect_err(|err| error!("failed to get current rewarded set: {err}")) + .map(Into::into) + } + + pub(crate) async fn current_nymnodes( + &mut self, + ) -> Result { + self.active_client() + .await + .get_all_expanded_nodes() + .await + .inspect_err(|err| error!("failed to get network nodes: {err}")) + } + + pub(crate) async fn query_nym_nodes_addresses( + &mut self, + ips: Vec, + ) -> Result { + self.active_client() + .await + .nodes_by_addresses(ips) + .await + .inspect_err(|err| error!("failed to obtain node information: {err}")) + } } impl InnerClient { diff --git a/nym-node/src/node/nyxd_watcher/mod.rs b/nym-node/src/node/nyxd_watcher/mod.rs new file mode 100644 index 0000000000..47b0749bbe --- /dev/null +++ b/nym-node/src/node/nyxd_watcher/mod.rs @@ -0,0 +1,4 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod network_monitor_agents; diff --git a/nym-node/src/node/nyxd_watcher/network_monitor_agents.rs b/nym-node/src/node/nyxd_watcher/network_monitor_agents.rs new file mode 100644 index 0000000000..dfdaa208de --- /dev/null +++ b/nym-node/src/node/nyxd_watcher/network_monitor_agents.rs @@ -0,0 +1,295 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Real-time blockchain watcher for Network Monitor agents changes. +//! +//! This module processes blockchain transactions involving the Network Monitors smart contract +//! and automatically updates the list of authorised network monitor agents whenever it is invoked. +//! +//! # Authorisation Flow +//! +//! 1. Network Monitor orchestrator submits `AuthoriseNetworkMonitor { address }` to the contract +//! 2. Transaction is committed to Nyx blockchain +//! 3. This module receives the message via `MsgModule::handle_msg()` +//! 4. The IP address is added to `DeclaredNetworkMonitors` (lock-free via ArcSwap) +//! 5. Future packets from that IP can bypass replay protection until revoked +//! +//! # Security +//! +//! Only transactions executed against the configured Network Monitors contract address are +//! processed. + +use crate::node::routing_filter::network_filter::RoutableNetworkMonitors; +use async_trait::async_trait; +use nym_crypto::asymmetric::x25519; +use nym_noise::config::{NetworkMonitorAgentNode, NoiseNetworkView, NoiseNode}; +use nym_noise_keys::{NoiseVersion, VersionedNoiseKeyV1}; +use nym_validator_client::nyxd::cosmwasm::MsgExecuteContract; +use nym_validator_client::nyxd::nym_network_monitors_contract_common::ExecuteMsg; +use nym_validator_client::nyxd::{AccountId, Any, Msg, Name}; +use nyxd_scraper_shared::error::ScraperError; +use nyxd_scraper_shared::{DecodedMessage, MsgModule, ParsedTransactionDetails, parse_msg}; +use std::net::SocketAddr; +use tracing::{debug, error, info, warn}; + +/// Blockchain message handler for Network Monitor agent authorisation events. +/// +/// Watches for `MsgExecuteContract` messages targeting the Network Monitors smart contract +/// and updates the runtime list of authorised agents accordingly. +pub(crate) struct NetworkMonitorAgentsModule { + /// The on-chain address of the Network Monitors smart contract. + /// Only messages to this contract are processed. + pub(crate) contract_address: AccountId, + + /// Shared handle to the runtime list of authorised network monitor IPs. + /// Updates are immediately visible to all packet processing threads. + pub(crate) routable_network_monitors: RoutableNetworkMonitors, + + /// Shared handle to the runtime list of noise keys of all network nodes + pub(crate) noise_view: NoiseNetworkView, +} + +impl NetworkMonitorAgentsModule { + pub(crate) fn new( + contract_address: AccountId, + routable_network_monitors: RoutableNetworkMonitors, + noise_view: NoiseNetworkView, + ) -> Self { + Self { + contract_address, + routable_network_monitors, + noise_view, + } + } + + /// Register a newly authorised NM agent in both the routing filter and the noise key map. + async fn new_agent( + &mut self, + address: SocketAddr, + bs58_x25519_noise: String, + noise_version: u8, + ) { + debug!("adding new NM agent {address}"); + + let Ok(x25519_pubkey) = x25519::PublicKey::from_base58_string(&bs58_x25519_noise) else { + error!("network monitor agent {address} has announced an invalid noise key - ignoring"); + return; + }; + + let key = VersionedNoiseKeyV1 { + supported_version: NoiseVersion::from(noise_version), + x25519_pubkey, + }; + + // add ip to the routing filter (it's a no-op if it already exists) + self.routable_network_monitors.add_known(address.ip()); + + // add noise key to the known nodes + let update_permit = self.noise_view.get_update_permit().await; + let mut nodes = self.noise_view.all_nodes(); + // canonicalise so lookups via supports_noise (which canonicalises) always match + let ip = address.ip().to_canonical(); + let port = address.port(); + + match nodes.get_mut(&ip) { + None => { + nodes.insert(ip, NoiseNode::new_agent(address, key)); + } + Some(existing_entry) => match existing_entry { + NoiseNode::NymNode { .. } => { + error!( + "the authorised agent runs on the same host as a known nym-node! ignoring" + ); + } + NoiseNode::NetworkMonitorAgent { nodes } => { + if let Some(existing) = nodes.iter_mut().find(|n| n.port == address.port()) { + existing.key = key; + } else { + nodes.push(NetworkMonitorAgentNode { port, key }); + } + } + }, + } + + self.noise_view.swap_view(update_permit, nodes); + } + + async fn revoked_agent(&mut self, address: SocketAddr) { + debug!("revoking NM agent {address}"); + + // canonicalise to match the stored representation + let ip = address.ip().to_canonical(); + + let update_permit = self.noise_view.get_update_permit().await; + let mut nodes = self.noise_view.all_nodes(); + + let mut final_agent = false; + match nodes.get_mut(&ip) { + None => { + warn!("attempted to revoke a non-existent agent at {address}"); + return; + } + Some(node) => match node { + NoiseNode::NymNode { .. } => { + error!( + "the revoked agent runs on the same host as a known nym-node! ignoring the revocation" + ); + return; + } + NoiseNode::NetworkMonitorAgent { nodes } => { + nodes.retain(|agent| agent.port != address.port()); + if nodes.is_empty() { + final_agent = true; + } + } + }, + } + + if final_agent { + nodes.remove(&ip); + self.routable_network_monitors.remove_known(ip); + } + self.noise_view.swap_view(update_permit, nodes); + } + + async fn revoked_all_agents(&mut self) { + info!("revoking all NM agents"); + + self.routable_network_monitors.reset(); + + // remove all noise keys from the known nodes + let update_permit = self.noise_view.get_update_permit().await; + let mut nodes = self.noise_view.all_nodes(); + + // Only remove NM agent entries; nym-node entries must be preserved because they are + // managed by a completely separate code path (the nym-api topology refresher) and + // would not be restored until the next full topology refresh cycle. + nodes.retain(|_, node| node.is_nym_node()); + self.noise_view.swap_view(update_permit, nodes); + } +} + +#[async_trait] +impl MsgModule for NetworkMonitorAgentsModule { + // we're only interested in contract messages + fn type_url(&self) -> String { + ::Proto::type_url() + } + + async fn handle_msg( + &mut self, + _: usize, + msg: &Any, + _: &DecodedMessage, + tx: &ParsedTransactionDetails, + ) -> Result<(), ScraperError> { + // don't process failed transactions + if !tx.tx_result.code.is_ok() { + return Ok(()); + } + + // propagate error as this is a critical failure indicating our code is incompatible with + // the current CometBFT schema so parsing can't proceed + let execute_msg: MsgExecuteContract = parse_msg(msg)?; + + // not the contract we're interested in + if execute_msg.contract != self.contract_address { + return Ok(()); + } + + let exec_msg: ExecuteMsg = match serde_json::from_slice(&execute_msg.msg) { + Ok(msg) => msg, + Err(err) => { + // do NOT propagate error. this just means the contract might have updated. + // further block processing should continue + error!( + "failed to parse out network monitors contract ExecuteMsg - has the contact schema been updated? error was: {err}" + ); + return Ok(()); + } + }; + + match exec_msg { + ExecuteMsg::AuthoriseNetworkMonitor { + mixnet_address, + bs58_x25519_noise, + noise_version, + } => { + self.new_agent(mixnet_address, bs58_x25519_noise, noise_version) + .await + } + ExecuteMsg::RevokeNetworkMonitor { address } => self.revoked_agent(address).await, + ExecuteMsg::RevokeAllNetworkMonitors => self.revoked_all_agents().await, + + // we're not interested in those messages + ExecuteMsg::UpdateAdmin { .. } + | ExecuteMsg::AuthoriseNetworkMonitorOrchestrator { .. } + | ExecuteMsg::RevokeNetworkMonitorOrchestrator { .. } + | ExecuteMsg::UpdateOrchestratorIdentityKey { .. } => {} + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_crypto::asymmetric::x25519; + use nym_test_utils::helpers::deterministic_rng; + use std::net::{IpAddr, Ipv4Addr}; + + fn module() -> NetworkMonitorAgentsModule { + NetworkMonitorAgentsModule::new( + "n1pefc2utwpy5w78p2kqdsfmpjxfwmn9d39k5mqa".parse().unwrap(), + RoutableNetworkMonitors::default(), + NoiseNetworkView::new_empty(), + ) + } + + // Regression: an agent registered via blockchain events must end up keyed in the noise map + // under the **canonical** IP form, so the responder's `supports_noise` (which canonicalises + // on lookup) finds it regardless of whether the inbound socket presents plain IPv4 or the + // v4-mapped IPv6 form. Before the fix, `new_agent` inserted `address.ip()` raw, leaving the + // map keyed on a non-canonical IPv4-mapped IPv6 address whenever the contract submission used + // that form, while the routing filter (which canonicalises on both sides) accepted the + // packet — producing the "can't speak Noise yet, falling back to TCP" warning. + #[tokio::test] + async fn new_agent_stores_under_canonical_ip() { + let mut module = module(); + let pubkey = x25519::PublicKey::from(&x25519::PrivateKey::new(&mut deterministic_rng())); + let bs58 = pubkey.to_base58_string(); + + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let v6_mapped = IpAddr::V6(Ipv4Addr::new(1, 2, 3, 4).to_ipv6_mapped()); + + // register agent using v4-mapped IPv6 form (the form that triggered the bug) + module + .new_agent(SocketAddr::new(v6_mapped, 39322), bs58, 1) + .await; + + let stored = module.noise_view.all_nodes(); + // the stored key must be canonical so canonical-form lookups succeed + assert!( + stored.contains_key(&v4), + "noise map must contain the canonical IPv4 key, got: {:?}", + stored.keys().collect::>() + ); + } + + // Counterpart: same invariant when the contract submission already used plain IPv4 — the + // map should still be keyed on the canonical form (which for plain IPv4 is itself). + #[tokio::test] + async fn new_agent_stores_under_canonical_ip_for_plain_v4_input() { + let mut module = module(); + let pubkey = x25519::PublicKey::from(&x25519::PrivateKey::new(&mut deterministic_rng())); + let bs58 = pubkey.to_base58_string(); + + let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + + module.new_agent(SocketAddr::new(v4, 39322), bs58, 1).await; + + let stored = module.noise_view.all_nodes(); + assert!(stored.contains_key(&v4)); + } +} diff --git a/nym-node/src/node/routing_filter/mod.rs b/nym-node/src/node/routing_filter/mod.rs index 751ff2bc58..38fd205dfb 100644 --- a/nym-node/src/node/routing_filter/mod.rs +++ b/nym-node/src/node/routing_filter/mod.rs @@ -6,14 +6,14 @@ use std::net::IpAddr; pub(crate) mod network_filter; pub(crate) trait RoutingFilter { - fn should_route(&self, ip: IpAddr) -> bool; + fn should_route(&self, ip: IpAddr, is_network_monitor_packet: bool) -> bool; } #[derive(Debug, Copy, Clone, Default)] pub(crate) struct OpenFilter; impl RoutingFilter for OpenFilter { - fn should_route(&self, _: IpAddr) -> bool { + fn should_route(&self, _: IpAddr, _: bool) -> bool { true } } diff --git a/nym-node/src/node/routing_filter/network_filter.rs b/nym-node/src/node/routing_filter/network_filter.rs index 274015f784..ec9e692f5a 100644 --- a/nym-node/src/node/routing_filter/network_filter.rs +++ b/nym-node/src/node/routing_filter/network_filter.rs @@ -10,13 +10,14 @@ use std::sync::Arc; use tokio::sync::RwLock; impl RoutingFilter for NetworkRoutingFilter { - fn should_route(&self, ip: IpAddr) -> bool { + fn should_route(&self, ip: IpAddr, is_network_monitor_packet: bool) -> bool { // only allow non-global ips on testnets if self.testnet_mode && !is_global_ip(&ip) { return true; } - self.attempt_resolve(ip).should_route() + self.attempt_resolve(ip, is_network_monitor_packet) + .should_route() } } @@ -40,26 +41,72 @@ impl NetworkRoutingFilter { } } - pub(crate) fn attempt_resolve(&self, ip: IpAddr) -> Resolution { - if self.resolved.inner.allowed.load().contains(&ip) { + #[must_use] + pub(crate) fn with_known_network_monitors( + mut self, + known_network_monitors: HashSet, + ) -> Self { + self.resolved.network_monitors = RoutableNetworkMonitors::new(known_network_monitors); + self + } + + pub(crate) fn known_network_monitors_handle(&self) -> RoutableNetworkMonitors { + self.resolved.network_monitors.clone() + } + + pub(crate) fn attempt_resolve( + &self, + ip: IpAddr, + is_network_monitor_packet: bool, + ) -> Resolution { + // if packet has come from a network monitor it can ONLY go to another network monitor + if is_network_monitor_packet { + return if self.resolved.network_monitors.is_known(&ip) { + Resolution::Accept + } else { + Resolution::Deny + }; + } + + if self.resolved.nym_nodes.inner.allowed.load().contains(&ip) { + // accept any traffic to known and resolved nym-nodes Resolution::Accept - } else if self.resolved.inner.denied.load().contains(&ip) { + } else if self.resolved.nym_nodes.inner.denied.load().contains(&ip) { + // deny any traffic to confirmed non-nym nodes Resolution::Deny + } else if self.resolved.network_monitors.is_known(&ip) { + // accept any traffic to known network monitors + Resolution::Accept } else { + // put any unknown destinations into resolution queue self.pending.try_insert(ip); Resolution::Unknown } } pub(crate) fn allowed_nodes_copy(&self) -> HashSet { - self.resolved.inner.allowed.load_full().as_ref().clone() + self.resolved.nym_nodes.clone_allowed() } pub(crate) fn denied_nodes_copy(&self) -> HashSet { - self.resolved.inner.denied.load_full().as_ref().clone() + self.resolved.nym_nodes.clone_denied() } } +/// Temporary queue of IP addresses that need resolution (are they Nym nodes or not?). +/// +/// # Behaviour +/// +/// - Packets from unknown IPs are denied initially and the IP is queued here +/// - A background task periodically processes this queue via nym-api lookups +/// - Once resolved, IPs are moved to either `allowed` or `denied` sets +/// +/// # Lock Strategy +/// +/// Uses `try_insert()` to avoid blocking the packet processing path: +/// - If lock is immediately available: insert the IP +/// - If lock is contended: skip insertion, will retry on next packet from same IP +/// - This is acceptable because resolution happens periodically anyway #[derive(Clone, Default)] pub(crate) struct UnknownNodes(Arc>>); @@ -84,17 +131,125 @@ impl UnknownNodes { // for now we don't care about keys, etc. // we only want to know if given ip belongs to a known node -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone, Default)] pub(crate) struct KnownNodes { - inner: Arc, + nym_nodes: KnownNymNodes, + network_monitors: RoutableNetworkMonitors, +} + +impl KnownNodes { + pub(crate) fn swap_allowed(&self, new: HashSet) { + self.nym_nodes.swap_allowed(new) + } + + pub(crate) fn swap_denied(&self, new: HashSet) { + self.nym_nodes.swap_denied(new) + } +} + +/// Thread-safe, lock-free storage for authorised Network Monitor agents IP addresses. +/// +/// # Concurrency Strategy +/// +/// Uses `ArcSwap` for lock-free reads on the hot path (packet processing). Writes are rare +/// (only when blockchain authorisation events occur) +/// and involve cloning the HashSet, but this is acceptable because: +/// - Network monitor authorisations change extremely infrequently (on orchestrator startup with >5s per block) +/// - Read performance is critical (happens on every packet from unknown IPs) +/// - The HashSet is typically very small (<100 entries) +/// +/// # Cloning +/// +/// Cloning `DeclaredNetworkMonitors` is cheap (only clones the `Arc`), not the underlying data. +#[derive(Clone, Debug, Default)] +pub(crate) struct RoutableNetworkMonitors { + inner: Arc, +} + +impl RoutableNetworkMonitors { + pub(crate) fn new(known: HashSet) -> Self { + // ensure the provided addresses are always canonical + Self { + inner: Arc::new(DeclaredNetworkMonitorsInner { + known: ArcSwap::from_pointee(known.iter().map(IpAddr::to_canonical).collect()), + }), + } + } + + fn swap(&self, new: HashSet) { + self.inner.known.store(Arc::new(new)) + } + + pub(crate) fn add_known(&self, address: IpAddr) { + let to_add = address.to_canonical(); + if self.is_known(&to_add) { + return; + } + let mut known = self.inner.known.load().as_ref().clone(); + known.insert(to_add); + self.swap(known); + } + + pub(crate) fn remove_known(&self, address: IpAddr) { + let to_remove = address.to_canonical(); + if !self.is_known(&to_remove) { + return; + } + let mut known = self.inner.known.load().as_ref().clone(); + known.remove(&to_remove); + self.swap(known); + } + + pub(crate) fn reset(&self) { + self.swap(HashSet::new()) + } + + pub(crate) fn is_known(&self, address: &IpAddr) -> bool { + self.inner.known.load().contains(&address.to_canonical()) + } } #[derive(Debug, Default)] -struct KnownNodesInner { +struct DeclaredNetworkMonitorsInner { + known: ArcSwap>, +} + +#[derive(Clone, Debug, Default)] +struct KnownNymNodes { + inner: Arc, +} + +impl KnownNymNodes { + fn clone_allowed(&self) -> HashSet { + self.inner.allowed.load_full().as_ref().clone() + } + + fn clone_denied(&self) -> HashSet { + self.inner.denied.load_full().as_ref().clone() + } + + fn swap_allowed(&self, new: HashSet) { + self.inner.allowed.store(Arc::new(new)) + } + + fn swap_denied(&self, new: HashSet) { + self.inner.denied.store(Arc::new(new)) + } +} + +#[derive(Debug, Default)] +struct KnownNymNodesInner { allowed: ArcSwap>, denied: ArcSwap>, } +/// Result of attempting to resolve whether an IP address should be allowed to route packets. +/// +/// # Semantics +/// +/// - `Accept`: IP is a known Nym node OR authorised network monitor - route the packet +/// - `Deny`: IP has been confirmed as NOT a Nym node - drop the packet +/// - `Unknown`: IP hasn't been resolved yet - queue for lookup but DENY the packet pub(crate) enum Resolution { Unknown, Deny, @@ -117,12 +272,35 @@ impl Resolution { } } -impl KnownNodes { - pub(crate) fn swap_allowed(&self, new: HashSet) { - self.inner.allowed.store(Arc::new(new)) +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + #[test] + fn test_canonical_address_init() { + let registered = "80.200.100.60".parse::().unwrap(); + let received = "[::ffff:80.200.100.60]:39322" + .parse::() + .unwrap(); + let mut initial = HashSet::new(); + initial.insert(registered); + + let monitors = RoutableNetworkMonitors::new(initial); + + assert!(monitors.is_known(&received.ip())) } - pub(crate) fn swap_denied(&self, new: HashSet) { - self.inner.denied.store(Arc::new(new)) + #[test] + fn test_canonical_address_add() { + let registered = "80.200.100.60".parse::().unwrap(); + let received = "[::ffff:80.200.100.60]:39322" + .parse::() + .unwrap(); + + let monitors = RoutableNetworkMonitors::new(HashSet::new()); + monitors.add_known(registered); + + assert!(monitors.is_known(&received.ip())) } } diff --git a/nym-node/src/node/shared_network.rs b/nym-node/src/node/shared_network.rs deleted file mode 100644 index d89ad44a1b..0000000000 --- a/nym-node/src/node/shared_network.rs +++ /dev/null @@ -1,446 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use crate::error::NymNodeError; -use crate::node::key_rotation::active_keys::ActiveSphinxKeys; -use crate::node::lp::directory::LpNodes; -use crate::node::routing_filter::network_filter::NetworkRoutingFilter; -use async_trait::async_trait; -use nym_crypto::asymmetric::ed25519; -use nym_gateway::node::UserAgent; -use nym_http_api_client::Client; -use nym_node_metrics::prometheus_wrapper::{PROMETHEUS_METRICS, PrometheusMetric}; -use nym_noise::config::NoiseNetworkView; -use nym_task::ShutdownToken; -use nym_topology::node::RoutingNode; -use nym_topology::provider_trait::ToTopologyMetadata; -use nym_topology::{ - EntryDetails, EpochRewardedSet, NodeId, NymTopology, NymTopologyMetadata, Role, - TopologyProvider, -}; -use nym_validator_client::ValidatorClientError; -use nym_validator_client::nym_api::NymApiClientExt; -use nym_validator_client::nym_nodes::{ - NodesByAddressesResponse, SemiSkimmedNodeV1, SemiSkimmedNodesWithMetadata, -}; -use std::collections::{HashMap, HashSet}; -use std::net::{IpAddr, SocketAddr}; -use std::ops::Deref; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::RwLock; -use tokio::time::{Instant, interval, sleep}; -use tracing::log::error; -use tracing::{debug, trace, warn}; -use url::Url; - -const LOCAL_NODE_ID: NodeId = 1234567890; - -struct NodesQuerier { - client: Client, - nym_api_urls: Vec, - currently_used_api: usize, -} - -impl NodesQuerier { - fn use_next_nym_api(&mut self) { - if self.nym_api_urls.len() == 1 { - warn!( - "There's only a single nym API available - it won't be possible to use a different one" - ); - return; - } - - self.currently_used_api = (self.currently_used_api + 1) % self.nym_api_urls.len(); - self.client - .change_base_urls(self.nym_api_urls.iter().map(|u| u.clone().into()).collect()) - } - - async fn rewarded_set(&mut self) -> Result { - let res = self - .client - .get_current_rewarded_set() - .await - .inspect_err(|err| error!("failed to get current rewarded set: {err}")); - - if res.is_err() { - self.use_next_nym_api() - } - Ok(res?.into()) - } - - async fn current_nymnodes( - &mut self, - ) -> Result { - let res = self - .client - .get_all_expanded_nodes() - .await - .inspect_err(|err| error!("failed to get network nodes: {err}")); - - if res.is_err() { - self.use_next_nym_api() - } - Ok(res?) - } - - async fn query_for_info( - &mut self, - ips: Vec, - ) -> Result { - let res = self - .client - .nodes_by_addresses(ips) - .await - .inspect_err(|err| error!("failed to obtain node information: {err}")); - - if res.is_err() { - self.use_next_nym_api() - } - Ok(res?) - } -} - -pub(crate) struct LocalGatewayNode { - pub(crate) active_sphinx_keys: ActiveSphinxKeys, - pub(crate) mix_host: SocketAddr, - pub(crate) identity_key: ed25519::PublicKey, - pub(crate) entry: EntryDetails, -} - -impl LocalGatewayNode { - pub(crate) fn to_routing_node(&self) -> RoutingNode { - RoutingNode { - node_id: LOCAL_NODE_ID, - mix_host: self.mix_host, - entry: Some(self.entry.clone()), - identity_key: self.identity_key, - sphinx_key: self.active_sphinx_keys.primary().deref().x25519_pubkey(), - supported_roles: nym_topology::SupportedRoles { - mixnode: false, - mixnet_entry: true, - mixnet_exit: true, - }, - } - } -} - -#[derive(Clone)] -pub struct CachedTopologyProvider { - gateway_node: Arc, - cached_network: CachedNetwork, - min_mix_performance: u8, -} - -impl CachedTopologyProvider { - pub(crate) fn new( - gateway_node: LocalGatewayNode, - cached_network: CachedNetwork, - min_mix_performance: u8, - ) -> Self { - CachedTopologyProvider { - gateway_node: Arc::new(gateway_node), - cached_network, - min_mix_performance, - } - } -} - -#[async_trait] -impl TopologyProvider for CachedTopologyProvider { - async fn get_new_topology(&mut self) -> Option { - let self_node = self.gateway_node.identity_key; - - let mut topology = self - .cached_network - .network_topology(self.min_mix_performance) - .await; - - if !topology.has_node(self.gateway_node.identity_key) { - debug!("{self_node} didn't exist in topology. inserting it.",); - topology.insert_node_details(self.gateway_node.to_routing_node()); - } - topology.force_set_active(LOCAL_NODE_ID, Role::EntryGateway); - - Some(topology) - } -} - -#[derive(Clone)] -pub(crate) struct CachedNetwork { - inner: Arc>, -} - -impl CachedNetwork { - fn new_empty() -> Self { - CachedNetwork { - inner: Arc::new(RwLock::new(CachedNetworkInner { - rewarded_set: Default::default(), - topology_metadata: Default::default(), - network_nodes: vec![], - })), - } - } - - async fn network_topology(&self, min_mix_performance: u8) -> NymTopology { - let network_guard = self.inner.read().await; - - NymTopology::new( - network_guard.topology_metadata, - network_guard.rewarded_set.clone(), - Vec::new(), - ) - .with_additional_nodes( - network_guard - .network_nodes - .iter() - .map(|node| &node.basic) - .filter(|node| { - if node.supported_roles.mixnode { - node.performance.round_to_integer() >= min_mix_performance - } else { - true - } - }), - ) - } -} - -struct CachedNetworkInner { - rewarded_set: EpochRewardedSet, - topology_metadata: NymTopologyMetadata, - network_nodes: Vec, -} - -pub struct NetworkRefresher { - querier: NodesQuerier, - full_refresh_interval: Duration, - pending_check_interval: Duration, - shutdown_token: ShutdownToken, - - network: CachedNetwork, - routing_filter: NetworkRoutingFilter, - noise_view: NoiseNetworkView, - lp_nodes: LpNodes, -} - -impl NetworkRefresher { - #[allow(clippy::too_many_arguments)] - pub(crate) async fn initialise_new( - testnet: bool, - user_agent: UserAgent, - nym_api_urls: Vec, - full_refresh_interval: Duration, - pending_check_interval: Duration, - max_startup_waiting_period: Duration, - min_mix_performance: u8, - shutdown_token: ShutdownToken, - ) -> Result { - let nym_api = nym_http_api_client::Client::builder(nym_api_urls[0].clone())? - .no_hickory_dns() - .with_user_agent(user_agent) - .build()?; - - let mut this = NetworkRefresher { - querier: NodesQuerier { - client: nym_api, - nym_api_urls, - currently_used_api: 0, - }, - full_refresh_interval, - pending_check_interval, - shutdown_token, - network: CachedNetwork::new_empty(), - routing_filter: NetworkRoutingFilter::new_empty(testnet), - noise_view: NoiseNetworkView::new_empty(), - lp_nodes: Default::default(), - }; - - this.obtain_initial_network(max_startup_waiting_period, min_mix_performance) - .await?; - Ok(this) - } - - async fn inspect_pending(&mut self) { - let to_resolve = self.routing_filter.pending.nodes().await; - - // no pending requests to resolve - if to_resolve.is_empty() { - return; - } - - let mut allowed = self.routing_filter().allowed_nodes_copy(); - let mut denied = self.routing_filter().denied_nodes_copy(); - - // short circuit: check if the pending nodes are not already resolved - // (it could happen due to lack of full sync between pending lock and arcswap(s)) - if to_resolve - .iter() - .all(|p| allowed.contains(p) || denied.contains(p)) - { - return; - } - - // 1. attempt to use the new nym-api query to get information just by ips - let nodes = to_resolve.into_iter().collect(); - if let Ok(res) = self.querier.query_for_info(nodes).await { - for (ip, maybe_id) in res.existence { - if maybe_id.is_some() { - allowed.insert(ip); - } else { - denied.insert(ip); - } - } - - self.routing_filter.resolved.swap_allowed(allowed); - self.routing_filter.resolved.swap_denied(denied); - self.routing_filter.pending.clear().await; - return; - } - - // 2. we assume nym-api doesn't support that query yet - we have to do the full refresh - self.refresh_network_nodes().await; - } - - async fn refresh_network_nodes_inner(&mut self) -> Result<(), ValidatorClientError> { - let rewarded_set = self.querier.rewarded_set().await?; - let res = self.querier.current_nymnodes().await?; - let nodes = res.nodes; - let metadata = res.metadata; - - // collect all known/allowed nodes information - let known_nodes = nodes - .iter() - .flat_map(|n| n.basic.ip_addresses.iter()) - .copied() - .collect::>(); - - let pending = self.routing_filter.pending.nodes().await; - let mut current_denied = self.routing_filter.denied_nodes_copy(); - - for allowed in &known_nodes { - // if some node has become known, it should be removed from the denied set - current_denied.remove(allowed); - } - - // any pending node, if not present in the new set of allowed nodes, should be added in the denied set - for pending_node in pending { - if !known_nodes.contains(&pending_node) { - current_denied.insert(pending_node); - } - } - - self.routing_filter.resolved.swap_allowed(known_nodes); - self.routing_filter.resolved.swap_denied(current_denied); - self.routing_filter.pending.clear().await; - - //update noise Nodes - let noise_nodes = nodes - .iter() - .filter(|n| n.x25519_noise_versioned_key.is_some()) - .flat_map(|n| { - n.basic.ip_addresses.iter().map(|ip_addr| { - ( - SocketAddr::new(*ip_addr, n.basic.mix_port), - #[allow(clippy::unwrap_used)] - n.x25519_noise_versioned_key.unwrap(), // SAFETY : we filtered out nodes where this option can be None - ) - }) - }) - .collect::>(); - self.noise_view.swap_view(noise_nodes); - debug!("unimplemented: update LP nodes data"); - - let mut network_guard = self.network.inner.write().await; - network_guard.topology_metadata = metadata.to_topology_metadata(); - network_guard.network_nodes = nodes; - network_guard.rewarded_set = rewarded_set; - - Ok(()) - } - - async fn refresh_network_nodes(&mut self) { - let timer = - PROMETHEUS_METRICS.start_timer(PrometheusMetric::ProcessTopologyQueryResolutionLatency); - - if self.refresh_network_nodes_inner().await.is_err() { - // don't use the histogram observation as some queries didn't complete - if let Some(obs) = timer { - obs.stop_and_discard(); - } - } - } - - pub(crate) async fn obtain_initial_network( - &mut self, - max_startup_waiting_period: Duration, - min_mix_performance: u8, - ) -> Result<(), NymNodeError> { - // make it configurable - const STARTUP_REFRESH_INTERVAL: Duration = Duration::from_secs(30); - - let start = Instant::now(); - - loop { - self.refresh_network_nodes_inner() - .await - .map_err(|source| NymNodeError::InitialTopologyQueryFailure { source })?; - - let topology = self.network.network_topology(min_mix_performance).await; - if topology.is_minimally_routable() { - return Ok(()); - } - - if start.elapsed() > max_startup_waiting_period { - return Err(NymNodeError::InitialTopologyTimeout); - } - - sleep(STARTUP_REFRESH_INTERVAL).await; - } - } - - pub(crate) fn routing_filter(&self) -> NetworkRoutingFilter { - self.routing_filter.clone() - } - - pub(crate) fn cached_network(&self) -> CachedNetwork { - self.network.clone() - } - - pub(crate) fn noise_view(&self) -> NoiseNetworkView { - self.noise_view.clone() - } - - pub(crate) fn lp_nodes(&self) -> LpNodes { - self.lp_nodes.clone() - } - - pub(crate) async fn run(&mut self) { - let mut full_refresh_interval = interval(self.full_refresh_interval); - full_refresh_interval.reset(); - - let mut pending_check_interval = interval(self.pending_check_interval); - pending_check_interval.reset(); - - loop { - tokio::select! { - biased; - _ = self.shutdown_token.cancelled() => { - trace!("NetworkRefresher: Received shutdown"); - break; - } - _ = pending_check_interval.tick() => { - self.inspect_pending().await; - } - _ = full_refresh_interval.tick() => { - self.refresh_network_nodes().await; - } - } - } - trace!("NetworkRefresher: Exiting"); - } - - pub(crate) fn start(mut self) { - tokio::spawn(async move { self.run().await }); - } -} diff --git a/nym-node/src/node/shared_network/mod.rs b/nym-node/src/node/shared_network/mod.rs new file mode 100644 index 0000000000..d900f6cc1a --- /dev/null +++ b/nym-node/src/node/shared_network/mod.rs @@ -0,0 +1,56 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_topology::{EpochRewardedSet, NymTopology, NymTopologyMetadata}; +use nym_validator_client::nym_nodes::SemiSkimmedNodeV1; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub(crate) mod refresher; +pub(crate) mod topology_provider; + +#[derive(Clone)] +pub(crate) struct CachedNetwork { + inner: Arc>, +} + +impl CachedNetwork { + fn new_empty() -> Self { + CachedNetwork { + inner: Arc::new(RwLock::new(CachedNetworkInner { + rewarded_set: Default::default(), + topology_metadata: Default::default(), + network_nodes: vec![], + })), + } + } + + async fn network_topology(&self, min_mix_performance: u8) -> NymTopology { + let network_guard = self.inner.read().await; + + NymTopology::new( + network_guard.topology_metadata, + network_guard.rewarded_set.clone(), + Vec::new(), + ) + .with_additional_nodes( + network_guard + .network_nodes + .iter() + .map(|node| &node.basic) + .filter(|node| { + if node.supported_roles.mixnode { + node.performance.round_to_integer() >= min_mix_performance + } else { + true + } + }), + ) + } +} + +struct CachedNetworkInner { + rewarded_set: EpochRewardedSet, + topology_metadata: NymTopologyMetadata, + network_nodes: Vec, +} diff --git a/nym-node/src/node/shared_network/refresher.rs b/nym-node/src/node/shared_network/refresher.rs new file mode 100644 index 0000000000..dc54a67404 --- /dev/null +++ b/nym-node/src/node/shared_network/refresher.rs @@ -0,0 +1,337 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Background task that periodically refreshes network topology and routing information. +//! +//! # Responsibilities +//! +//! - Fetches the current Nym node list from nym-api +//! - Resolves pending (unknown) IP addresses from ingress mixnet packets +//! - Updates routing filter with allowed/denied node lists +//! - Maintains Noise protocol key mappings +//! - Ensures minimally routable topology at startup +//! +//! # Refresh Strategy +//! +//! Two independent refresh cycles run in parallel: +//! 1. **Full refresh** (typically every 60s): Complete network state from nym-api +//! 2. **Pending check** (typically every 5s): Quick resolution of recently seen unknown IPs +//! +//! The pending check uses an optimized nym-api endpoint when available, falling back to +//! full refresh if the endpoint is not supported. + +use crate::error::NymNodeError; +use crate::node::lp::directory::LpNodes; +use crate::node::nym_apis_client::NymApisClient; +use crate::node::routing_filter::network_filter::NetworkRoutingFilter; +use crate::node::shared_network::CachedNetwork; +use nym_node_metrics::prometheus_wrapper::{PROMETHEUS_METRICS, PrometheusMetric}; +use nym_noise::config::{NoiseNetworkView, NoiseNode}; +use nym_task::ShutdownToken; +use nym_topology::provider_trait::ToTopologyMetadata; +use nym_validator_client::ValidatorClientError; +use std::collections::{HashMap, HashSet}; +use std::time::Duration; +use tokio::time::{Instant, interval, sleep}; +use tracing::{debug, trace}; + +pub struct NetworkRefresher { + config: NetworkRefresherConfig, + client: NymApisClient, + shutdown_token: ShutdownToken, + + network: CachedNetwork, + routing_filter: NetworkRoutingFilter, + noise_view: NoiseNetworkView, + lp_nodes: LpNodes, +} + +#[derive(Debug, Clone, Copy)] +pub struct NetworkRefresherConfig { + full_refresh_interval: Duration, + pending_check_interval: Duration, + max_startup_waiting_period: Duration, + min_mix_performance: u8, +} + +impl NetworkRefresherConfig { + pub fn new( + full_refresh_interval: Duration, + pending_check_interval: Duration, + max_startup_waiting_period: Duration, + min_mix_performance: u8, + ) -> Self { + Self { + full_refresh_interval, + pending_check_interval, + max_startup_waiting_period, + min_mix_performance, + } + } +} + +impl NetworkRefresher { + pub(crate) async fn initialise_new( + config: NetworkRefresherConfig, + client: NymApisClient, + routing_filter: NetworkRoutingFilter, + noise_view: NoiseNetworkView, + shutdown_token: ShutdownToken, + ) -> Result { + let mut this = NetworkRefresher { + config, + client, + shutdown_token, + network: CachedNetwork::new_empty(), + routing_filter, + noise_view, + lp_nodes: Default::default(), + }; + + this.obtain_initial_network( + config.max_startup_waiting_period, + config.min_mix_performance, + ) + .await?; + Ok(this) + } + + /// Attempt to resolve pending (unknown) IP addresses that were recently seen in packet traffic. + /// + /// # Algorithm + /// + /// 1. Collect all pending IPs that need resolution (lock required) + /// 2. Short-circuit if all pending IPs are already in allowed/denied sets (race condition check) + /// 3. Try optimised nym-api query: `query_nym_nodes_addresses(ips)` for bulk lookup + /// - If supported: Get immediate results, update allowed/denied sets, clear pending queue + /// - If not supported (404): Fall back to full network refresh + /// + /// # Performance + /// + /// The optimised query avoids fetching the entire network topology (~1000 nodes) when we only + /// need to check a handful of IPs. This is crucial for minimising latency between when a new + /// node joins and when it can successfully route packets. + /// + /// # Fallback Behaviour + /// + /// If nym-api doesn't support the optimised endpoint, we do a full refresh. This is acceptable + /// because: + /// - Full refresh is needed anyway for topology updates + /// - The pending queue typically has <10 entries + /// - This only affects older nym-api versions + async fn inspect_pending(&mut self) { + let to_resolve = self.routing_filter.pending.nodes().await; + + // no pending requests to resolve + if to_resolve.is_empty() { + return; + } + + let mut allowed = self.routing_filter.allowed_nodes_copy(); + let mut denied = self.routing_filter.denied_nodes_copy(); + + // short circuit: check if the pending nodes are not already resolved + // (it could happen due to lack of full sync between pending lock and arcswap(s)) + if to_resolve + .iter() + .all(|p| allowed.contains(p) || denied.contains(p)) + { + return; + } + + // 1. attempt to use the new nym-api query to get information just by ips + let nodes = to_resolve.into_iter().collect(); + if let Ok(res) = self.client.query_nym_nodes_addresses(nodes).await { + for (ip, maybe_id) in res.existence { + if maybe_id.is_some() { + allowed.insert(ip); + } else { + denied.insert(ip); + } + } + + self.routing_filter.resolved.swap_allowed(allowed); + self.routing_filter.resolved.swap_denied(denied); + self.routing_filter.pending.clear().await; + return; + } + + // 2. we assume nym-api doesn't support that query yet - we have to do the full refresh + self.refresh_network_nodes().await; + } + + async fn refresh_network_nodes_inner(&mut self) -> Result<(), ValidatorClientError> { + let rewarded_set = self.client.rewarded_set().await?; + let res = self.client.current_nymnodes().await?; + let nodes = res.nodes; + let metadata = res.metadata; + + // collect all known/allowed nodes information + let known_nodes = nodes + .iter() + .flat_map(|n| n.basic.ip_addresses.iter()) + .copied() + .collect::>(); + + let pending = self.routing_filter.pending.nodes().await; + let mut current_denied = self.routing_filter.denied_nodes_copy(); + + for allowed in &known_nodes { + // if some node has become known, it should be removed from the denied set + current_denied.remove(allowed); + } + + // any pending node, if not present in the new set of allowed nodes, should be added in the denied set + for pending_node in pending { + if !known_nodes.contains(&pending_node) { + current_denied.insert(pending_node); + } + } + + self.routing_filter.resolved.swap_allowed(known_nodes); + self.routing_filter.resolved.swap_denied(current_denied); + self.routing_filter.pending.clear().await; + + let noise_update_permit = self.noise_view.get_update_permit().await; + let current_nodes = self.noise_view.all_nodes(); + + // update noise Nodes + let mut new_noise_nodes = HashMap::new(); + + // Rebuild the noise map in two passes to respect the two independent data sources: + // + // 1. Preserve existing NM agent entries — these come from blockchain events processed + // by `NetworkMonitorAgentsModule` and are NOT available from nym-api. Dropping them + // here would leave agents unable to perform noise handshakes until their + // registration is renewed. + // Keys are canonicalised so the map matches what supports_noise/get_noise_key look up. + for (ip, node) in current_nodes { + if !node.is_nym_node() { + new_noise_nodes.insert(ip.to_canonical(), node); + } + } + + // 2. Replace all nym-node entries with fresh data from nym-api. + // (Stale nym-node keys are safe to overwrite — the source of truth is always nym-api) + for node in &nodes { + let Some(noise_key) = node.x25519_noise_versioned_key else { + continue; + }; + let entry = NoiseNode::new_nym_node(noise_key); + for ip_addr in &node.basic.ip_addresses { + new_noise_nodes.insert(ip_addr.to_canonical(), entry.clone()); + } + } + self.noise_view + .swap_view(noise_update_permit, new_noise_nodes); + debug!("unimplemented: update LP nodes data - will work very similarly to noise nodes"); + + let mut network_guard = self.network.inner.write().await; + network_guard.topology_metadata = metadata.to_topology_metadata(); + network_guard.network_nodes = nodes; + network_guard.rewarded_set = rewarded_set; + + Ok(()) + } + + async fn refresh_network_nodes(&mut self) { + let timer = + PROMETHEUS_METRICS.start_timer(PrometheusMetric::ProcessTopologyQueryResolutionLatency); + + if self.refresh_network_nodes_inner().await.is_err() { + // don't use the histogram observation as some queries didn't complete + if let Some(obs) = timer { + obs.stop_and_discard(); + } + } + } + + /// Block until we obtain a minimally routable network topology at startup. + /// + /// # Startup Sequence + /// + /// 1. Query nym-api for full network state (nodes + rewarded set) + /// 2. Check if topology has sufficient mixnodes in each layer for routing + /// 3. If not routable: wait `STARTUP_REFRESH_INTERVAL` (30s) and retry + /// 4. If still not routable after `max_startup_waiting_period`: return error and abort startup + /// + /// # Why Block Startup? + /// + /// We MUST have a routable topology before accepting packets, otherwise: + /// - Packets would be dropped due to incomplete routing tables + /// - The node would appear non-functional to the network + /// - Internal service providers would be unable to construct return packets + /// + /// # Timeout Behavior + /// + /// If the network remains non-routable for too long, this indicates: + /// - Network-wide outage (not enough mixnodes online) + /// - nym-api connectivity issues + /// - Configuration error (wrong nym-api URL) + /// + /// In any case, the node should NOT start packet processing. + pub(crate) async fn obtain_initial_network( + &mut self, + max_startup_waiting_period: Duration, + min_mix_performance: u8, + ) -> Result<(), NymNodeError> { + // make it configurable + const STARTUP_REFRESH_INTERVAL: Duration = Duration::from_secs(30); + + let start = Instant::now(); + + loop { + self.refresh_network_nodes_inner() + .await + .map_err(|source| NymNodeError::InitialTopologyQueryFailure { source })?; + + let topology = self.network.network_topology(min_mix_performance).await; + if topology.is_minimally_routable() { + return Ok(()); + } + + if start.elapsed() > max_startup_waiting_period { + return Err(NymNodeError::InitialTopologyTimeout); + } + + sleep(STARTUP_REFRESH_INTERVAL).await; + } + } + + pub(crate) fn cached_network(&self) -> CachedNetwork { + self.network.clone() + } + + pub(crate) fn lp_nodes(&self) -> LpNodes { + self.lp_nodes.clone() + } + + pub(crate) async fn run(&mut self) { + let mut full_refresh_interval = interval(self.config.full_refresh_interval); + full_refresh_interval.reset(); + + let mut pending_check_interval = interval(self.config.pending_check_interval); + pending_check_interval.reset(); + + loop { + tokio::select! { + biased; + _ = self.shutdown_token.cancelled() => { + trace!("NetworkRefresher: Received shutdown"); + break; + } + _ = pending_check_interval.tick() => { + self.inspect_pending().await; + } + _ = full_refresh_interval.tick() => { + self.refresh_network_nodes().await; + } + } + } + trace!("NetworkRefresher: Exiting"); + } + + pub(crate) fn start(mut self) { + tokio::spawn(async move { self.run().await }); + } +} diff --git a/nym-node/src/node/shared_network/topology_provider.rs b/nym-node/src/node/shared_network/topology_provider.rs new file mode 100644 index 0000000000..1002ad7e21 --- /dev/null +++ b/nym-node/src/node/shared_network/topology_provider.rs @@ -0,0 +1,79 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node::key_rotation::active_keys::ActiveSphinxKeys; +use crate::node::shared_network::CachedNetwork; +use async_trait::async_trait; +use nym_crypto::asymmetric::ed25519; +use nym_topology::{EntryDetails, NodeId, NymTopology, Role, RoutingNode, TopologyProvider}; +use std::net::SocketAddr; +use std::ops::Deref; +use std::sync::Arc; +use tracing::debug; + +const LOCAL_NODE_ID: NodeId = 1234567890; + +pub(crate) struct LocalGatewayNode { + pub(crate) active_sphinx_keys: ActiveSphinxKeys, + pub(crate) mix_host: SocketAddr, + pub(crate) identity_key: ed25519::PublicKey, + pub(crate) entry: EntryDetails, +} + +impl LocalGatewayNode { + pub(crate) fn to_routing_node(&self) -> RoutingNode { + RoutingNode { + node_id: LOCAL_NODE_ID, + mix_host: self.mix_host, + entry: Some(self.entry.clone()), + identity_key: self.identity_key, + sphinx_key: self.active_sphinx_keys.primary().deref().x25519_pubkey(), + supported_roles: nym_topology::SupportedRoles { + mixnode: false, + mixnet_entry: true, + mixnet_exit: true, + }, + } + } +} + +#[derive(Clone)] +pub struct CachedTopologyProvider { + gateway_node: Arc, + cached_network: CachedNetwork, + min_mix_performance: u8, +} + +impl CachedTopologyProvider { + pub(crate) fn new( + gateway_node: LocalGatewayNode, + cached_network: CachedNetwork, + min_mix_performance: u8, + ) -> Self { + CachedTopologyProvider { + gateway_node: Arc::new(gateway_node), + cached_network, + min_mix_performance, + } + } +} + +#[async_trait] +impl TopologyProvider for CachedTopologyProvider { + async fn get_new_topology(&mut self) -> Option { + let self_node = self.gateway_node.identity_key; + + let mut topology = self + .cached_network + .network_topology(self.min_mix_performance) + .await; + + if !topology.has_node(self.gateway_node.identity_key) { + debug!("{self_node} didn't exist in topology. inserting it.",); + topology.insert_node_details(self.gateway_node.to_routing_node()); + } + topology.force_set_active(LOCAL_NODE_ID, Role::EntryGateway); + + Some(topology) + } +} diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index e8d51ea07c..cce78b4a2a 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4833,7 +4833,6 @@ version = "1.20.4" dependencies = [ "cargo_metadata 0.19.2", "dotenvy", - "log", "regex", "schemars", "serde", @@ -4843,6 +4842,18 @@ dependencies = [ "utoipa", ] +[[package]] +name = "nym-network-monitors-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-node-requests" version = "1.20.4" @@ -4855,6 +4866,7 @@ dependencies = [ "nym-exit-policy", "nym-http-api-client", "nym-kkt-ciphersuite", + "nym-network-defaults", "nym-noise-keys", "nym-upgrade-mode-check", "nym-wireguard-types", @@ -5018,6 +5030,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-multisig-contract-common", "nym-network-defaults", + "nym-network-monitors-contract-common", "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", diff --git a/nym-wallet/nym-wallet-types/src/network/sandbox.rs b/nym-wallet/nym-wallet-types/src/network/sandbox.rs index f8e66ebe3e..a70bf52613 100644 --- a/nym-wallet/nym-wallet-types/src/network/sandbox.rs +++ b/nym-wallet/nym-wallet-types/src/network/sandbox.rs @@ -28,6 +28,9 @@ pub(crate) const COCONUT_DKG_CONTRACT_ADDRESS: &str = pub(crate) const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; // /\ TODO: this has to be updated once the contract is deployed +pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str = + "n1x5krtvyqklj360x38v62ze42g8s8trfsfqzlv8c9296chcpvqadssqnem5"; + // -- Constructor functions -- pub(crate) fn validators() -> Vec { @@ -51,6 +54,9 @@ pub(crate) fn network_details() -> nym_network_defaults::NymNetworkDetails { mixnet_contract_address: parse_optional_str(MIXNET_CONTRACT_ADDRESS), vesting_contract_address: parse_optional_str(VESTING_CONTRACT_ADDRESS), performance_contract_address: parse_optional_str(PERFORMANCE_CONTRACT_ADDRESS), + network_monitors_contract_address: parse_optional_str( + NETWORK_MONITORS_CONTRACT_ADDRESS, + ), ecash_contract_address: parse_optional_str(ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(MULTISIG_CONTRACT_ADDRESS), diff --git a/nym-wallet/src-tauri/src/operations/nym_api/status.rs b/nym-wallet/src-tauri/src/operations/nym_api/status.rs index e17c828514..9b88b22435 100644 --- a/nym-wallet/src-tauri/src/operations/nym_api/status.rs +++ b/nym-wallet/src-tauri/src/operations/nym_api/status.rs @@ -14,7 +14,7 @@ use nym_mixnet_contract_common::{ }; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::{ - AnnotationResponse, DisplayRole, GatewayCoreStatusResponse, GatewayStatusReportResponse, + AnnotationResponseV1, DisplayRole, GatewayCoreStatusResponse, GatewayStatusReportResponse, MixnodeCoreStatusResponse, MixnodeStatusResponse, StakeSaturationResponse, }; use serde::{Deserialize, Serialize}; @@ -167,6 +167,6 @@ pub async fn get_nymnode_role( pub async fn get_nymnode_annotation( node_id: NodeId, state: tauri::State<'_, WalletState>, -) -> Result { +) -> Result { Ok(api_client!(state).get_node_annotation(node_id).await?) } diff --git a/nyx-chain-watcher/src/chain_scraper/mod.rs b/nyx-chain-watcher/src/chain_scraper/mod.rs index 5bf6eff337..2228a6f6d4 100644 --- a/nyx-chain-watcher/src/chain_scraper/mod.rs +++ b/nyx-chain-watcher/src/chain_scraper/mod.rs @@ -5,9 +5,10 @@ use crate::env::vars::{ }; use crate::http::state::BankScraperModuleState; use async_trait::async_trait; -use nym_validator_client::nyxd::{Any, Coin, CosmosCoin, Hash, Msg, MsgSend, Name}; +use nym_validator_client::nyxd::{Any, Coin, CosmosCoin, Msg, MsgSend, Name}; use nyxd_scraper_sqlite::{ - MsgModule, ParsedTransactionResponse, PruningOptions, ScraperError, SqliteNyxdScraper, + DecodedMessage, MsgModule, ParsedTransactionDetails, PruningOptions, ScraperError, + SqliteNyxdScraper, parse_msg, }; use sqlx::SqlitePool; use std::fs; @@ -130,22 +131,8 @@ impl BankScraperModule { .find(|coin| coin.denom.as_ref() == "unym") .map(|c| c.clone().into()) } - - // TODO: ideally this should be done by the scraper itself - fn recover_bank_msg( - &self, - tx_hash: Hash, - index: usize, - msg: &Any, - ) -> Result { - MsgSend::from_any(msg).map_err(|source| ScraperError::MsgParseFailure { - hash: tx_hash, - index, - type_url: self.type_url(), - source, - }) - } } + #[async_trait] impl MsgModule for BankScraperModule { fn type_url(&self) -> String { @@ -156,7 +143,8 @@ impl MsgModule for BankScraperModule { &mut self, index: usize, msg: &Any, - tx: &ParsedTransactionResponse, + _: &DecodedMessage, + tx: &ParsedTransactionDetails, ) -> Result<(), ScraperError> { let memo = tx.tx.body.memo.clone(); @@ -165,7 +153,7 @@ impl MsgModule for BankScraperModule { return Ok(()); } - let msg = self.recover_bank_msg(tx.hash, index, msg)?; + let msg: MsgSend = parse_msg(msg)?; // Check if any watcher is watching this recipient let is_watched = self @@ -184,7 +172,7 @@ impl MsgModule for BankScraperModule { ); warn!("{warn}"); self.shared_state - .new_rejection(tx.hash.to_string(), tx.height.value(), index as u32, warn) + .new_rejection(tx.hash.to_string(), tx.height().value(), index as u32, warn) .await; // we don't want to fail the whole processing - this is not a failure in that sense! @@ -194,7 +182,7 @@ impl MsgModule for BankScraperModule { if let Err(err) = self .store_transfer_event( &tx.hash.to_string(), - tx.height.value() as i64, + tx.height().value() as i64, index as i64, msg.from_address.to_string(), msg.to_address.to_string(), @@ -207,7 +195,7 @@ impl MsgModule for BankScraperModule { self.shared_state .new_rejection( tx.hash.to_string(), - tx.height.value(), + tx.height().value(), index as u32, format!("storage failure: {err}"), ) diff --git a/nyx-chain-watcher/src/http/state.rs b/nyx-chain-watcher/src/http/state.rs index 8ab26dff9b..05f1b2119b 100644 --- a/nyx-chain-watcher/src/http/state.rs +++ b/nyx-chain-watcher/src/http/state.rs @@ -7,7 +7,7 @@ use axum::extract::FromRef; use nym_bin_common::bin_info; use nym_bin_common::build_information::BinaryBuildInformation; use nym_validator_client::nyxd::{Coin, MsgSend}; -use nyxd_scraper_sqlite::ParsedTransactionResponse; +use nyxd_scraper_sqlite::ParsedTransactionDetails; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::ops::Deref; @@ -289,7 +289,7 @@ impl BankScraperModuleState { pub(crate) async fn new_bank_msg( &self, - tx: &ParsedTransactionResponse, + tx: &ParsedTransactionDetails, index: usize, msg: &MsgSend, is_watched: bool, @@ -300,7 +300,7 @@ impl BankScraperModuleState { let details = BankMsgDetails { processed_at: OffsetDateTime::now_utc(), tx_hash: tx.hash.to_string(), - height: tx.height.value(), + height: tx.height().value(), index: index as u32, from: msg.from_address.to_string(), to: msg.to_address.to_string(), diff --git a/tools/ts-rs-cli/src/main.rs b/tools/ts-rs-cli/src/main.rs index 4b21ada952..79ba3ef7ee 100644 --- a/tools/ts-rs-cli/src/main.rs +++ b/tools/ts-rs-cli/src/main.rs @@ -1,11 +1,11 @@ #![allow(deprecated)] use nym_api_requests::models::{ - AnnotationResponse, DeclaredRolesV1, DescribedNodeTypeV1, GatewayCoreStatusResponse, - HistoricalPerformanceResponse, HistoricalUptimeResponse, MixnodeCoreStatusResponse, - MixnodeStatus, MixnodeStatusResponse, NodeAnnotation, NodeDatePerformanceResponse, - NodePerformanceResponse, PerformanceHistoryResponse, StakeSaturationResponse, - UptimeHistoryResponse, + AnnotationResponseV1, AnnotationResponseV2, DeclaredRolesV1, DescribedNodeTypeV1, + GatewayCoreStatusResponse, HistoricalPerformanceResponse, HistoricalUptimeResponse, + MixnodeCoreStatusResponse, MixnodeStatus, MixnodeStatusResponse, NodeAnnotationV1, + NodeAnnotationV2, NodeDatePerformanceResponse, NodePerformanceResponse, + PerformanceHistoryResponse, StakeSaturationResponse, UptimeHistoryResponse, }; use nym_api_requests::pagination::{PaginatedResponse, Pagination}; use nym_mixnet_contract_common::nym_node::{NodeConfigUpdate, Role}; @@ -150,8 +150,10 @@ fn main() -> anyhow::Result<()> { do_export!(MixnodeStatus); do_export!(MixnodeStatusResponse); do_export!(StakeSaturationResponse); - do_export!(NodeAnnotation); - do_export!(AnnotationResponse); + do_export!(NodeAnnotationV1); + do_export!(AnnotationResponseV1); + do_export!(NodeAnnotationV2); + do_export!(AnnotationResponseV2); do_export!(NodePerformanceResponse); do_export!(NodeDatePerformanceResponse); do_export!(PerformanceHistoryResponse);