diff --git a/common/client-core/src/client/base_client/mod.rs b/common/client-core/src/client/base_client/mod.rs index a093977bbb..868f281e6c 100644 --- a/common/client-core/src/client/base_client/mod.rs +++ b/common/client-core/src/client/base_client/mod.rs @@ -199,6 +199,7 @@ pub struct BaseClientBuilder { custom_gateway_transceiver: Option>, shutdown: Option, user_agent: Option, + custom_nym_api_client: Option, setup_method: GatewaySetup, @@ -227,6 +228,7 @@ where custom_gateway_transceiver: None, shutdown: None, user_agent: None, + custom_nym_api_client: None, setup_method: GatewaySetup::MustLoad { gateway_id: None }, #[cfg(unix)] connection_fd_callback: None, @@ -234,6 +236,12 @@ where } } + #[must_use] + pub fn with_nym_api_client(mut self, client: nym_http_api_client::Client) -> Self { + self.custom_nym_api_client = Some(client); + self + } + #[must_use] pub fn with_derivation_material( mut self, @@ -820,7 +828,17 @@ where fn construct_nym_api_client( config: &Config, user_agent: Option, + custom_client: Option, ) -> Result { + // If a custom client was provided (e.g., with domain fronting support), use it + if let Some(client) = custom_client { + tracing::info!("Using CUSTOM nym-api HTTP client (with domain fronting support)"); + return Ok(client); + } + + tracing::warn!("No custom HTTP client provided - creating DEFAULT client from config"); + + // Otherwise, create a basic client let mut nym_api_urls = config.get_nym_api_endpoints(); nym_api_urls.shuffle(&mut thread_rng()); @@ -911,7 +929,11 @@ where .dkg_query_client .map(|client| BandwidthController::new(credential_store, client)); - let nym_api_client = Self::construct_nym_api_client(&self.config, self.user_agent.clone())?; + let nym_api_client = Self::construct_nym_api_client( + &self.config, + self.user_agent.clone(), + self.custom_nym_api_client, + )?; let key_rotation_config = Self::determine_key_rotation_state(&nym_api_client).await?; let topology_provider = Self::setup_topology_provider( diff --git a/common/client-core/src/client/topology_control/nym_api_provider.rs b/common/client-core/src/client/topology_control/nym_api_provider.rs index a81118665c..b4dbfebb19 100644 --- a/common/client-core/src/client/topology_control/nym_api_provider.rs +++ b/common/client-core/src/client/topology_control/nym_api_provider.rs @@ -6,6 +6,7 @@ use nym_mixnet_contract_common::EpochRewardedSet; use nym_topology::provider_trait::{ToTopologyMetadata, TopologyProvider}; use nym_topology::NymTopology; use nym_validator_client::nym_api::NymApiClientExt; +use nym_validator_client::nym_nodes::NodeRole; use rand::prelude::SliceRandom; use rand::thread_rng; use std::cmp::min; @@ -135,31 +136,31 @@ impl NymApiTopologyProvider { } else { // if we're not using extended topology, we're only getting active set mixnodes and gateways - let mixnodes_fut = self - .validator_client - .get_all_basic_active_mixing_assigned_nodes_with_metadata(); + // Get ALL bonded nodes and filter for both mixnodes and gateways + // This ensures consistent metadata between both calls + let all_nodes_fut = self.validator_client.get_all_basic_nodes_with_metadata(); - // TODO: we really should be getting ACTIVE gateways only - let gateways_fut = self - .validator_client - .get_all_basic_entry_assigned_nodes_with_metadata(); + let (rewarded_set, all_nodes_res) = futures::try_join!(rewarded_set_fut, all_nodes_fut) + .inspect_err(|err| { + error!("failed to get network nodes: {err}"); + }) + .ok()?; - let (rewarded_set, mixnodes_res, gateways_res) = - futures::try_join!(rewarded_set_fut, mixnodes_fut, gateways_fut) - .inspect_err(|err| { - error!("failed to get network nodes: {err}"); - }) - .ok()?; + let metadata = all_nodes_res.metadata; + let all_nodes = all_nodes_res.nodes; - let metadata = mixnodes_res.metadata; - let mixnodes = mixnodes_res.nodes; + // Filter for active mixing nodes (role=Mixnode) + let mixnodes: Vec<_> = all_nodes + .iter() + .filter(|n| n.supported_roles.mixnode && matches!(n.role, NodeRole::Mixnode { .. })) + .cloned() + .collect(); - if !gateways_res.metadata.consistency_check(&metadata) { - warn!("inconsistent nodes metadata between mixnodes and gateways calls! {metadata:?} and {:?}", gateways_res.metadata); - return None; - } - - let gateways = gateways_res.nodes; + // Filter for entry-capable nodes (supported_roles.entry == true) + let gateways: Vec<_> = all_nodes + .into_iter() + .filter(|n| n.supported_roles.entry) + .collect(); debug!( "there are {} mixnodes and {} gateways in total (before performance filtering)", @@ -221,3 +222,27 @@ impl TopologyProvider for NymApiTopologyProvider { Some(topology) } } + +#[cfg(test)] +mod tests { + #[test] + fn test_filtering_logic() { + // This test verifies the filtering logic used in get_current_compatible_topology + // We test that the role matching pattern works correctly + use nym_validator_client::nym_nodes::NodeRole; + + // Test that the pattern matching for mixnodes works + let mixnode_role = NodeRole::Mixnode { layer: 1 }; + assert!( + matches!(mixnode_role, NodeRole::Mixnode { .. }), + "Should match mixnode role pattern" + ); + + // Test that other roles don't match + let entry_role = NodeRole::EntryGateway; + assert!( + !matches!(entry_role, NodeRole::Mixnode { .. }), + "Entry gateway should not match mixnode pattern" + ); + } +} diff --git a/common/client-core/src/init/helpers.rs b/common/client-core/src/init/helpers.rs index bcb1b42f8b..6ebd9d56a0 100644 --- a/common/client-core/src/init/helpers.rs +++ b/common/client-core/src/init/helpers.rs @@ -89,16 +89,22 @@ async fn get_all_basic_entry_nodes_with_metadata( client: &nym_http_api_client::Client, use_bincode: bool, ) -> Result { - // Get first page to obtain metadata + // Get ALL nodes (not just entry-assigned) because in some environments (like sandbox), + // nodes may be capable of entry gateway role but not currently assigned to it let mut page = 0; let res = client - .get_basic_entry_assigned_nodes_v2(false, Some(page), None, use_bincode) + .get_basic_nodes_v2(false, Some(page), None, use_bincode) .await?; let mut nodes = res.nodes.data; let metadata = res.metadata; if res.nodes.pagination.total == nodes.len() { - return Ok(SkimmedNodesWithMetadata::new(nodes, metadata)); + // Filter for entry-capable nodes (nodes with supported_roles.entry == true) + let entry_nodes: Vec<_> = nodes + .into_iter() + .filter(|n| n.supported_roles.entry) + .collect(); + return Ok(SkimmedNodesWithMetadata::new(entry_nodes, metadata)); } page += 1; @@ -106,7 +112,7 @@ async fn get_all_basic_entry_nodes_with_metadata( // Collect remaining pages loop { let mut res = client - .get_basic_entry_assigned_nodes_v2(false, Some(page), None, use_bincode) + .get_basic_nodes_v2(false, Some(page), None, use_bincode) .await?; if !metadata.consistency_check(&res.metadata) { @@ -123,7 +129,13 @@ async fn get_all_basic_entry_nodes_with_metadata( } } - Ok(SkimmedNodesWithMetadata::new(nodes, metadata)) + // Filter for entry-capable nodes (nodes with supported_roles.entry == true) + let entry_nodes: Vec<_> = nodes + .into_iter() + .filter(|n| n.supported_roles.entry) + .collect(); + + Ok(SkimmedNodesWithMetadata::new(entry_nodes, metadata)) } impl<'a, G: ConnectableGateway> GatewayWithLatency<'a, G> { @@ -182,13 +194,41 @@ pub async fn gateways_for_init( // filter out gateways below minimum performance and ones that could operate as a mixnode // (we don't want instability) - let valid_gateways = gateways + let total_fetched = gateways.len(); + + let after_role_filter_count = gateways + .iter() + .filter(|g| ignore_epoch_roles || !g.supported_roles.mixnode) + .count(); + tracing::debug!( + "After role filter: {} (removed {} mixnode-capable)", + after_role_filter_count, + total_fetched - after_role_filter_count + ); + + let after_performance_filter_count = gateways + .iter() + .filter(|g| ignore_epoch_roles || !g.supported_roles.mixnode) + .filter(|g| g.performance.round_to_integer() >= minimum_performance) + .count(); + tracing::debug!( + "After performance filter (>= {}%): {} (removed {} low-performance)", + minimum_performance, + after_performance_filter_count, + after_role_filter_count - after_performance_filter_count + ); + + let valid_gateways: Vec = gateways .iter() .filter(|g| ignore_epoch_roles || !g.supported_roles.mixnode) .filter(|g| g.performance.round_to_integer() >= minimum_performance) .filter_map(|gateway| gateway.try_into().ok()) - .collect::>(); - tracing::debug!("After checking validity: {}", valid_gateways.len()); + .collect(); + tracing::debug!( + "After conversion to RoutingNode: {} (removed {} invalid)", + valid_gateways.len(), + after_performance_filter_count - valid_gateways.len() + ); tracing::trace!("Valid gateways: {valid_gateways:#?}"); tracing::info!( @@ -196,6 +236,14 @@ pub async fn gateways_for_init( valid_gateways.len() ); + for gw in &valid_gateways { + tracing::debug!( + "Available gateway: {} (node_id: {})", + gw.identity_key.to_base58_string(), + gw.node_id + ); + } + Ok(valid_gateways) } @@ -355,13 +403,25 @@ pub(super) fn get_specified_gateway( must_use_tls: bool, ) -> Result { tracing::debug!("Requesting specified gateway: {gateway_identity}"); + tracing::debug!("Searching in {} available gateways", gateways.len()); + for gw in gateways { + tracing::debug!( + "Gateway in list: {} (node_id: {})", + gw.identity_key.to_base58_string(), + gw.node_id + ); + } + let user_gateway = ed25519::PublicKey::from_base58_string(gateway_identity) .map_err(ClientCoreError::UnableToCreatePublicKeyFromGatewayId)?; let gateway = gateways .iter() .find(|gateway| gateway.identity_key == user_gateway) - .ok_or_else(|| ClientCoreError::NoGatewayWithId(gateway_identity.to_string()))?; + .ok_or_else(|| { + tracing::error!("Gateway {gateway_identity} NOT FOUND in available gateways list!"); + ClientCoreError::NoGatewayWithId(gateway_identity.to_string()) + })?; let Some(entry_details) = gateway.entry.as_ref() else { return Err(ClientCoreError::UnsupportedEntry { @@ -427,7 +487,6 @@ pub(super) async fn register_with_gateway( #[cfg(test)] mod tests { - use super::*; use url::Url; #[test] @@ -473,4 +532,113 @@ mod tests { assert!(nym_api_urls.is_empty(), "Empty list should remain empty"); } + + #[test] + fn test_gateway_filtering_logic() { + // NOTE: This test validates the filtering logic in isolation. + // It does NOT test the actual implementation in get_all_basic_entry_nodes_with_metadata + // or gateways_for_init (which would require mocking the HTTP client). + // The real proof is building and running the daemon. + // + // Test the core filtering logic used in gateways_for_init: + // 1. Filter by supported_roles.entry (not by epoch role assignment) + // 2. Filter by performance + // + // This test verifies the fix where nodes have role=Inactive + // but supported_roles.entry=true + + #[derive(Debug)] + struct TestNode { + id: u32, + entry_capable: bool, // supported_roles.entry + mixnode_capable: bool, // supported_roles.mixnode + performance: u8, // 0-100 + } + + let nodes = vec![ + // Node 53: entry-capable, good performance + TestNode { + id: 53, + entry_capable: true, + mixnode_capable: false, + performance: 100, + }, + // Node 97: entry-capable (but role=Inactive in sandbox), good performance + TestNode { + id: 97, + entry_capable: true, + mixnode_capable: false, + performance: 100, + }, + // Node 75: NOT entry-capable (mixnode only) + TestNode { + id: 75, + entry_capable: false, + mixnode_capable: true, + performance: 100, + }, + // Node 99: entry-capable but low performance + TestNode { + id: 99, + entry_capable: true, + mixnode_capable: false, + performance: 0, + }, + ]; + + let minimum_performance = 50; + let ignore_epoch_roles = true; + + // Step 1: Filter by supported_roles.entry (this is what the fix enables) + let entry_capable: Vec<_> = nodes.iter().filter(|n| n.entry_capable).collect(); + + assert_eq!( + entry_capable.len(), + 3, + "Should have 3 entry-capable nodes (53, 97, 99) - this includes Inactive nodes!" + ); + + // Step 2: Filter by role (exclude mixnode-capable if not ignoring epoch roles) + let after_role_filter: Vec<_> = entry_capable + .iter() + .filter(|g| ignore_epoch_roles || !g.mixnode_capable) + .collect(); + + assert_eq!( + after_role_filter.len(), + 3, + "All entry-capable nodes pass role filter with ignore_epoch_roles=true" + ); + + // Step 3: Filter by performance + let after_performance_filter: Vec<_> = after_role_filter + .iter() + .filter(|g| g.performance >= minimum_performance) + .collect(); + + assert_eq!( + after_performance_filter.len(), + 2, + "Should have 2 nodes after performance filter (53 and 97, excluding 99 with 0%)" + ); + + // Verify the correct nodes made it through + let node_ids: Vec = after_performance_filter.iter().map(|n| n.id).collect(); + assert!( + node_ids.contains(&53), + "Node 53 (actively assigned entry gateway) should be included" + ); + assert!( + node_ids.contains(&97), + "Node 97 (Inactive but entry-capable) should be included - THIS IS THE FIX!" + ); + assert!( + !node_ids.contains(&75), + "Node 75 (mixnode-only, not entry-capable) should be excluded" + ); + assert!( + !node_ids.contains(&99), + "Node 99 (low performance) should be excluded" + ); + } } diff --git a/nym-registration-client/Cargo.toml b/nym-registration-client/Cargo.toml index faa96b86cc..6dc3335357 100644 --- a/nym-registration-client/Cargo.toml +++ b/nym-registration-client/Cargo.toml @@ -22,6 +22,7 @@ nym-authenticator-client = { path = "../nym-authenticator-client" } nym-bandwidth-controller = { path = "../common/bandwidth-controller" } nym-credential-storage = { path = "../common/credential-storage" } nym-credentials-interface = { path = "../common/credentials-interface" } +nym-http-api-client = { path = "../common/http-api-client" } nym-ip-packet-client = { path = "../nym-ip-packet-client" } nym-registration-common = { path = "../common/registration" } nym-sdk = { path = "../sdk/rust/nym-sdk" } diff --git a/nym-registration-client/src/builder/config.rs b/nym-registration-client/src/builder/config.rs index 494e3fb2a1..526934277c 100644 --- a/nym-registration-client/src/builder/config.rs +++ b/nym-registration-client/src/builder/config.rs @@ -34,6 +34,7 @@ pub struct BuilderConfig { pub two_hops: bool, pub user_agent: UserAgent, pub custom_topology_provider: Box, + pub custom_nym_api_client: Option, pub network_env: NymNetworkDetails, pub cancel_token: CancellationToken, #[cfg(unix)] @@ -56,6 +57,44 @@ pub struct MixnetClientConfig { } impl BuilderConfig { + /// Create a new BuilderConfig without domain fronting support + /// + /// For domain fronting support, set `custom_nym_api_client` to Some(client) after creation + #[allow(clippy::too_many_arguments)] + pub fn new( + entry_node: NymNodeWithKeys, + exit_node: NymNodeWithKeys, + data_path: Option, + mixnet_client_config: MixnetClientConfig, + two_hops: bool, + user_agent: UserAgent, + custom_topology_provider: Box, + network_env: NymNetworkDetails, + cancel_token: CancellationToken, + #[cfg(unix)] connection_fd_callback: Arc, + ) -> Self { + Self { + entry_node, + exit_node, + data_path, + mixnet_client_config, + two_hops, + user_agent, + custom_topology_provider, + custom_nym_api_client: None, + network_env, + cancel_token, + #[cfg(unix)] + connection_fd_callback, + } + } + + /// Set a custom nym-api HTTP client (for domain fronting support) + pub fn with_nym_api_client(mut self, client: nym_http_api_client::Client) -> Self { + self.custom_nym_api_client = Some(client); + self + } + pub fn mixnet_client_debug_config(&self) -> DebugConfig { if self.two_hops { two_hop_debug_config(&self.mixnet_client_config) @@ -108,7 +147,7 @@ impl BuilderConfig { RememberMe::new_mixnet() }; - let builder = builder + let mut builder = builder .with_user_agent(self.user_agent) .request_gateway(self.entry_node.node.identity.to_string()) .network_details(self.network_env) @@ -117,6 +156,13 @@ impl BuilderConfig { .with_remember_me(remember_me) .custom_topology_provider(self.custom_topology_provider); + if let Some(nym_api_client) = self.custom_nym_api_client { + tracing::info!("Registration client: Passing custom nym-api HTTP client to SDK"); + builder = builder.with_nym_api_client(nym_api_client); + } else { + tracing::warn!("Registration client: No custom nym-api HTTP client provided"); + } + #[cfg(unix)] let builder = builder.with_connection_fd_callback(self.connection_fd_callback); @@ -206,3 +252,36 @@ fn log_mixnet_client_config(debug_config: &DebugConfig) { fn true_to_disabled(val: bool) -> &'static str { if val { "disabled" } else { "enabled" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mixnet_client_config_default_values() { + let config = MixnetClientConfig::default(); + assert!(!config.disable_poisson_rate); + assert!(!config.disable_background_cover_traffic); + assert_eq!(config.min_mixnode_performance, None); + assert_eq!(config.min_gateway_performance, None); + } + + #[test] + fn test_builder_config_has_custom_client_field() { + // Verify that BuilderConfig has the custom_nym_api_client field + // by creating a simple HTTP client + let http_client = nym_http_api_client::Client::builder( + nym_http_api_client::Url::parse("https://validator.nymtech.net/api").unwrap(), + ) + .expect("Failed to create client builder") + .build() + .expect("Failed to build client"); + + // Verify the client works + let urls = http_client.base_urls(); + assert!( + !urls.is_empty() && urls[0].as_str().contains("validator.nymtech.net"), + "HTTP client should be configured with correct URL" + ); + } +} diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index 5729b40393..44663142ec 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -54,6 +54,7 @@ pub struct MixnetClientBuilder { custom_topology_provider: Option>, custom_gateway_transceiver: Option>, custom_shutdown: Option, + custom_nym_api_client: Option, force_tls: bool, user_agent: Option, #[cfg(unix)] @@ -92,6 +93,7 @@ impl MixnetClientBuilder { socks5_config: None, wait_for_gateway: false, custom_topology_provider: None, + custom_nym_api_client: None, storage: storage_paths .initialise_default_persistent_storage() .await?, @@ -129,6 +131,7 @@ where wait_for_gateway: false, custom_topology_provider: None, custom_gateway_transceiver: None, + custom_nym_api_client: None, custom_shutdown: None, force_tls: false, user_agent: None, @@ -152,6 +155,7 @@ where wait_for_gateway: self.wait_for_gateway, custom_topology_provider: self.custom_topology_provider, custom_gateway_transceiver: self.custom_gateway_transceiver, + custom_nym_api_client: self.custom_nym_api_client, custom_shutdown: self.custom_shutdown, force_tls: self.force_tls, user_agent: self.user_agent, @@ -283,6 +287,12 @@ where self } + #[must_use] + pub fn with_nym_api_client(mut self, client: nym_http_api_client::Client) -> Self { + self.custom_nym_api_client = Some(client); + self + } + #[must_use] pub fn with_statistics_reporting(mut self, config: StatsReporting) -> Self { self.config.debug_config.stats_reporting = config; @@ -323,6 +333,7 @@ where client.custom_gateway_transceiver = self.custom_gateway_transceiver; client.custom_topology_provider = self.custom_topology_provider; + client.custom_nym_api_client = self.custom_nym_api_client; client.custom_shutdown = self.custom_shutdown; client.wait_for_gateway = self.wait_for_gateway; client.force_tls = self.force_tls; @@ -372,6 +383,9 @@ where /// advanced usage of custom gateways custom_gateway_transceiver: Option>, + /// Custom nym-api HTTP client (for domain fronting support) + custom_nym_api_client: Option, + /// Attempt to wait for the selected gateway (if applicable) to come online if its currently not bonded. wait_for_gateway: bool, @@ -440,6 +454,7 @@ where storage, custom_topology_provider: None, custom_gateway_transceiver: None, + custom_nym_api_client: None, wait_for_gateway: false, force_tls: false, custom_shutdown: None, @@ -535,7 +550,9 @@ where async fn available_gateways(&mut self) -> Result, ClientCoreError> { if let Some(ref mut custom_provider) = self.custom_topology_provider { if let Some(topology) = custom_provider.get_new_topology().await { - return Ok(topology.entry_gateways().cloned().collect()); + // Use entry_capable_nodes() instead of entry_gateways() to include + // all entry-capable nodes, not just actively assigned ones + return Ok(topology.entry_capable_nodes().cloned().collect()); } } @@ -696,6 +713,13 @@ where base_builder = base_builder.with_user_agent(user_agent); } + if let Some(nym_api_client) = self.custom_nym_api_client { + tracing::info!("SDK: Passing custom nym-api HTTP client to BaseClientBuilder"); + base_builder = base_builder.with_nym_api_client(nym_api_client); + } else { + tracing::warn!("SDK: No custom nym-api HTTP client configured"); + } + if let Some(topology_provider) = self.custom_topology_provider { base_builder = base_builder.with_topology_provider(topology_provider); } @@ -858,3 +882,76 @@ impl IncludedSurbs { Self::ExposeSelfAddress } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mixnet_builder_default_no_custom_client() { + let builder = MixnetClientBuilder::new_ephemeral(); + assert!( + builder.build().is_ok(), + "Builder should succeed without custom client" + ); + } + + #[test] + fn test_mixnet_builder_with_custom_client() { + let http_client = nym_http_api_client::Client::builder( + nym_http_api_client::Url::parse("https://validator.nymtech.net/api").unwrap(), + ) + .expect("Failed to create client builder") + .build() + .expect("Failed to build client"); + + let builder = MixnetClientBuilder::new_ephemeral().with_nym_api_client(http_client); + + assert!( + builder.build().is_ok(), + "Builder should succeed with custom client" + ); + } + + // Note: Tests for entry_capable_nodes() vs entry_gateways() are in nym-topology crate + // These tests verify the builder functionality only + + #[test] + fn test_custom_client_transfer_through_build() { + let http_client = nym_http_api_client::Client::builder( + nym_http_api_client::Url::parse("https://validator.nymtech.net/api").unwrap(), + ) + .expect("Failed to create client builder") + .build() + .expect("Failed to build client"); + + let builder = MixnetClientBuilder::new_ephemeral().with_nym_api_client(http_client); + let disconnected_client = builder.build(); + + assert!( + disconnected_client.is_ok(), + "Build should transfer custom_nym_api_client successfully" + ); + } + + #[test] + fn test_builder_storage_transfer_includes_custom_client() { + use nym_client_core::client::base_client::storage::Ephemeral; + + let http_client = nym_http_api_client::Client::builder( + nym_http_api_client::Url::parse("https://validator.nymtech.net/api").unwrap(), + ) + .expect("Failed to create client builder") + .build() + .expect("Failed to build client"); + + let builder = MixnetClientBuilder::new_ephemeral().with_nym_api_client(http_client); + let new_storage = Ephemeral::default(); + let builder_with_new_storage = builder.set_storage(new_storage); + + assert!( + builder_with_new_storage.build().is_ok(), + "set_storage should preserve custom_nym_api_client" + ); + } +}