diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 6205e55998..8e1b756bd3 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -30,10 +30,10 @@ use time::Date; use url::Url; pub use crate::nym_api::NymApiClientExt; +use nym_mixnet_contract_common::NymNodeDetails; pub use nym_mixnet_contract_common::{ mixnode::MixNodeDetails, GatewayBond, IdentityKey, IdentityKeyRef, NodeId, }; - // re-export the type to not break existing imports pub use crate::coconut::EcashApiClient; @@ -106,7 +106,9 @@ impl Config { pub struct Client { // ideally they would have been read-only, but unfortunately rust doesn't have such features + // #[deprecated(note = "please use `nym_api_client` instead")] pub nym_api: nym_api::Client, + // pub nym_api_client: NymApiClient, pub nyxd: NyxdClient, } @@ -243,6 +245,50 @@ impl Client { Ok(self.nym_api.get_gateways().await?) } + // TODO: combine with NymApiClient... + pub async fn get_all_cached_described_nodes( + &self, + ) -> Result, ValidatorClientError> { + // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere + let mut page = 0; + let mut descriptions = Vec::new(); + + loop { + let mut res = self.nym_api.get_nodes_described(Some(page), None).await?; + + descriptions.append(&mut res.data); + if descriptions.len() < res.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(descriptions) + } + + // TODO: combine with NymApiClient... + pub async fn get_all_cached_bonded_nym_nodes( + &self, + ) -> Result, ValidatorClientError> { + // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere + let mut page = 0; + let mut bonds = Vec::new(); + + loop { + let mut res = self.nym_api.get_nym_nodes(Some(page), None).await?; + + bonds.append(&mut res.data); + if bonds.len() < res.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(bonds) + } + pub async fn blind_sign( &self, request_body: &BlindSignRequestBody, @@ -418,6 +464,27 @@ impl NymApiClient { Ok(descriptions) } + pub async fn get_all_bonded_nym_nodes( + &self, + ) -> Result, ValidatorClientError> { + // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere + let mut page = 0; + let mut bonds = Vec::new(); + + loop { + let mut res = self.nym_api.get_nym_nodes(Some(page), None).await?; + + bonds.append(&mut res.data); + if bonds.len() < res.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(bonds) + } + pub async fn get_gateway_core_status_count( &self, identity: IdentityKeyRef<'_>, 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 8e13c84580..69185c4d82 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -39,7 +39,7 @@ use nym_contracts_common::IdentityKey; pub use nym_http_api_client::Client; use nym_http_api_client::{ApiClient, NO_PARAMS}; use nym_mixnet_contract_common::mixnode::MixNodeDetails; -use nym_mixnet_contract_common::{GatewayBond, IdentityKeyRef, NodeId}; +use nym_mixnet_contract_common::{GatewayBond, IdentityKeyRef, NodeId, NymNodeDetails}; use time::format_description::BorrowedFormatItem; use time::Date; @@ -139,6 +139,25 @@ pub trait NymApiClientExt: ApiClient { .await } + async fn get_nym_nodes( + &self, + page: Option, + per_page: Option, + ) -> Result, NymAPIError> { + let mut params = Vec::new(); + + if let Some(page) = page { + params.push(("page", page.to_string())) + } + + if let Some(per_page) = per_page { + params.push(("per_page", per_page.to_string())) + } + + self.get_json(&[routes::API_VERSION, "nym-nodes", "bonded"], ¶ms) + .await + } + async fn get_basic_mixnodes( &self, semver_compatibility: Option, diff --git a/explorer-api/explorer-client/src/lib.rs b/explorer-api/explorer-client/src/lib.rs index 3ff4807b2b..f23d10683d 100644 --- a/explorer-api/explorer-client/src/lib.rs +++ b/explorer-api/explorer-client/src/lib.rs @@ -12,6 +12,8 @@ pub use nym_explorer_api_requests::{ // Paths const API_VERSION: &str = "v1"; +const TMP: &str = "tmp"; +const UNSTABLE: &str = "unstable"; const MIXNODES: &str = "mix-nodes"; const GATEWAYS: &str = "gateways"; @@ -53,6 +55,12 @@ impl ExplorerClient { Ok(Self { client, url }) } + #[cfg(not(target_arch = "wasm32"))] + pub fn new_with_timeout(url: url::Url, timeout: Duration) -> Result { + let client = reqwest::Client::builder().timeout(timeout).build()?; + Ok(Self { client, url }) + } + async fn send_get_request( &self, paths: &[&str], @@ -86,6 +94,13 @@ impl ExplorerClient { pub async fn get_gateways(&self) -> Result, ExplorerApiError> { self.query_explorer_api(&[API_VERSION, GATEWAYS]).await } + + pub async fn unstable_get_gateways( + &self, + ) -> Result, ExplorerApiError> { + self.query_explorer_api(&[API_VERSION, TMP, UNSTABLE, GATEWAYS]) + .await + } } fn combine_url(mut base_url: Url, paths: &[&str]) -> Result { diff --git a/explorer-api/src/country_statistics/geolocate.rs b/explorer-api/src/country_statistics/geolocate.rs index 858f593986..18442fc6d7 100644 --- a/explorer-api/src/country_statistics/geolocate.rs +++ b/explorer-api/src/country_statistics/geolocate.rs @@ -4,6 +4,7 @@ use crate::state::ExplorerApiStateContext; use log::{info, warn}; use nym_explorer_api_requests::Location; +use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT; use nym_task::TaskClient; pub(crate) struct GeoLocateTask { @@ -25,6 +26,7 @@ impl GeoLocateTask { _ = interval_timer.tick() => { self.locate_mix_nodes().await; self.locate_gateways().await; + self.locate_nym_nodes().await; } _ = self.shutdown.recv() => { trace!("Listener: Received shutdown"); @@ -109,6 +111,83 @@ impl GeoLocateTask { trace!("All mix nodes located"); } + async fn locate_nym_nodes(&mut self) { + // I'm unwrapping to the default value to get rid of an extra indentation level from the `if let Some(...) = ...` + // If the value is None, we'll unwrap to an empty hashmap and the `values()` loop won't do any work anyway + let nym_nodes = self.state.inner.nymnodes.get_bonded_nymnodes().await; + + let geo_ip = self.state.inner.geo_ip.0.clone(); + + for (i, cache_item) in nym_nodes.values().enumerate() { + if self + .state + .inner + .nymnodes + .is_location_valid(cache_item.node_id()) + .await + { + // when the cached location is valid, don't locate and continue to next mix node + continue; + } + + let bonded_host = &cache_item.bond_information.node.host; + + match geo_ip.query( + bonded_host, + Some( + cache_item + .bond_information + .node + .custom_http_port + .unwrap_or(DEFAULT_NYM_NODE_HTTP_PORT), + ), + ) { + Ok(opt) => match opt { + Some(location) => { + let location: Location = location.into(); + + trace!( + "{} mix nodes already located. host {} is located in {:#?}", + i, + bonded_host, + location.three_letter_iso_country_code, + ); + + if i > 0 && (i % 100) == 0 { + info!("Located {} nym-nodes...", i + 1,); + } + + self.state + .inner + .nymnodes + .set_location(cache_item.node_id(), Some(location)) + .await; + + // one node has been located, so return out of the loop + return; + } + None => { + warn!("❌ Location for {bonded_host} not found."); + self.state + .inner + .nymnodes + .set_location(cache_item.node_id(), None) + .await; + } + }, + Err(_e) => { + // warn!( + // "❌ Oh no! Location for {} failed. Error: {:#?}", + // cache_item.mix_node().host, + // e + // ); + } + }; + } + + trace!("All nym-nodes nodes located"); + } + async fn locate_gateways(&mut self) { let gateways = self.state.inner.gateways.get_gateways().await; diff --git a/explorer-api/src/http/mod.rs b/explorer-api/src/http/mod.rs index 559093671e..2d4e2d9fab 100644 --- a/explorer-api/src/http/mod.rs +++ b/explorer-api/src/http/mod.rs @@ -10,6 +10,7 @@ use crate::gateways::http::gateways_make_default_routes; use crate::http::swagger::get_docs; use crate::mix_node::http::mix_node_make_default_routes; use crate::mix_nodes::http::mix_nodes_make_default_routes; +use crate::nym_nodes::http::unstable_temp_nymnodes_make_default_routes; use crate::overview::http::overview_make_default_routes; use crate::ping::http::ping_make_default_routes; use crate::service_providers::http::service_providers_make_default_routes; @@ -58,6 +59,7 @@ fn configure_rocket(state: ExplorerApiStateContext) -> Rocket { "/ping" => ping_make_default_routes(&openapi_settings), "/validators" => validators_make_default_routes(&openapi_settings), "/service-providers" => service_providers_make_default_routes(&openapi_settings), + "/tmp/unstable" => unstable_temp_nymnodes_make_default_routes(&openapi_settings), }; building_rocket diff --git a/explorer-api/src/main.rs b/explorer-api/src/main.rs index 8cb98280af..ecaae3f492 100644 --- a/explorer-api/src/main.rs +++ b/explorer-api/src/main.rs @@ -22,6 +22,7 @@ mod http; mod location; mod mix_node; pub(crate) mod mix_nodes; +mod nym_nodes; mod overview; mod ping; pub(crate) mod service_providers; diff --git a/explorer-api/src/nym_nodes/http.rs b/explorer-api/src/nym_nodes/http.rs new file mode 100644 index 0000000000..378fcc78d7 --- /dev/null +++ b/explorer-api/src/nym_nodes/http.rs @@ -0,0 +1,26 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::state::ExplorerApiStateContext; +use nym_explorer_api_requests::PrettyDetailedGatewayBond; +use okapi::openapi3::OpenApi; +use rocket::serde::json::Json; +use rocket::{Route, State}; +use rocket_okapi::settings::OpenApiSettings; + +pub fn unstable_temp_nymnodes_make_default_routes( + settings: &OpenApiSettings, +) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: all_gateways] +} + +#[openapi(tag = "UNSTABLE")] +#[get("/gateways")] +pub(crate) async fn all_gateways( + state: &State, +) -> Json> { + let mut gateways = state.inner.gateways.get_detailed_gateways().await; + gateways.append(&mut state.inner.nymnodes.pretty_gateways().await); + + Json(gateways) +} diff --git a/explorer-api/src/nym_nodes/location.rs b/explorer-api/src/nym_nodes/location.rs new file mode 100644 index 0000000000..134023f3ca --- /dev/null +++ b/explorer-api/src/nym_nodes/location.rs @@ -0,0 +1,8 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_mixnet_contract_common::NodeId; + +use crate::location::LocationCache; + +pub(crate) type NymNodeLocationCache = LocationCache; diff --git a/explorer-api/src/nym_nodes/mod.rs b/explorer-api/src/nym_nodes/mod.rs new file mode 100644 index 0000000000..2ceb85885a --- /dev/null +++ b/explorer-api/src/nym_nodes/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::time::Duration; + +pub(crate) mod http; +pub(crate) mod location; +pub(crate) mod models; + +pub(crate) const CACHE_ENTRY_TTL: Duration = Duration::from_secs(1200); diff --git a/explorer-api/src/nym_nodes/models.rs b/explorer-api/src/nym_nodes/models.rs new file mode 100644 index 0000000000..7cdbbbdee3 --- /dev/null +++ b/explorer-api/src/nym_nodes/models.rs @@ -0,0 +1,154 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::location::{LocationCache, LocationCacheItem}; +use crate::nym_nodes::location::NymNodeLocationCache; +use crate::nym_nodes::CACHE_ENTRY_TTL; +use nym_explorer_api_requests::{Location, PrettyDetailedGatewayBond}; +use nym_mixnet_contract_common::{Gateway, NodeId, NymNodeDetails}; +use nym_validator_client::models::NymNodeDescription; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tokio::sync::{RwLock, RwLockReadGuard}; + +pub(crate) struct NymNodesCache { + pub(crate) valid_until: SystemTime, + pub(crate) bonded_nym_nodes: HashMap, + pub(crate) described_nodes: HashMap, +} + +impl NymNodesCache { + fn new() -> Self { + NymNodesCache { + valid_until: SystemTime::now() - Duration::from_secs(60), // in the past + bonded_nym_nodes: Default::default(), + described_nodes: Default::default(), + } + } + + // fn is_valid(&self) -> bool { + // self.valid_until >= SystemTime::now() + // } +} + +#[derive(Clone)] +pub(crate) struct ThreadSafeNymNodesCache { + nymnodes: Arc>, + locations: Arc>>, +} + +impl ThreadSafeNymNodesCache { + pub(crate) fn new() -> Self { + ThreadSafeNymNodesCache { + nymnodes: Arc::new(RwLock::new(NymNodesCache::new())), + locations: Arc::new(RwLock::new(NymNodeLocationCache::new())), + } + } + + pub(crate) fn new_with_location_cache(locations: NymNodeLocationCache) -> Self { + ThreadSafeNymNodesCache { + nymnodes: Arc::new(RwLock::new(NymNodesCache::new())), + locations: Arc::new(RwLock::new(locations)), + } + } + + pub(crate) async fn is_location_valid(&self, node_id: NodeId) -> bool { + self.locations + .read() + .await + .get(&node_id) + .map_or(false, |cache_item| { + cache_item.valid_until > SystemTime::now() + }) + } + + pub(crate) async fn get_bonded_nymnodes( + &self, + ) -> RwLockReadGuard> { + let guard = self.nymnodes.read().await; + RwLockReadGuard::map(guard, |n| &n.bonded_nym_nodes) + } + + pub(crate) async fn get_locations(&self) -> NymNodeLocationCache { + self.locations.read().await.clone() + } + + pub(crate) async fn set_location(&self, node_id: NodeId, location: Option) { + // cache the location for this mix node so that it can be used when the mix node list is refreshed + self.locations + .write() + .await + .insert(node_id, LocationCacheItem::new_from_location(location)); + } + + pub(crate) async fn update_cache( + &self, + all_bonds: Vec, + descriptions: Vec, + ) { + let mut guard = self.nymnodes.write().await; + guard.bonded_nym_nodes = all_bonds + .into_iter() + .map(|details| (details.node_id(), details)) + .collect(); + guard.described_nodes = descriptions + .into_iter() + .map(|description| (description.node_id, description)) + .collect(); + + guard.valid_until = SystemTime::now() + CACHE_ENTRY_TTL; + } + + pub(crate) async fn pretty_gateways(&self) -> Vec { + let nodes_guard = self.nymnodes.read().await; + let location_guard = self.locations.read().await; + + let mut pretty_gateways = vec![]; + + for (node_id, native_nymnode) in &nodes_guard.bonded_nym_nodes { + let Some(description) = nodes_guard.described_nodes.get(node_id) else { + continue; + }; + + if description.description.declared_role.entry { + let location = location_guard.get(node_id); + let bond = &native_nymnode.bond_information; + + pretty_gateways.push(PrettyDetailedGatewayBond { + pledge_amount: bond.original_pledge.clone(), + owner: bond.owner.clone(), + block_height: bond.bonding_height, + gateway: Gateway { + host: bond.node.host.clone(), + mix_port: description.description.mix_port(), + clients_port: description.description.mixnet_websockets.ws_port, + location: description + .description + .auxiliary_details + .location + .as_ref() + .map(|l| l.to_string()) + .unwrap_or_default(), + sphinx_key: description + .description + .host_information + .keys + .x25519 + .to_base58_string(), + identity_key: bond.node.identity_key.clone(), + version: description + .description + .build_information + .build_version + .clone(), + }, + proxy: None, + location: location.and_then(|l| l.location.clone()), + }) + } + } + + pretty_gateways + } +} diff --git a/explorer-api/src/state.rs b/explorer-api/src/state.rs index b007fae76d..28373aef80 100644 --- a/explorer-api/src/state.rs +++ b/explorer-api/src/state.rs @@ -18,6 +18,8 @@ use crate::gateways::models::ThreadsafeGatewayCache; use crate::mix_node::models::ThreadsafeMixNodeCache; use crate::mix_nodes::location::MixnodeLocationCache; use crate::mix_nodes::models::ThreadsafeMixNodesCache; +use crate::nym_nodes::location::NymNodeLocationCache; +use crate::nym_nodes::models::ThreadSafeNymNodesCache; use crate::ping::models::ThreadsafePingCache; use crate::validators::models::ThreadsafeValidatorCache; @@ -30,6 +32,7 @@ pub struct ExplorerApiState { pub(crate) gateways: ThreadsafeGatewayCache, pub(crate) mixnode: ThreadsafeMixNodeCache, pub(crate) mixnodes: ThreadsafeMixNodesCache, + pub(crate) nymnodes: ThreadSafeNymNodesCache, pub(crate) ping: ThreadsafePingCache, pub(crate) validators: ThreadsafeValidatorCache, pub(crate) geo_ip: ThreadsafeGeoIp, @@ -49,6 +52,7 @@ pub struct ExplorerApiStateOnDisk { pub(crate) country_node_distribution: CountryNodesDistribution, pub(crate) mixnode_location_cache: MixnodeLocationCache, pub(crate) gateway_location_cache: GatewayLocationCache, + pub(crate) nymnode_location_cache: NymNodeLocationCache, pub(crate) as_at: DateTime, } @@ -85,6 +89,9 @@ impl ExplorerApiStateContext { mixnodes: ThreadsafeMixNodesCache::new_with_location_cache( state.mixnode_location_cache, ), + nymnodes: ThreadSafeNymNodesCache::new_with_location_cache( + state.nymnode_location_cache, + ), ping: ThreadsafePingCache::new(), validators: ThreadsafeValidatorCache::new(), validator_client: ThreadsafeValidatorClient::new(), @@ -101,6 +108,7 @@ impl ExplorerApiStateContext { gateways: ThreadsafeGatewayCache::new(), mixnode: ThreadsafeMixNodeCache::new(), mixnodes: ThreadsafeMixNodesCache::new(), + nymnodes: ThreadSafeNymNodesCache::new(), ping: ThreadsafePingCache::new(), validators: ThreadsafeValidatorCache::new(), validator_client: ThreadsafeValidatorClient::new(), @@ -117,6 +125,7 @@ impl ExplorerApiStateContext { country_node_distribution: self.inner.country_node_distribution.get_all().await, mixnode_location_cache: self.inner.mixnodes.get_locations().await, gateway_location_cache: self.inner.gateways.get_locations().await, + nymnode_location_cache: self.inner.nymnodes.get_locations().await, as_at: Utc::now(), }; serde_json::to_writer(file, &state).expect("error writing state to disk"); diff --git a/explorer-api/src/tasks.rs b/explorer-api/src/tasks.rs index f14408f1c8..efb03f34a6 100644 --- a/explorer-api/src/tasks.rs +++ b/explorer-api/src/tasks.rs @@ -1,16 +1,16 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use nym_mixnet_contract_common::GatewayBond; +use crate::mix_nodes::CACHE_REFRESH_RATE; +use crate::state::ExplorerApiStateContext; +use nym_mixnet_contract_common::{GatewayBond, NymNodeDetails}; use nym_task::TaskClient; -use nym_validator_client::models::MixNodeBondAnnotated; +use nym_validator_client::models::{MixNodeBondAnnotated, NymNodeDescription}; use nym_validator_client::nyxd::error::NyxdError; use nym_validator_client::nyxd::{Paging, TendermintRpcClient, ValidatorResponse}; use nym_validator_client::{QueryHttpRpcValidatorClient, ValidatorClientError}; use std::future::Future; - -use crate::mix_nodes::CACHE_REFRESH_RATE; -use crate::state::ExplorerApiStateContext; +use tokio::time::MissedTickBehavior; pub(crate) struct ExplorerApiTasks { state: ExplorerApiStateContext, @@ -39,6 +39,28 @@ impl ExplorerApiTasks { bonds } + async fn retrieve_bonded_nymnodes(&self) -> Result, ValidatorClientError> { + info!("About to retrieve all nymnode bonds..."); + self.state + .inner + .validator_client + .0 + .get_all_cached_bonded_nym_nodes() + .await + } + + async fn retrieve_node_descriptions( + &self, + ) -> Result, ValidatorClientError> { + info!("About to retrieve node descriptions..."); + self.state + .inner + .validator_client + .0 + .get_all_cached_described_nodes() + .await + } + async fn retrieve_all_mixnodes(&self) -> Vec { info!("About to retrieve all mixnode bonds..."); self.retrieve_mixnodes( @@ -130,10 +152,33 @@ impl ExplorerApiTasks { } } + async fn update_nymnodes_cache(&self) { + let nym_node_bonds = self.retrieve_bonded_nymnodes().await.unwrap_or_else(|err| { + error!("failed to retrieve nym node bonds: {err}"); + Vec::new() + }); + + let all_descriptions = self + .retrieve_node_descriptions() + .await + .unwrap_or_else(|err| { + error!("failed to retrieve node descriptions: {err}"); + Vec::new() + }); + + self.state + .inner + .nymnodes + .update_cache(nym_node_bonds, all_descriptions) + .await + } + pub(crate) fn start(mut self) { info!("Spawning mix nodes task runner..."); tokio::spawn(async move { let mut interval_timer = tokio::time::interval(CACHE_REFRESH_RATE); + interval_timer.set_missed_tick_behavior(MissedTickBehavior::Skip); + while !self.shutdown.is_shutdown() { tokio::select! { _ = interval_timer.tick() => { @@ -147,6 +192,10 @@ impl ExplorerApiTasks { info!("Updating mix node cache..."); self.update_mixnode_cache().await; + + info!("Updating nymnode cache..."); + self.update_nymnodes_cache().await; + info!("Done"); } _ = self.shutdown.recv() => { trace!("Listener: Received shutdown");