diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d045624c3..38b033c5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// ## [Unreleased] +## [v1.1.12] (2023-03-07) + +- Fix generated docs for mixnet and vesting contract on docs.rs ([#3093]) +- Introduce a way of injecting topology into the client ([#3044]) +- Update mixnet TypeScript client methods #1 ([#2783]) +- Update tooltips for routing and average score ([#3133]) +- update selected service provider description style ([#3128]) + +[#3093]: https://github.com/nymtech/nym/issues/3093 +[#3044]: https://github.com/nymtech/nym/issues/3044 +[#2783]: https://github.com/nymtech/nym/issues/2783 +[#3133]: https://github.com/nymtech/nym/pull/3133 +[#3128]: https://github.com/nymtech/nym/pull/3128 + ## [v1.1.11] (2023-02-28) - Fix empty dealer set loop ([#3105]) diff --git a/Cargo.lock b/Cargo.lock index d4bc125d42..2b5b1c56bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -654,7 +654,7 @@ dependencies = [ [[package]] name = "client-core" -version = "1.1.11" +version = "1.1.12" dependencies = [ "async-trait", "dashmap 5.4.0", @@ -1701,7 +1701,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "explorer-api" -version = "1.1.11" +version = "1.1.12" dependencies = [ "chrono", "clap 4.1.4", @@ -3174,7 +3174,7 @@ dependencies = [ [[package]] name = "nym-api" -version = "1.1.12" +version = "1.1.13" dependencies = [ "anyhow", "async-trait", @@ -3254,7 +3254,7 @@ dependencies = [ [[package]] name = "nym-bin-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap 4.1.4", "clap_complete", @@ -3283,7 +3283,7 @@ dependencies = [ [[package]] name = "nym-cli" -version = "1.1.11" +version = "1.1.12" dependencies = [ "anyhow", "base64 0.13.1", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "nym-client" -version = "1.1.11" +version = "1.1.12" dependencies = [ "clap 4.1.4", "client-core", @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "nym-contracts-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bs58", "cosmwasm-std", @@ -3402,7 +3402,7 @@ dependencies = [ [[package]] name = "nym-crypto" -version = "0.1.0" +version = "0.2.0" dependencies = [ "aes 0.8.2", "blake3", @@ -3435,7 +3435,7 @@ dependencies = [ [[package]] name = "nym-gateway" -version = "1.1.11" +version = "1.1.12" dependencies = [ "anyhow", "async-trait", @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bs58", "cosmwasm-std", @@ -3512,7 +3512,7 @@ dependencies = [ [[package]] name = "nym-mixnode" -version = "1.1.12" +version = "1.1.13" dependencies = [ "anyhow", "atty", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "nym-network-requester" -version = "1.1.11" +version = "1.1.12" dependencies = [ "async-trait", "clap 4.1.4", @@ -3604,7 +3604,7 @@ dependencies = [ [[package]] name = "nym-network-statistics" -version = "1.1.11" +version = "1.1.12" dependencies = [ "dirs", "log", @@ -3648,7 +3648,7 @@ dependencies = [ [[package]] name = "nym-pemstore" -version = "0.1.0" +version = "0.2.0" dependencies = [ "pem", ] @@ -3667,6 +3667,7 @@ dependencies = [ "nym-network-defaults", "nym-sphinx", "nym-task", + "nym-topology", "pretty_env_logger", "rand 0.7.3", "tap", @@ -3679,7 +3680,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.11" +version = "1.1.12" dependencies = [ "clap 4.1.4", "client-core", @@ -3839,7 +3840,7 @@ dependencies = [ [[package]] name = "nym-sphinx-types" -version = "0.1.0" +version = "0.2.0" dependencies = [ "sphinx-packet", ] @@ -3874,6 +3875,7 @@ dependencies = [ name = "nym-topology" version = "0.1.0" dependencies = [ + "async-trait", "bs58", "log", "nym-bin-common", @@ -3913,7 +3915,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract" -version = "1.1.4" +version = "1.2.0-pre.0" dependencies = [ "cosmwasm-std", "cw-storage-plus", @@ -3930,7 +3932,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cosmwasm-std", "nym-contracts-common", diff --git a/Cargo.toml b/Cargo.toml index 8b44af4c01..ae2be51ec6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,7 +105,7 @@ edition = "2021" license = "Apache-2.0" [workspace.dependencies] -async-trait = "0.1.63" +async-trait = "0.1.64" cfg-if = "1.0.0" dotenvy = "0.15.6" lazy_static = "1.4.0" diff --git a/clients/client-core/Cargo.toml b/clients/client-core/Cargo.toml index a6b2936b00..be8f3e048c 100644 --- a/clients/client-core/Cargo.toml +++ b/clients/client-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "client-core" -version = "1.1.11" +version = "1.1.12" authors = ["Dave Hrycyszyn "] edition = "2021" rust-version = "1.66" @@ -8,7 +8,7 @@ rust-version = "1.66" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { version = "0.1.58" } +async-trait = { workspace = true } dirs = "4.0" dashmap = "5.4.0" futures = "0.3" diff --git a/clients/client-core/src/client/base_client/mod.rs b/clients/client-core/src/client/base_client/mod.rs index 64a292d2b8..e8a3d215f9 100644 --- a/clients/client-core/src/client/base_client/mod.rs +++ b/clients/client-core/src/client/base_client/mod.rs @@ -15,6 +15,7 @@ use crate::client::replies::reply_controller::{ReplyControllerReceiver, ReplyCon use crate::client::replies::reply_storage::{ CombinedReplyStorage, PersistentReplyStorage, ReplyStorageBackend, SentReplyKeys, }; +use crate::client::topology_control::nym_api_provider::NymApiTopologyProvider; use crate::client::topology_control::{ TopologyAccessor, TopologyRefresher, TopologyRefresherConfig, }; @@ -37,10 +38,12 @@ use nym_sphinx::addressing::nodes::NodeIdentity; use nym_sphinx::receiver::ReconstructedMessage; use nym_task::connections::{ConnectionCommandReceiver, ConnectionCommandSender, LaneQueueLengths}; use nym_task::{TaskClient, TaskManager}; +use nym_topology::provider_trait::TopologyProvider; use std::sync::Arc; use std::time::Duration; use tap::TapFallible; use url::Url; + #[cfg(not(target_arch = "wasm32"))] use validator_client::nyxd::CosmWasmClient; @@ -90,6 +93,7 @@ impl ClientOutput { pub struct ClientState { pub shared_lane_queue_lengths: LaneQueueLengths, pub reply_controller_sender: ReplyControllerSender, + pub topology_accessor: TopologyAccessor, } pub enum ClientInputStatus { @@ -154,6 +158,7 @@ pub struct BaseClientBuilder<'a, B, C: Clone> { nym_api_endpoints: Vec, reply_storage_backend: B, + custom_topology_provider: Option>, bandwidth_controller: Option>, key_manager: KeyManager, } @@ -177,6 +182,7 @@ where bandwidth_controller, reply_storage_backend, key_manager, + custom_topology_provider: None, } } @@ -195,11 +201,17 @@ where disabled_credentials: credentials_toggle.is_disabled(), nym_api_endpoints, reply_storage_backend, + custom_topology_provider: None, bandwidth_controller, key_manager, } } + pub fn with_topology_provider(mut self, provider: Box) -> Self { + self.custom_topology_provider = Some(provider); + self + } + pub fn as_mix_recipient(&self) -> Recipient { Recipient::new( *self.key_manager.identity_keypair().public_key(), @@ -341,25 +353,38 @@ where Ok(gateway_client) } + fn setup_topology_provider( + custom_provider: Option>, + nym_api_urls: Vec, + ) -> Box { + // if no custom provider was ... provided ..., create one using nym-api + custom_provider.unwrap_or_else(|| { + Box::new(NymApiTopologyProvider::new( + nym_api_urls, + env!("CARGO_PKG_VERSION").to_string(), + )) + }) + } + // future responsible for periodically polling directory server and updating // the current global view of topology async fn start_topology_refresher( - nym_api_urls: Vec, + topology_provider: Box, refresh_rate: Duration, topology_accessor: TopologyAccessor, shutdown: TaskClient, ) -> Result<(), ClientCoreError> { - let topology_refresher_config = TopologyRefresherConfig::new( - nym_api_urls, - refresh_rate, - env!("CARGO_PKG_VERSION").to_string(), + let topology_refresher_config = TopologyRefresherConfig::new(refresh_rate); + + let mut topology_refresher = TopologyRefresher::new( + topology_refresher_config, + topology_accessor, + topology_provider, ); - let mut topology_refresher = - TopologyRefresher::new(topology_refresher_config, topology_accessor); // before returning, block entire runtime to refresh the current network view so that any // components depending on topology would see a non-empty view info!("Obtaining initial network topology"); - topology_refresher.refresh().await; + topology_refresher.try_refresh().await; if let Err(err) = topology_refresher.ensure_topology_is_routable().await { log::error!( @@ -468,8 +493,12 @@ where ) .await?; + let topology_provider = Self::setup_topology_provider( + self.custom_topology_provider.take(), + self.nym_api_endpoints, + ); Self::start_topology_refresher( - self.nym_api_endpoints.clone(), + topology_provider, self.debug_config.topology_refresh_rate, shared_topology_accessor.clone(), task_manager.subscribe(), @@ -530,7 +559,7 @@ where self.debug_config, self.key_manager.ack_key(), self_address, - shared_topology_accessor, + shared_topology_accessor.clone(), sphinx_message_sender, task_manager.subscribe(), ); @@ -554,6 +583,7 @@ where client_state: ClientState { shared_lane_queue_lengths, reply_controller_sender, + topology_accessor: shared_topology_accessor, }, task_manager, }) diff --git a/clients/client-core/src/client/topology_control.rs b/clients/client-core/src/client/topology_control.rs deleted file mode 100644 index e94340f074..0000000000 --- a/clients/client-core/src/client/topology_control.rs +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright 2021-2022 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::spawn_future; -use futures::StreamExt; -use log::*; -use nym_sphinx::addressing::clients::Recipient; -use nym_sphinx::params::DEFAULT_NUM_MIX_HOPS; -use nym_topology::{nym_topology_from_detailed, NymTopology, NymTopologyError}; -use rand::seq::SliceRandom; -use rand::thread_rng; -use std::ops::Deref; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{RwLock, RwLockReadGuard}; -use url::Url; - -// I'm extremely curious why compiler NEVER complained about lack of Debug here before -#[derive(Debug)] -pub struct TopologyAccessorInner(Option); - -impl AsRef> for TopologyAccessorInner { - fn as_ref(&self) -> &Option { - &self.0 - } -} - -impl TopologyAccessorInner { - fn new() -> Self { - TopologyAccessorInner(None) - } - - fn update(&mut self, new: Option) { - self.0 = new; - } -} - -pub struct TopologyReadPermit<'a> { - permit: RwLockReadGuard<'a, TopologyAccessorInner>, -} - -impl<'a> Deref for TopologyReadPermit<'a> { - type Target = TopologyAccessorInner; - - fn deref(&self) -> &Self::Target { - &self.permit - } -} - -impl<'a> TopologyReadPermit<'a> { - /// Using provided topology read permit, tries to get an immutable reference to the underlying - /// topology. For obvious reasons the lifetime of the topology reference is bound to the permit. - pub(super) fn try_get_valid_topology_ref( - &'a self, - ack_recipient: &Recipient, - packet_recipient: Option<&Recipient>, - ) -> Result<&'a NymTopology, NymTopologyError> { - // 1. Have we managed to get anything from the refresher, i.e. have the nym-api queries gone through? - let topology = self - .permit - .as_ref() - .as_ref() - .ok_or(NymTopologyError::EmptyNetworkTopology)?; - - // 2. does it have any mixnode at all? - // 3. does it have any gateways at all? - // 4. does it have a mixnode on each layer? - topology.ensure_can_construct_path_through(DEFAULT_NUM_MIX_HOPS)?; - - // 5. does it contain OUR gateway (so that we could create an ack packet)? - if !topology.gateway_exists(ack_recipient.gateway()) { - return Err(NymTopologyError::NonExistentGatewayError { - identity_key: ack_recipient.gateway().to_base58_string(), - }); - } - - // 6. for our target recipient, does it contain THEIR gateway (so that we could create - if let Some(recipient) = packet_recipient { - if !topology.gateway_exists(recipient.gateway()) { - return Err(NymTopologyError::NonExistentGatewayError { - identity_key: recipient.gateway().to_base58_string(), - }); - } - } - - Ok(topology) - } -} - -impl<'a> From> for TopologyReadPermit<'a> { - fn from(read_permit: RwLockReadGuard<'a, TopologyAccessorInner>) -> Self { - TopologyReadPermit { - permit: read_permit, - } - } -} - -#[derive(Clone, Debug)] -pub struct TopologyAccessor { - // `RwLock` *seems to* be the better approach for this as write access is only requested every - // few seconds, while reads are needed every single packet generated. - // However, proper benchmarks will be needed to determine if `RwLock` is indeed a better - // approach than a `Mutex` - inner: Arc>, -} - -impl TopologyAccessor { - pub fn new() -> Self { - TopologyAccessor { - inner: Arc::new(RwLock::new(TopologyAccessorInner::new())), - } - } - - pub async fn get_read_permit(&self) -> TopologyReadPermit<'_> { - self.inner.read().await.into() - } - - async fn update_global_topology(&self, new_topology: Option) { - self.inner.write().await.update(new_topology); - } - - // only used by the client at startup to get a slightly more reasonable error message - // (currently displays as unused because health checker is disabled due to required changes) - pub async fn ensure_is_routable(&self) -> Result<(), NymTopologyError> { - match &self.inner.read().await.0 { - None => Err(NymTopologyError::EmptyNetworkTopology), - Some(ref topology) => topology.ensure_can_construct_path_through(DEFAULT_NUM_MIX_HOPS), - } - } -} - -impl Default for TopologyAccessor { - fn default() -> Self { - TopologyAccessor::new() - } -} - -pub struct TopologyRefresherConfig { - nym_api_urls: Vec, - refresh_rate: Duration, - client_version: String, -} - -impl TopologyRefresherConfig { - pub fn new(nym_api_urls: Vec, refresh_rate: Duration, client_version: String) -> Self { - TopologyRefresherConfig { - nym_api_urls, - refresh_rate, - client_version, - } - } -} - -pub struct TopologyRefresher { - validator_client: validator_client::client::NymApiClient, - client_version: String, - - nym_api_urls: Vec, - topology_accessor: TopologyAccessor, - refresh_rate: Duration, - - currently_used_api: usize, - was_latest_valid: bool, -} - -impl TopologyRefresher { - pub fn new(mut cfg: TopologyRefresherConfig, topology_accessor: TopologyAccessor) -> Self { - cfg.nym_api_urls.shuffle(&mut thread_rng()); - - TopologyRefresher { - validator_client: validator_client::client::NymApiClient::new( - cfg.nym_api_urls[0].clone(), - ), - client_version: cfg.client_version, - nym_api_urls: cfg.nym_api_urls, - topology_accessor, - refresh_rate: cfg.refresh_rate, - currently_used_api: 0, - was_latest_valid: true, - } - } - - 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.validator_client - .change_nym_api(self.nym_api_urls[self.currently_used_api].clone()) - } - - /// Verifies whether nodes a reasonably distributed among all mix layers. - /// - /// In ideal world we would have 33% nodes on layer 1, 33% on layer 2 and 33% on layer 3. - /// However, this is a rather unrealistic expectation, instead we check whether there exists - /// a layer with more than 66% of nodes or with fewer than 15% and if so, we trigger a failure. - /// - /// # Arguments - /// - /// * `topology`: active topology constructed from validator api data - fn check_layer_distribution(&self, active_topology: &NymTopology) -> bool { - let mixes = active_topology.mixes(); - let mixnodes_count = active_topology.num_mixnodes(); - - if active_topology.gateways().is_empty() { - return false; - } - - // trivial check to see if have at least a single node on each layer (regardless of active set size) - if mixes.get(&1).is_none() || mixes.get(&2).is_none() || mixes.get(&3).is_none() { - return false; - } - - let upper_bound = (mixnodes_count as f32 * 0.66) as usize; - let lower_bound = (mixnodes_count as f32 * 0.15) as usize; - - let layer1 = mixes.get(&1).unwrap().len(); - let layer2 = mixes.get(&2).unwrap().len(); - let layer3 = mixes.get(&3).unwrap().len(); - - if layer1 < lower_bound || layer1 > upper_bound { - warn!( - "nodes: {}, layer1: {}, layer2: {}, layer3: {}", - mixnodes_count, layer1, layer2, layer3 - ); - return false; - } - - if layer2 < lower_bound || layer2 > upper_bound { - warn!( - "nodes: {}, layer1: {}, layer2: {}, layer3: {}", - mixnodes_count, layer1, layer2, layer3 - ); - return false; - } - - if layer3 < lower_bound || layer3 > upper_bound { - warn!( - "nodes: {}, layer1: {}, layer2: {}, layer3: {}", - mixnodes_count, layer1, layer2, layer3 - ); - return false; - } - - true - } - - async fn get_current_compatible_topology(&self) -> Option { - // TODO: optimization for the future: - // only refresh mixnodes on timer and refresh gateways only when - // we have to send to a new, unknown, gateway - - let mixnodes = match self.validator_client.get_cached_active_mixnodes().await { - Err(err) => { - error!("failed to get network mixnodes - {err}"); - return None; - } - Ok(mixes) => mixes, - }; - - let gateways = match self.validator_client.get_cached_gateways().await { - Err(err) => { - error!("failed to get network gateways - {err}"); - return None; - } - Ok(gateways) => gateways, - }; - - let topology = nym_topology_from_detailed(mixnodes, gateways) - .filter_system_version(&self.client_version); - - if !self.check_layer_distribution(&topology) { - warn!("The current filtered active topology has extremely skewed layer distribution. It cannot be used."); - None - } else { - Some(topology) - } - } - - pub async fn refresh(&mut self) { - trace!("Refreshing the topology"); - let new_topology = self.get_current_compatible_topology().await; - - if new_topology.is_none() { - self.use_next_nym_api(); - } - - if new_topology.is_none() && self.was_latest_valid { - // if we failed to grab this topology, but the one before it was alright, let's assume - // validator had a tiny hiccup and use the old data - warn!("we're going to keep on using the old topology for this iteration"); - self.was_latest_valid = false; - return; - } else if new_topology.is_some() { - self.was_latest_valid = true; - } - - self.topology_accessor - .update_global_topology(new_topology) - .await; - } - - pub async fn ensure_topology_is_routable(&self) -> Result<(), NymTopologyError> { - self.topology_accessor.ensure_is_routable().await - } - - pub fn start_with_shutdown(mut self, mut shutdown: nym_task::TaskClient) { - spawn_future(async move { - debug!("Started TopologyRefresher with graceful shutdown support"); - - #[cfg(not(target_arch = "wasm32"))] - let mut interval = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( - self.refresh_rate, - )); - - #[cfg(target_arch = "wasm32")] - let mut interval = - gloo_timers::future::IntervalStream::new(self.refresh_rate.as_millis() as u32); - - while !shutdown.is_shutdown() { - tokio::select! { - _ = interval.next() => { - self.refresh().await; - }, - _ = shutdown.recv() => { - log::trace!("TopologyRefresher: Received shutdown"); - }, - } - } - shutdown.recv_timeout().await; - log::debug!("TopologyRefresher: Exiting"); - }) - } -} diff --git a/clients/client-core/src/client/topology_control/accessor.rs b/clients/client-core/src/client/topology_control/accessor.rs new file mode 100644 index 0000000000..fcb272c934 --- /dev/null +++ b/clients/client-core/src/client/topology_control/accessor.rs @@ -0,0 +1,154 @@ +// Copyright 2021-2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_sphinx::addressing::clients::Recipient; +use nym_sphinx::params::DEFAULT_NUM_MIX_HOPS; +use nym_topology::{NymTopology, NymTopologyError}; +use std::ops::Deref; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::{Notify, RwLock, RwLockReadGuard}; + +#[derive(Debug)] +pub struct TopologyAccessorInner { + controlled_manually: AtomicBool, + released_manual_control: Notify, + // `RwLock` *seems to* be the better approach for this as write access is only requested every + // few seconds, while reads are needed every single packet generated. + // However, proper benchmarks will be needed to determine if `RwLock` is indeed a better + // approach than a `Mutex` + topology: RwLock>, +} + +impl TopologyAccessorInner { + fn new() -> Self { + TopologyAccessorInner { + controlled_manually: AtomicBool::new(false), + released_manual_control: Notify::new(), + topology: RwLock::new(None), + } + } + + async fn update(&self, new: Option) { + *self.topology.write().await = new; + } +} + +pub struct TopologyReadPermit<'a> { + permit: RwLockReadGuard<'a, Option>, +} + +impl<'a> Deref for TopologyReadPermit<'a> { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.permit + } +} + +impl<'a> TopologyReadPermit<'a> { + /// Using provided topology read permit, tries to get an immutable reference to the underlying + /// topology. For obvious reasons the lifetime of the topology reference is bound to the permit. + pub(crate) fn try_get_valid_topology_ref( + &'a self, + ack_recipient: &Recipient, + packet_recipient: Option<&Recipient>, + ) -> Result<&'a NymTopology, NymTopologyError> { + // 1. Have we managed to get anything from the refresher, i.e. have the nym-api queries gone through? + let topology = self + .permit + .as_ref() + .ok_or(NymTopologyError::EmptyNetworkTopology)?; + + // 2. does it have any mixnode at all? + // 3. does it have any gateways at all? + // 4. does it have a mixnode on each layer? + topology.ensure_can_construct_path_through(DEFAULT_NUM_MIX_HOPS)?; + + // 5. does it contain OUR gateway (so that we could create an ack packet)? + if !topology.gateway_exists(ack_recipient.gateway()) { + return Err(NymTopologyError::NonExistentGatewayError { + identity_key: ack_recipient.gateway().to_base58_string(), + }); + } + + // 6. for our target recipient, does it contain THEIR gateway (so that we could create + if let Some(recipient) = packet_recipient { + if !topology.gateway_exists(recipient.gateway()) { + return Err(NymTopologyError::NonExistentGatewayError { + identity_key: recipient.gateway().to_base58_string(), + }); + } + } + + Ok(topology) + } +} + +impl<'a> From>> for TopologyReadPermit<'a> { + fn from(read_permit: RwLockReadGuard<'a, Option>) -> Self { + TopologyReadPermit { + permit: read_permit, + } + } +} + +#[derive(Clone, Debug)] +pub struct TopologyAccessor { + inner: Arc, +} + +impl TopologyAccessor { + pub fn new() -> Self { + TopologyAccessor { + inner: Arc::new(TopologyAccessorInner::new()), + } + } + + pub fn controlled_manually(&self) -> bool { + self.inner.controlled_manually.load(Ordering::SeqCst) + } + + pub async fn get_read_permit(&self) -> TopologyReadPermit<'_> { + self.inner.topology.read().await.into() + } + + pub(crate) async fn update_global_topology(&self, new_topology: Option) { + self.inner.update(new_topology).await; + } + + pub(crate) async fn wait_for_released_manual_control(&self) { + self.inner.released_manual_control.notified().await + } + + pub async fn current_topology(&self) -> Option { + self.inner.topology.read().await.clone() + } + + pub async fn manually_change_topology(&self, new_topology: NymTopology) { + self.inner.controlled_manually.store(true, Ordering::SeqCst); + self.inner.update(Some(new_topology)).await; + } + + pub fn release_manual_control(&self) { + self.inner + .controlled_manually + .store(false, Ordering::SeqCst); + self.inner.released_manual_control.notify_waiters(); + } + + // only used by the client at startup to get a slightly more reasonable error message + // (currently displays as unused because health checker is disabled due to required changes) + pub async fn ensure_is_routable(&self) -> Result<(), NymTopologyError> { + match self.inner.topology.read().await.deref() { + None => Err(NymTopologyError::EmptyNetworkTopology), + Some(ref topology) => topology.ensure_can_construct_path_through(DEFAULT_NUM_MIX_HOPS), + } + } +} + +impl Default for TopologyAccessor { + fn default() -> Self { + TopologyAccessor::new() + } +} diff --git a/clients/client-core/src/client/topology_control/mod.rs b/clients/client-core/src/client/topology_control/mod.rs new file mode 100644 index 0000000000..168767cd3e --- /dev/null +++ b/clients/client-core/src/client/topology_control/mod.rs @@ -0,0 +1,115 @@ +// Copyright 2021-2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::spawn_future; +pub(crate) use accessor::{TopologyAccessor, TopologyReadPermit}; +use futures::StreamExt; +use log::*; +use nym_topology::provider_trait::TopologyProvider; +use nym_topology::NymTopologyError; +use std::time::Duration; + +mod accessor; +pub(crate) mod nym_api_provider; + +// TODO: move it to config later +const MAX_FAILURE_COUNT: usize = 10; + +pub struct TopologyRefresherConfig { + refresh_rate: Duration, +} + +impl TopologyRefresherConfig { + pub fn new(refresh_rate: Duration) -> Self { + TopologyRefresherConfig { refresh_rate } + } +} + +pub struct TopologyRefresher { + topology_provider: Box, + topology_accessor: TopologyAccessor, + + refresh_rate: Duration, + consecutive_failure_count: usize, +} + +impl TopologyRefresher { + pub fn new( + cfg: TopologyRefresherConfig, + topology_accessor: TopologyAccessor, + topology_provider: Box, + ) -> Self { + TopologyRefresher { + topology_provider, + topology_accessor, + refresh_rate: cfg.refresh_rate, + consecutive_failure_count: 0, + } + } + + pub fn change_topology_provider(&mut self, provider: Box) { + self.topology_provider = provider; + } + + pub async fn try_refresh(&mut self) { + trace!("Refreshing the topology"); + + if self.topology_accessor.controlled_manually() { + info!("topology is being controlled manually - we're going to wait until the control is released..."); + self.topology_accessor + .wait_for_released_manual_control() + .await; + } + + let new_topology = self.topology_provider.get_new_topology().await; + if new_topology.is_none() { + warn!("failed to obtain new network topology"); + } + + if new_topology.is_none() && self.consecutive_failure_count < MAX_FAILURE_COUNT { + // if we failed to grab this topology, but the one before it was alright, let's assume + // validator had a tiny hiccup and use the old data + warn!("we're going to keep on using the old topology for this iteration"); + self.consecutive_failure_count += 1; + return; + } else if new_topology.is_some() { + self.consecutive_failure_count = 0; + } + + self.topology_accessor + .update_global_topology(new_topology) + .await; + } + + pub async fn ensure_topology_is_routable(&self) -> Result<(), NymTopologyError> { + self.topology_accessor.ensure_is_routable().await + } + + pub fn start_with_shutdown(mut self, mut shutdown: nym_task::TaskClient) { + spawn_future(async move { + debug!("Started TopologyRefresher with graceful shutdown support"); + + #[cfg(not(target_arch = "wasm32"))] + let mut interval = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval( + self.refresh_rate, + )); + + #[cfg(target_arch = "wasm32")] + let mut interval = + gloo_timers::future::IntervalStream::new(self.refresh_rate.as_millis() as u32); + + while !shutdown.is_shutdown() { + tokio::select! { + _ = interval.next() => { + self.try_refresh().await; + }, + _ = shutdown.recv() => { + log::trace!("TopologyRefresher: Received shutdown"); + }, + } + } + shutdown.recv_timeout().await; + log::debug!("TopologyRefresher: Exiting"); + }) + } +} diff --git a/clients/client-core/src/client/topology_control/nym_api_provider.rs b/clients/client-core/src/client/topology_control/nym_api_provider.rs new file mode 100644 index 0000000000..159387d921 --- /dev/null +++ b/clients/client-core/src/client/topology_control/nym_api_provider.rs @@ -0,0 +1,106 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use log::{error, warn}; +use nym_topology::provider_trait::TopologyProvider; +use nym_topology::{nym_topology_from_detailed, NymTopology, NymTopologyError}; +use rand::prelude::SliceRandom; +use rand::thread_rng; +use url::Url; + +pub(crate) struct NymApiTopologyProvider { + validator_client: validator_client::client::NymApiClient, + nym_api_urls: Vec, + + client_version: String, + currently_used_api: usize, +} + +impl NymApiTopologyProvider { + pub(crate) fn new(mut nym_api_urls: Vec, client_version: String) -> Self { + nym_api_urls.shuffle(&mut thread_rng()); + + NymApiTopologyProvider { + validator_client: validator_client::client::NymApiClient::new(nym_api_urls[0].clone()), + nym_api_urls, + client_version, + currently_used_api: 0, + } + } + + 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.validator_client + .change_nym_api(self.nym_api_urls[self.currently_used_api].clone()) + } + + /// Verifies whether nodes a reasonably distributed among all mix layers. + /// + /// In ideal world we would have 33% nodes on layer 1, 33% on layer 2 and 33% on layer 3. + /// However, this is a rather unrealistic expectation, instead we check whether there exists + /// a layer with more than 66% of nodes or with fewer than 15% and if so, we trigger a failure. + /// + /// # Arguments + /// + /// * `topology`: active topology constructed from validator api data + fn check_layer_distribution( + &self, + active_topology: &NymTopology, + ) -> Result<(), NymTopologyError> { + let lower_threshold = 0.15; + let upper_threshold = 0.66; + active_topology.ensure_even_layer_distribution(lower_threshold, upper_threshold) + } + + async fn get_current_compatible_topology(&mut self) -> Option { + let mixnodes = match self.validator_client.get_cached_active_mixnodes().await { + Err(err) => { + error!("failed to get network mixnodes - {err}"); + return None; + } + Ok(mixes) => mixes, + }; + + let gateways = match self.validator_client.get_cached_gateways().await { + Err(err) => { + error!("failed to get network gateways - {err}"); + return None; + } + Ok(gateways) => gateways, + }; + + let topology = nym_topology_from_detailed(mixnodes, gateways) + .filter_system_version(&self.client_version); + + if let Err(err) = self.check_layer_distribution(&topology) { + warn!("The current filtered active topology has extremely skewed layer distribution. It cannot be used: {err}"); + self.use_next_nym_api(); + None + } else { + Some(topology) + } + } +} + +// hehe, wasm +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +impl TopologyProvider for NymApiTopologyProvider { + async fn get_new_topology(&mut self) -> Option { + self.get_current_compatible_topology().await + } +} + +#[cfg(target_arch = "wasm32")] +#[async_trait(?Send)] +impl TopologyProvider for NymApiTopologyProvider { + async fn get_new_topology(&mut self) -> Option { + self.get_current_compatible_topology().await + } +} diff --git a/clients/native/Cargo.toml b/clients/native/Cargo.toml index ffebf29114..ec7b7942b3 100644 --- a/clients/native/Cargo.toml +++ b/clients/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-client" -version = "1.1.11" +version = "1.1.12" authors = ["Dave Hrycyszyn ", "Jędrzej Stuczyński "] description = "Implementation of the Nym Client" edition = "2021" diff --git a/clients/native/src/client/mod.rs b/clients/native/src/client/mod.rs index 1e4e17d94c..796435fb5f 100644 --- a/clients/native/src/client/mod.rs +++ b/clients/native/src/client/mod.rs @@ -101,6 +101,7 @@ impl SocketClient { let ClientState { shared_lane_queue_lengths, reply_controller_sender, + .. } = client_state; let websocket_handler = websocket::HandlerBuilder::new( diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index c1724aa8bb..2dbe43dac6 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-socks5-client" -version = "1.1.11" +version = "1.1.12" authors = ["Dave Hrycyszyn "] description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address" edition = "2021" diff --git a/clients/socks5/src/client/mod.rs b/clients/socks5/src/client/mod.rs index 99a78c86b8..5a6454d361 100644 --- a/clients/socks5/src/client/mod.rs +++ b/clients/socks5/src/client/mod.rs @@ -123,7 +123,7 @@ impl NymClient { let ClientState { shared_lane_queue_lengths, - reply_controller_sender: _, + .. } = client_status; let authenticator = Authenticator::new(auth_methods, allowed_users); diff --git a/common/bin-common/Cargo.toml b/common/bin-common/Cargo.toml index fedfe96ea2..b19d692d74 100644 --- a/common/bin-common/Cargo.toml +++ b/common/bin-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-bin-common" -version = "0.1.0" +version = "0.2.0" description = "Common code for nym binaries" edition = { workspace = true } authors = { workspace = true } diff --git a/common/client-libs/gateway-client/Cargo.toml b/common/client-libs/gateway-client/Cargo.toml index 195d7df616..a9867755d8 100644 --- a/common/client-libs/gateway-client/Cargo.toml +++ b/common/client-libs/gateway-client/Cargo.toml @@ -14,7 +14,7 @@ log = { workspace = true } thiserror = "1.0" url = "2.2" rand = { version = "0.7.3", features = ["wasm-bindgen"] } -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } tokio = { version = "1.24.1", features = ["macros"] } # internal diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index ca8c00d9b3..911dee4db4 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -36,7 +36,7 @@ nym-execute = { path = "../../execute" } # required for nyxd-client # at some point it might be possible to make it wasm-compatible # perhaps after https://github.com/cosmos/cosmos-rust/pull/97 is resolved (and tendermint-rs is updated) -async-trait = { version = "0.1.51", optional = true } +async-trait = { workspace = true, optional = true } bip39 = { version = "1", features = ["rand"], optional = true } nym-config = { path = "../../config", optional = true } cosmrs = { git = "https://github.com/neacsu/cosmos-rust", branch = "neacsu/feegrant_support", features = ["rpc", "bip32", "cosmwasm"], optional = true} diff --git a/common/client-libs/validator-client/src/nyxd/coin.rs b/common/client-libs/validator-client/src/nyxd/coin.rs index 8f1afe2a30..3ac51997b8 100644 --- a/common/client-libs/validator-client/src/nyxd/coin.rs +++ b/common/client-libs/validator-client/src/nyxd/coin.rs @@ -1,10 +1,13 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use serde::{Deserialize, Serialize}; -use std::fmt; +use crate::nyxd::{Gas, GasPrice}; pub use cosmrs::Coin as CosmosCoin; pub use cosmwasm_std::Coin as CosmWasmCoin; +use cosmwasm_std::{Fraction, Uint128}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::ops::Div; #[derive(Serialize, Deserialize, Clone, Copy, Default, Debug, PartialEq, Eq)] pub struct MismatchedDenoms; @@ -19,6 +22,40 @@ pub struct Coin { pub denom: String, } +impl Div for Coin { + type Output = Gas; + + fn div(self, rhs: GasPrice) -> Self::Output { + &self / rhs + } +} + +impl<'a> Div for &'a Coin { + type Output = Gas; + + fn div(self, rhs: GasPrice) -> Self::Output { + if self.denom != rhs.denom { + panic!( + "attempted to use two different denoms for gas calculation ({} and {})", + self.denom, rhs.denom + ); + } + + // tsk, tsk. somebody tried to divide by zero here! + let Some(gas_price_inv) = rhs.amount.inv() else { + panic!("attempted to divide by zero!") + }; + + let implicit_gas_limit = gas_price_inv * Uint128::new(self.amount); + if implicit_gas_limit.u128() >= u64::MAX as u128 { + u64::MAX + } else { + implicit_gas_limit.u128() as u64 + } + .into() + } +} + impl Coin { pub fn new>(amount: u128, denom: S) -> Self { Coin { @@ -128,3 +165,67 @@ impl CoinConverter for CosmWasmCoin { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn division_by_zero_gas_price() { + let gas_price: GasPrice = "0unym".parse().unwrap(); + let amount = Coin::new(123, "unym"); + let _res = amount / gas_price; + } + + #[test] + #[should_panic] + fn division_by_gas_price_of_different_denom() { + let gas_price: GasPrice = "0.025unyx".parse().unwrap(); + let amount = Coin::new(123, "unym"); + let _res = amount / gas_price; + } + + #[test] + fn gas_price_division() { + let amount = Coin::new(3938, "unym"); + let gas_price = "0.025unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(157520, res.value()); + + let amount = Coin::new(1234567890, "unym"); + let gas_price = "0.025unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(49382715600, res.value()); + + let amount = Coin::new(1, "unym"); + let gas_price = "0.025unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(40, res.value()); + + let amount = Coin::new(150_000_000, "unym"); + let gas_price = "0.001234unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(121555915721, res.value()); + + let amount = Coin::new(150_000_000, "unym"); + let gas_price = "1unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(150_000_000, res.value()); + + let amount = Coin::new(150_000_000, "unym"); + let gas_price = "1234.56unym".parse().unwrap(); + let res = amount / gas_price; + assert_eq!(121500, res.value()); + } + + #[test] + fn gas_price_division_identity() { + let amount = Coin::new(1234567890, "unym"); + let gas_price: GasPrice = "0.025unym".parse().unwrap(); + let res1 = (&amount) / gas_price.clone(); + let res2 = &gas_price * res1; + + assert_eq!(amount, Coin::from(res2)); + } +} diff --git a/common/client-libs/validator-client/src/nyxd/fee/mod.rs b/common/client-libs/validator-client/src/nyxd/fee/mod.rs index b887c08128..e1ee4b4d5c 100644 --- a/common/client-libs/validator-client/src/nyxd/fee/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/fee/mod.rs @@ -1,8 +1,8 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::nyxd::Coin; use crate::nyxd::Gas; +use crate::nyxd::{Coin, GasPrice}; use cosmrs::{tx, AccountId}; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; @@ -64,6 +64,12 @@ impl Display for Fee { } impl Fee { + pub fn manual_with_gas_price(fee: Coin, gas_price: GasPrice) -> Self { + let gas_limit = &fee / gas_price; + + Fee::Manual(tx::Fee::from_amount_and_gas(fee.into(), gas_limit)) + } + pub fn new_payer_granter_auto( gas_adjustment: Option, payer: Option, diff --git a/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml b/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml index b0b93d70e8..e5b6dd66eb 100644 --- a/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml +++ b/common/cosmwasm-smart-contracts/contracts-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-contracts-common" -version = "0.1.0" +version = "0.2.0" description = "Common library for Nym cosmwasm contracts" edition = { workspace = true } authors = { workspace = true } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml b/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml index fec9864943..670c993554 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml +++ b/common/cosmwasm-smart-contracts/mixnet-contract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-mixnet-contract-common" -version = "0.1.0" +version = "0.2.0" description = "Common library for the Nym mixnet contract" rust-version = "1.62" edition = { workspace = true } @@ -15,7 +15,7 @@ serde = { version = "1.0", features = ["derive"] } serde_repr = "0.1" schemars = "0.8" thiserror = "1.0" -contracts-common = { path = "../contracts-common", package = "nym-contracts-common", version = "0.1.0" } +contracts-common = { path = "../contracts-common", package = "nym-contracts-common", version = "0.2.0" } serde_json = "1.0.0" humantime-serde = "1.1.1" diff --git a/common/cosmwasm-smart-contracts/vesting-contract/Cargo.toml b/common/cosmwasm-smart-contracts/vesting-contract/Cargo.toml index 8e0d24b9df..7742b7c7a8 100644 --- a/common/cosmwasm-smart-contracts/vesting-contract/Cargo.toml +++ b/common/cosmwasm-smart-contracts/vesting-contract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-vesting-contract-common" -version = "0.1.0" +version = "0.2.0" description = "Common library for the Nym vesting contract" edition = { workspace = true } authors = { workspace = true } @@ -9,8 +9,8 @@ repository = { workspace = true } [dependencies] cosmwasm-std = "1.0.0" -mixnet-contract-common = { path = "../mixnet-contract", package = "nym-mixnet-contract-common", version = "0.1.0" } -contracts-common = { path = "../contracts-common", package = "nym-contracts-common", version = "0.1.0" } +mixnet-contract-common = { path = "../mixnet-contract", package = "nym-mixnet-contract-common", version = "0.2.0" } +contracts-common = { path = "../contracts-common", package = "nym-contracts-common", version = "0.2.0" } serde = { version = "1.0", features = ["derive"] } schemars = "0.8" ts-rs = {version = "6.1.2", optional = true} diff --git a/common/credential-storage/Cargo.toml b/common/credential-storage/Cargo.toml index 0c10ef7535..ec828bce97 100644 --- a/common/credential-storage/Cargo.toml +++ b/common/credential-storage/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } log = { workspace = true } sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"]} diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 0600adf87d..21ff8cb7a3 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-crypto" -version = "0.1.0" +version = "0.2.0" description = "Crypto library for the nym mixnet" edition = { workspace = true } authors = { workspace = true } @@ -26,8 +26,8 @@ subtle-encoding = { version = "0.5", features = ["bech32-preview"]} thiserror = "1.0.37" # internal -nym-sphinx-types = { path = "../nymsphinx/types", version = "0.1.0" } -nym-pemstore = { path = "../../common/pemstore", version = "0.1.0" } +nym-sphinx-types = { path = "../nymsphinx/types", version = "0.2.0" } +nym-pemstore = { path = "../../common/pemstore", version = "0.2.0" } [dev-dependencies] rand_chacha = "0.2" diff --git a/common/crypto/src/asymmetric/encryption/mod.rs b/common/crypto/src/asymmetric/encryption/mod.rs index e81bea6e85..4d079d4378 100644 --- a/common/crypto/src/asymmetric/encryption/mod.rs +++ b/common/crypto/src/asymmetric/encryption/mod.rs @@ -3,6 +3,7 @@ use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair}; use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; use thiserror::Error; #[cfg(feature = "rand")] @@ -131,6 +132,14 @@ impl PublicKey { } } +impl FromStr for PublicKey { + type Err = KeyRecoveryError; + + fn from_str(s: &str) -> Result { + PublicKey::from_base58_string(s) + } +} + #[cfg(feature = "serde")] impl Serialize for PublicKey { fn serialize(&self, serializer: S) -> Result diff --git a/common/mobile-storage/Cargo.toml b/common/mobile-storage/Cargo.toml index 3c38de8d2d..98b9d9b42f 100644 --- a/common/mobile-storage/Cargo.toml +++ b/common/mobile-storage/Cargo.toml @@ -6,6 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } thiserror = "1.0" diff --git a/common/nymsphinx/Cargo.toml b/common/nymsphinx/Cargo.toml index d5d10ea633..c4d53a70a5 100644 --- a/common/nymsphinx/Cargo.toml +++ b/common/nymsphinx/Cargo.toml @@ -23,7 +23,7 @@ nym-sphinx-types = { path = "types" } # those dependencies are due to intriducing preparer and receiver. Perpaphs that indicates they should be moved # to separate crate? -nym-crypto = { path = "../crypto", version = "0.1.0" } +nym-crypto = { path = "../crypto", version = "0.2.0" } nym-topology = { path = "../topology" } [dev-dependencies] diff --git a/common/nymsphinx/types/Cargo.toml b/common/nymsphinx/types/Cargo.toml index 53e399ebcf..6be749396a 100644 --- a/common/nymsphinx/types/Cargo.toml +++ b/common/nymsphinx/types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-sphinx-types" -version = "0.1.0" +version = "0.2.0" description = "Re-export sphinx packet types" edition = { workspace = true } authors = { workspace = true } diff --git a/common/pemstore/Cargo.toml b/common/pemstore/Cargo.toml index 9fbbcf263b..1f1b8af550 100644 --- a/common/pemstore/Cargo.toml +++ b/common/pemstore/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "nym-pemstore" description = "Store private-public keypairs in PEM format" -version = "0.1.0" +version = "0.2.0" edition = { workspace = true } authors = { workspace = true } license = { workspace = true } diff --git a/common/published-crates.txt b/common/published-crates.txt new file mode 100644 index 0000000000..5aee0f41d8 --- /dev/null +++ b/common/published-crates.txt @@ -0,0 +1,8 @@ +# List of published common crates +nym-bin-common +nym-contracts-common +nym-crypto +nym-mixnet-contract-common +nym-pemstore +nym-sphinx-types +nym-vesting-contract-common diff --git a/common/statistics/Cargo.toml b/common/statistics/Cargo.toml index 5649e22414..29573f1e06 100644 --- a/common/statistics/Cargo.toml +++ b/common/statistics/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } log = { workspace = true } reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } diff --git a/common/statistics/src/api.rs b/common/statistics/src/api.rs index 6d88cb8234..57c0f6f2c6 100644 --- a/common/statistics/src/api.rs +++ b/common/statistics/src/api.rs @@ -5,7 +5,7 @@ use crate::error::StatsError; use crate::StatsMessage; pub const DEFAULT_STATISTICS_SERVICE_ADDRESS: &str = "127.0.0.1"; -pub const DEFAULT_STATISTICS_SERVICE_PORT: u16 = 8090; +pub const DEFAULT_STATISTICS_SERVICE_PORT: u16 = 8091; pub const STATISTICS_SERVICE_VERSION: &str = "/v1"; pub const STATISTICS_SERVICE_API_STATISTICS: &str = "statistic"; diff --git a/common/topology/Cargo.toml b/common/topology/Cargo.toml index 70c8e2db9a..fbb5985ad5 100644 --- a/common/topology/Cargo.toml +++ b/common/topology/Cargo.toml @@ -16,6 +16,7 @@ bs58 = "0.4" log = { workspace = true } rand = { version = "0.7.3", features = ["wasm-bindgen"] } thiserror = "1.0.37" +async-trait = { workspace = true, optional = true } ## internal nym-crypto = { path = "../crypto" } @@ -23,3 +24,7 @@ nym-mixnet-contract-common = { path = "../cosmwasm-smart-contracts/mixnet-contra nym-sphinx-addressing = { path = "../nymsphinx/addressing" } nym-sphinx-types = { path = "../nymsphinx/types" } nym-bin-common = { path = "../bin-common" } + +[features] +default = ["provider-trait"] +provider-trait = ["async-trait"] \ No newline at end of file diff --git a/common/topology/src/lib.rs b/common/topology/src/lib.rs index 497a45e69e..4eab444605 100644 --- a/common/topology/src/lib.rs +++ b/common/topology/src/lib.rs @@ -20,6 +20,9 @@ pub mod filter; pub mod gateway; pub mod mix; +#[cfg(feature = "provider-trait")] +pub mod provider_trait; + #[derive(Debug, Clone, Error)] pub enum NymTopologyError { #[error("The provided network topology is empty - there are no mixnodes and no gateways on it - the network request(s) probably failed")] @@ -39,6 +42,16 @@ pub enum NymTopologyError { #[error("No mixnodes available on layer {layer}")] EmptyMixLayer { layer: MixLayer }, + + #[error("Uneven layer distribution. Layer {layer} has {nodes} on it, while we expected a value between {lower_bound} and {upper_bound} as we have {total_nodes} nodes in total. Full breakdown: {layer_distribution:?}")] + UnevenLayerDistribution { + layer: MixLayer, + nodes: usize, + lower_bound: usize, + upper_bound: usize, + total_nodes: usize, + layer_distribution: Vec<(MixLayer, usize)>, + }, } #[derive(Debug, Clone)] @@ -97,7 +110,7 @@ impl NymTopology { } pub fn num_mixnodes(&self) -> usize { - self.mixes.values().flat_map(|m| m.iter()).count() + self.mixes.values().map(|m| m.len()).sum() } pub fn mixes_as_vec(&self) -> Vec { @@ -237,6 +250,46 @@ impl NymTopology { Ok(()) } + pub fn ensure_even_layer_distribution( + &self, + lower_threshold: f32, + upper_threshold: f32, + ) -> Result<(), NymTopologyError> { + let mixnodes_count = self.num_mixnodes(); + + let layers = self + .mixes + .iter() + .map(|(k, v)| (*k, v.len())) + .collect::>(); + + if self.gateways.is_empty() { + return Err(NymTopologyError::NoGatewaysAvailable); + } + + if layers.is_empty() { + return Err(NymTopologyError::NoMixnodesAvailable); + } + + let upper_bound = (mixnodes_count as f32 * upper_threshold) as usize; + let lower_bound = (mixnodes_count as f32 * lower_threshold) as usize; + + for (layer, nodes) in &layers { + if nodes < &lower_bound || nodes > &upper_bound { + return Err(NymTopologyError::UnevenLayerDistribution { + layer: *layer, + nodes: *nodes, + lower_bound, + upper_bound, + total_nodes: mixnodes_count, + layer_distribution: layers, + }); + } + } + + Ok(()) + } + #[must_use] pub fn filter_system_version(&self, expected_version: &str) -> Self { self.filter_node_versions(expected_version) diff --git a/common/topology/src/mix.rs b/common/topology/src/mix.rs index c03e9d9978..14dcc02857 100644 --- a/common/topology/src/mix.rs +++ b/common/topology/src/mix.rs @@ -3,7 +3,8 @@ use crate::{filter, NetworkAddress}; use nym_crypto::asymmetric::{encryption, identity}; -use nym_mixnet_contract_common::{Layer, MixId, MixNodeBond}; +pub use nym_mixnet_contract_common::Layer; +use nym_mixnet_contract_common::{MixId, MixNodeBond}; use nym_sphinx_addressing::nodes::NymNodeRoutingAddress; use nym_sphinx_types::Node as SphinxNode; use std::convert::{TryFrom, TryInto}; diff --git a/common/topology/src/provider_trait.rs b/common/topology/src/provider_trait.rs new file mode 100644 index 0000000000..562cfaa5f7 --- /dev/null +++ b/common/topology/src/provider_trait.rs @@ -0,0 +1,44 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::NymTopology; +pub use async_trait::async_trait; + +// hehe, wasm +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +pub trait TopologyProvider: Send { + async fn get_new_topology(&mut self) -> Option; +} + +#[cfg(target_arch = "wasm32")] +#[async_trait(?Send)] +pub trait TopologyProvider { + async fn get_new_topology(&mut self) -> Option; +} + +pub struct HardcodedTopologyProvider { + topology: NymTopology, +} + +impl HardcodedTopologyProvider { + pub fn new(topology: NymTopology) -> Self { + HardcodedTopologyProvider { topology } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +impl TopologyProvider for HardcodedTopologyProvider { + async fn get_new_topology(&mut self) -> Option { + Some(self.topology.clone()) + } +} + +#[cfg(target_arch = "wasm32")] +#[async_trait(?Send)] +impl TopologyProvider for HardcodedTopologyProvider { + async fn get_new_topology(&mut self) -> Option { + Some(self.topology.clone()) + } +} diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 4677516d02..b75700efd1 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-mixnet-contract" -version = "1.1.2" +version = "1.2.0-pre.0" description = "Nym mixnet contract" edition = { workspace = true } authors = { workspace = true } @@ -22,8 +22,8 @@ name = "mixnet_contract" crate-type = ["cdylib", "rlib"] [dependencies] -mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common", version = "0.1.0" } -vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.1.0" } +mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common", version = "0.2.0" } +vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.2.0" } #nym-config = { path = "../../common/config"} cosmwasm-std = "1.0.0" diff --git a/contracts/mixnet/build.rs b/contracts/mixnet/build.rs index 01b3a20dc6..93402c2fbd 100644 --- a/contracts/mixnet/build.rs +++ b/contracts/mixnet/build.rs @@ -4,5 +4,10 @@ use vergen::{vergen, Config}; fn main() { - vergen(Config::default()).expect("failed to extract build metadata") + let mut config = Config::default(); + if std::env::var("DOCS_RS").is_ok() { + // If we don't have access to git information, such as in a docs.rs build, don't error + *config.git_mut().skip_if_error_mut() = true; + } + vergen(config).expect("failed to extract build metadata"); } diff --git a/contracts/mixnet/src/mixnet_contract_settings/queries.rs b/contracts/mixnet/src/mixnet_contract_settings/queries.rs index 1181c87d2f..569f266bd8 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/queries.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/queries.rs @@ -28,9 +28,13 @@ pub(crate) fn query_contract_version() -> ContractBuildInformation { ContractBuildInformation { build_timestamp: env!("VERGEN_BUILD_TIMESTAMP").to_string(), build_version: env!("VERGEN_BUILD_SEMVER").to_string(), - commit_sha: env!("VERGEN_GIT_SHA").to_string(), - commit_timestamp: env!("VERGEN_GIT_COMMIT_TIMESTAMP").to_string(), - commit_branch: env!("VERGEN_GIT_BRANCH").to_string(), + commit_sha: option_env!("VERGEN_GIT_SHA").unwrap_or("NONE").to_string(), + commit_timestamp: option_env!("VERGEN_GIT_COMMIT_TIMESTAMP") + .unwrap_or("NONE") + .to_string(), + commit_branch: option_env!("VERGEN_GIT_BRANCH") + .unwrap_or("NONE") + .to_string(), rustc_version: env!("VERGEN_RUSTC_SEMVER").to_string(), } } diff --git a/contracts/vesting/Cargo.toml b/contracts/vesting/Cargo.toml index a6d088af5b..759864b713 100644 --- a/contracts/vesting/Cargo.toml +++ b/contracts/vesting/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-vesting-contract" -version = "1.1.4" +version = "1.2.0-pre.0" description = "Nym vesting contract" edition = { workspace = true } authors = { workspace = true } @@ -20,9 +20,9 @@ name = "vesting_contract" crate-type = ["cdylib", "rlib"] [dependencies] -mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common", version = "0.1.0" } -contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", package = "nym-contracts-common", version = "0.1.0" } -vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.1.0" } +mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", package = "nym-mixnet-contract-common", version = "0.2.0" } +contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", package = "nym-contracts-common", version = "0.2.0" } +vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract", package = "nym-vesting-contract-common", version = "0.2.0" } cosmwasm-std = { version = "1.0.0 "} cw2 = { version = "0.13.4" } @@ -43,4 +43,4 @@ serde_json = "1.0.66" vergen = { version = "=7.4.3", default-features = false, features = ["build", "git", "rustc"] } [profile.release] -overflow-checks = true \ No newline at end of file +overflow-checks = true diff --git a/contracts/vesting/build.rs b/contracts/vesting/build.rs index 01b3a20dc6..3567a950a4 100644 --- a/contracts/vesting/build.rs +++ b/contracts/vesting/build.rs @@ -4,5 +4,10 @@ use vergen::{vergen, Config}; fn main() { + let mut config = Config::default(); + if std::env::var("DOCS_RS").is_ok() { + // If we don't have access to git information, such as in a docs.rs build, don't error + *config.git_mut().skip_if_error_mut() = true; + } vergen(Config::default()).expect("failed to extract build metadata") } diff --git a/contracts/vesting/src/contract.rs b/contracts/vesting/src/contract.rs index 756cb1caa4..39eff3a86c 100644 --- a/contracts/vesting/src/contract.rs +++ b/contracts/vesting/src/contract.rs @@ -820,9 +820,13 @@ pub fn get_contract_version() -> ContractBuildInformation { ContractBuildInformation { build_timestamp: env!("VERGEN_BUILD_TIMESTAMP").to_string(), build_version: env!("VERGEN_BUILD_SEMVER").to_string(), - commit_sha: env!("VERGEN_GIT_SHA").to_string(), - commit_timestamp: env!("VERGEN_GIT_COMMIT_TIMESTAMP").to_string(), - commit_branch: env!("VERGEN_GIT_BRANCH").to_string(), + commit_sha: option_env!("VERGEN_GIT_SHA").unwrap_or("NONE").to_string(), + commit_timestamp: option_env!("VERGEN_GIT_COMMIT_TIMESTAMP") + .unwrap_or("NONE") + .to_string(), + commit_branch: option_env!("VERGEN_GIT_BRANCH") + .unwrap_or("NONE") + .to_string(), rustc_version: env!("VERGEN_RUSTC_SEMVER").to_string(), } } diff --git a/explorer-api/Cargo.toml b/explorer-api/Cargo.toml index c3245494ea..72ed732e62 100644 --- a/explorer-api/Cargo.toml +++ b/explorer-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "explorer-api" -version = "1.1.11" +version = "1.1.12" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/explorer-api/src/http/mod.rs b/explorer-api/src/http/mod.rs index 31f638ee5f..559093671e 100644 --- a/explorer-api/src/http/mod.rs +++ b/explorer-api/src/http/mod.rs @@ -12,6 +12,7 @@ use crate::mix_node::http::mix_node_make_default_routes; use crate::mix_nodes::http::mix_nodes_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; use crate::state::ExplorerApiStateContext; use crate::validators::http::validators_make_default_routes; @@ -56,6 +57,7 @@ fn configure_rocket(state: ExplorerApiStateContext) -> Rocket { "/overview" => overview_make_default_routes(&openapi_settings), "/ping" => ping_make_default_routes(&openapi_settings), "/validators" => validators_make_default_routes(&openapi_settings), + "/service-providers" => service_providers_make_default_routes(&openapi_settings), }; building_rocket diff --git a/explorer-api/src/main.rs b/explorer-api/src/main.rs index 77d2551dbd..676f1c5cea 100644 --- a/explorer-api/src/main.rs +++ b/explorer-api/src/main.rs @@ -23,6 +23,7 @@ mod mix_node; pub(crate) mod mix_nodes; mod overview; mod ping; +pub(crate) mod service_providers; mod state; mod tasks; mod validators; diff --git a/explorer-api/src/mix_node/models.rs b/explorer-api/src/mix_node/models.rs index ff94f921b4..6cbb295b6b 100644 --- a/explorer-api/src/mix_node/models.rs +++ b/explorer-api/src/mix_node/models.rs @@ -11,7 +11,7 @@ use serde::Serialize; use std::sync::Arc; use std::time::SystemTime; use tokio::sync::RwLock; -use validator_client::models::SelectionChance; +use validator_client::models::{NodePerformance, SelectionChance}; #[derive(Clone, Debug, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] @@ -36,6 +36,7 @@ pub(crate) struct PrettyDetailedMixNodeBond { pub stake_saturation: f32, pub uncapped_saturation: f32, pub avg_uptime: u8, + pub node_performance: NodePerformance, pub estimated_operator_apy: f64, pub estimated_delegators_apy: f64, pub operating_cost: Coin, diff --git a/explorer-api/src/mix_nodes/models.rs b/explorer-api/src/mix_nodes/models.rs index c4483cf192..0cbc7b0167 100644 --- a/explorer-api/src/mix_nodes/models.rs +++ b/explorer-api/src/mix_nodes/models.rs @@ -153,6 +153,7 @@ impl ThreadsafeMixNodesCache { layer: node.mixnode_details.bond_information.layer, mix_node: node.mixnode_details.bond_information.mix_node.clone(), avg_uptime: node.performance.round_to_integer(), + node_performance: node.node_performance.clone(), stake_saturation: best_effort_small_dec_to_f64(node.stake_saturation) as f32, uncapped_saturation: best_effort_small_dec_to_f64(node.uncapped_stake_saturation) as f32, diff --git a/explorer-api/src/service_providers/http.rs b/explorer-api/src/service_providers/http.rs new file mode 100644 index 0000000000..418e113a96 --- /dev/null +++ b/explorer-api/src/service_providers/http.rs @@ -0,0 +1,26 @@ +use crate::service_providers::models::DirectoryService; +use okapi::openapi3::OpenApi; +use reqwest::Error as ReqwestError; +use rocket::{serde::json::Json, Route}; +use rocket_okapi::settings::OpenApiSettings; + +static SERVICE_PROVIDER_WELLKNOWN_URL: &str = + "https://nymtech.net/.wellknown/connect/service-providers.json"; + +pub fn service_providers_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: get_service_providers] +} + +pub async fn get_services() -> Result, ReqwestError> { + reqwest::get(SERVICE_PROVIDER_WELLKNOWN_URL) + .await? + .json::>() + .await +} + +#[openapi(tag = "service_providers")] +#[get("/")] +pub(crate) async fn get_service_providers() -> Json> { + let result = get_services().await.unwrap(); + Json(result) +} diff --git a/explorer-api/src/service_providers/mod.rs b/explorer-api/src/service_providers/mod.rs new file mode 100644 index 0000000000..5df938f83c --- /dev/null +++ b/explorer-api/src/service_providers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod http; +pub(crate) mod models; diff --git a/explorer-api/src/service_providers/models.rs b/explorer-api/src/service_providers/models.rs new file mode 100644 index 0000000000..21ee1df812 --- /dev/null +++ b/explorer-api/src/service_providers/models.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct DirectoryServiceProvider { + pub id: String, + pub description: String, + pub address: String, + pub gateway: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct DirectoryService { + pub id: String, + pub description: String, + pub items: Vec, +} diff --git a/explorer/package.json b/explorer/package.json index 5e5f347052..9290a8c750 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -1,6 +1,6 @@ { "name": "@nym/network-explorer", - "version": "1.0.6", + "version": "1.0.7", "private": true, "license": "Apache-2.0", "dependencies": { diff --git a/explorer/src/api/constants.ts b/explorer/src/api/constants.ts index 81f6592c48..e3f39a3cae 100644 --- a/explorer/src/api/constants.ts +++ b/explorer/src/api/constants.ts @@ -14,7 +14,8 @@ export const VALIDATORS_API = `${VALIDATOR_BASE_URL}/validators`; export const BLOCK_API = `${NYM_API_BASE_URL}/block`; export const COUNTRY_DATA_API = `${API_BASE_URL}/countries`; export const UPTIME_STORY_API = `${NYM_API_BASE_URL}/api/v1/status/mixnode`; // add ID then '/history' to this. -export const UPTIME_STORY_API_GATEWAY = `${NYM_API_BASE_URL}/api/v1/status/gateway`; // add ID then '/history' or '/report' to this. +export const UPTIME_STORY_API_GATEWAY = `${NYM_API_BASE_URL}/api/v1/status/gateway`; // add ID then '/history' or '/report' to this +export const SERVICE_PROVIDERS = `${API_BASE_URL}/service-providers`; // errors export const MIXNODE_API_ERROR = "We're having trouble finding that record, please try again or Contact Us."; diff --git a/explorer/src/api/index.ts b/explorer/src/api/index.ts index 894dd3e179..c83b033283 100644 --- a/explorer/src/api/index.ts +++ b/explorer/src/api/index.ts @@ -9,6 +9,7 @@ import { OVERVIEW_API, UPTIME_STORY_API, VALIDATORS_API, + SERVICE_PROVIDERS, } from './constants'; import { @@ -28,6 +29,7 @@ import { ValidatorsResponse, GatewayBondAnnotated, GatewayBond, + DirectoryService, } from '../typeDefs/explorer-api'; function getFromCache(key: string) { @@ -62,6 +64,7 @@ export class Api { if (cachedMixnodes) { return cachedMixnodes; } + const res = await fetch(MIXNODES_API); const json = await res.json(); storeInCache('mixnodes', JSON.stringify(json)); @@ -147,4 +150,10 @@ export class Api { static fetchUptimeStoryById = async (id: string): Promise => (await fetch(`${UPTIME_STORY_API}/${id}/history`)).json(); + + static fetchServiceProviders = async (): Promise => { + const res = await fetch(SERVICE_PROVIDERS); + const json = await res.json(); + return json; + }; } diff --git a/explorer/src/components/CustomColumnHeading.tsx b/explorer/src/components/CustomColumnHeading.tsx index c15ef27f9b..1f85806fa2 100644 --- a/explorer/src/components/CustomColumnHeading.tsx +++ b/explorer/src/components/CustomColumnHeading.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Box, Typography } from '@mui/material'; -import { ExpandLess, ExpandMore } from '@mui/icons-material'; import { useTheme } from '@mui/material/styles'; import { Tooltip } from '@nymproject/react/tooltip/Tooltip'; @@ -8,14 +7,10 @@ export const CustomColumnHeading: FCWithChildren<{ headingTitle: string; tooltip headingTitle, tooltipInfo, }) => { - const [filter, toggleFilter] = React.useState(false); const theme = useTheme(); - const handleClick = () => { - toggleFilter(!filter); - }; return ( - + {tooltipInfo && ( )} - - {headingTitle}  + + {headingTitle} - {filter ? : } ); }; diff --git a/explorer/src/components/DetailTable.tsx b/explorer/src/components/DetailTable.tsx index 80e7ce969b..d333f0eb59 100644 --- a/explorer/src/components/DetailTable.tsx +++ b/explorer/src/components/DetailTable.tsx @@ -1,5 +1,15 @@ import * as React from 'react'; -import { Link, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; +import { + Link, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableCellProps, +} from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { Tooltip } from '@nymproject/react/tooltip/Tooltip'; import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard'; @@ -8,13 +18,13 @@ import { cellStyles } from './Universal-DataGrid'; import { unymToNym } from '../utils/currency'; import { GatewayEnrichedRowType } from './Gateways'; import { MixnodeRowType } from './MixNodes'; +import { StakeSaturationProgressBar } from './MixNodes/Economics/StakeSaturationProgressBar'; export type ColumnsType = { field: string; title: string; - headerAlign: string; - flex?: number; - width?: number; + headerAlign?: TableCellProps['align']; + width?: string | number; tooltipInfo?: string; }; @@ -50,6 +60,10 @@ function formatCellValues(val: string | number, field: string) { ); } + if (field === 'stake_saturation') { + return ; + } + return val; } @@ -61,11 +75,11 @@ export const DetailTable: FCWithChildren<{ const theme = useTheme(); return ( - +
- {columnsData?.map(({ field, title, flex, tooltipInfo }) => ( - + {columnsData?.map(({ field, title, width, tooltipInfo }) => ( + {tooltipInfo && ( diff --git a/explorer/src/components/MixNodes/Economics/Columns.ts b/explorer/src/components/MixNodes/Economics/Columns.ts index 3c20e0eebd..c3ac85baaf 100644 --- a/explorer/src/components/MixNodes/Economics/Columns.ts +++ b/explorer/src/components/MixNodes/Economics/Columns.ts @@ -4,57 +4,48 @@ export const EconomicsInfoColumns: ColumnsType[] = [ { field: 'estimatedTotalReward', title: 'Estimated Total Reward', - flex: 1, - headerAlign: 'left', + width: '15%', tooltipInfo: 'Estimated node reward (total for the operator and delegators) in the current epoch. There are roughly 24 epochs in a day.', }, { field: 'estimatedOperatorReward', title: 'Estimated Operator Reward', - flex: 1, - headerAlign: 'left', + width: '15%', tooltipInfo: "Estimated operator's reward (including PM and Operating Cost) in the current epoch. There are roughly 24 epochs in a day.", }, { field: 'selectionChance', title: 'Active Set Probability', - flex: 1, - headerAlign: 'left', + width: '12.5%', tooltipInfo: 'Probability of getting selected in the reward set (active and standby nodes) in the next epoch. The more your stake, the higher the chances to be selected.', }, - { - field: 'stakeSaturation', - title: 'Stake Saturation', - flex: 1, - headerAlign: 'left', - tooltipInfo: - 'Level of stake saturation for this node. Nodes receive more rewards the higher their saturation level, up to 100%. Beyond 100% no additional rewards are granted. The current stake saturation level is 730k NYM, computed as S/K where S is target amount of tokens staked in the network and K is the number of nodes in the reward set.', - }, { field: 'profitMargin', title: 'Profit Margin', - flex: 1, - headerAlign: 'left', + width: '12.5%', tooltipInfo: 'Percentage of the delegators rewards that the operator takes as fee before rewards are distributed to the delegators.', }, { field: 'operatingCost', title: 'Operating Cost', - flex: 1, - headerAlign: 'left', + width: '10%', tooltipInfo: 'Monthly operational cost of running this node. This cost is set by the operator and it influences how the rewards are split between the operator and delegators.', }, { field: 'avgUptime', + title: 'Avg. Score', + width: '10%', + tooltipInfo: "Mixnode's average routing score in the last 24 hour", + }, + { + field: 'nodePerformance', title: 'Routing Score', - flex: 1, - headerAlign: 'left', tooltipInfo: - 'Node’s routing score is relative to that of the network. Each time a node is tested, the test packets have to go through the full path of the network (a gateway + 3 nodes). If a node in the path drop packets it will affect the score of other nodes in the test.', + "Mixnode's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test.", }, ]; diff --git a/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx b/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx index 9f8525a754..24db62d167 100644 --- a/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx +++ b/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx @@ -9,14 +9,14 @@ const parseToNumber = (value: number | undefined | string) => export const EconomicsProgress: FCWithChildren< LinearProgressProps & { threshold?: number; + color: string; } -> = ({ threshold, ...props }) => { +> = ({ threshold, color, ...props }) => { const theme = useTheme(); const { value } = props; const valueNumber: number = parseToNumber(value); const thresholdNumber: number = parseToNumber(threshold); - const percentageColor = valueNumber > (threshold || 100) ? 'warning' : 'inherit'; const percentageToDisplay = Math.min(valueNumber, thresholdNumber); return ( @@ -29,9 +29,9 @@ export const EconomicsProgress: FCWithChildren< ); diff --git a/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx b/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx index aa3156ccf1..c9c82c0438 100644 --- a/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx +++ b/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx @@ -14,9 +14,7 @@ const row: EconomicsInfoRowWithIndex = { selectionChance: { value: 'High', }, - avgUptime: { - value: '65 %', - }, + estimatedOperatorReward: { value: '80000.123456 NYM', }, @@ -29,9 +27,11 @@ const row: EconomicsInfoRowWithIndex = { operatingCost: { value: '11121 NYM', }, - stakeSaturation: { - value: '80 %', - progressBarValue: 80, + avgUptime: { + value: '-', + }, + nodePerformance: { + value: '-', }, }; @@ -55,9 +55,7 @@ const emptyRow: EconomicsInfoRowWithIndex = { value: '-', progressBarValue: 0, }, - avgUptime: { - value: '-', - }, + estimatedOperatorReward: { value: '-', }, @@ -70,9 +68,11 @@ const emptyRow: EconomicsInfoRowWithIndex = { operatingCost: { value: '-', }, - stakeSaturation: { + avgUptime: { + value: '-', + }, + nodePerformance: { value: '-', - progressBarValue: 0, }, }; diff --git a/explorer/src/components/MixNodes/Economics/Rows.ts b/explorer/src/components/MixNodes/Economics/Rows.ts index 61049a5ca3..a1387a03de 100644 --- a/explorer/src/components/MixNodes/Economics/Rows.ts +++ b/explorer/src/components/MixNodes/Economics/Rows.ts @@ -14,11 +14,15 @@ export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => { currencyToString((economicDynamicsStats?.data?.estimated_total_node_reward || '').toString()) || '-'; const estimatedOperatorRewards = currencyToString((economicDynamicsStats?.data?.estimated_operator_reward || '').toString()) || '-'; - const stakeSaturation = economicDynamicsStats?.data?.uncapped_saturation || '-'; const profitMargin = mixNode?.data?.profit_margin_percent ? toPercentIntegerString(mixNode?.data?.profit_margin_percent) : '-'; - const avgUptime = economicDynamicsStats?.data?.current_interval_uptime; + const avgUptime = mixNode?.data?.node_performance + ? toPercentIntegerString(mixNode?.data?.node_performance.last_24h) + : '-'; + const nodePerformance = mixNode?.data?.node_performance + ? toPercentIntegerString(mixNode?.data?.node_performance.most_recent) + : '-'; const opCost = mixNode?.data?.operating_cost; @@ -33,10 +37,6 @@ export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => { selectionChance: { value: selectionChance(economicDynamicsStats), }, - stakeSaturation: { - progressBarValue: typeof stakeSaturation === 'number' ? stakeSaturation * 100 : 0, - value: typeof stakeSaturation === 'number' ? `${(stakeSaturation * 100).toFixed(2)} %` : '-', - }, profitMargin: { value: profitMargin ? `${profitMargin} %` : '-', }, @@ -46,5 +46,8 @@ export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => { avgUptime: { value: avgUptime ? `${avgUptime} %` : '-', }, + nodePerformance: { + value: nodePerformance, + }, }; }; diff --git a/explorer/src/components/MixNodes/Economics/StakeSaturationProgressBar.tsx b/explorer/src/components/MixNodes/Economics/StakeSaturationProgressBar.tsx new file mode 100644 index 0000000000..918d024643 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/StakeSaturationProgressBar.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { useIsMobile } from '../../../hooks/useIsMobile'; +import { EconomicsProgress } from './EconomicsProgress'; + +export const StakeSaturationProgressBar = ({ value, threshold }: { value: number; threshold: number }) => { + const isTablet = useIsMobile('lg'); + const percentageColor = value > (threshold || 100) ? 'warning' : 'inherit'; + const textColor = percentageColor === 'warning' ? 'warning.main' : 'nym.wallet.fee'; + + return ( + + + {value}% + + + + ); +}; diff --git a/explorer/src/components/MixNodes/Economics/Table.tsx b/explorer/src/components/MixNodes/Economics/Table.tsx index 929445254a..74bfa30251 100644 --- a/explorer/src/components/MixNodes/Economics/Table.tsx +++ b/explorer/src/components/MixNodes/Economics/Table.tsx @@ -1,70 +1,19 @@ import * as React from 'react'; import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; import { Box } from '@mui/system'; -import { useTheme, Theme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import { Tooltip } from '@nymproject/react/tooltip/Tooltip'; import { EconomicsRowsType, EconomicsInfoRowWithIndex } from './types'; -import { EconomicsProgress } from './EconomicsProgress'; -import { cellStyles } from '../../Universal-DataGrid'; import { UniversalTableProps } from '../../DetailTable'; -import { useIsMobile } from '../../../hooks/useIsMobile'; +import { textColour } from '../../../utils'; -const threshold = 100; - -const textColour = (value: EconomicsRowsType, field: string, theme: Theme) => { - const progressBarValue = value?.progressBarValue || 0; - const fieldValue = value.value; - - if (progressBarValue > 100) { - return theme.palette.warning.main; - } - if (field === 'selectionChance') { - // TODO: when v2 will be deployed, remove cases: VeryHigh, Moderate and VeryLow - switch (fieldValue) { - case 'High': - case 'VeryHigh': - return theme.palette.nym.networkExplorer.selectionChance.overModerate; - case 'Good': - case 'Moderate': - return theme.palette.nym.networkExplorer.selectionChance.moderate; - case 'Low': - case 'VeryLow': - return theme.palette.nym.networkExplorer.selectionChance.underModerate; - default: - return theme.palette.nym.wallet.fee; - } - } - return theme.palette.nym.wallet.fee; -}; - -const formatCellValues = (value: EconomicsRowsType, field: string) => { - const isTablet = useIsMobile('lg'); - if (value.progressBarValue) { - return ( - - - {value.value} - - - - ); - } - return ( - - - {value.value} - - - ); -}; +const formatCellValues = (value: EconomicsRowsType, field: string) => ( + + + {value.value} + + +); export const DelegatorsInfoTable: FCWithChildren> = ({ tableName, @@ -78,21 +27,19 @@ export const DelegatorsInfoTable: FCWithChildren - {columnsData?.map(({ field, title, flex, tooltipInfo }) => ( - + {columnsData?.map(({ field, title, tooltipInfo, width }) => ( + {tooltipInfo && ( - - - + )} {title} @@ -106,19 +53,11 @@ export const DelegatorsInfoTable: FCWithChildren { const { field } = columnsData[index]; const value: EconomicsRowsType = (eachRow as any)[field]; - + console.log(value); return ( void; + onChangeSearch?: (arg: string) => void; onChangePageSize: (event: SelectChangeEvent) => void; pageSize: string; - searchTerm: string; + searchTerm?: string; withFilters?: boolean; childrenBefore?: React.ReactNode; childrenAfter?: React.ReactNode; @@ -39,41 +39,43 @@ export const TableToolbar: FCWithChildren = ({ {childrenBefore} - + + + - :not(style)': { - height: fieldsHeight, - }, - }} - value={searchTerm} - data-testid="search-box" - placeholder="search" - onChange={(event) => onChangeSearch(event.target.value)} - /> + {!!onChangeSearch && ( + onChangeSearch(event.target.value)} + /> + )} = ({ rows, columns }} columns={columns} pageSize={Number(pageSize)} - rowsPerPageOptions={[5]} disableSelectionOnClick autoHeight hideFooter={!pagination} diff --git a/explorer/src/context/main.tsx b/explorer/src/context/main.tsx index 182a2bcc89..6c0dc490e1 100644 --- a/explorer/src/context/main.tsx +++ b/explorer/src/context/main.tsx @@ -4,6 +4,7 @@ import { ApiState, BlockResponse, CountryDataResponse, + DirectoryService, GatewayResponse, MixNodeResponse, MixnodeStatus, @@ -24,6 +25,7 @@ interface StateData { mode: PaletteMode; navState: NavOptionType[]; validators?: ApiState; + serviceProviders?: ApiState; } interface StateApi { @@ -63,6 +65,7 @@ export const MainContextProvider: FCWithChildren = ({ children }) => { const [validators, setValidators] = React.useState>(); const [block, setBlock] = React.useState>(); const [countryData, setCountryData] = React.useState>(); + const [serviceProviders, setServiceProviders] = React.useState>(); const toggleMode = () => setMode((m) => (m !== 'light' ? 'light' : 'dark')); @@ -156,6 +159,20 @@ export const MainContextProvider: FCWithChildren = ({ children }) => { }); } }; + + const fetchServiceProviders = async () => { + setServiceProviders({ data: undefined, isLoading: true }); + try { + const [res] = await Api.fetchServiceProviders(); + setServiceProviders({ data: res, isLoading: false }); + } catch (error) { + setServiceProviders({ + error: error instanceof Error ? error : new Error('Service provider api fail'), + isLoading: false, + }); + } + }; + const updateNavState = (id: number) => { const updated = navState.map((option) => ({ ...option, @@ -165,7 +182,14 @@ export const MainContextProvider: FCWithChildren = ({ children }) => { }; React.useEffect(() => { - Promise.all([fetchOverviewSummary(), fetchGateways(), fetchValidators(), fetchBlock(), fetchCountryData()]); + Promise.all([ + fetchOverviewSummary(), + fetchGateways(), + fetchValidators(), + fetchBlock(), + fetchCountryData(), + fetchServiceProviders(), + ]); }, []); const state = React.useMemo( @@ -183,8 +207,20 @@ export const MainContextProvider: FCWithChildren = ({ children }) => { toggleMode, updateNavState, validators, + serviceProviders, }), - [block, countryData, gateways, globalError, mixnodes, mode, navState, summaryOverview, validators], + [ + block, + countryData, + gateways, + globalError, + mixnodes, + mode, + navState, + summaryOverview, + validators, + serviceProviders, + ], ); return {children}; diff --git a/explorer/src/context/nav.tsx b/explorer/src/context/nav.tsx index 819676c289..ad65e0e50b 100644 --- a/explorer/src/context/nav.tsx +++ b/explorer/src/context/nav.tsx @@ -44,6 +44,11 @@ export const originalNavOptions: NavOptionType[] = [ url: `${BIG_DIPPER}/validators`, title: 'Validators', }, + { + id: 6, + url: 'network-components/service-providers', + title: 'Service Providers', + }, ], }, { diff --git a/explorer/src/pages/GatewayDetail/index.tsx b/explorer/src/pages/GatewayDetail/index.tsx index 8995b1733f..cad23ad16f 100644 --- a/explorer/src/pages/GatewayDetail/index.tsx +++ b/explorer/src/pages/GatewayDetail/index.tsx @@ -22,23 +22,20 @@ const columns: ColumnsType[] = [ { field: 'bond', title: 'Bond', - flex: 1, headerAlign: 'left', }, { field: 'node_performance', title: 'Routing Score', - flex: 1, headerAlign: 'left', tooltipInfo: - 'Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test.', + "Gateway's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test", }, { field: 'avgUptime', title: 'Avg. Score', - flex: 1, headerAlign: 'left', - tooltipInfo: 'Is the average routing score in the last 24 hours', + tooltipInfo: "Gateway's average routing score in the last 24 hours", }, { field: 'host', @@ -50,19 +47,16 @@ const columns: ColumnsType[] = [ field: 'location', title: 'Location', headerAlign: 'left', - flex: 1, }, { field: 'owner', title: 'Owner', headerAlign: 'left', - flex: 1, }, { field: 'version', title: 'Version', headerAlign: 'left', - flex: 1, }, ]; diff --git a/explorer/src/pages/Gateways/index.tsx b/explorer/src/pages/Gateways/index.tsx index 98e14be808..5c52f4a5b3 100644 --- a/explorer/src/pages/Gateways/index.tsx +++ b/explorer/src/pages/Gateways/index.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; import { Link as RRDLink } from 'react-router-dom'; import { Box, Card, Grid, Link as MuiLink } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard'; import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { SelectChangeEvent } from '@mui/material/Select'; -import { diff, rcompare } from 'semver'; +import { diff, gte, rcompare } from 'semver'; +import { Tooltip as InfoTooltip } from '@nymproject/react/tooltip/Tooltip'; import { useMainContext } from '../../context/main'; import { gatewayToGridRow } from '../../components/Gateways'; import { GatewayResponse } from '../../typeDefs/explorer-api'; @@ -25,6 +27,8 @@ export const PageGateways: FCWithChildren = () => { const [searchTerm, setSearchTerm] = React.useState(''); const [versionFilter, setVersionFilter] = React.useState(VersionSelectOptions.latestVersion); + const theme = useTheme(); + const handleSearch = (str: string) => { setSearchTerm(str.toLowerCase()); }; @@ -89,6 +93,7 @@ export const PageGateways: FCWithChildren = () => { renderHeader: () => , headerClassName: 'MuiDataGrid-header-override', width: 380, + disableColumnMenu: true, headerAlign: 'left', renderCell: (params: GridRenderCellParams) => ( <> @@ -107,28 +112,24 @@ export const PageGateways: FCWithChildren = () => { ), }, - { - field: 'bond', - width: 150, - type: 'number', - renderHeader: () => , - headerClassName: 'MuiDataGrid-header-override', - headerAlign: 'left', - renderCell: (params: GridRenderCellParams) => ( - - {unymToNym(params.value, 6)} - - ), - }, { field: 'node_performance', - renderHeader: () => , - width: 150, + renderHeader: () => ( + <> + + + + ), + width: 175, + disableColumnMenu: true, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', renderCell: (params: GridRenderCellParams) => ( @@ -143,26 +144,32 @@ export const PageGateways: FCWithChildren = () => { ), }, { - field: 'host', - renderHeader: () => , - width: 180, + field: 'version', + renderHeader: () => , + width: 150, + disableColumnMenu: true, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', renderCell: (params: GridRenderCellParams) => ( {params.value} ), + sortComparator: (a, b) => { + if (gte(a, b)) return 1; + return -1; + }, }, { field: 'location', renderHeader: () => , width: 180, + disableColumnMenu: true, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', renderCell: (params: GridRenderCellParams) => ( @@ -185,11 +192,30 @@ export const PageGateways: FCWithChildren = () => { ), }, + { + field: 'host', + renderHeader: () => , + width: 180, + disableColumnMenu: true, + headerAlign: 'left', + headerClassName: 'MuiDataGrid-header-override', + renderCell: (params: GridRenderCellParams) => ( + + {params.value} + + ), + }, { field: 'owner', headerName: 'Owner', renderHeader: () => , width: 180, + disableColumnMenu: true, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', renderCell: (params: GridRenderCellParams) => ( @@ -204,19 +230,21 @@ export const PageGateways: FCWithChildren = () => { ), }, { - field: 'version', - renderHeader: () => , + field: 'bond', width: 150, - headerAlign: 'left', + disableColumnMenu: true, + type: 'number', + renderHeader: () => , headerClassName: 'MuiDataGrid-header-override', + headerAlign: 'left', renderCell: (params: GridRenderCellParams) => ( - {params.value} + {unymToNym(params.value, 6)} ), }, diff --git a/explorer/src/pages/MixnodeDetail/index.tsx b/explorer/src/pages/MixnodeDetail/index.tsx index 776eda4504..706dcba197 100644 --- a/explorer/src/pages/MixnodeDetail/index.tsx +++ b/explorer/src/pages/MixnodeDetail/index.tsx @@ -17,45 +17,45 @@ const columns: ColumnsType[] = [ { field: 'owner', title: 'Owner', - headerAlign: 'left', - width: 230, + width: '15%', }, { field: 'identity_key', title: 'Identity Key', - headerAlign: 'left', - width: 230, + width: '15%', }, { field: 'bond', title: 'Stake', - flex: 1, - headerAlign: 'left', + width: '12.5%', + }, + { + field: 'stake_saturation', + title: 'Stake Saturation', + width: '12.5%', + tooltipInfo: + 'Level of stake saturation for this node. Nodes receive more rewards the higher their saturation level, up to 100%. Beyond 100% no additional rewards are granted. The current stake saturation level is 730k NYM, computed as S/K where S is target amount of tokens staked in the network and K is the number of nodes in the reward set.', }, { field: 'self_percentage', + width: '10%', title: 'Bond %', - headerAlign: 'left', - width: 99, }, + { field: 'host', + width: '10%', title: 'Host', - headerAlign: 'left', - flex: 1, }, { field: 'location', title: 'Location', - headerAlign: 'left', - flex: 1, }, + { field: 'layer', title: 'Layer', - headerAlign: 'left', - flex: 1, }, ]; @@ -64,11 +64,10 @@ const columns: ColumnsType[] = [ */ const PageMixnodeDetailWithState: FCWithChildren = () => { const { mixNode, mixNodeRow, description, stats, status, uptimeStory, uniqDelegations } = useMixnodeContext(); - + console.log(mixNodeRow); return ( - <Grid container spacing={2} mt={1} mb={6}> <Grid item xs={12}> {mixNodeRow && description?.data && ( @@ -76,13 +75,11 @@ const PageMixnodeDetailWithState: FCWithChildren = () => { )} </Grid> </Grid> - <Grid container> <Grid item xs={12}> <DetailTable columnsData={columns} tableName="Mixnode detail table" rows={mixNodeRow ? [mixNodeRow] : []} /> </Grid> </Grid> - <Grid container spacing={2} mt={0}> <Grid item xs={12}> <DelegatorsInfoTable @@ -92,7 +89,6 @@ const PageMixnodeDetailWithState: FCWithChildren = () => { /> </Grid> </Grid> - <Grid container spacing={2} mt={0}> <Grid item xs={12}> <ContentCard title={`Stake Breakdown (${uniqDelegations?.data?.length} delegators)`}> @@ -100,7 +96,6 @@ const PageMixnodeDetailWithState: FCWithChildren = () => { </ContentCard> </Grid> </Grid> - <Grid container spacing={2} mt={0}> <Grid item xs={12} md={4}> <ContentCard title="Mixnode Stats"> @@ -144,7 +139,6 @@ const PageMixnodeDetailWithState: FCWithChildren = () => { )} </Grid> </Grid> - <Grid container spacing={2} mt={0}> <Grid item xs={12} md={4}> {status && ( diff --git a/explorer/src/pages/Mixnodes/index.tsx b/explorer/src/pages/Mixnodes/index.tsx index 5aaa02640f..d42b99efef 100644 --- a/explorer/src/pages/Mixnodes/index.tsx +++ b/explorer/src/pages/Mixnodes/index.tsx @@ -85,6 +85,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'mix_id', headerName: 'Mix ID', + disableColumnMenu: true, renderHeader: () => <CustomColumnHeading headingTitle="Mix ID" />, headerClassName: 'MuiDataGrid-header-override', width: 100, @@ -103,6 +104,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'identity_key', headerName: 'Identity Key', + disableColumnMenu: true, renderHeader: () => <CustomColumnHeading headingTitle="Identity Key" />, headerClassName: 'MuiDataGrid-header-override', width: 170, @@ -128,6 +130,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'bond', headerName: 'Stake', + disableColumnMenu: true, renderHeader: () => <CustomColumnHeading headingTitle="Stake" />, type: 'number', headerClassName: 'MuiDataGrid-header-override', @@ -146,6 +149,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'stake_saturation', headerName: 'Stake Saturation', + disableColumnMenu: true, renderHeader: () => ( <CustomColumnHeading headingTitle="Stake Saturation" @@ -164,13 +168,14 @@ export const PageMixnodes: FCWithChildren = () => { component={RRDLink} to={`/network-components/mixnode/${params.row.mix_id}`} > - {`${params.value.toFixed(2)} %`} + {`${params.value} %`} </MuiLink> ), }, { field: 'pledge_amount', headerName: 'Bond', + disableColumnMenu: true, width: 175, headerClassName: 'MuiDataGrid-header-override', renderHeader: () => <CustomColumnHeading headingTitle="Bond" tooltipInfo="Node operator's share of stake." />, @@ -189,6 +194,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'profit_percentage', headerName: 'Profit Margin', + disableColumnMenu: true, renderHeader: () => ( <CustomColumnHeading headingTitle="Profit Margin" @@ -220,6 +226,7 @@ export const PageMixnodes: FCWithChildren = () => { headerClassName: 'MuiDataGrid-header-override', width: 170, headerAlign: 'left', + disableColumnMenu: true, renderCell: (params: GridRenderCellParams) => ( <MuiLink sx={{ ...getCellStyles(theme, params.row), textAlign: 'left' }} @@ -231,12 +238,13 @@ export const PageMixnodes: FCWithChildren = () => { ), }, { - field: 'avg_uptime', + field: 'node_performance', headerName: 'Routing Score', + disableColumnMenu: true, renderHeader: () => ( <CustomColumnHeading headingTitle="Routing Score" - tooltipInfo="Node’s routing score is relative to that of the network. Each time a node is tested, the test packets have to go through the full path of the network (a gateway + 3 nodes). If a node in the path drop packets it will affect the score of other nodes in the test." + tooltipInfo="Mixnode's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test." /> ), headerClassName: 'MuiDataGrid-header-override', @@ -255,6 +263,7 @@ export const PageMixnodes: FCWithChildren = () => { { field: 'owner', headerName: 'Owner', + disableColumnMenu: true, renderHeader: () => <CustomColumnHeading headingTitle="Owner" />, width: 120, headerAlign: 'left', @@ -274,6 +283,7 @@ export const PageMixnodes: FCWithChildren = () => { field: 'location', headerName: 'Location', renderHeader: () => <CustomColumnHeading headingTitle="Location" />, + disableColumnMenu: true, width: 120, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', @@ -303,6 +313,7 @@ export const PageMixnodes: FCWithChildren = () => { field: 'host', headerName: 'Host', renderHeader: () => <CustomColumnHeading headingTitle="Host" />, + disableColumnMenu: true, headerClassName: 'MuiDataGrid-header-override', width: 130, headerAlign: 'left', @@ -321,7 +332,6 @@ export const PageMixnodes: FCWithChildren = () => { const handlePageSize = (event: SelectChangeEvent<string>) => { setPageSize(event.target.value); }; - return ( <> <Title text="Mixnodes" /> diff --git a/explorer/src/pages/Overview/index.tsx b/explorer/src/pages/Overview/index.tsx index 7213035542..acf03ad77a 100644 --- a/explorer/src/pages/Overview/index.tsx +++ b/explorer/src/pages/Overview/index.tsx @@ -3,6 +3,7 @@ import { Box, Grid, Link, Typography } from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useTheme } from '@mui/material/styles'; import { useNavigate } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; import { WorldMap } from '../../components/WorldMap'; import { useMainContext } from '../../context/main'; import { formatNumber } from '../../utils'; @@ -18,7 +19,7 @@ import { Icons } from '../../components/Icons'; export const PageOverview: FCWithChildren = () => { const theme = useTheme(); const navigate = useNavigate(); - const { summaryOverview, gateways, validators, block, countryData } = useMainContext(); + const { summaryOverview, gateways, validators, block, countryData, serviceProviders } = useMainContext(); return ( <Box component="main" sx={{ flexGrow: 1 }}> <Grid> @@ -61,7 +62,7 @@ export const PageOverview: FCWithChildren = () => { </> )} {gateways && ( - <Grid item xs={12} md={6}> + <Grid item xs={12} md={4}> <StatsCard onClick={() => navigate('/network-components/gateways')} title="Gateways" @@ -71,9 +72,19 @@ export const PageOverview: FCWithChildren = () => { /> </Grid> )} - + {serviceProviders && ( + <Grid item xs={12} md={4}> + <StatsCard + onClick={() => navigate('/network-components/service-providers')} + title="Service providers" + icon={<PeopleAlt />} + count={serviceProviders.data?.items.length} + errorMsg={summaryOverview?.error} + /> + </Grid> + )} {validators && ( - <Grid item xs={12} md={6}> + <Grid item xs={12} md={4}> <StatsCard onClick={() => window.open(`${BIG_DIPPER}/validators`)} title="Validators" diff --git a/explorer/src/pages/ServiceProviders/index.tsx b/explorer/src/pages/ServiceProviders/index.tsx new file mode 100644 index 0000000000..63bdf956ea --- /dev/null +++ b/explorer/src/pages/ServiceProviders/index.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react'; +import { Card, FormControl, Grid, MenuItem, Select, SelectChangeEvent } from '@mui/material'; +import { Api } from '../../api'; +import { TableToolbar } from '../../components/TableToolbar'; +import { Title } from '../../components/Title'; +import { UniversalDataGrid } from '../../components/Universal-DataGrid'; +import { DirectoryService } from '../../typeDefs/explorer-api'; + +const columns = [ + { + headerName: 'Client ID', + field: 'address', + disableColumnMenu: true, + flex: 1, + }, +]; + +const SupportedApps = () => { + const [selected, setSelected] = useState<string>(''); + const handleChange = (e: SelectChangeEvent) => setSelected(e.target.value); + return ( + <FormControl size="small"> + <Select value={selected} onChange={handleChange} displayEmpty sx={{ mr: 2 }}> + <MenuItem value="">Supported Apps</MenuItem> + <MenuItem sx={{ opacity: 1 }}>Keybase</MenuItem> + <MenuItem>Telegram</MenuItem> + <MenuItem>Electrum</MenuItem> + </Select> + </FormControl> + ); +}; + +export const ServiceProviders = () => { + const [serviceProviders, setServiceProviders] = useState<DirectoryService>(); + const [pageSize, setPageSize] = React.useState('10'); + + const getServiceproviders = async () => { + const [data] = await Api.fetchServiceProviders(); + setServiceProviders(data); + }; + + useEffect(() => { + getServiceproviders(); + }, []); + + const handleOnPageSizeChange = (event: SelectChangeEvent<string>) => { + setPageSize(event.target.value); + }; + + if (!serviceProviders) return null; + + return ( + <> + <Title text="Service Providers" /> + <Grid container> + <Grid item xs={12}> + <Card + sx={{ + padding: 2, + }} + > + <TableToolbar + onChangePageSize={handleOnPageSizeChange} + pageSize={pageSize} + childrenBefore={<SupportedApps />} + /> + <UniversalDataGrid pagination rows={serviceProviders.items} columns={columns} pageSize={pageSize} /> + </Card> + </Grid> + </Grid> + </> + ); +}; diff --git a/explorer/src/routes/network-components.tsx b/explorer/src/routes/network-components.tsx index 9bd51dbc33..266a75aa2f 100644 --- a/explorer/src/routes/network-components.tsx +++ b/explorer/src/routes/network-components.tsx @@ -5,6 +5,7 @@ import { PageGateways } from '../pages/Gateways'; import { PageGatewayDetail } from '../pages/GatewayDetail'; import { PageMixnodeDetail } from '../pages/MixnodeDetail'; import { PageMixnodes } from '../pages/Mixnodes'; +import { ServiceProviders } from '../pages/ServiceProviders'; const ValidatorRoute: FCWithChildren = () => { const navigate = useNavigate(); @@ -21,6 +22,6 @@ export const NetworkComponentsRoutes: FCWithChildren = () => ( <Route path="gateways" element={<PageGateways />} /> <Route path="gateway/:id" element={<PageGatewayDetail />} /> <Route path="validators" element={<ValidatorRoute />} /> - <Route path="gateways/:id" element={<h1> Specific Gateways ID</h1>} /> + <Route path="service-providers" element={<ServiceProviders />} /> </ReactRouterRoutes> ); diff --git a/explorer/src/styles.css b/explorer/src/styles.css index e06c314f6b..818211ccea 100644 --- a/explorer/src/styles.css +++ b/explorer/src/styles.css @@ -4,31 +4,27 @@ the theme declaration in index.tsx or the style prop in <DataGrid /> */ -.MuiDataGrid-sortIcon, .MuiDataGrid-menuIcon, .MuiDataGrid-iconButtonContainer{ - display: none !important; -} - .MuiDataGrid-columnSeparator { - visibility: hidden; + visibility: hidden; } /* TODO - this should be managed somehow in MUI DataGrid but documentation doesnt offer a way to do it. Possibly only included in Data Grid Pro package */ .MuiDataGrid-header-override { - height: 55px; - min-height: 55px; + height: 55px; + min-height: 55px; } /* Again, no offered way to add sx to this specific div to kill the padding which puts it out of sync with other (sx styled) cells */ div div.MuiDataGrid-root .MuiDataGrid-columnHeaderTitleContainer { - padding-left: 0; + padding-left: 0; } @media screen and (max-width: 900px) { - .MuiDrawer-paperAnchorLeft { - min-width: 100vw; - margin-top: 58px; - } + .MuiDrawer-paperAnchorLeft { + min-width: 100vw; + margin-top: 58px; + } } diff --git a/explorer/src/typeDefs/explorer-api.ts b/explorer/src/typeDefs/explorer-api.ts index e430427aff..6f1fe6a891 100644 --- a/explorer/src/typeDefs/explorer-api.ts +++ b/explorer/src/typeDefs/explorer-api.ts @@ -84,6 +84,7 @@ export interface MixNodeResponseItem { }; mix_node: MixNode; avg_uptime: number; + node_performance: NodePerformance; stake_saturation: number; uncapped_saturation: number; operating_cost: Amount; @@ -236,3 +237,16 @@ export type MixNodeEconomicDynamicsStatsResponse = { estimated_delegators_reward: number; current_interval_uptime: number; }; + +export type DirectoryServiceProvider = { + id: string; + description: string; + address: string; + gateway: string; +}; + +export type DirectoryService = { + id: string; + descrtiption: string; + items: DirectoryServiceProvider[]; +}; diff --git a/explorer/src/utils/index.ts b/explorer/src/utils/index.ts index 1065c000ae..4f922d870d 100644 --- a/explorer/src/utils/index.ts +++ b/explorer/src/utils/index.ts @@ -1,7 +1,9 @@ /* eslint-disable camelcase */ import { MutableRefObject } from 'react'; +import { Theme } from '@mui/material/styles'; import { registerLocale, getName } from 'i18n-iso-countries'; import { CountryData } from '../typeDefs/explorer-api'; +import { EconomicsRowsType } from '../components/MixNodes/Economics/types'; registerLocale(require('i18n-iso-countries/langs/en.json')); @@ -53,3 +55,29 @@ export const splice = (start: number, deleteCount: number, address?: string): st * @returns A stringified integer */ export const toPercentIntegerString = (value: string) => Math.round(Number(value) * 100).toString(); + +export const textColour = (value: EconomicsRowsType, field: string, theme: Theme) => { + const progressBarValue = value?.progressBarValue || 0; + const fieldValue = value.value; + + if (progressBarValue > 100) { + return theme.palette.warning.main; + } + if (field === 'selectionChance') { + // TODO: when v2 will be deployed, remove cases: VeryHigh, Moderate and VeryLow + switch (fieldValue) { + case 'High': + case 'VeryHigh': + return theme.palette.nym.networkExplorer.selectionChance.overModerate; + case 'Good': + case 'Moderate': + return theme.palette.nym.networkExplorer.selectionChance.moderate; + case 'Low': + case 'VeryLow': + return theme.palette.nym.networkExplorer.selectionChance.underModerate; + default: + return theme.palette.nym.wallet.fee; + } + } + return theme.palette.nym.wallet.fee; +}; diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 356721a58b..5f4737ff52 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-gateway" -version = "1.1.11" +version = "1.1.12" authors = [ "Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>", @@ -16,7 +16,7 @@ rust-version = "1.56" [dependencies] anyhow = "1.0.53" -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } bip39 = "1.0.1" bs58 = "0.4.0" clap = { version = "4.0", features = ["cargo", "derive"] } diff --git a/mixnode/Cargo.toml b/mixnode/Cargo.toml index 2e4341c37a..343aab2622 100644 --- a/mixnode/Cargo.toml +++ b/mixnode/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-mixnode" -version = "1.1.12" +version = "1.1.13" authors = [ "Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>", @@ -45,7 +45,7 @@ mixnet-client = { path="../common/client-libs/mixnet-client" } mixnode-common = { path="../common/mixnode-common" } nym-nonexhaustive-delayqueue = { path="../common/nonexhaustive-delayqueue" } nym-sphinx = { path="../common/nymsphinx" } -nym-pemstore = { path = "../common/pemstore", version = "0.1.0" } +nym-pemstore = { path = "../common/pemstore", version = "0.2.0" } nym-task = { path = "../common/task" } nym-types = { path = "../common/types" } nym-topology = { path="../common/topology" } diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 4a1003bcd2..054eb6cd26 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-api" -version = "1.1.12" +version = "1.1.13" authors = [ "Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>", @@ -15,7 +15,7 @@ rust-version = "1.56" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = "0.1.52" +async-trait = { workspace = true } bs58 = {version = "0.4.0" } bip39 = "1" cfg-if = "1.0" diff --git a/nym-api/src/network_monitor/monitor/mod.rs b/nym-api/src/network_monitor/monitor/mod.rs index 5fa7ef0793..3dc98ccb9d 100644 --- a/nym-api/src/network_monitor/monitor/mod.rs +++ b/nym-api/src/network_monitor/monitor/mod.rs @@ -88,10 +88,7 @@ impl Monitor { ) .await { - error!( - "Failed to submit monitor run information to the database - {}", - err - ); + error!("Failed to submit monitor run information to the database - {err}",); // TODO: slightly more graceful shutdown here process::exit(1); @@ -124,12 +121,8 @@ impl Monitor { for route in routes { let mut packet_preparer = self.packet_preparer.clone(); let route = route.clone(); - let route_test_packets = self.route_test_packets; - let gateway_packets = tokio::spawn(async move { - packet_preparer.prepare_test_route_viability_packets(&route, route_test_packets) - }) - .await - .unwrap(); + let gateway_packets = packet_preparer + .prepare_test_route_viability_packets(&route, self.route_test_packets); packets.push(gateway_packets); } diff --git a/nym-connect/desktop/CHANGELOG.md b/nym-connect/desktop/CHANGELOG.md index 4bfd4a39e8..4510836e7e 100644 --- a/nym-connect/desktop/CHANGELOG.md +++ b/nym-connect/desktop/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [v1.1.12] (2023-03-07) + +- NymConnect - Update display for selected Service Provider ([#3116]) + +[#3116]: https://github.com/nymtech/nym/issues/3116 + ## [v1.1.11] (2023-02-28) - NC - add the option to manually select and use a specific Service Provider ([#2953]) diff --git a/nym-connect/desktop/package.json b/nym-connect/desktop/package.json index f1e5bef205..032593ab66 100644 --- a/nym-connect/desktop/package.json +++ b/nym-connect/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@nym/nym-connect", - "version": "1.1.11", + "version": "1.1.12", "main": "index.js", "license": "MIT", "scripts": { diff --git a/nym-connect/desktop/src-tauri/Cargo.toml b/nym-connect/desktop/src-tauri/Cargo.toml index 7b578e1ca6..c7e509b38d 100644 --- a/nym-connect/desktop/src-tauri/Cargo.toml +++ b/nym-connect/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-connect" -version = "1.1.11" +version = "1.1.12" description = "nym-connect" authors = ["Nym Technologies SA"] license = "" diff --git a/nym-connect/desktop/src-tauri/tauri.conf.json b/nym-connect/desktop/src-tauri/tauri.conf.json index 8a52a95e2d..854e729827 100644 --- a/nym-connect/desktop/src-tauri/tauri.conf.json +++ b/nym-connect/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "package": { "productName": "nym-connect", - "version": "1.1.11" + "version": "1.1.12" }, "build": { "distDir": "../dist", diff --git a/nym-connect/desktop/src/pages/menu/settings/ServiceProviderSettings.tsx b/nym-connect/desktop/src/pages/menu/settings/ServiceProviderSettings.tsx index 8f18caaacd..0d0a0cdd20 100644 --- a/nym-connect/desktop/src/pages/menu/settings/ServiceProviderSettings.tsx +++ b/nym-connect/desktop/src/pages/menu/settings/ServiceProviderSettings.tsx @@ -30,7 +30,7 @@ export const ServiceProviderSettings = () => { if (match) return match.description; - return 'The service provider specified is not in our known list.'; + return 'N/A'; }; const validateInput = (value: string) => { @@ -86,7 +86,12 @@ export const ServiceProviderSettings = () => { )} </FormControl> {userDefinedSPAddress.address && userDefinedSPAddress.isActive && ( - <Typography sx={{ mt: 1 }}>{getSPDescription(userDefinedSPAddress.address)}</Typography> + <Box sx={{ mt: 2 }}> + <Typography variant="body2">Name of Service Provider</Typography> + <Typography variant="body2" sx={{ mt: 0.5 }} color="grey.400"> + {getSPDescription(userDefinedSPAddress.address)} + </Typography> + </Box> )} </Box> <AppVersion /> diff --git a/nym-connect/mobile/src-tauri/Cargo.lock b/nym-connect/mobile/src-tauri/Cargo.lock index 9881aaf323..7be88d72f7 100644 --- a/nym-connect/mobile/src-tauri/Cargo.lock +++ b/nym-connect/mobile/src-tauri/Cargo.lock @@ -3782,6 +3782,7 @@ dependencies = [ name = "nym-topology" version = "0.1.0" dependencies = [ + "async-trait", "bs58", "log", "nym-bin-common", diff --git a/nym-wallet/CHANGELOG.md b/nym-wallet/CHANGELOG.md index fa1141b782..882ae175ad 100644 --- a/nym-wallet/CHANGELOG.md +++ b/nym-wallet/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [v1.1.11] (2023-03-07) + +- Wallet: optional gas and memo fields ([#2222]) + +[#2222]: https://github.com/nymtech/nym/issues/2222 + ## [v1.1.10] (2023-02-20) - Wallet - Fix send address bug ([#3030]) diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index fc8720b750..6550d74ec0 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -2785,7 +2785,7 @@ dependencies = [ [[package]] name = "nym-bin-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "clap_complete", @@ -2811,7 +2811,7 @@ dependencies = [ [[package]] name = "nym-contracts-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bs58", "cosmwasm-std", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bs58", "cosmwasm-std", @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "nym-pemstore" -version = "0.1.0" +version = "0.2.0" dependencies = [ "pem", ] @@ -2893,7 +2893,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract" -version = "1.1.4" +version = "1.2.0-pre.0" dependencies = [ "cosmwasm-std", "cw-storage-plus", @@ -2910,7 +2910,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cosmwasm-std", "nym-contracts-common", @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "nym_wallet" -version = "1.1.10" +version = "1.1.11" dependencies = [ "aes-gcm", "argon2 0.3.4", diff --git a/nym-wallet/package.json b/nym-wallet/package.json index faac47b023..f44d127d96 100644 --- a/nym-wallet/package.json +++ b/nym-wallet/package.json @@ -1,6 +1,6 @@ { "name": "@nymproject/nym-wallet-app", - "version": "1.1.10", + "version": "1.1.11", "main": "index.js", "license": "MIT", "scripts": { diff --git a/nym-wallet/src-tauri/Cargo.toml b/nym-wallet/src-tauri/Cargo.toml index d482c768f3..8c89ab7428 100644 --- a/nym-wallet/src-tauri/Cargo.toml +++ b/nym-wallet/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym_wallet" -version = "1.1.10" +version = "1.1.11" description = "Nym Native Wallet" authors = ["Nym Technologies SA"] license = "" diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index a018e66a70..15f03fc481 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -143,6 +143,7 @@ fn main() { vesting::queries::vesting_start_time, simulate::admin::simulate_update_contract_settings, simulate::cosmos::simulate_send, + simulate::cosmos::get_custom_fees, simulate::mixnet::simulate_bond_gateway, simulate::mixnet::simulate_unbond_gateway, simulate::mixnet::simulate_bond_mixnode, diff --git a/nym-wallet/src-tauri/src/operations/simulate/cosmos.rs b/nym-wallet/src-tauri/src/operations/simulate/cosmos.rs index 3679a1d863..a83aaf618a 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/cosmos.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/cosmos.rs @@ -33,3 +33,13 @@ pub async fn simulate_send( let result = client.nyxd.simulate(vec![msg]).await?; guard.create_detailed_fee(result) } + +#[tauri::command] +pub async fn get_custom_fees( + fees_amount: DecCoin, + state: tauri::State<'_, WalletState>, +) -> Result<FeeDetails, BackendError> { + let guard = state.read().await; + let fee = guard.attempt_convert_to_fixed_fee(fees_amount.clone())?; + Ok(FeeDetails::new(Some(fees_amount), fee)) +} diff --git a/nym-wallet/src-tauri/src/state.rs b/nym-wallet/src-tauri/src/state.rs index ef7eaa1cb4..6e9474d3a4 100644 --- a/nym-wallet/src-tauri/src/state.rs +++ b/nym-wallet/src-tauri/src/state.rs @@ -88,6 +88,17 @@ pub(crate) struct WalletAccountIds { } impl WalletStateInner { + pub fn attempt_convert_to_fixed_fee(&self, coin: DecCoin) -> Result<Fee, BackendError> { + // first we have to convert the coin to its base denomination + let base_coin = self.attempt_convert_to_base_coin(coin)?; + + // then we get the gas price for the current network + let current_client = self.current_client()?; + let gas_price = current_client.nyxd.gas_price(); + + Ok(Fee::manual_with_gas_price(base_coin, gas_price.clone())) + } + // note that `Coin` is ALWAYS the base coin pub fn attempt_convert_to_base_coin(&self, coin: DecCoin) -> Result<Coin, BackendError> { let registered_coins = self diff --git a/nym-wallet/src-tauri/tauri.conf.json b/nym-wallet/src-tauri/tauri.conf.json index ad4430ecc3..607ecd1673 100644 --- a/nym-wallet/src-tauri/tauri.conf.json +++ b/nym-wallet/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "package": { "productName": "nym-wallet", - "version": "1.1.10" + "version": "1.1.11" }, "build": { "distDir": "../dist", diff --git a/nym-wallet/src/components/Modals/ModalListItem.tsx b/nym-wallet/src/components/Modals/ModalListItem.tsx index 7e5194a21c..916603e011 100644 --- a/nym-wallet/src/components/Modals/ModalListItem.tsx +++ b/nym-wallet/src/components/Modals/ModalListItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Stack, Typography, TypographyProps } from '@mui/material'; +import { Box, Stack, SxProps, Typography, TypographyProps } from '@mui/material'; import { ModalDivider } from './ModalDivider'; export const ModalListItem: FCWithChildren<{ @@ -9,14 +9,15 @@ export const ModalListItem: FCWithChildren<{ fontWeight?: TypographyProps['fontWeight']; light?: boolean; value?: React.ReactNode; -}> = ({ label, value, hidden, fontWeight, divider }) => ( + sxValue?: SxProps; +}> = ({ label, value, hidden, fontWeight, divider, sxValue }) => ( <Box sx={{ display: hidden ? 'none' : 'block' }}> <Stack direction="row" justifyContent="space-between"> <Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}> {label} </Typography> {value && ( - <Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}> + <Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14, ...sxValue }}> {value} </Typography> )} diff --git a/nym-wallet/src/components/Send/SendDetails.stories.tsx b/nym-wallet/src/components/Send/SendDetails.stories.tsx index 88e231a2b6..ced7ee44ce 100644 --- a/nym-wallet/src/components/Send/SendDetails.stories.tsx +++ b/nym-wallet/src/components/Send/SendDetails.stories.tsx @@ -35,6 +35,9 @@ export const SendInput = () => { onClose={() => {}} onAddressChange={() => {}} onAmountChange={() => {}} + onUserFeesChange={() => {}} + onMemoChange={() => {}} + setShowMore={() => {}} {...storybookStylesModal(theme)} /> ); diff --git a/nym-wallet/src/components/Send/SendDetailsModal.tsx b/nym-wallet/src/components/Send/SendDetailsModal.tsx index f2b7d13491..366bf7ba9c 100644 --- a/nym-wallet/src/components/Send/SendDetailsModal.tsx +++ b/nym-wallet/src/components/Send/SendDetailsModal.tsx @@ -16,6 +16,7 @@ export const SendDetailsModal = ({ onSend, sx, backdropProps, + memo, }: { fromAddress?: string; toAddress: string; @@ -27,6 +28,7 @@ export const SendDetailsModal = ({ onSend: (data: { val: DecCoin; to: string }) => void; sx?: SxProps; backdropProps?: object; + memo?: string; }) => ( <SimpleModal header="Send details" @@ -43,6 +45,20 @@ export const SendDetailsModal = ({ <ModalListItem label="To" value={toAddress} divider /> <ModalListItem label="Amount" value={`${amount?.amount} ${denom.toUpperCase()}`} divider /> <ModalFee fee={fee} divider isLoading={false} /> + {memo && ( + <ModalListItem + label="Memo" + value={memo} + sxValue={{ + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + overflowWrap: 'anywhere', + maxWidth: '300px', + }} + divider + /> + )} <ModalTotalAmount fee={fee} amount={amount?.amount} divider isLoading={false} /> </Stack> </SimpleModal> diff --git a/nym-wallet/src/components/Send/SendInputModal.tsx b/nym-wallet/src/components/Send/SendInputModal.tsx index a54541d040..ca4d865f4c 100644 --- a/nym-wallet/src/components/Send/SendInputModal.tsx +++ b/nym-wallet/src/components/Send/SendInputModal.tsx @@ -1,11 +1,15 @@ import React, { useEffect, useState } from 'react'; -import { Stack, TextField, Typography, SxProps } from '@mui/material'; +import { Stack, TextField, Typography, SxProps, FormControlLabel, Checkbox } from '@mui/material'; +import Big from 'big.js'; import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; -import { CurrencyDenom, DecCoin } from '@nymproject/types'; +import { CurrencyDenom, DecCoin, isValidRawCoin } from '@nymproject/types'; import { validateAmount } from 'src/utils'; import { SimpleModal } from '../Modals/SimpleModal'; import { ModalListItem } from '../Modals/ModalListItem'; +const maxUserFees = '10.0'; +const minUserFees = '0.000001'; // aka 1 unym + export const SendInputModal = ({ fromAddress, toAddress, @@ -19,6 +23,12 @@ export const SendInputModal = ({ onAddressChange, sx, backdropProps, + userFees, + memo, + onUserFeesChange, + onMemoChange, + showMore, + setShowMore, }: { fromAddress?: string; toAddress: string; @@ -26,24 +36,61 @@ export const SendInputModal = ({ balance?: string; denom?: CurrencyDenom; error?: string; + showMore?: boolean; + setShowMore: (show: boolean) => void; onNext: () => void; onClose: () => void; onAmountChange: (value: DecCoin) => void; onAddressChange: (value: string) => void; sx?: SxProps; backdropProps?: object; + userFees?: DecCoin; + memo?: string; + onUserFeesChange: (value: DecCoin) => void; + onMemoChange: (value: string) => void; }) => { const [isValid, setIsValid] = useState(false); + const [memoIsValid, setMemoIsValid] = useState(true); + const [feeAmountIsValid, setFeeAmountIsValid] = useState(true); const validate = async (value: DecCoin) => { const isValidAmount = await validateAmount(value.amount, '0'); setIsValid(isValidAmount); }; + const validateUserFees = (fees: DecCoin) => { + if (!isValidRawCoin(fees.amount) || !Number(fees.amount)) { + setFeeAmountIsValid(false); + return; + } + const f = Big(fees.amount); + if (f.gt(maxUserFees) || f.lt(minUserFees)) { + setFeeAmountIsValid(false); + return; + } + setFeeAmountIsValid(true); + }; + useEffect(() => { if (amount) validate(amount); }, []); + useEffect(() => { + if (memo && !/^(\w|\s)+$/.test(memo)) { + setMemoIsValid(false); + return; + } + setMemoIsValid(true); + }, [memo]); + + useEffect(() => { + if (userFees) { + validateUserFees(userFees); + } else { + setFeeAmountIsValid(true); + } + }, [userFees]); + return ( <SimpleModal header="Send" @@ -51,7 +98,7 @@ export const SendInputModal = ({ onClose={onClose} okLabel="Next" onOk={async () => onNext()} - okDisabled={!isValid} + okDisabled={!isValid || !memoIsValid || !feeAmountIsValid} sx={sx} backdropProps={backdropProps} > @@ -84,6 +131,35 @@ export const SendInputModal = ({ Est. fee for this transaction will be show on the next page </Typography> </Stack> + <FormControlLabel + control={<Checkbox onChange={() => setShowMore(!showMore)} checked={showMore} />} + label="More options" + sx={{ mt: 2 }} + /> + {showMore && ( + <Stack direction="column" gap={3} mt={2} mb={3}> + <CurrencyFormField + label="Fees" + onChanged={(v) => onUserFeesChange(v)} + initialValue={userFees?.amount} + fullWidth + /> + <TextField + name="memo" + label="Memo" + onChange={(e) => onMemoChange(e.target.value)} + value={memo} + error={!memoIsValid} + helperText={ + !memoIsValid + ? ' The text is invalid, only alphanumeric characters and white spaces are allowed' + : undefined + } + InputLabelProps={{ shrink: true }} + fullWidth + /> + </Stack> + )} </SimpleModal> ); }; diff --git a/nym-wallet/src/components/Send/SendModal.tsx b/nym-wallet/src/components/Send/SendModal.tsx index 5758b2ceae..650fa4124c 100644 --- a/nym-wallet/src/components/Send/SendModal.tsx +++ b/nym-wallet/src/components/Send/SendModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { DecCoin } from '@nymproject/types'; import { AppContext, urls } from 'src/context'; import { useGetFee } from 'src/hooks/useGetFee'; @@ -18,11 +18,28 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void const [modal, setModal] = useState<'send' | 'send details'>('send'); const [error, setError] = useState<string>(); const [sendError, setSendError] = useState(false); + const [gasError, setGasError] = useState<string>(); const [isLoading, setIsLoading] = useState(false); + const [userFees, setUserFees] = useState<DecCoin>(); + const [memo, setMemo] = useState<string>(); const [txDetails, setTxDetails] = useState<TTransactionDetails>(); + const [showMoreOptions, setShowMoreOptions] = useState(false); const { clientDetails, userBalance, network } = useContext(AppContext); - const { fee, getFee, feeError } = useGetFee(); + const { fee, getFee, feeError, setFeeManually } = useGetFee(); + + useEffect(() => { + if (userFees?.amount.length === 0) { + setUserFees(undefined); + } + }, [userFees]); + + useEffect(() => { + if (!showMoreOptions) { + setUserFees(undefined); + setMemo(undefined); + } + }, [showMoreOptions]); // removes any zero-width spaces and trailing white space const sanitizeAddress = (address: string) => address.replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); @@ -32,7 +49,11 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void setIsLoading(true); setError(undefined); try { - await getFee(simulateSend, { address: toAddress, amount }); + if (userFees) { + await setFeeManually(userFees); + } else { + await getFee(simulateSend, { address: toAddress, amount }); + } setModal('send details'); } catch (e) { setError(e as string); @@ -48,14 +69,18 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void setIsLoading(true); setError(undefined); try { - const txResponse = await send({ amount: val, address: to, memo: '', fee: fee?.fee }); + const txResponse = await send({ amount: val, address: to, memo: memo || '', fee: fee?.fee }); setTxDetails({ amount: `${amount?.amount} ${clientDetails?.display_mix_denom.toUpperCase()}`, txUrl: `${urls(network).blockExplorer}/transaction/${txResponse.tx_hash}`, }); } catch (e) { Console.error(e as string); - setSendError(true); + if (/Raw log: out of gas/.test(e as string)) { + setGasError('Out of gas, please increase the amount of fees'); + } else { + setSendError(true); + } } finally { setIsLoading(false); } @@ -65,6 +90,10 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void if (sendError) return <SendErrorModal onClose={onClose} error={feeError} />; + if (gasError) { + return <SendErrorModal onClose={onClose} error={gasError} />; + } + if (txDetails) return <SendSuccessModal txDetails={txDetails} onClose={onClose} />; if (modal === 'send details') @@ -78,6 +107,7 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void onPrev={() => setModal('send')} onSend={handleSend} denom={clientDetails?.display_mix_denom || 'nym'} + memo={memo} {...hasStorybookStyles} /> ); @@ -92,8 +122,14 @@ export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void onNext={handleOnNext} error={error} denom={clientDetails?.display_mix_denom} + userFees={userFees} + memo={memo} + showMore={showMoreOptions} onAmountChange={(value) => setAmount(value)} onAddressChange={(value) => setToAddress(sanitizeAddress(value))} + onUserFeesChange={(value) => setUserFees(value)} + onMemoChange={(value) => setMemo(value)} + setShowMore={setShowMoreOptions} {...hasStorybookStyles} /> ); diff --git a/nym-wallet/src/hooks/useGetFee.ts b/nym-wallet/src/hooks/useGetFee.ts index a38179c7f4..a563549182 100644 --- a/nym-wallet/src/hooks/useGetFee.ts +++ b/nym-wallet/src/hooks/useGetFee.ts @@ -1,6 +1,7 @@ -import { FeeDetails } from '@nymproject/types'; +import { DecCoin, FeeDetails } from '@nymproject/types'; import { useState } from 'react'; import { Console } from 'src/utils/console'; +import { getCustomFees } from '../requests'; export function useGetFee() { const [fee, setFee] = useState<FeeDetails>(); @@ -19,6 +20,18 @@ export function useGetFee() { setIsFeeLoading(false); } + async function setFeeManually(amount: DecCoin) { + try { + setIsFeeLoading(true); + const fees = await getCustomFees({ feesAmount: amount }); + setFee(fees); + } catch (e) { + Console.error(e); + setFeeError(e as string); + } + setIsFeeLoading(false); + } + const resetFeeState = () => { setFee(undefined); setIsFeeLoading(false); @@ -30,6 +43,7 @@ export function useGetFee() { isFeeLoading, feeError, getFee, + setFeeManually, resetFeeState, }; } diff --git a/nym-wallet/src/requests/simulate.ts b/nym-wallet/src/requests/simulate.ts index bee1644298..d5aeacc2eb 100644 --- a/nym-wallet/src/requests/simulate.ts +++ b/nym-wallet/src/requests/simulate.ts @@ -59,6 +59,9 @@ export const simulateWithdrawVestedCoins = async (args: any) => export const simulateSend = async ({ address, amount }: { address: string; amount: DecCoin }) => invokeWrapper<FeeDetails>('simulate_send', { address, amount }); +export const getCustomFees = async ({ feesAmount }: { feesAmount: DecCoin }) => + invokeWrapper<FeeDetails>('get_custom_fees', { feesAmount }); + export const simulateClaimOperatorReward = async () => invokeWrapper<FeeDetails>('simulate_claim_operator_reward'); export const simulateVestingClaimOperatorReward = async () => diff --git a/sdk/rust/nym-sdk/Cargo.toml b/sdk/rust/nym-sdk/Cargo.toml index 6c9027a70d..32f98097ca 100644 --- a/sdk/rust/nym-sdk/Cargo.toml +++ b/sdk/rust/nym-sdk/Cargo.toml @@ -13,6 +13,7 @@ gateway-requests = { path = "../../../gateway/gateway-requests" } nym-network-defaults = { path = "../../../common/network-defaults" } nym-sphinx = { path = "../../../common/nymsphinx" } nym-task = { path = "../../../common/task" } +nym-topology = { path = "../../../common/topology" } validator-client = { path = "../../../common/client-libs/validator-client", features = ["nyxd-client"] } futures = "0.3" diff --git a/sdk/rust/nym-sdk/examples/custom_topology_provider.rs b/sdk/rust/nym-sdk/examples/custom_topology_provider.rs new file mode 100644 index 0000000000..59da44e711 --- /dev/null +++ b/sdk/rust/nym-sdk/examples/custom_topology_provider.rs @@ -0,0 +1,78 @@ +// Copyright 2023 - Nym Technologies SA <contact@nymtech.net> +// SPDX-License-Identifier: Apache-2.0 + +use nym_sdk::mixnet; +use nym_topology::provider_trait::{async_trait, TopologyProvider}; +use nym_topology::{nym_topology_from_detailed, NymTopology}; +use url::Url; + +struct MyTopologyProvider { + validator_client: validator_client::client::NymApiClient, +} + +impl MyTopologyProvider { + fn new(nym_api_url: Url) -> MyTopologyProvider { + MyTopologyProvider { + validator_client: validator_client::client::NymApiClient::new(nym_api_url), + } + } + + async fn get_topology(&self) -> NymTopology { + let mixnodes = self + .validator_client + .get_cached_active_mixnodes() + .await + .unwrap(); + + // in our topology provider only use mixnodes that have mix_id divisible by 3 + // and have more than 100k nym (i.e. 100'000'000'000 unym) in stake + // why? because this is just an example to showcase arbitrary uses and capabilities of this trait + let filtered_mixnodes = mixnodes + .into_iter() + .filter(|mix| { + mix.mix_id() % 3 == 0 && mix.total_stake() > "100000000000".parse().unwrap() + }) + .collect::<Vec<_>>(); + + let gateways = self.validator_client.get_cached_gateways().await.unwrap(); + + nym_topology_from_detailed(filtered_mixnodes, gateways) + } +} + +#[async_trait] +impl TopologyProvider for MyTopologyProvider { + // this will be manually refreshed on a timer specified inside mixnet client config + async fn get_new_topology(&mut self) -> Option<NymTopology> { + Some(self.get_topology().await) + } +} + +#[tokio::main] +async fn main() { + nym_bin_common::logging::setup_logging(); + + let nym_api = "https://validator.nymtech.net/api/".parse().unwrap(); + let my_topology_provider = MyTopologyProvider::new(nym_api); + + // Passing no config makes the client fire up an ephemeral session and figure shit out on its own + let mut client = mixnet::MixnetClientBuilder::new() + .custom_topology_provider(Box::new(my_topology_provider)) + .build::<mixnet::EmptyReplyStorage>() + .await + .unwrap() + .connect_to_mixnet() + .await + .unwrap(); + + let our_address = client.nym_address(); + println!("Our client nym address is: {our_address}"); + + // Send a message through the mixnet to ourselves + client.send_str(*our_address, "hello there").await; + + println!("Waiting for message (ctrl-c to exit)"); + client + .on_messages(|msg| println!("Received: {}", String::from_utf8_lossy(&msg.message))) + .await; +} diff --git a/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs b/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs new file mode 100644 index 0000000000..0c951c736b --- /dev/null +++ b/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs @@ -0,0 +1,90 @@ +// Copyright 2023 - Nym Technologies SA <contact@nymtech.net> +// SPDX-License-Identifier: Apache-2.0 + +use nym_sdk::mixnet; +use nym_topology::mix::Layer; +use nym_topology::{mix, NymTopology}; +use std::collections::HashMap; + +#[tokio::main] +async fn main() { + nym_bin_common::logging::setup_logging(); + + // Passing no config makes the client fire up an ephemeral session and figure shit out on its own + let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); + let starting_topology = client.read_current_topology().await.unwrap(); + + // but we don't like our default topology, we want to use only those very specific, hardcoded, nodes: + let mut mixnodes = HashMap::new(); + mixnodes.insert( + 1, + vec![mix::Node { + mix_id: 63, + owner: "n1k52k5n45cqt5qpjh8tcwmgqm0wkt355yy0g5vu".to_string(), + host: "172.105.92.48".parse().unwrap(), + mix_host: "172.105.92.48:1789".parse().unwrap(), + identity_key: "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK" + .parse() + .unwrap(), + sphinx_key: "CBmYewWf43iarBq349KhbfYMc9ys2ebXWd4Vp4CLQ5Rq" + .parse() + .unwrap(), + layer: Layer::One, + version: "1.1.0".to_string(), + }], + ); + mixnodes.insert( + 2, + vec![mix::Node { + mix_id: 23, + owner: "n1fzv4jc7fanl9s0qj02ge2ezk3kts545kjtek47".to_string(), + host: "178.79.143.65".parse().unwrap(), + mix_host: "178.79.143.65:1789".parse().unwrap(), + identity_key: "4Yr4qmEHd9sgsuQ83191FR2hD88RfsbMmB4tzhhZWriz" + .parse() + .unwrap(), + sphinx_key: "8ndjk5oZ6HxUZNScLJJ7hk39XtUqGexdKgW7hSX6kpWG" + .parse() + .unwrap(), + layer: Layer::Two, + version: "1.1.0".to_string(), + }], + ); + mixnodes.insert( + 3, + vec![mix::Node { + mix_id: 66, + owner: "n1ae2pjd7q9p0dea65pqkvcm4x9s264v4fktpyru".to_string(), + host: "139.162.247.97".parse().unwrap(), + mix_host: "139.162.247.97:1789".parse().unwrap(), + identity_key: "66UngapebhJRni3Nj52EW1qcNsWYiuonjkWJzHFsmyYY" + .parse() + .unwrap(), + sphinx_key: "7KyZh8Z8KxuVunqytAJ2eXFuZkCS7BLTZSzujHJZsGa2" + .parse() + .unwrap(), + layer: Layer::Three, + version: "1.1.0".to_string(), + }], + ); + + // but we like the available gateways, so keep using them! + // (we like them because the author of this example is too lazy to use the same hardcoded gateway + // during client initialisation to make sure we are able to send to ourselves : ) ) + let custom_topology = NymTopology::new(mixnodes, starting_topology.gateways().to_vec()); + + client.manually_overwrite_topology(custom_topology).await; + + // and everything we send now should only ever go via those nodes + + let our_address = client.nym_address(); + println!("Our client nym address is: {our_address}"); + + // Send a message through the mixnet to ourselves + client.send_str(*our_address, "hello there").await; + + println!("Waiting for message (ctrl-c to exit)"); + client + .on_messages(|msg| println!("Received: {}", String::from_utf8_lossy(&msg.message))) + .await; +} diff --git a/sdk/rust/nym-sdk/src/mixnet.rs b/sdk/rust/nym-sdk/src/mixnet.rs index 5dafb6e94d..c2a6c42108 100644 --- a/sdk/rust/nym-sdk/src/mixnet.rs +++ b/sdk/rust/nym-sdk/src/mixnet.rs @@ -52,4 +52,5 @@ pub use nym_sphinx::{ addressing::clients::{ClientIdentity, Recipient}, receiver::ReconstructedMessage, }; +pub use nym_topology::{provider_trait::TopologyProvider, NymTopology}; pub use paths::{GatewayKeyMode, KeyMode, StoragePaths}; diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index 36b51a0428..7b754d8af2 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -23,6 +23,8 @@ use nym_task::{ }; use futures::StreamExt; +use nym_topology::provider_trait::TopologyProvider; +use nym_topology::NymTopology; use validator_client::nyxd::SigningNyxdClient; use crate::{Error, Result}; @@ -38,6 +40,7 @@ pub struct MixnetClientBuilder { storage_paths: Option<StoragePaths>, keys: Option<Keys>, gateway_config: Option<GatewayEndpointConfig>, + custom_topology_provider: Option<Box<dyn TopologyProvider>>, } impl MixnetClientBuilder { @@ -70,6 +73,15 @@ impl MixnetClientBuilder { self } + #[must_use] + pub fn custom_topology_provider( + mut self, + topology_provider: Box<dyn TopologyProvider>, + ) -> Self { + self.custom_topology_provider = Some(topology_provider); + self + } + /// Construct a [`DisconnectedMixnetClient`] from the setup specified. pub async fn build<B>(self) -> Result<DisconnectedMixnetClient<B>> where @@ -79,7 +91,12 @@ impl MixnetClientBuilder { let config = self.config.unwrap_or_default(); let storage_paths = self.storage_paths; - let mut client = DisconnectedMixnetClient::new(Some(config), storage_paths).await?; + let mut client = DisconnectedMixnetClient::new( + Some(config), + storage_paths, + self.custom_topology_provider, + ) + .await?; if let Some(keys) = self.keys { client.set_keys(keys); @@ -118,6 +135,9 @@ where /// The storage backend for reply-SURBs reply_storage_backend: B, + + /// Alternative provider of network topology used for constructing sphinx packets. + custom_topology_provider: Option<Box<dyn TopologyProvider>>, } impl<B> DisconnectedMixnetClient<B> @@ -133,6 +153,7 @@ where async fn new( config: Option<Config>, paths: Option<StoragePaths>, + custom_topology_provider: Option<Box<dyn TopologyProvider>>, ) -> Result<DisconnectedMixnetClient<B>> where <B as ReplyStorageBackend>::StorageError: Send + Sync, @@ -189,6 +210,7 @@ where storage_paths: paths, state: BuilderState::New, reply_storage_backend, + custom_topology_provider, }) } @@ -377,7 +399,7 @@ where // TODO: we currently don't support having a bandwidth controller let bandwidth_controller = None; - let base_builder: BaseClientBuilder<'_, _, SigningNyxdClient> = BaseClientBuilder::new( + let mut base_builder: BaseClientBuilder<'_, _, SigningNyxdClient> = BaseClientBuilder::new( &gateway_endpoint_config, &self.config.debug_config, self.key_manager.clone(), @@ -387,6 +409,10 @@ where self.config.nym_api_endpoints.clone(), ); + if let Some(topology_provider) = self.custom_topology_provider { + base_builder = base_builder.with_topology_provider(topology_provider); + } + let mut started_client = base_builder.start_base().await?; let client_input = started_client.client_input.register_producer(); let mut client_output = started_client.client_output.register_consumer(); @@ -502,7 +528,7 @@ impl MixnetClient { } /// Get a shallow clone of [`ConnectionCommandSender`]. This is useful if you want to e.g - /// explictly close a transmission lane that is still sending data even though it should + /// explicitly close a transmission lane that is still sending data even though it should /// cancel. pub fn connection_command_sender(&self) -> ConnectionCommandSender { self.client_input.connection_command_sender.clone() @@ -514,6 +540,25 @@ impl MixnetClient { self.client_state.shared_lane_queue_lengths.clone() } + /// Change the network topology used by this client for constructing sphinx packets into the + /// provided one. + pub async fn manually_overwrite_topology(&self, new_topology: NymTopology) { + self.client_state + .topology_accessor + .manually_change_topology(new_topology) + .await + } + + /// Gets the value of the currently used network topology. + pub async fn read_current_topology(&self) -> Option<NymTopology> { + self.client_state.topology_accessor.current_topology().await + } + + /// Restore default topology refreshing behaviour of this client. + pub fn restore_automatic_topology_refreshing(&self) { + self.client_state.topology_accessor.release_manual_control() + } + /// Sends stringy data to the supplied Nym address /// /// # Example diff --git a/service-providers/network-requester/Cargo.toml b/service-providers/network-requester/Cargo.toml index 615d17b9c4..6a779787cd 100644 --- a/service-providers/network-requester/Cargo.toml +++ b/service-providers/network-requester/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-network-requester" -version = "1.1.11" +version = "1.1.12" authors.workspace = true edition.workspace = true rust-version = "1.65" @@ -11,7 +11,7 @@ rust-version = "1.65" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = { version = "0.1.51" } +async-trait = { workspace = true } clap = {version = "4.0", features = ["cargo", "derive"]} dirs = "4.0" futures = "0.3.24" diff --git a/service-providers/network-statistics/Cargo.toml b/service-providers/network-statistics/Cargo.toml index 60bdaff297..929c8fda8e 100644 --- a/service-providers/network-statistics/Cargo.toml +++ b/service-providers/network-statistics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-network-statistics" -version = "1.1.11" +version = "1.1.12" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index 18e8c0ef35..cf62275946 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-cli" -version = "1.1.11" +version = "1.1.12" authors.workspace = true edition = "2021"