Compare commits

...

49 Commits

Author SHA1 Message Date
Bogdan-Ștefan Neacşu 697d55248d Simplify pub key translatations 2024-05-23 13:31:28 +00:00
Bogdan-Ștefan Neacşu 570cc36385 Merge remote-tracking branch 'origin/feature/refine_wg_config' into bogdan/testing 2024-05-23 13:24:06 +00:00
Bogdan-Ștefan Neacşu ee64762b87 Init wireguard on migration 2024-05-20 14:32:30 +00:00
Bogdan-Ștefan Neacşu f4528bb521 Remove upgrade code 2024-05-20 11:25:32 +00:00
Bogdan-Ștefan Neacşu f4630e0b8a Bump gateway version number 2024-05-20 10:59:56 +00:00
Bogdan-Ștefan Neacşu 65f948d012 Remove logs 2024-05-20 10:26:13 +00:00
Bogdan-Ștefan Neacşu d16a288b6d Propagate wireguard setup error message 2024-05-20 10:06:55 +00:00
Bogdan-Ștefan Neacşu 72c40d8576 Rename network to ip for wg gw 2024-05-20 09:48:06 +00:00
Bogdan-Ștefan Neacşu 34e1709b75 Generate and use own private key 2024-05-20 09:05:07 +00:00
Jon Häggblad a06ae48e2f Add methods to MixnetClient to sign messages (#4602) 2024-05-20 10:22:28 +02:00
Bogdan-Ștefan Neacşu 257df97e3a Use client reg 2024-05-17 12:48:46 +00:00
Bogdan-Ștefan Neacşu 870570d5c3 Move key paths to separate structure 2024-05-17 11:33:30 +00:00
Bogdan-Ștefan Neacşu 0000baa343 Activate wg feature in gw dep 2024-05-17 11:33:30 +00:00
Bogdan-Ștefan Neacşu 6a307d59b4 Create wg keypair 2024-05-17 11:33:30 +00:00
Bogdan-Ștefan Neacşu a4808635f9 Support nym node first 2024-05-17 11:33:30 +00:00
Bogdan-Ștefan Neacşu 29965782a2 Include wireguard in gw config 2024-05-17 11:33:30 +00:00
Fouad e5f41731ae Explorer NextJS Rebuild (#4534)
* bootstrap next app + add overview page

* fix AssetList type

* fix up nav stuff

* Refactor Nav component and add network components pages

* Refactor WorldMap component and update TelegramIcon, GitHubIcon, NymVpnIcon, DiscordIcon, and TwitterIcon components

* add service providers page

* mixnodes page

* delegations page + use material react table for all tables

* nodes map page

* Refactor StyledLink component and remove unnecessary console.log statements

* Refactor ESLint configuration, remove unused dependencies, and update component imports

* update deps

* Refactor imports and update dependencies

* fix dark mode

* build single mixnode page

* build single gateway page

* Refactor handleOnDelegate function to use useCallback in mixnodes page.tsx

* Add defaults for constants

---------

Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
2024-05-16 16:12:06 +01:00
Jędrzej Stuczyński a6fda391ae Feature/rewarder voucher issuance (#4548)
* retrieve ed25519 identities of issuers

* signature verification on issued credentials

* wip

* persisting information about verified deposits, any failures and foul plays

* clippy
2024-05-16 10:15:24 +02:00
Tommy Verrall 1ded24dcfc Merge pull request #4586 from nymtech/bugfix/nym-api-noop-nobanner
[bugfix] noop flag for nym-api for nymvisor compatibility
2024-05-15 14:07:30 +01:00
Tommy Verrall 8c42640853 Merge pull request #4591 from nymtech/jon/described-offsettime-parsing
Use rfc3339 for last_polled in described nym-api endpoint
2024-05-15 14:03:42 +01:00
Tommy Verrall 38aabc7983 Merge pull request #4593 from nymtech/feature/extend-max-freepass-validity
change maximum validity of issuable freepass
2024-05-15 13:40:49 +01:00
Tommy Verrall 4324845d29 Merge pull request #4596 from nymtech/update-contract-addr
update mainnet contract addresses
2024-05-15 10:07:08 +01:00
Jędrzej Stuczyński b9524a0f58 Chore/additional helpers (#4585)
* exposed additional helpers

* changes lost in rebasing
2024-05-14 18:33:56 +02:00
Sachin Kamath e7cd417894 update mainnet contract addresses 2024-05-14 21:30:31 +05:30
benedetta davico ca25db845a Merge pull request #4573 from nymtech/feature/axum-upgrade
upgraded axum and related deps to the most recent version
2024-05-14 17:38:15 +02:00
benedetta davico 64a0ce31a8 Merge branch 'develop' into feature/axum-upgrade 2024-05-14 17:21:44 +02:00
Jędrzej Stuczyński a8fe8d9bfb change maximum validity of issuable freepass 2024-05-14 14:35:53 +01:00
Jędrzej Stuczyński c346f145d1 backwards compatibility to fallback to default in case of failures 2024-05-14 10:15:16 +01:00
Jon Häggblad 45dd6f2632 Fix typo for StorageError::ConstraintUnique (#4592) 2024-05-13 17:48:58 +02:00
Jon Häggblad 22d28759ab Explicitly use rfc3339 for last_polled 2024-05-13 15:55:32 +02:00
Jędrzej Stuczyński 890d0f7440 fixed incorrect dependency path for 'axum-extra' 2024-05-13 14:18:33 +01:00
Jędrzej Stuczyński b342eb870e removed explicit drops 2024-05-13 11:31:34 +01:00
Jędrzej Stuczyński fc71e0cafd fixed tests 2024-05-13 11:31:33 +01:00
Tommy Verrall 1ecb57fda0 Merge pull request #4588 from nymtech/jon/explicitly-handle-sqlite-constraint-violation
Explicitly handle constraint unique violation when importing credential
2024-05-13 11:30:31 +01:00
Tommy Verrall 3c1ec82289 Merge pull request #4588 from nymtech/jon/explicitly-handle-sqlite-constraint-violation
Explicitly handle constraint unique violation when importing credential
2024-05-13 09:57:42 +01:00
Tommy Verrall 089e403d87 Merge pull request #4589 from nymtech/master
merge latest master to develop
2024-05-13 09:56:34 +01:00
Tommy Verrall dd2b477cda Merge pull request #4587 from nymtech/jon/lock-files
Update stale lock files
2024-05-13 09:54:52 +01:00
benedetta davico 0902539332 Merge pull request #4583 from nymtech/release/2024.4-nutella
Release 2024.4-nutella to master
2024-05-13 10:27:08 +02:00
Jon Häggblad 0783c532de Explicitly handle constraint unique violation when importing credential 2024-05-13 10:19:01 +02:00
Jon Häggblad 8817ae7805 Update stale lock files 2024-05-13 08:57:49 +02:00
Jędrzej Stuczyński 6a900c3c42 fixed linter issue in nyxd-scraper 2024-05-10 15:05:37 +01:00
Jędrzej Stuczyński 0ba80c9a86 moved startup 'Starting nym api...' message from stdout to stderr 2024-05-10 11:16:10 +01:00
Jędrzej Stuczyński d712b65ec5 [bugfix] noop flag for nym-api for nymvisor compatibility 2024-05-10 11:02:21 +01:00
Tommy Verrall 383b2c1351 Merge pull request #4552 from nymtech/jon/validator-client-rustls
Add rustls-tls to reqwest in validator-client
2024-05-08 16:51:46 +01:00
benedettadavico f0a4350e83 pruning
version bump and changelog updates
2024-05-08 11:01:50 +02:00
Jon Häggblad 6f4b00b5c2 Add default-features = true for tungstenite and non-wasm in client-core 2024-04-30 14:14:44 +02:00
Jon Häggblad d681ad20cf Keep default features off for tungstenite and wasm 2024-04-30 14:14:44 +02:00
Jon Häggblad 5818d58caf tweak feature args to tungstenite 2024-04-30 14:14:42 +02:00
Jon Häggblad da4eab8fdb Add rustls-tls to reqwest in validator-client 2024-04-30 14:14:00 +02:00
209 changed files with 54867 additions and 7636 deletions
+10
View File
@@ -4,6 +4,16 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2024.4-nutella] (2024-05-08)
- [fix] apply disable_poisson_rate from internal NR/IPR cfgs ([#4579])
- updating sign commands to include nym-node ([#4578])
- changed nym-node redirects from 308 'Permanent Redirect' to 303: 'See Other' ([#4572])
[#4579]: https://github.com/nymtech/nym/pull/4579
[#4578]: https://github.com/nymtech/nym/pull/4578
[#4572]: https://github.com/nymtech/nym/pull/4572
## [2024.3-eclipse] (2024-04-22)
- Initial release of the first iteration of the Nym Node
Generated
+1284 -3130
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -160,7 +160,8 @@ license = "Apache-2.0"
[workspace.dependencies]
anyhow = "1.0.71"
async-trait = "0.1.68"
axum = "0.6.20"
axum = "0.7.5"
axum-extra = "0.9.3"
base64 = "0.21.4"
bs58 = "0.5.0"
bip39 = { version = "2.0.0", features = ["zeroize"] }
@@ -171,15 +172,16 @@ dotenvy = "0.15.6"
futures = "0.3.28"
generic-array = "0.14.7"
getrandom = "0.2.10"
headers = "0.4.0"
humantime-serde = "1.1.1"
hyper = "0.14.27"
hyper = "1.3.1"
k256 = "0.13"
lazy_static = "1.4.0"
log = "0.4"
once_cell = "1.7.2"
parking_lot = "0.12.1"
rand = "0.8.5"
reqwest = { version = "0.11.22", default-features = false }
reqwest = { version = "0.12.4", default-features = false }
schemars = "0.8.1"
serde = "1.0.152"
serde_json = "1.0.91"
@@ -193,8 +195,8 @@ tokio-tungstenite = { version = "0.20.1" }
tracing = "0.1.37"
tungstenite = { version = "0.20.1", default-features = false }
ts-rs = "7.0.0"
utoipa = "3.5.0"
utoipa-swagger-ui = "3.1.5"
utoipa = "4.2.0"
utoipa-swagger-ui = "6.0.0"
url = "2.4"
zeroize = "1.6.0"
+11 -3
View File
@@ -25,7 +25,6 @@ si-scale = "0.2.2"
tap = "1.0.1"
thiserror = { workspace = true }
url = { workspace = true, features = ["serde"] }
tungstenite = { workspace = true, default-features = false }
tokio = { workspace = true, features = ["macros"] }
time = { workspace = true }
zeroize = { workspace = true }
@@ -74,8 +73,17 @@ workspace = true
features = ["time"]
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio-tungstenite]
version = "0.20.1"
features = ["rustls-tls-native-roots"]
workspace = true
features = ["rustls-tls-webpki-roots"]
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tungstenite]
workspace = true
default-features = true
features = ["rustls-tls-webpki-roots"]
[target."cfg(target_arch = \"wasm32\")".dependencies.tungstenite]
workspace = true
default-features = false
[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen-futures]
workspace = true
@@ -39,7 +39,7 @@ use log::{debug, error, info, warn};
use nym_bandwidth_controller::BandwidthController;
use nym_client_core_gateways_storage::{GatewayDetails, GatewaysDetailsStore};
use nym_credential_storage::storage::Storage as CredentialStorage;
use nym_crypto::asymmetric::encryption;
use nym_crypto::asymmetric::{encryption, identity};
use nym_gateway_client::{
AcknowledgementReceiver, GatewayClient, GatewayConfig, MixnetMessageReceiver, PacketRouter,
};
@@ -670,6 +670,7 @@ where
let self_address = Self::mix_address(&init_res);
let ack_key = init_res.client_keys.ack_key();
let encryption_keys = init_res.client_keys.encryption_keypair();
let identity_keys = init_res.client_keys.identity_keypair();
// the components are started in very specific order. Unless you know what you are doing,
// do not change that.
@@ -792,6 +793,7 @@ where
Ok(BaseClient {
address: self_address,
identity_keys,
client_input: ClientInputStatus::AwaitingProducer {
client_input: ClientInput {
connection_command_sender: client_connection_tx,
@@ -816,6 +818,7 @@ where
pub struct BaseClient {
pub address: Recipient,
pub identity_keys: Arc<identity::KeyPair>,
pub client_input: ClientInputStatus,
pub client_output: ClientOutputStatus,
pub client_state: ClientState,
+1 -4
View File
@@ -48,10 +48,7 @@ features = ["net", "sync", "time"]
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio-tungstenite]
workspace = true
# the choice of this particular tls feature was arbitrary;
# if you reckon a different one would be more appropriate, feel free to change it
# features = ["native-tls"]
features = ["rustls-tls-native-roots"]
features = ["rustls-tls-webpki-roots"]
# wasm-only dependencies
[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen]
@@ -24,7 +24,6 @@ nym-group-contract-common = { path = "../../cosmwasm-smart-contracts/group-contr
nym-service-provider-directory-common = { path = "../../cosmwasm-smart-contracts/service-provider-directory" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
nym-http-api-client = { path = "../../../common/http-api-client"}
thiserror = { workspace = true }
log = { workspace = true }
@@ -67,6 +66,14 @@ cosmwasm-std = { workspace = true }
workspace = true
features = ["tokio"]
[target."cfg(target_arch = \"wasm32\")".dependencies.reqwest]
workspace = true
features = ["json"]
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.reqwest]
workspace = true
features = ["json", "rustls-tls"]
[dev-dependencies]
bip39 = { workspace = true }
cosmrs = { workspace = true, features = ["bip32"] }
@@ -328,4 +328,8 @@ impl EpochState {
pub fn is_dealing_exchange(&self) -> bool {
matches!(self, EpochState::DealingExchange { .. })
}
pub fn is_waiting_initialisation(&self) -> bool {
matches!(self, EpochState::WaitingInitialisation)
}
}
+3
View File
@@ -18,4 +18,7 @@ pub enum StorageError {
#[error("No unused credential in database. You need to buy at least one")]
NoCredential,
#[error("Database unique constraint violation. Is the credential already imported?")]
ConstraintUnique,
}
@@ -69,9 +69,21 @@ impl Storage for PersistentStorage {
bandwidth_credential.credential_data,
bandwidth_credential.epoch_id,
)
.await?;
Ok(())
.await
.map_err(|err| {
// There is one error we want to handle specifically.
// Check if database_error is `SqliteError` with code 2067 which
// means UNIQUE constraint violation
if let Some(db_error) = err.as_database_error() {
if db_error.code().map_or(false, |code| code == "2067") {
StorageError::ConstraintUnique
} else {
err.into()
}
} else {
err.into()
}
})
}
async fn get_next_unspent_credential(
+5 -5
View File
@@ -8,11 +8,11 @@ use std::str::FromStr;
use thiserror::Error;
pub use nym_coconut::{
aggregate_signature_shares_and_verify, aggregate_verification_keys, blind_sign, hash_to_scalar,
keygen, prepare_blind_sign, prove_bandwidth_credential, verify_credential, Attribute, Base58,
BlindSignRequest, BlindedSerialNumber, BlindedSignature, Bytable, CoconutError, KeyPair,
Parameters, PrivateAttribute, PublicAttribute, SecretKey, Signature, SignatureShare,
VerificationKey, VerifyCredentialRequest,
aggregate_signature_shares, aggregate_signature_shares_and_verify, aggregate_verification_keys,
blind_sign, hash_to_scalar, keygen, prepare_blind_sign, prove_bandwidth_credential,
verify_credential, Attribute, Base58, BlindSignRequest, BlindedSerialNumber, BlindedSignature,
Bytable, CoconutError, KeyPair, Parameters, PrivateAttribute, PublicAttribute, SecretKey,
Signature, SignatureShare, VerificationKey, VerifyCredentialRequest,
};
pub const VOUCHER_INFO_TYPE: &str = "BandwidthVoucher";
@@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime, Time};
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const MAX_FREE_PASS_VALIDITY: Duration = Duration::WEEK; // 1 week
pub const DEFAULT_FREE_PASS_VALIDITY: Duration = Duration::WEEK; // 1 week
pub const MAX_FREE_PASS_VALIDITY: Duration = Duration::weeks(12); // 12 weeks
#[derive(Debug, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub struct FreePassIssuedData {
@@ -77,9 +78,9 @@ impl FreePassIssuanceData {
}
pub fn default_expiry_date() -> OffsetDateTime {
// set it to furthest midnight in the future such as it's no more than a week away,
// set it to the furthest midnight in the future such as it's no more than a week away,
// i.e. if it's currently for example 9:43 on 2nd March 2024, it will set it to 0:00 on 9th March 2024
(OffsetDateTime::now_utc() + MAX_FREE_PASS_VALIDITY).replace_time(Time::MIDNIGHT)
(OffsetDateTime::now_utc() + DEFAULT_FREE_PASS_VALIDITY).replace_time(Time::MIDNIGHT)
}
pub fn expiry_date_attribute(&self) -> &Attribute {
@@ -10,9 +10,9 @@ use crate::coconut::bandwidth::{
use crate::coconut::utils::scalar_serde_helper;
use crate::error::Error;
use nym_credentials_interface::{
aggregate_signature_shares_and_verify, hash_to_scalar, prepare_blind_sign, Attribute,
BlindedSerialNumber, BlindedSignature, Parameters, PrivateAttribute, PublicAttribute,
Signature, SignatureShare, VerificationKey,
aggregate_signature_shares, aggregate_signature_shares_and_verify, hash_to_scalar,
prepare_blind_sign, Attribute, BlindedSerialNumber, BlindedSignature, Parameters,
PrivateAttribute, PublicAttribute, Signature, SignatureShare, VerificationKey,
};
use nym_crypto::asymmetric::{encryption, identity};
use nym_validator_client::nym_api::EpochId;
@@ -266,6 +266,13 @@ impl IssuanceBandwidthCredential {
self.unblind_signature(validator_vk, &signing_data, blinded_signature)
}
pub fn unchecked_aggregate_signature_shares(
&self,
shares: &[SignatureShare],
) -> Result<Signature, Error> {
aggregate_signature_shares(shares).map_err(Error::SignatureAggregationError)
}
pub fn aggregate_signature_shares(
&self,
verification_key: &VerificationKey,
@@ -6,7 +6,7 @@ use crate::coconut::utils::scalar_serde_helper;
use crate::error::Error;
use nym_api_requests::coconut::BlindSignRequestBody;
use nym_credentials_interface::{
hash_to_scalar, Attribute, BlindSignRequest, BlindedSignature, PublicAttribute,
hash_to_scalar, Attribute, BlindSignRequest, BlindedSignature, CredentialType, PublicAttribute,
};
use nym_crypto::asymmetric::{encryption, identity};
use nym_validator_client::nyxd::{Coin, Hash};
@@ -123,6 +123,10 @@ impl BandwidthVoucherIssuanceData {
&self.value_prehashed
}
pub fn typ() -> CredentialType {
CredentialType::Voucher
}
pub fn tx_hash(&self) -> Hash {
self.deposit_tx_hash
}
+6 -3
View File
@@ -18,9 +18,12 @@ pub const VESTING_CONTRACT_ADDRESS: &str =
"n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw";
pub const COCONUT_BANDWIDTH_CONTRACT_ADDRESS: &str = "";
pub const GROUP_CONTRACT_ADDRESS: &str = "";
pub const MULTISIG_CONTRACT_ADDRESS: &str = "";
pub const COCONUT_DKG_CONTRACT_ADDRESS: &str = "";
pub const GROUP_CONTRACT_ADDRESS: &str =
"n1e2zq4886zzewpvpucmlw8v9p7zv692f6yck4zjzxh699dkcmlrfqk2knsr";
pub const MULTISIG_CONTRACT_ADDRESS: &str =
"n1txayqfz5g9qww3rlflpg025xd26m9payz96u54x4fe3s2ktz39xqk67gzx";
pub const COCONUT_DKG_CONTRACT_ADDRESS: &str =
"n19604yflqggs9mk2z26mqygq43q2kr3n932egxx630svywd5mpxjsztfpvx";
pub const EPHEMERA_CONTRACT_ADDRESS: &str = "";
pub const REWARDING_VALIDATOR_ADDRESS: &str = "n10yyd98e2tuwu0f7ypz9dy3hhjw7v772q6287gy";
+4
View File
@@ -157,6 +157,10 @@ impl BlindSignRequest {
)
}
pub fn verify_commitment_hash(&self, public_attributes: &[&Attribute]) -> bool {
self.commitment_hash == compute_hash(self.commitment, public_attributes)
}
pub fn get_commitment_hash(&self) -> G1Projective {
self.commitment_hash
}
@@ -249,7 +249,7 @@ impl BlockProcessor {
match to_prune {
v if v > 1000 => warn!("approximately {v} blocks worth of data will be pruned"),
v if v > 100 => info!("approximately {v} blocks worth of data will be pruned"),
v if v == 0 => trace!("no blocks to prune"),
0 => trace!("no blocks to prune"),
v => debug!("approximately {v} blocks worth of data will be pruned"),
}
+22
View File
@@ -171,3 +171,25 @@ impl fmt::Display for GatewayIpPacketRouterDetails {
writeln!(f, "\taddress: {}", self.address)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GatewayWireguardDetails {
pub enabled: bool,
pub announced_port: u16,
pub private_network_prefix: u8,
}
impl fmt::Display for GatewayWireguardDetails {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "wireguard:")?;
writeln!(f, "\tenabled: {}", self.enabled)?;
writeln!(f, "\tannounced_port: {}", self.announced_port)?;
writeln!(
f,
"\tprivate_network_prefix: {}",
self.private_network_prefix
)
}
}
+2
View File
@@ -17,7 +17,9 @@ log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
nym-config = { path = "../config" }
nym-crypto = { path = "../crypto", features = ["asymmetric"] }
nym-network-defaults = { path = "../network-defaults" }
# feature-specific dependencies:
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::net::{IpAddr, SocketAddr};
#[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
pub struct Config {
/// Socket address this node will use for binding its wireguard interface.
/// default: `0.0.0.0:51822`
pub bind_address: SocketAddr,
/// Private IP address of the wireguard gateway.
/// default: `10.1.0.1`
pub private_ip: IpAddr,
/// Port announced to external clients wishing to connect to the wireguard interface.
/// Useful in the instances where the node is behind a proxy.
pub announced_port: u16,
/// The prefix denoting the maximum number of the clients that can be connected via Wireguard.
/// The maximum value for IPv4 is 32 and for IPv6 is 128
pub private_network_prefix: u8,
}
+37 -1
View File
@@ -1,15 +1,51 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use dashmap::DashMap;
use nym_crypto::asymmetric::encryption::KeyPair;
use std::sync::Arc;
pub mod config;
pub mod error;
pub mod public_key;
pub mod registration;
pub use config::Config;
pub use error::Error;
pub use public_key::PeerPublicKey;
pub use registration::{
ClientMac, ClientMessage, ClientRegistrationResponse, GatewayClient, InitMessage, Nonce,
ClientMac, ClientMessage, ClientRegistrationResponse, GatewayClient, GatewayClientRegistry,
InitMessage, Nonce,
};
#[cfg(feature = "verify")]
pub use registration::HmacSha256;
#[derive(Clone)]
pub struct WireguardGatewayData {
config: Config,
keypair: Arc<KeyPair>,
client_registry: Arc<GatewayClientRegistry>,
}
impl WireguardGatewayData {
pub fn new(config: Config, keypair: Arc<KeyPair>) -> Self {
WireguardGatewayData {
config,
keypair,
client_registry: Arc::new(DashMap::default()),
}
}
pub fn config(&self) -> Config {
self.config
}
pub fn keypair(&self) -> &Arc<KeyPair> {
&self.keypair
}
pub fn client_registry(&self) -> &Arc<GatewayClientRegistry> {
&self.client_registry
}
}
+4
View File
@@ -24,6 +24,10 @@ impl PeerPublicKey {
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
pub fn inner(&self) -> PublicKey {
self.0
}
}
impl fmt::Display for PeerPublicKey {
+2 -4
View File
@@ -12,7 +12,7 @@ use std::{fmt, ops::Deref, str::FromStr};
#[cfg(feature = "verify")]
use hmac::{Hmac, Mac};
#[cfg(feature = "verify")]
use nym_crypto::asymmetric::encryption::{PrivateKey, PublicKey};
use nym_crypto::asymmetric::encryption::PrivateKey;
#[cfg(feature = "verify")]
use sha2::Sha256;
@@ -87,7 +87,7 @@ impl GatewayClient {
#[cfg(feature = "verify")]
pub fn new(
local_secret: &PrivateKey,
remote_public: PublicKey,
remote_public: x25519_dalek::PublicKey,
private_ip: IpAddr,
nonce: u64,
) -> Self {
@@ -96,8 +96,6 @@ impl GatewayClient {
let static_secret = x25519_dalek::StaticSecret::from(local_secret.to_bytes());
let local_public: x25519_dalek::PublicKey = (&static_secret).into();
let remote_public = x25519_dalek::PublicKey::from(remote_public.to_bytes());
let dh = static_secret.diffie_hellman(&remote_public);
// TODO: change that to use our nym_crypto::hmac module instead
+15 -18
View File
@@ -3,40 +3,37 @@
// #![warn(clippy::expect_used)]
// #![warn(clippy::unwrap_used)]
pub mod setup;
/// Start wireguard device
#[cfg(target_os = "linux")]
pub async fn start_wireguard(
mut task_client: nym_task::TaskClient,
_gateway_client_registry: std::sync::Arc<
nym_wireguard_types::registration::GatewayClientRegistry,
>,
wireguard_data: std::sync::Arc<nym_wireguard_types::WireguardGatewayData>,
) -> Result<defguard_wireguard_rs::WGApi, Box<dyn std::error::Error + Send + Sync + 'static>> {
use crate::setup::{peer_allowed_ips, peer_static_public_key, PRIVATE_KEY};
use base64::{prelude::BASE64_STANDARD, Engine};
use defguard_wireguard_rs::{
host::Peer, key::Key, net::IpAddrMask, InterfaceConfiguration, WGApi, WireguardInterfaceApi,
};
use nym_network_defaults::{WG_PORT, WG_TUN_DEVICE_ADDRESS};
let mut peers = vec![];
for peer_client in wireguard_data.client_registry().iter() {
let mut peer = Peer::new(Key::new(peer_client.pub_key.to_bytes()));
let peer_ip_mask = IpAddrMask::new(peer_client.private_ip, 32);
peer.set_allowed_ips(vec![peer_ip_mask]);
peers.push(peer);
}
let ifname = String::from("wg0");
let wgapi = WGApi::new(ifname.clone(), false)?;
wgapi.create_interface()?;
let interface_config = InterfaceConfiguration {
name: ifname.clone(),
prvkey: PRIVATE_KEY.to_string(),
address: WG_TUN_DEVICE_ADDRESS.to_string(),
port: WG_PORT as u32,
peers: vec![],
prvkey: BASE64_STANDARD.encode(wireguard_data.keypair().private_key().to_bytes()),
address: wireguard_data.config().private_ip.to_string(),
port: wireguard_data.config().announced_port as u32,
peers,
};
wgapi.configure_interface(&interface_config)?;
let peer = peer_static_public_key();
let mut peer = Peer::new(Key::new(peer.to_bytes()));
let peer_ip = peer_allowed_ips();
let peer_ip_mask = IpAddrMask::new(peer_ip.network_address(), peer_ip.netmask());
peer.set_allowed_ips(vec![peer_ip_mask]);
wgapi.configure_peer(&peer)?;
wgapi.configure_peer_routing(&[peer.clone()])?;
// wgapi.configure_peer_routing(&peers)?;
tokio::spawn(async move { task_client.recv().await });
-56
View File
@@ -1,56 +0,0 @@
use std::net::IpAddr;
use base64::{engine::general_purpose, Engine as _};
use log::info;
// The wireguard UDP listener
pub const WG_ADDRESS: &str = "0.0.0.0";
// The private key of the listener
// Corresponding public key: "WM8s8bYegwMa0TJ+xIwhk+dImk2IpDUKslDBCZPizlE="
pub(crate) const PRIVATE_KEY: &str = "AEqXrLFT4qjYq3wmX0456iv94uM6nDj5ugp6Jedcflg=";
// The AllowedIPs for the connected peer, which is one a single IP and the same as the IP that the
// peer has configured on their side.
const ALLOWED_IPS: &str = "10.1.0.2";
fn decode_base64_key(base64_key: &str) -> [u8; 32] {
general_purpose::STANDARD
.decode(base64_key)
.unwrap()
.try_into()
.unwrap()
}
pub fn server_static_private_key() -> x25519_dalek::StaticSecret {
// TODO: this is a temporary solution for development
let static_private_bytes: [u8; 32] = decode_base64_key(PRIVATE_KEY);
let static_private = x25519_dalek::StaticSecret::from(static_private_bytes);
let static_public = x25519_dalek::PublicKey::from(&static_private);
info!(
"wg public key: {}",
general_purpose::STANDARD.encode(static_public)
);
static_private
}
pub fn peer_static_public_key() -> x25519_dalek::PublicKey {
// A single static public key is used during development
// Read from NYM_PEER_PUBLIC_KEY env variable
let peer = std::env::var("NYM_PEER_PUBLIC_KEY").expect("NYM_PEER_PUBLIC_KEY must be set");
let peer_static_public_bytes: [u8; 32] = decode_base64_key(&peer);
let peer_static_public = x25519_dalek::PublicKey::from(peer_static_public_bytes);
info!(
"Adding wg peer public key: {}",
general_purpose::STANDARD.encode(peer_static_public)
);
peer_static_public
}
pub fn peer_allowed_ips() -> ip_network::IpNetwork {
let key: IpAddr = ALLOWED_IPS.parse().unwrap();
let cidr = 32u8;
ip_network::IpNetwork::new_truncate(key, cidr).unwrap()
}
+2 -2
View File
@@ -148,8 +148,8 @@ Options:
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `0.0.0.0:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-private-network-ip <WIREGUARD_PRIVATE_NETWORK_IP>
Ip address of the private wireguard network. default: `10.1.0.0` [env: NYMNODE_WG_IP_NETWORK=]
--wireguard-private-gw-ip <WIREGUARD_PRIVATE_IP>
Private IP address of the wireguard gateway. default: `10.1.0.1` [env: NYMNODE_WG_IP=]
--wireguard-announced-port <WIREGUARD_ANNOUNCED_PORT>
Port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
+3
View File
@@ -14,6 +14,9 @@ DENOMS_EXPONENT=6
MIXNET_CONTRACT_ADDRESS=n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr
VESTING_CONTRACT_ADDRESS=n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw
GROUP_CONTRACT_ADDRESS=n1e2zq4886zzewpvpucmlw8v9p7zv692f6yck4zjzxh699dkcmlrfqk2knsr
MULTISIG_CONTRACT_ADDRESS=n1txayqfz5g9qww3rlflpg025xd26m9payz96u54x4fe3s2ktz39xqk67gzx
COCONUT_DKG_CONTRACT_ADDRESS=n19604yflqggs9mk2z26mqygq43q2kr3n932egxx630svywd5mpxjsztfpvx
REWARDING_VALIDATOR_ADDRESS=n10yyd98e2tuwu0f7ypz9dy3hhjw7v772q6287gy
STATISTICS_SERVICE_DOMAIN_ADDRESS="https://mainnet-stats.nymte.ch:8090"
+2 -2
View File
@@ -37,7 +37,7 @@ nym-config = { path = "../common/config" }
nym-ephemera-common = { path = "../common/cosmwasm-smart-contracts/ephemera" }
pretty_env_logger = "0.4"
refinery = { version = "0.8.7", features = ["rusqlite"], optional = true }
reqwest = { version = "0.11.22", default_features = false, features = ["rustls-tls", "json"] }
reqwest = { version = "0.12.4", default_features = false, features = ["rustls-tls", "json"] }
# Rocksdb kills compilation times and we're not currently using it. The reason
# we comment it out is that rust-analyzer runs with --all-features
#rocksdb = { version = "0.21.0", optional = true }
@@ -46,7 +46,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_derive = "1.0.149"
serde_json = "1.0.91"
thiserror = { workspace = true }
tokio = { version = "1", features = ["macros", "net","rt-multi-thread"] }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true, features = ["full"] }
toml = "0.7.0"
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import { Navbar } from './components/Nav/Navbar'
import { Providers } from './providers'
const App = ({ children }: { children: React.ReactNode }) => (
<Providers>
<Navbar>{children}</Navbar>
</Providers>
)
export { App }
+34
View File
@@ -0,0 +1,34 @@
// master APIs
export const API_BASE_URL = process.env.NEXT_PUBLIC_EXPLORER_API_URL || 'https://explorer.nymtech.net/api/v1';
export const NYM_API_BASE_URL = process.env.NEXT_PUBLIC_NYM_API_URL || 'https://validator.nymtech.net';
export const NYX_RPC_BASE_URL = process.env.NEXT_PUBLIC_NYX_RPC_BASE_URL || 'https://rpc.nymtech.net';
export const VALIDATOR_BASE_URL = process.env.NEXT_PUBLIC_VALIDATOR_URL || 'https://rpc.nymtech.net';
export const BIG_DIPPER = process.env.NEXT_PUBLIC_BIG_DIPPER_URL || 'https://nym.explorers.guru';
// specific API routes
export const OVERVIEW_API = `${API_BASE_URL}/overview`;
export const MIXNODE_PING = `${API_BASE_URL}/ping`;
export const MIXNODES_API = `${API_BASE_URL}/mix-nodes`;
export const MIXNODE_API = `${API_BASE_URL}/mix-node`;
export const GATEWAYS_EXPLORER_API = `${API_BASE_URL}/gateways`;
export const GATEWAYS_API = `${NYM_API_BASE_URL}/api/v1/status/gateways/detailed`;
export const VALIDATORS_API = `${NYX_RPC_BASE_URL}/validators`;
export const BLOCK_API = `${NYX_RPC_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 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.";
export const NYM_WEBSITE = 'https://nymtech.net';
export const NYM_BIG_DIPPER = 'https://mixnet.explorers.guru';
export const NYM_MIXNET_CONTRACT =
process.env.NYM_MIXNET_CONTRACT || 'n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr';
export const COSMOS_KIT_USE_CHAIN = process.env.NEXT_PUBLIC_COSMOS_KIT_USE_CHAIN || 'sandbox';
export const WALLET_CONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || '';
+173
View File
@@ -0,0 +1,173 @@
import keyBy from 'lodash/keyBy';
import {
API_BASE_URL,
BLOCK_API,
COUNTRY_DATA_API,
GATEWAYS_API,
UPTIME_STORY_API_GATEWAY,
MIXNODE_API,
MIXNODE_PING,
MIXNODES_API,
OVERVIEW_API,
UPTIME_STORY_API,
VALIDATORS_API,
SERVICE_PROVIDERS,
GATEWAYS_EXPLORER_API,
} from './constants';
import {
CountryDataResponse,
DelegationsResponse,
UniqDelegationsResponse,
GatewayReportResponse,
UptimeStoryResponse,
MixNodeDescriptionResponse,
MixNodeResponse,
MixNodeResponseItem,
MixnodeStatus,
MixNodeEconomicDynamicsStatsResponse,
StatsResponse,
StatusResponse,
SummaryOverviewResponse,
ValidatorsResponse,
Environment,
GatewayBondAnnotated,
GatewayBond,
DirectoryServiceProvider,
LocatedGateway,
} from '../typeDefs/explorer-api';
function getFromCache(key: string) {
const ts = Number(localStorage.getItem('ts'));
const hasExpired = Date.now() - ts > 5000;
const curr = localStorage.getItem(key);
if (curr && !hasExpired) {
return JSON.parse(curr);
}
return undefined;
}
function storeInCache(key: string, data: any) {
localStorage.setItem(key, data);
localStorage.setItem('ts', Date.now().toString());
}
export class Api {
static fetchOverviewSummary = async (): Promise<SummaryOverviewResponse> => {
const cache = getFromCache('overview-summary');
if (cache) {
return cache;
}
const res = await fetch(`${OVERVIEW_API}/summary`);
const json = await res.json();
storeInCache('overview-summary', JSON.stringify(json));
return json;
};
static fetchMixnodes = async (): Promise<MixNodeResponse> => {
const cachedMixnodes = getFromCache('mixnodes');
if (cachedMixnodes) {
return cachedMixnodes;
}
const res = await fetch(MIXNODES_API);
const json = await res.json();
storeInCache('mixnodes', JSON.stringify(json));
return json;
};
static fetchMixnodesActiveSetByStatus = async (status: MixnodeStatus): Promise<MixNodeResponse> => {
const cachedMixnodes = getFromCache(`mixnodes-${status}`);
if (cachedMixnodes) {
return cachedMixnodes;
}
const res = await fetch(`${MIXNODES_API}/active-set/${status}`);
const json = await res.json();
storeInCache(`mixnodes-${status}`, JSON.stringify(json));
return json;
};
static fetchMixnodeByID = async (id: string): Promise<MixNodeResponseItem | undefined> => {
const response = await fetch(`${MIXNODE_API}/${id}`);
// when the mixnode is not found, returned undefined
if (response.status === 404) {
return undefined;
}
return response.json();
};
static fetchGateways = async (): Promise<GatewayBond[]> => {
const res = await fetch(GATEWAYS_API);
const gatewaysAnnotated: GatewayBondAnnotated[] = await res.json();
const res2 = await fetch(GATEWAYS_EXPLORER_API);
const locatedGateways: LocatedGateway[] = await res2.json();
const locatedGatewaysByOwner = keyBy(locatedGateways, 'owner');
return gatewaysAnnotated.map(({ gateway_bond, node_performance }) => ({
...gateway_bond,
node_performance,
location: locatedGatewaysByOwner[gateway_bond.owner]?.location,
}));
};
static fetchGatewayUptimeStoryById = async (id: string): Promise<UptimeStoryResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/history`)).json();
static fetchGatewayReportById = async (id: string): Promise<GatewayReportResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/report`)).json();
static fetchValidators = async (): Promise<ValidatorsResponse> => {
const res = await fetch(VALIDATORS_API);
const json = await res.json();
return json.result;
};
static fetchBlock = async (): Promise<number> => {
const res = await fetch(BLOCK_API);
const json = await res.json();
const { height } = json.result.block.header;
return height;
};
static fetchCountryData = async (): Promise<CountryDataResponse> => {
const result: CountryDataResponse = {};
const res = await fetch(COUNTRY_DATA_API);
const json = await res.json();
Object.keys(json).forEach((ISO3) => {
result[ISO3] = { ISO3, nodes: json[ISO3] };
});
return result;
};
static fetchDelegationsById = async (id: string): Promise<DelegationsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/delegations`)).json();
static fetchUniqDelegationsById = async (id: string): Promise<UniqDelegationsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/delegations/summed`)).json();
static fetchStatsById = async (id: string): Promise<StatsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/stats`)).json();
static fetchMixnodeDescriptionById = async (id: string): Promise<MixNodeDescriptionResponse> =>
(await fetch(`${MIXNODE_API}/${id}/description`)).json();
static fetchMixnodeEconomicDynamicsStatsById = async (id: string): Promise<MixNodeEconomicDynamicsStatsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/economic-dynamics-stats`)).json();
static fetchStatusById = async (id: string): Promise<StatusResponse> => (await fetch(`${MIXNODE_PING}/${id}`)).json();
static fetchUptimeStoryById = async (id: string): Promise<UptimeStoryResponse> =>
(await fetch(`${UPTIME_STORY_API}/${id}/history`)).json();
static fetchServiceProviders = async (): Promise<DirectoryServiceProvider[]> => {
const res = await fetch(SERVICE_PROVIDERS);
const json = await res.json();
return json;
};
}
export const getEnvironment = (): Environment => {
const matchEnv = (env: Environment) => API_BASE_URL?.toLocaleLowerCase().includes(env) && env;
return matchEnv('sandbox') || matchEnv('qa') || 'mainnet';
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import { Typography } from '@mui/material';
import * as React from 'react';
export const ComponentError: FCWithChildren<{ text: string }> = ({ text }) => (
<Typography
sx={{ marginTop: 2, color: 'primary.main', fontSize: 10 }}
variant="body1"
data-testid="delegation-total-amount"
>
{text}
</Typography>
);
@@ -0,0 +1,38 @@
import { Card, CardHeader, CardContent, Typography } from '@mui/material'
import React, { ReactEventHandler } from 'react'
type ContentCardProps = {
title?: React.ReactNode
subtitle?: string
Icon?: React.ReactNode
Action?: React.ReactNode
errorMsg?: string
onClick?: ReactEventHandler
}
export const ContentCard: FCWithChildren<ContentCardProps> = ({
title,
Icon,
Action,
subtitle,
errorMsg,
children,
onClick,
}) => (
<Card onClick={onClick} sx={{ height: '100%' }}>
{title && (
<CardHeader
title={title || ''}
avatar={Icon}
action={Action}
subheader={subtitle}
/>
)}
{children && <CardContent>{children}</CardContent>}
{errorMsg && (
<Typography variant="body2" sx={{ color: 'danger', padding: 2 }}>
{errorMsg}
</Typography>
)}
</Card>
)
@@ -0,0 +1,30 @@
import * as React from 'react'
import { Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { Tooltip } from '@nymproject/react/tooltip/Tooltip'
export const CustomColumnHeading: FCWithChildren<{
headingTitle: string
tooltipInfo?: string
}> = ({ headingTitle, tooltipInfo }) => {
const theme = useTheme()
return (
<Box alignItems="center" display="flex">
{tooltipInfo && (
<Tooltip
title={tooltipInfo}
id={headingTitle}
placement="top-start"
textColor={theme.palette.nym.networkExplorer.tooltip.color}
bgColor={theme.palette.nym.networkExplorer.tooltip.background}
maxWidth={230}
arrow
/>
)}
<Typography variant="body2" fontWeight={600} data-testid={headingTitle}>
{headingTitle}
</Typography>
</Box>
)
}
@@ -0,0 +1,83 @@
import React from 'react';
import {
Breakpoint,
Button,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
SxProps,
Typography,
} from '@mui/material';
export interface ConfirmationModalProps {
open: boolean;
onConfirm: () => void;
onClose?: () => void;
children?: React.ReactNode;
title: React.ReactNode | string;
subTitle?: React.ReactNode | string;
confirmButton: React.ReactNode | string;
disabled?: boolean;
sx?: SxProps;
fullWidth?: boolean;
maxWidth?: Breakpoint;
backdropProps?: object;
}
export const ConfirmationModal = ({
open,
onConfirm,
onClose,
children,
title,
subTitle,
confirmButton,
disabled,
sx,
fullWidth,
maxWidth,
backdropProps,
}: ConfirmationModalProps) => {
const Title = (
<DialogTitle id="responsive-dialog-title" sx={{ pb: 2 }}>
{title}
{subTitle &&
(typeof subTitle === 'string' ? (
<Typography fontWeight={400} variant="subtitle1" fontSize={12} color="grey">
{subTitle}
</Typography>
) : (
subTitle
))}
</DialogTitle>
);
const ConfirmButton =
typeof confirmButton === 'string' ? (
<Button onClick={onConfirm} variant="contained" fullWidth disabled={disabled} sx={{ py: 1.6 }}>
<Typography variant="button" fontSize="large">
{confirmButton}
</Typography>
</Button>
) : (
confirmButton
);
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="responsive-dialog-title"
maxWidth={maxWidth || 'sm'}
sx={{ textAlign: 'center', ...sx }}
fullWidth={fullWidth}
BackdropProps={backdropProps}
PaperComponent={Paper}
PaperProps={{ elevation: 0 }}
>
{Title}
<DialogContent>{children}</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>{ConfirmButton}</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,39 @@
import * as React from 'react'
import { Button, IconButton } from '@mui/material'
import { SxProps } from '@mui/system'
import { useIsMobile } from '@/app/hooks'
import { DelegateIcon } from '@/app/icons/DelevateSVG'
export const DelegateIconButton: FCWithChildren<{
size?: 'small' | 'medium'
disabled?: boolean
tooltip?: React.ReactNode
sx?: SxProps
onDelegate: () => void
}> = ({ onDelegate, sx, disabled, size = 'medium' }) => {
const isMobile = useIsMobile()
const handleOnDelegate = () => {
onDelegate()
}
if (isMobile) {
return (
<IconButton size="small" disabled={disabled} onClick={handleOnDelegate}>
<DelegateIcon fontSize="small" />
</IconButton>
)
}
return (
<Button
variant="outlined"
size={size}
disabled={disabled}
onClick={handleOnDelegate}
sx={sx}
>
Delegate
</Button>
)
}
@@ -0,0 +1,191 @@
'use client'
import React, { useState } from 'react'
import { Box, SxProps } from '@mui/material'
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'
import { CurrencyDenom, DecCoin } from '@nymproject/types'
import { useWalletContext } from '@/app/context/wallet'
import { urls } from '@/app/utils'
import { useDelegationsContext } from '@/app/context/delegations'
import { validateAmount } from '@/app/utils/currency'
import { SimpleModal } from './SimpleModal'
import { ModalListItem } from './ModalListItem'
import { DelegationModalProps } from './DelegationModal'
const MIN_AMOUNT_TO_DELEGATE = 10
type Props = {
mixId: number
identityKey: string
header?: string
buttonText?: string
rewardInterval?: string
estimatedReward?: number
profitMarginPercentage?: string | null
nodeUptimePercentage?: number | null
denom: CurrencyDenom
sx?: SxProps
backdropProps?: object
onClose: () => void
onOk?: (delegationModalProps: DelegationModalProps) => void
}
export const DelegateModal = ({
mixId,
identityKey,
onClose,
onOk,
denom,
sx,
}: Props) => {
const [amount, setAmount] = useState<DecCoin | undefined>({
amount: '10',
denom: 'nym',
})
const [isValidated, setValidated] = useState<boolean>(false)
const [errorAmount, setErrorAmount] = useState<string | undefined>()
const { address, balance } = useWalletContext()
const { handleDelegate } = useDelegationsContext()
const validate = async () => {
let newValidatedValue = true
let errorAmountMessage
if (amount && !(await validateAmount(amount.amount, '0'))) {
newValidatedValue = false
errorAmountMessage = 'Please enter a valid amount'
}
if (amount && +amount.amount < MIN_AMOUNT_TO_DELEGATE) {
errorAmountMessage = `Min. delegation amount: ${MIN_AMOUNT_TO_DELEGATE} ${denom.toUpperCase()}`
newValidatedValue = false
}
if (!amount?.amount.length) {
newValidatedValue = false
}
if (amount && balance.data && +balance.data - +amount.amount <= 0) {
errorAmountMessage = 'Not enough funds'
newValidatedValue = false
}
setErrorAmount(errorAmountMessage)
setValidated(newValidatedValue)
}
const delegateToMixnode = async ({
delegationMixId,
delegationAmount,
}: {
delegationMixId: number
delegationAmount: string
}) => {
try {
const tx = await handleDelegate(delegationMixId, delegationAmount)
return tx
} catch (e) {
console.error('Failed to delegate to mixnode', e)
throw e
}
}
const handleConfirm = async () => {
if (mixId && amount && onOk) {
onOk({
status: 'loading',
})
try {
if (!address) {
throw new Error('Please connect your wallet')
}
const tx = await delegateToMixnode({
delegationMixId: mixId,
delegationAmount: amount.amount,
})
if (!tx) {
throw new Error('Failed to delegate')
}
onOk({
status: 'success',
message: 'Delegation can take up to one hour to process',
transactions: [
{
url: `${urls('MAINNET').blockExplorer}/transaction/${
tx.transactionHash
}`,
hash: tx.transactionHash,
},
],
})
} catch (e) {
console.error('Failed to delegate', e)
onOk({
status: 'error',
message: (e as Error).message,
})
}
}
}
const handleAmountChanged = (newAmount: DecCoin) => {
setAmount(newAmount)
}
React.useEffect(() => {
validate()
}, [amount, identityKey, mixId])
return (
<SimpleModal
open
onClose={onClose}
onOk={handleConfirm}
header="Delegate"
okLabel="Delegate"
okDisabled={!isValidated}
sx={sx}
>
<Box sx={{ mt: 3 }} gap={2}>
<IdentityKeyFormField
required
fullWidth
label="Node identity key"
onChanged={() => undefined}
initialValue={identityKey}
readOnly
showTickOnValid={false}
/>
</Box>
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 3 }}>
<CurrencyFormField
showCoinMark={false}
required
fullWidth
autoFocus
label="Amount"
initialValue={amount?.amount || '10'}
onChanged={handleAmountChanged}
denom={denom}
validationError={errorAmount}
/>
</Box>
<Box sx={{ mt: 3 }}>
<ModalListItem
label="Account balance"
value={`${balance.data} NYM`}
divider
fontWeight={600}
/>
</Box>
<ModalListItem label="Est. fee for this transaction will be calculated in your connected wallet" />
</SimpleModal>
)
}
@@ -0,0 +1,95 @@
import React from 'react'
import { Typography, SxProps, Stack } from '@mui/material'
import { Link } from '@nymproject/react/link/Link'
import { LoadingModal } from './LoadingModal'
import { ConfirmationModal } from './ConfirmationModal'
import { ErrorModal } from './ErrorModal'
export type DelegationModalProps = {
status: 'loading' | 'success' | 'error' | 'info'
message?: string
transactions?: {
url: string
hash: string
}[]
}
export const DelegationModal: FCWithChildren<
DelegationModalProps & {
open: boolean
onClose: () => void
sx?: SxProps
backdropProps?: object
children?: React.ReactNode
}
> = ({
status,
message,
transactions,
open,
onClose,
children,
sx,
backdropProps,
}) => {
if (status === 'loading')
return <LoadingModal sx={sx} backdropProps={backdropProps} />
if (status === 'error') {
return (
<ErrorModal message={message} sx={sx} open={open} onClose={onClose}>
{children}
</ErrorModal>
)
}
if (status === 'info') {
return (
<ConfirmationModal
open={open}
title="Connect wallet"
confirmButton="OK"
onConfirm={onClose}
>
<Typography>{message}</Typography>
</ConfirmationModal>
)
}
return (
<ConfirmationModal
open={open}
onConfirm={onClose || (() => {})}
title="Transaction successful"
confirmButton="Done"
>
<Stack alignItems="center" spacing={2} mb={0}>
{message && <Typography>{message}</Typography>}
{transactions?.length === 1 && (
<Link
href={transactions[0].url}
target="_blank"
sx={{ ml: 1 }}
text="View on blockchain"
noIcon
/>
)}
{transactions && transactions.length > 1 && (
<Stack alignItems="center" spacing={1}>
<Typography>View the transactions on blockchain:</Typography>
{transactions.map(({ url, hash }) => (
<Link
href={url}
target="_blank"
sx={{ ml: 1 }}
text={hash.slice(0, 6)}
key={hash}
noIcon
/>
))}
</Stack>
)}
</Stack>
</ConfirmationModal>
)
}
@@ -0,0 +1,28 @@
import React from 'react';
import { Box, Button, Modal, SxProps, Typography } from '@mui/material';
import { modalStyle } from './SimpleModal';
export const ErrorModal: FCWithChildren<{
open: boolean;
title?: string;
message?: string;
sx?: SxProps;
backdropProps?: object;
onClose: () => void;
children?: React.ReactNode;
}> = ({ children, open, title, message, sx, backdropProps, onClose }) => (
<Modal open={open} onClose={onClose} BackdropProps={backdropProps}>
<Box sx={{ ...modalStyle(), ...sx }} textAlign="center">
<Typography color={(theme) => theme.palette.error.main} mb={1}>
{title || 'Oh no! Something went wrong...'}
</Typography>
<Typography my={5} color="text.primary" sx={{ textOverflow: 'wrap', overflowWrap: 'break-word' }}>
{message}
</Typography>
{children}
<Button variant="contained" onClick={onClose}>
Close
</Button>
</Box>
</Modal>
);
@@ -0,0 +1,18 @@
import React from 'react';
import { Box, CircularProgress, Modal, Stack, Typography, SxProps } from '@mui/material';
import { modalStyle } from './SimpleModal';
export const LoadingModal: FCWithChildren<{
text?: string;
sx?: SxProps;
backdropProps?: object;
}> = ({ sx, text = 'Please wait...' }) => (
<Modal open>
<Box sx={{ ...modalStyle(), ...sx }} textAlign="center">
<Stack spacing={4} direction="row" alignItems="center">
<CircularProgress />
<Typography sx={{ color: 'text.primary' }}>{text}</Typography>
</Stack>
</Box>
</Modal>
);
@@ -0,0 +1,6 @@
import React from 'react';
import { Box, SxProps } from '@mui/material';
export const ModalDivider: FCWithChildren<{
sx?: SxProps;
}> = ({ sx }) => <Box borderTop="1px solid" borderColor="rgba(141, 147, 153, 0.2)" my={1} sx={sx} />;
@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Stack, SxProps, Typography, TypographyProps } from '@mui/material';
import { ModalDivider } from './ModalDivider';
export const ModalListItem: FCWithChildren<{
label: string;
divider?: boolean;
hidden?: boolean;
fontWeight?: TypographyProps['fontWeight'];
fontSize?: TypographyProps['fontSize'];
light?: boolean;
value?: React.ReactNode;
sxValue?: SxProps;
}> = ({ label, value, hidden, fontWeight, fontSize, divider, sxValue }) => (
<Box sx={{ display: hidden ? 'none' : 'block' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}>
{label}
</Typography>
{value && (
<Typography
fontSize="smaller"
fontWeight={fontWeight}
sx={{ color: 'text.primary', fontSize: fontSize || 14, ...sxValue }}
>
{value}
</Typography>
)}
</Stack>
{divider && <ModalDivider />}
</Box>
);
@@ -0,0 +1,152 @@
import React from 'react'
import { Box, Button, Modal, Stack, SxProps, Typography } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import ErrorOutline from '@mui/icons-material/ErrorOutline'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export const modalStyle = (width: number | string = 600) => ({
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
width,
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
boxShadow: 24,
borderRadius: '16px',
p: 4,
})
export const StyledBackButton = ({
onBack,
label,
fullWidth,
sx,
}: {
onBack: () => void
label?: string
fullWidth?: boolean
sx?: SxProps
}) => (
<Button
disableFocusRipple
size="large"
fullWidth={fullWidth}
variant="outlined"
onClick={onBack}
sx={sx}
>
{label || <ArrowBackIosNewIcon fontSize="small" />}
</Button>
)
export const SimpleModal: FCWithChildren<{
open: boolean
hideCloseIcon?: boolean
displayErrorIcon?: boolean
displayInfoIcon?: boolean
headerStyles?: SxProps
subHeaderStyles?: SxProps
buttonFullWidth?: boolean
onClose?: () => void
onOk?: () => Promise<void>
onBack?: () => void
header: string | React.ReactNode
subHeader?: string
okLabel: string
backLabel?: string
backButtonFullWidth?: boolean
okDisabled?: boolean
sx?: SxProps
children?: React.ReactNode
}> = ({
open,
hideCloseIcon,
displayErrorIcon,
displayInfoIcon,
headerStyles,
buttonFullWidth,
onClose,
okDisabled,
onOk,
onBack,
header,
subHeader,
okLabel,
backLabel,
backButtonFullWidth,
sx,
children,
}) => {
const isMobile = useIsMobile()
return (
<Modal open={open} onClose={onClose}>
<Box sx={{ ...modalStyle(isMobile ? '90%' : 600), ...sx }}>
{displayErrorIcon && <ErrorOutline color="error" sx={{ mb: 3 }} />}
{displayInfoIcon && <InfoOutlinedIcon sx={{ mb: 2, color: 'blue' }} />}
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
{typeof header === 'string' ? (
<Typography
fontSize={20}
fontWeight={600}
sx={{ color: 'text.primary', ...headerStyles }}
>
{header}
</Typography>
) : (
header
)}
{!hideCloseIcon && <CloseIcon onClick={onClose} cursor="pointer" />}
</Stack>
<Typography
mt={subHeader ? 0.5 : 0}
mb={3}
fontSize={12}
color={(theme) => theme.palette.text.secondary}
>
{subHeader}
</Typography>
{children}
{(onOk || onBack) && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mt: 2,
width: buttonFullWidth ? '100%' : null,
}}
>
{onBack && (
<StyledBackButton
onBack={onBack}
label={backLabel}
fullWidth={backButtonFullWidth}
/>
)}
{onOk && (
<Button
variant="contained"
fullWidth
size="large"
onClick={onOk}
disabled={okDisabled}
>
{okLabel}
</Button>
)}
</Box>
)}
</Box>
</Modal>
)
}
@@ -0,0 +1,10 @@
export * from './ConfirmationModal';
export * from './DelegateIconButton';
export * from './DelegationModal';
export * from './DelegateModal';
export * from './ErrorModal';
export * from './LoadingModal';
export * from './ModalDivider';
export * from './ModalListItem';
export * from './SimpleModal';
export * from './styles';
@@ -0,0 +1,21 @@
import { Theme } from '@mui/material/styles';
export const backDropStyles = (theme: Theme) => {
const { mode } = theme.palette;
return {
style: {
left: mode === 'light' ? '0' : '50%',
width: '50%',
},
};
};
export const modalStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '25%' : '75%' };
};
export const dialogStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '-50%' : '50%' };
};
@@ -0,0 +1,145 @@
import * as React from 'react'
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'
import { Box } from '@mui/system'
import { unymToNym } from '@/app/utils/currency'
import { GatewayEnrichedRowType } from './Gateways/Gateways'
import { MixnodeRowType } from './MixNodes'
import { StakeSaturationProgressBar } from './MixNodes/Economics/StakeSaturationProgressBar'
export type ColumnsType = {
field: string
title: string
headerAlign?: TableCellProps['align']
width?: string | number
tooltipInfo?: string
}
export interface UniversalTableProps<T = any> {
tableName: string
columnsData: ColumnsType[]
rows: T[]
}
function formatCellValues(val: string | number, field: string) {
if (field === 'identity_key' && typeof val === 'string') {
return (
<Box display="flex" justifyContent="flex-end">
<CopyToClipboard
sx={{ mr: 1, mt: 0.5, fontSize: '18px' }}
value={val}
tooltip={`Copy identity key ${val} to clipboard`}
/>
<span>{val}</span>
</Box>
)
}
if (field === 'bond') {
return unymToNym(val, 6)
}
if (field === 'owner') {
return (
<Link
underline="none"
color="inherit"
target="_blank"
href={`https://mixnet.explorers.guru/account/${val}`}
>
{val}
</Link>
)
}
if (field === 'stake_saturation') {
return <StakeSaturationProgressBar value={Number(val)} threshold={100} />
}
return val
}
export const DetailTable: FCWithChildren<{
tableName: string
columnsData: ColumnsType[]
rows: MixnodeRowType[] | GatewayEnrichedRowType[]
}> = ({ tableName, columnsData, rows }: UniversalTableProps) => {
const theme = useTheme()
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 1080 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, width, tooltipInfo }) => (
<TableCell
key={field}
sx={{ fontSize: 14, fontWeight: 600, width }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{tooltipInfo && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip
title={tooltipInfo}
id={field}
placement="top-start"
textColor={
theme.palette.nym.networkExplorer.tooltip.color
}
bgColor={
theme.palette.nym.networkExplorer.tooltip.background
}
maxWidth={230}
arrow
/>
</Box>
)}
{title}
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((eachRow) => (
<TableRow
key={eachRow.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columnsData?.map((data, index) => (
<TableCell
key={data.title}
component="th"
scope="row"
variant="body"
sx={{
padding: 2,
width: 200,
fontSize: 14,
}}
data-testid={`${data.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(
eachRow[columnsData[index].field],
columnsData[index].field
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
@@ -0,0 +1,193 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogActions,
DialogTitle,
Slider,
Typography,
Box,
Snackbar,
Slide,
Alert,
} from '@mui/material'
import { useParams } from 'next/navigation'
import { useMainContext } from '@/app/context/main'
import {
MixnodeStatusWithAll,
toMixnodeStatus,
} from '@/app/typeDefs/explorer-api'
import { EnumFilterKey, TFilterItem, TFilters } from '@/app/typeDefs/filters'
import { Api } from '@/app/api'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { formatOnSave, generateFilterSchema } from './filterSchema'
import FiltersButton from './FiltersButton'
const FilterItem = ({
label,
id,
tooltipInfo,
value,
isSmooth,
marks,
scale,
min,
max,
onChange,
}: TFilterItem & {
onChange: (id: EnumFilterKey, newValue: number[]) => void
}) => (
<Box sx={{ p: 2 }}>
<Typography gutterBottom>{label}</Typography>
<Typography fontSize={12}>{tooltipInfo}</Typography>
<Slider
value={value}
onChange={(e: Event, newValue: number | number[]) =>
onChange(id, newValue as number[])
}
valueLabelDisplay={isSmooth ? 'auto' : 'off'}
marks={marks}
step={isSmooth ? 1 : null}
scale={scale}
min={min}
max={max}
valueLabelFormat={(val: number) =>
val === 100 && id === 'stakeSaturation' ? '>100' : val
}
/>
</Box>
)
export const Filters = () => {
const { filterMixnodes, fetchMixnodes, mixnodes } = useMainContext()
const { status } = useParams<{
status: 'active' | 'standby' | 'inactive' | 'all'
}>()
const isMobile = useIsMobile()
const [showFilters, setShowFilters] = useState(false)
const [isFiltered, setIsFiltered] = useState(false)
const [filters, setFilters] = React.useState<TFilters>()
const [upperSaturationValue, setUpperSaturationValue] =
React.useState<number>(100)
const baseFilters = useRef<TFilters>()
const prevFilters = useRef<TFilters>()
const handleToggleShowFilters = () => setShowFilters(!showFilters)
const initialiseFilters = useCallback(async () => {
const allMixnodes = await Api.fetchMixnodes()
if (allMixnodes) {
setUpperSaturationValue(
Math.round(
Math.max(...allMixnodes.map((m) => m.stake_saturation)) * 100 + 1
)
)
const initFilters = generateFilterSchema()
baseFilters.current = initFilters
prevFilters.current = initFilters
setFilters(initFilters)
}
}, [])
const handleOnChange = (id: EnumFilterKey, newValue: number[]) => {
if (id === 'stakeSaturation' && newValue[1] === 100) {
newValue.splice(1, 1, upperSaturationValue)
}
setFilters((ftrs) => {
if (ftrs)
return {
...ftrs,
[id]: {
...ftrs[id],
value: newValue,
},
}
return undefined
})
}
const handleOnSave = async () => {
setShowFilters(false)
await filterMixnodes(formatOnSave(filters!), status)
setIsFiltered(true)
prevFilters.current = filters
}
const handleOnCancel = () => {
setShowFilters(false)
setFilters(prevFilters.current)
}
const resetFilters = () => {
setFilters(baseFilters.current)
setIsFiltered(false)
prevFilters.current = baseFilters.current
}
const onClearFilters = async () => {
await fetchMixnodes(toMixnodeStatus(MixnodeStatusWithAll[status]))
resetFilters()
}
useEffect(() => {
initialiseFilters()
}, [initialiseFilters])
useEffect(() => {
resetFilters()
}, [status])
if (!filters) return null
return (
<>
<Snackbar
open={isFiltered}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
message="Filters applied"
TransitionComponent={Slide}
transitionDuration={250}
>
<Alert
severity="info"
variant={isMobile ? 'standard' : 'outlined'}
sx={{ color: (t) => t.palette.info.light }}
action={
<Button size="small" onClick={onClearFilters}>
CLEAR FILTERS
</Button>
}
>
{mixnodes?.data?.length} mixnodes matched your criteria
</Alert>
</Snackbar>
<FiltersButton onClick={handleToggleShowFilters} fullWidth />
<Dialog
open={showFilters}
onClose={handleToggleShowFilters}
maxWidth="md"
fullWidth
>
<DialogTitle>Mixnode filters</DialogTitle>
<DialogContent dividers>
{Object.values(filters).map((v) => (
<FilterItem {...v} key={v.id} onChange={handleOnChange} />
))}
</DialogContent>
<DialogActions>
<Button size="large" onClick={handleOnCancel}>
Cancel
</Button>
<Button variant="contained" size="large" onClick={handleOnSave}>
Save
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -0,0 +1,34 @@
import React from 'react';
import { Button, IconButton } from '@mui/material';
import { Tune } from '@mui/icons-material';
type FiltersButtonProps = {
iconOnly?: boolean;
fullWidth?: boolean;
onClick: () => void;
};
const FiltersButton = ({ iconOnly, fullWidth, onClick }: FiltersButtonProps) => {
if (iconOnly) {
return (
<IconButton onClick={onClick} color="primary">
<Tune />
</IconButton>
);
}
return (
<Button
fullWidth={fullWidth}
size="large"
variant="contained"
endIcon={<Tune />}
onClick={onClick}
sx={{ textTransform: 'none' }}
>
Filters
</Button>
);
};
export default FiltersButton;
@@ -0,0 +1,69 @@
import { EnumFilterKey, TFilters } from '../../typeDefs/filters';
export const generateFilterSchema = () => ({
profitMargin: {
label: 'Profit margin (%)',
id: EnumFilterKey.profitMargin,
value: [0, 100],
isSmooth: true,
marks: [
{ label: '0', value: 0 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '40', value: 40 },
{ label: '50', value: 50 },
{ label: '60', value: 60 },
{ label: '70', value: 70 },
{ label: '80', value: 80 },
{ label: '90', value: 90 },
{ label: '100', value: 100 },
],
tooltipInfo:
'As a delegator you want to chose nodes with lower profit margin, meaning more payout for their delegators',
},
stakeSaturation: {
label: 'Stake saturation (%)',
id: EnumFilterKey.stakeSaturation,
value: [0, 100],
isSmooth: true,
marks: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((value) => ({
value: value < 100 ? value : 100,
label: value < 100 ? value : '>100',
})),
tooltipInfo: "Select nodes with <100% saturation. Any additional stake above 100% saturation won't get rewards",
},
routingScore: {
label: 'Routing score (%)',
id: EnumFilterKey.routingScore,
value: [0, 100],
isSmooth: true,
marks: [
{ label: '0', value: 0 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '40', value: 40 },
{ label: '50', value: 50 },
{ label: '60', value: 60 },
{ label: '70', value: 70 },
{ label: '80', value: 80 },
{ label: '90', value: 90 },
{ label: '100', value: 100 },
],
tooltipInfo: 'The higher the routing score the better the performance of the node and so its rewards',
},
});
const formatStakeSaturationValues = ([value_1, value_2]: number[]) => {
const lowerValue = value_1 / 100;
const upperValue = value_2 / 100;
return [lowerValue, upperValue];
};
export const formatOnSave = (filters: TFilters) => ({
routingScore: filters.routingScore.value,
profitMargin: filters.profitMargin.value,
stakeSaturation: formatStakeSaturationValues(filters.stakeSaturation.value),
});
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import Box from '@mui/material/Box'
import MuiLink from '@mui/material/Link'
import Typography from '@mui/material/Typography'
import { useIsMobile } from '../hooks/useIsMobile'
import { NymVpnIcon } from '../icons/NymVpn'
import { Socials } from './Socials'
import Link from 'next/link'
export const Footer: FCWithChildren = () => {
const isMobile = useIsMobile()
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: 'auto',
mt: 3,
pt: 3,
pb: 3,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: 'auto',
justifyContent: 'center',
alignItems: 'center',
mb: 2,
}}
>
<Box marginRight={1}>
<Link href="http://nymvpn.com" target="_blank">
<NymVpnIcon />
</Link>
</Box>
<Socials isFooter />
</Box>
<Typography
sx={{
fontSize: 12,
textAlign: isMobile ? 'center' : 'end',
color: 'nym.muted.onDarkBg',
}}
>
© {new Date().getFullYear()} Nym Technologies SA, all rights reserved
</Typography>
</Box>
)
}
@@ -0,0 +1,52 @@
import { GatewayResponse, GatewayBond, GatewayReportResponse } from '@/app/typeDefs/explorer-api';
import { toPercentInteger } from '@/app/utils';
export type GatewayRowType = {
id: string;
owner: string;
identity_key: string;
bond: number;
host: string;
location: string;
version: string;
node_performance: number;
};
export type GatewayEnrichedRowType = GatewayRowType & {
routingScore: string;
avgUptime: string;
clientsPort: number;
mixPort: number;
};
export function gatewayToGridRow(arrayOfGateways: GatewayResponse): GatewayRowType[] {
return !arrayOfGateways
? []
: arrayOfGateways.map((gw) => ({
id: gw.owner,
owner: gw.owner,
identity_key: gw.gateway.identity_key || '',
location: gw.location?.country_name.toUpperCase() || '',
bond: gw.pledge_amount.amount || 0,
host: gw.gateway.host || '',
version: gw.gateway.version || '',
node_performance: toPercentInteger(gw.node_performance.last_24h),
}));
}
export function gatewayEnrichedToGridRow(gateway: GatewayBond, report: GatewayReportResponse): GatewayEnrichedRowType {
return {
id: gateway.owner,
owner: gateway.owner,
identity_key: gateway.gateway.identity_key || '',
location: gateway.location?.country_name.toUpperCase() || '',
bond: gateway.pledge_amount.amount || 0,
host: gateway.gateway.host || '',
version: gateway.gateway.version || '',
clientsPort: gateway.gateway.clients_port || 0,
mixPort: gateway.gateway.mix_port || 0,
routingScore: `${report.most_recent}%`,
avgUptime: `${report.last_day || report.last_hour}%`,
node_performance: toPercentInteger(gateway.node_performance.most_recent),
};
}
@@ -0,0 +1,51 @@
import React from 'react'
import { FormControl, MenuItem, Select } from '@mui/material'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export enum VersionSelectOptions {
latestVersion = 'Latest versions',
olderVersions = 'Older versions',
all = 'All',
}
export const VersionDisplaySelector = ({
selected,
handleChange,
}: {
selected: VersionSelectOptions
handleChange: (option: VersionSelectOptions) => void
}) => {
const isMobile = useIsMobile()
return (
<FormControl size="small">
<Select
value={selected}
onChange={(e) => handleChange(e.target.value as VersionSelectOptions)}
labelId="simple-select-label"
id="simple-select"
sx={{
marginRight: isMobile ? 0 : 2,
}}
>
<MenuItem
value={VersionSelectOptions.latestVersion}
data-testid="show-gateway-latest-version"
>
{VersionSelectOptions.latestVersion}
</MenuItem>
<MenuItem
value={VersionSelectOptions.olderVersions}
data-testid="show-gateway-old-versions"
>
{VersionSelectOptions.olderVersions}
</MenuItem>
<MenuItem
value={VersionSelectOptions.all}
data-testid="show-gateway-all-versions"
>
{VersionSelectOptions.all}
</MenuItem>
</Select>
</FormControl>
)
}
+28
View File
@@ -0,0 +1,28 @@
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline';
import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
import { MixnodeStatus } from '../typeDefs/explorer-api';
export const Icons = {
Mixnodes: {
Status: {
Active: CheckCircleOutlineIcon,
Standby: PauseCircleOutlineIcon,
Inactive: CircleOutlinedIcon,
},
},
};
export const getMixNodeIcon = (value: any) => {
if (value && typeof value === 'string') {
switch (value) {
case MixnodeStatus.active:
return Icons.Mixnodes.Status.Active;
case MixnodeStatus.standby:
return Icons.Mixnodes.Status.Standby;
default:
return Icons.Mixnodes.Status.Inactive;
}
}
return Icons.Mixnodes.Status.Inactive;
};
@@ -0,0 +1,213 @@
import * as React from 'react'
import { Alert, Box, CircularProgress, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Paper from '@mui/material/Paper'
import { ExpandMore } from '@mui/icons-material'
import { currencyToString } from '@/app/utils/currency'
import { useMixnodeContext } from '@/app/context/mixnode'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export const BondBreakdownTable: FCWithChildren = () => {
const { mixNode, delegations, uniqDelegations } = useMixnodeContext()
const [showDelegations, toggleShowDelegations] =
React.useState<boolean>(false)
const [bonds, setBonds] = React.useState({
delegations: '0',
pledges: '0',
bondsTotal: '0',
hasLoaded: false,
})
const theme = useTheme()
const isMobile = useIsMobile()
React.useEffect(() => {
if (mixNode?.data) {
// delegations
const decimalisedDelegations = currencyToString({
amount: mixNode.data.total_delegation.amount.toString(),
denom: mixNode.data.total_delegation.denom,
})
// pledges
const decimalisedPledges = currencyToString({
amount: mixNode.data.pledge_amount.amount.toString(),
denom: mixNode.data.pledge_amount.denom,
})
// bonds total (del + pledges)
const pledgesSum = Number(mixNode.data.pledge_amount.amount)
const delegationsSum = Number(mixNode.data.total_delegation.amount)
const bondsTotal = currencyToString({
amount: (pledgesSum + delegationsSum).toString(),
})
setBonds({
delegations: decimalisedDelegations,
pledges: decimalisedPledges,
bondsTotal,
hasLoaded: true,
})
}
}, [mixNode])
const expandDelegations = () => {
if (delegations?.data && delegations.data.length > 0) {
toggleShowDelegations(!showDelegations)
}
}
const calcBondPercentage = (num: number) => {
if (mixNode?.data) {
const rawDelegationAmount = Number(mixNode.data.total_delegation.amount)
const rawPledgeAmount = Number(mixNode.data.pledge_amount.amount)
const rawTotalBondsAmount = rawDelegationAmount + rawPledgeAmount
return ((num * 100) / rawTotalBondsAmount).toFixed(1)
}
return 0
}
if (mixNode?.isLoading || delegations?.isLoading) {
return <CircularProgress />
}
if (mixNode?.error) {
return <Alert severity="error">Mixnode not found</Alert>
}
if (delegations?.error) {
return <Alert severity="error">Unable to get delegations for mixnode</Alert>
}
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="bond breakdown totals">
<TableBody>
<TableRow sx={isMobile ? { minWidth: '70vw' } : null}>
<TableCell
sx={{
fontWeight: 400,
width: '150px',
}}
align="left"
>
Stake total
</TableCell>
<TableCell align="left" data-testid="bond-total-amount">
{bonds.bondsTotal}
</TableCell>
</TableRow>
<TableRow>
<TableCell align="left">Bond</TableCell>
<TableCell align="left" data-testid="pledge-total-amount">
{bonds.pledges}
</TableCell>
</TableRow>
<TableRow>
<TableCell onClick={expandDelegations} align="left">
<Box
sx={{
display: 'flex',
alignItems: 'center',
}}
>
Delegation total {'\u00A0'}
{delegations?.data && delegations?.data?.length > 0 && (
<ExpandMore />
)}
</Box>
</TableCell>
<TableCell align="left" data-testid="delegation-total-amount">
{bonds.delegations}
</TableCell>
</TableRow>
</TableBody>
</Table>
{showDelegations && (
<Box
sx={{
maxHeight: 400,
overflowY: 'scroll',
p: 2,
background: theme.palette.background.paper,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
width: '100%',
p: 2,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
data-testid="delegations-total-amount"
>
<Typography
sx={{
fontSize: 16,
fontWeight: 600,
}}
>
Delegations&nbsp;&nbsp;
</Typography>
</Box>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
}}
align="left"
>
Delegators
</TableCell>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
}}
align="left"
>
Amount
</TableCell>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
width: '200px',
}}
align="left"
>
Share of stake
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{uniqDelegations?.data?.map(({ owner, amount: { amount } }) => (
<TableRow key={owner}>
<TableCell sx={isMobile ? { width: 190 } : null} align="left">
{owner}
</TableCell>
<TableCell align="left">
{currencyToString({ amount: amount.toString() })}
</TableCell>
<TableCell align="left">
{calcBondPercentage(amount)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
</TableContainer>
)
}
@@ -0,0 +1,114 @@
import * as React from 'react'
import { Box, Button, Grid, Typography, useTheme } from '@mui/material'
import Identicon from 'react-identicons'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MixNodeDescriptionResponse } from '@/app/typeDefs/explorer-api'
import { getMixNodeStatusText, MixNodeStatus } from './Status'
import { MixnodeRowType } from '.'
interface MixNodeDetailProps {
mixNodeRow: MixnodeRowType
mixnodeDescription: MixNodeDescriptionResponse
}
export const MixNodeDetailSection: FCWithChildren<MixNodeDetailProps> = ({
mixNodeRow,
mixnodeDescription,
}) => {
const theme = useTheme()
const palette = [theme.palette.text.primary]
const isMobile = useIsMobile()
const statusText = React.useMemo(
() => getMixNodeStatusText(mixNodeRow.status),
[mixNodeRow.status]
)
return (
<Grid container>
<Grid item xs={12} md={6}>
<Box
display="flex"
flexDirection={isMobile ? 'column' : 'row'}
width="100%"
>
<Box
width={72}
height={72}
sx={{
minWidth: 72,
minHeight: 72,
borderWidth: 1,
borderColor: theme.palette.text.primary,
borderStyle: 'solid',
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
}}
>
<Identicon
size={43}
string={mixNodeRow.identity_key}
palette={palette}
/>
</Box>
<Box ml={isMobile ? 0 : 2} mt={isMobile ? 2 : 0}>
<Typography fontSize={21}>{mixnodeDescription.name}</Typography>
<Typography>
{(mixnodeDescription.description || '').slice(0, 1000)}
</Typography>
<Button
component="a"
variant="text"
sx={{
mt: isMobile ? 2 : 4,
borderRadius: '30px',
fontWeight: 600,
padding: 0,
}}
href={mixnodeDescription.link}
target="_blank"
>
<Typography
component="span"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
maxWidth="250px"
>
{mixnodeDescription.link}
</Typography>
</Button>
</Box>
</Box>
</Grid>
<Grid
item
xs={12}
md={6}
display="flex"
justifyContent={isMobile ? 'start' : 'end'}
mt={isMobile ? 3 : undefined}
>
<Box display="flex" flexDirection="column">
<Typography
fontWeight="600"
alignSelf={isMobile ? 'start' : 'self-end'}
>
Node status:
</Typography>
<Box mt={2} alignSelf={isMobile ? 'start' : 'self-end'}>
<MixNodeStatus status={mixNodeRow.status} />
</Box>
<Typography
mt={1}
alignSelf={isMobile ? 'start' : 'self-end'}
color={theme.palette.text.secondary}
fontSize="smaller"
>
This node is {statusText} in this epoch
</Typography>
</Box>
</Grid>
</Grid>
)
}
@@ -0,0 +1,51 @@
import { ColumnsType } from '../../DetailTable';
export const EconomicsInfoColumns: ColumnsType[] = [
{
field: 'estimatedTotalReward',
title: 'Estimated Total Reward',
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',
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',
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: 'profitMargin',
title: 'Profit Margin',
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',
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: 'nodePerformance',
title: 'Routing Score',
width: '10%',
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.",
},
{
field: 'avgUptime',
title: 'Avg. Score',
tooltipInfo: "Mixnode's average routing score in the last 24 hour",
},
];
@@ -0,0 +1,31 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { EconomicsProgress } from './EconomicsProgress';
export default {
title: 'Mix Node Detail/Economics/ProgressBar',
component: EconomicsProgress,
} as ComponentMeta<typeof EconomicsProgress>;
const Template: ComponentStory<typeof EconomicsProgress> = (args) => <EconomicsProgress {...args} />;
export const Empty = Template.bind({});
Empty.args = {};
export const OverThreshold = Template.bind({});
OverThreshold.args = {
threshold: 100,
value: 120,
};
export const UnderThreshold = Template.bind({});
UnderThreshold.args = {
threshold: 100,
value: 80,
};
export const OnThreshold = Template.bind({});
OnThreshold.args = {
threshold: 100,
value: 100,
};
@@ -0,0 +1,38 @@
import * as React from 'react';
import LinearProgress, { LinearProgressProps } from '@mui/material/LinearProgress';
import { useTheme } from '@mui/material/styles';
import { Box } from '@mui/system';
const parseToNumber = (value: number | undefined | string) =>
typeof value === 'string' ? parseInt(value || '', 10) : value || 0;
export const EconomicsProgress: FCWithChildren<
LinearProgressProps & {
threshold?: number;
color: string;
}
> = ({ threshold, color, ...props }) => {
const theme = useTheme();
const { value } = props;
const valueNumber: number = parseToNumber(value);
const thresholdNumber: number = parseToNumber(threshold);
const percentageToDisplay = Math.min(valueNumber, thresholdNumber);
return (
<Box
sx={{
width: 6 / 10,
color: valueNumber > (threshold || 100) ? theme.palette.warning.main : theme.palette.nym.wallet.fee,
}}
>
<LinearProgress
{...props}
variant="determinate"
color={color}
value={percentageToDisplay}
sx={{ width: '100%', borderRadius: '5px' }}
/>
</Box>
);
};
@@ -0,0 +1,107 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { DelegatorsInfoTable } from './Table';
import { EconomicsInfoColumns } from './Columns';
import { EconomicsInfoRowWithIndex } from './types';
export default {
title: 'Mix Node Detail/Economics',
component: DelegatorsInfoTable,
} as ComponentMeta<typeof DelegatorsInfoTable>;
const row: EconomicsInfoRowWithIndex = {
id: 1,
selectionChance: {
value: 'High',
},
estimatedOperatorReward: {
value: '80000.123456 NYM',
},
estimatedTotalReward: {
value: '80000.123456 NYM',
},
profitMargin: {
value: '10 %',
},
operatingCost: {
value: '11121 NYM',
},
avgUptime: {
value: '-',
},
nodePerformance: {
value: '-',
},
};
const rowGoodProbabilitySelection: EconomicsInfoRowWithIndex = {
...row,
selectionChance: {
value: 'Good',
},
};
const rowLowProbabilitySelection: EconomicsInfoRowWithIndex = {
...row,
selectionChance: {
value: 'Low',
},
};
const emptyRow: EconomicsInfoRowWithIndex = {
id: 1,
selectionChance: {
value: '-',
progressBarValue: 0,
},
estimatedOperatorReward: {
value: '-',
},
estimatedTotalReward: {
value: '-',
},
profitMargin: {
value: '-',
},
operatingCost: {
value: '-',
},
avgUptime: {
value: '-',
},
nodePerformance: {
value: '-',
},
};
const Template: ComponentStory<typeof DelegatorsInfoTable> = (args) => <DelegatorsInfoTable {...args} />;
export const Empty = Template.bind({});
Empty.args = {
rows: [emptyRow],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceHigh = Template.bind({});
selectionChanceHigh.args = {
rows: [row],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceGood = Template.bind({});
selectionChanceGood.args = {
rows: [rowGoodProbabilitySelection],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceLow = Template.bind({});
selectionChanceLow.args = {
rows: [rowLowProbabilitySelection],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
@@ -0,0 +1,57 @@
import { currencyToString, unymToNym } from '@/app/utils/currency';
import { useMixnodeContext } from '@/app/context/mixnode';
import { ApiState, MixNodeEconomicDynamicsStatsResponse } from '@/app/typeDefs/explorer-api';
import { toPercentIntegerString } from '@/app/utils';
import { EconomicsInfoRowWithIndex } from './types';
const selectionChance = (economicDynamicsStats: ApiState<MixNodeEconomicDynamicsStatsResponse> | undefined) =>
economicDynamicsStats?.data?.active_set_inclusion_probability || '-';
export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => {
const { economicDynamicsStats, mixNode } = useMixnodeContext();
const estimatedNodeRewards =
currencyToString({
amount: economicDynamicsStats?.data?.estimated_total_node_reward.toString() || '',
}) || '-';
const estimatedOperatorRewards =
currencyToString({
amount: economicDynamicsStats?.data?.estimated_operator_reward.toString() || '',
}) || '-';
const profitMargin = mixNode?.data?.profit_margin_percent
? toPercentIntegerString(mixNode?.data?.profit_margin_percent)
: '-';
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;
return {
id: 1,
estimatedTotalReward: {
value: estimatedNodeRewards,
},
estimatedOperatorReward: {
value: estimatedOperatorRewards,
},
selectionChance: {
value: selectionChance(economicDynamicsStats),
},
profitMargin: {
value: profitMargin ? `${profitMargin} %` : '-',
},
operatingCost: {
value: opCost ? `${unymToNym(opCost.amount, 6)} NYM` : '-',
},
avgUptime: {
value: avgUptime ? `${avgUptime} %` : '-',
},
nodePerformance: {
value: nodePerformance,
},
};
};
@@ -0,0 +1,47 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { useIsMobile } from '@/app/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 (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: isTablet ? 'column' : 'row',
}}
id="field"
color={percentageColor}
>
<Typography
sx={{
mr: isTablet ? 0 : 1,
mb: isTablet ? 1 : 0,
fontWeight: '600',
fontSize: '12px',
color: textColor,
}}
id="stake-saturation-progress-bar"
>
{value}%
</Typography>
<EconomicsProgress
value={value}
threshold={threshold}
color={percentageColor}
/>
</Box>
)
}
@@ -0,0 +1,91 @@
import * as React from 'react'
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material'
import { Box } from '@mui/system'
import { useTheme } from '@mui/material/styles'
import { Tooltip } from '@nymproject/react/tooltip/Tooltip'
import { EconomicsRowsType, EconomicsInfoRowWithIndex } from './types'
import { UniversalTableProps } from '@/app/components/DetailTable'
import { textColour } from '@/app/utils'
const formatCellValues = (value: EconomicsRowsType, field: string) => (
<Box sx={{ display: 'flex', alignItems: 'center' }} id="field">
<Typography sx={{ mr: 1, fontWeight: '600', fontSize: '12px' }} id={field}>
{value.value}
</Typography>
</Box>
)
export const DelegatorsInfoTable: FCWithChildren<
UniversalTableProps<EconomicsInfoRowWithIndex>
> = ({ tableName, columnsData, rows }) => {
const theme = useTheme()
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, tooltipInfo, width }) => (
<TableCell
key={field}
sx={{ fontSize: 14, fontWeight: 600, width }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{tooltipInfo && (
<Tooltip
title={tooltipInfo}
id={field}
placement="top-start"
textColor={
theme.palette.nym.networkExplorer.tooltip.color
}
bgColor={
theme.palette.nym.networkExplorer.tooltip.background
}
maxWidth={230}
arrow
/>
)}
{title}
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows?.map((eachRow) => (
<TableRow
key={eachRow.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columnsData?.map((_, index: number) => {
const { field } = columnsData[index]
const value: EconomicsRowsType = (eachRow as any)[field]
return (
<TableCell
key={_.title}
sx={{
color: textColour(value, field, theme),
}}
data-testid={`${_.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(value, columnsData[index].field)}
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
@@ -0,0 +1,3 @@
export { DelegatorsInfoTable } from './Table';
export { EconomicsInfoColumns } from './Columns';
export { EconomicsInfoRows } from './Rows';
@@ -0,0 +1,20 @@
export type EconomicsRowsType = {
progressBarValue?: number;
value: string;
};
type TEconomicsInfoProperties =
| 'estimatedTotalReward'
| 'estimatedOperatorReward'
| 'estimatedOperatorReward'
| 'selectionChance'
| 'profitMargin'
| 'avgUptime'
| 'nodePerformance'
| 'operatingCost';
export type EconomicsInfoRow = {
[k in TEconomicsInfoProperties]: EconomicsRowsType;
};
export type EconomicsInfoRowWithIndex = EconomicsInfoRow & { id: number };
@@ -0,0 +1,36 @@
import * as React from 'react'
import { Typography } from '@mui/material'
import { getMixNodeIcon } from '@/app/components/Icons'
import { MixnodeStatus } from '@/app/typeDefs/explorer-api'
import { useGetMixNodeStatusColor } from '@/app/hooks/useGetMixnodeStatusColor'
interface MixNodeStatusProps {
status: MixnodeStatus
}
// TODO: should be done with i18n
export const getMixNodeStatusText = (status: MixnodeStatus) => {
switch (status) {
case MixnodeStatus.active:
return 'active'
case MixnodeStatus.standby:
return 'on standby'
default:
return 'inactive'
}
}
export const MixNodeStatus: FCWithChildren<MixNodeStatusProps> = ({
status,
}) => {
const Icon = React.useMemo(() => getMixNodeIcon(status), [status])
const color = useGetMixNodeStatusColor(status)
return (
<Typography color={color} display="flex" alignItems="center">
<Icon />
<Typography ml={1} component="span" color="inherit">
{`${status[0].toUpperCase()}${status.slice(1)}`}
</Typography>
</Typography>
)
}
@@ -0,0 +1,83 @@
import * as React from 'react'
import { MenuItem } from '@mui/material'
import Select from '@mui/material/Select'
import { SelectChangeEvent } from '@mui/material/Select/SelectInput'
import { SxProps } from '@mui/system'
import {
MixnodeStatus,
MixnodeStatusWithAll,
} from '@/app/typeDefs/explorer-api'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MixNodeStatus } from './Status'
// TODO: replace with i18n
const ALL_NODES = 'All nodes'
interface MixNodeStatusDropdownProps {
status?: MixnodeStatusWithAll
sx?: SxProps
onSelectionChanged?: (status?: MixnodeStatusWithAll) => void
}
export const MixNodeStatusDropdown: FCWithChildren<
MixNodeStatusDropdownProps
> = ({ status, onSelectionChanged, sx }) => {
const isMobile = useIsMobile()
const [statusValue, setStatusValue] = React.useState<MixnodeStatusWithAll>(
status || MixnodeStatusWithAll.all
)
const onChange = React.useCallback(
(event: SelectChangeEvent) => {
setStatusValue(event.target.value as MixnodeStatusWithAll)
if (onSelectionChanged) {
onSelectionChanged(event.target.value as MixnodeStatusWithAll)
}
},
[onSelectionChanged]
)
return (
<Select
labelId="mixnodeStatusSelect_label"
id="mixnodeStatusSelect"
value={statusValue}
onChange={onChange}
renderValue={(value) => {
switch (value) {
case 'active':
case 'standby':
case 'inactive':
return <MixNodeStatus status={value as unknown as MixnodeStatus} />
default:
return ALL_NODES
}
}}
sx={{
width: isMobile ? '50%' : 200,
...sx,
}}
>
<MenuItem
value={MixnodeStatus.active}
data-testid="mixnodeStatusSelectOption_active"
>
<MixNodeStatus status={MixnodeStatus.active} />
</MenuItem>
<MenuItem
value={MixnodeStatus.standby}
data-testid="mixnodeStatusSelectOption_standby"
>
<MixNodeStatus status={MixnodeStatus.standby} />
</MenuItem>
<MenuItem
value={MixnodeStatus.inactive}
data-testid="mixnodeStatusSelectOption_inactive"
>
<MixNodeStatus status={MixnodeStatus.inactive} />
</MenuItem>
<MenuItem value={'all'} data-testid="mixnodeStatusSelectOption_allNodes">
{ALL_NODES}
</MenuItem>
</Select>
)
}
@@ -0,0 +1,3 @@
export * from './Status';
export * from './StatusDropdown';
export * from './mappings';
@@ -0,0 +1,57 @@
/* eslint-disable camelcase */
import { MixNodeResponse, MixNodeResponseItem, MixnodeStatus } from '../../typeDefs/explorer-api';
import { toPercentInteger, toPercentIntegerString } from '@/app/utils';
import { unymToNym } from '@/app/utils/currency';
export type MixnodeRowType = {
mix_id: number;
id: string;
status: MixnodeStatus;
owner: string;
location: string;
identity_key: string;
bond: number;
self_percentage: string;
pledge_amount: number;
host: string;
layer: string;
profit_percentage: number;
avg_uptime: string;
stake_saturation: React.ReactNode;
operating_cost: number;
node_performance: number;
blacklisted: boolean;
};
export function mixnodeToGridRow(arrayOfMixnodes?: MixNodeResponse): MixnodeRowType[] {
return (arrayOfMixnodes || []).map(mixNodeResponseItemToMixnodeRowType);
}
export function mixNodeResponseItemToMixnodeRowType(item: MixNodeResponseItem): MixnodeRowType {
const pledge = Number(item.pledge_amount.amount) || 0;
const delegations = Number(item.total_delegation.amount) || 0;
const totalBond = pledge + delegations;
const selfPercentage = ((pledge * 100) / totalBond).toFixed(2);
const profitPercentage = toPercentInteger(item.profit_margin_percent) || 0;
const uncappedSaturation = typeof item.uncapped_saturation === 'number' ? item.uncapped_saturation * 100 : 0;
return {
mix_id: item.mix_id,
id: item.owner,
status: item.status,
owner: item.owner,
identity_key: item.mix_node.identity_key || '',
bond: totalBond || 0,
location: item?.location?.country_name || '',
self_percentage: selfPercentage,
pledge_amount: pledge,
host: item?.mix_node?.host || '',
layer: item?.layer || '',
profit_percentage: profitPercentage,
avg_uptime: `${toPercentIntegerString(item.node_performance.last_24h)}%`,
stake_saturation: Number(uncappedSaturation.toFixed(2)),
operating_cost: Number(unymToNym(item.operating_cost?.amount, 6)) || 0,
node_performance: toPercentInteger(item.node_performance.most_recent),
blacklisted: item.blacklisted,
};
}
@@ -0,0 +1,373 @@
'use client'
import * as React from 'react'
import { ExpandLess, ExpandMore, Menu } from '@mui/icons-material'
import { CSSObject, styled, Theme, useTheme } from '@mui/material/styles'
import { Link as MuiLink } from '@mui/material'
import Button from '@mui/material/Button'
import Box from '@mui/material/Box'
import ListItem from '@mui/material/ListItem'
import MuiDrawer from '@mui/material/Drawer'
import AppBar from '@mui/material/AppBar'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import List from '@mui/material/List'
import IconButton from '@mui/material/IconButton'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import { NYM_WEBSITE } from '@/app/api/constants'
import { useMainContext } from '@/app/context/main'
import { MobileDrawerClose } from '@/app/icons/MobileDrawerClose'
import { NavOptionType, originalNavOptions } from '@/app/context/nav'
import { DarkLightSwitchDesktop } from '@/app/components/Switch'
import { Footer } from '@/app/components/Footer'
import { ConnectKeplrWallet } from '@/app/components/Wallet/ConnectKeplrWallet'
import { usePathname, useRouter } from 'next/navigation'
const drawerWidth = 255
const bannerHeight = 80
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: 'hidden',
})
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
})
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1),
height: 64,
}))
const Drawer = styled(MuiDrawer, {
shouldForwardProp: (prop) => prop !== 'open',
})(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
...(open && {
...openedMixin(theme),
'& .MuiDrawer-paper': openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
'& .MuiDrawer-paper': closedMixin(theme),
}),
}))
type ExpandableButtonType = {
title: string
url: string
isActive?: boolean
Icon?: React.ReactNode
nested?: NavOptionType[]
isChild?: boolean
isMobile: boolean
drawIsTempOpen: boolean
drawIsFixed: boolean
isExternalLink?: boolean
openDrawer: () => void
closeDrawer?: () => void
fixDrawerClose?: () => void
}
export const ExpandableButton: FCWithChildren<ExpandableButtonType> = ({
title,
url,
drawIsTempOpen,
drawIsFixed,
Icon,
nested,
isMobile,
isChild,
isExternalLink,
openDrawer,
closeDrawer,
fixDrawerClose,
}) => {
const { palette } = useTheme()
const pathname = usePathname()
const router = useRouter()
const handleClick = () => {
if (title === 'Network Components') {
return undefined
}
if (isExternalLink) {
window.open(url, '_blank')
return undefined
}
if (!isExternalLink) {
router.push(url, {})
}
if (closeDrawer) {
closeDrawer()
}
}
const selectedStyle = {
background: palette.nym.networkExplorer.nav.selected.main,
borderRight: `3px solid ${palette.nym.highlight}`,
}
return (
<>
<ListItem
disablePadding
disableGutters
sx={{
borderBottom: isChild ? 'none' : '1px solid rgba(255, 255, 255, 0.1)',
...(pathname === url
? selectedStyle
: {
background: palette.nym.networkExplorer.nav.background,
borderRight: 'none',
}),
}}
>
<ListItemButton
onClick={() => handleClick()}
sx={{
pt: 2,
pb: 2,
background: isChild
? palette.nym.networkExplorer.nav.selected.nested
: 'none',
}}
>
<ListItemIcon sx={{ minWidth: '39px' }}>{Icon}</ListItemIcon>
<ListItemText
primary={title}
sx={{
color: palette.nym.networkExplorer.nav.text,
}}
/>
</ListItemButton>
</ListItem>
{nested?.map((each) => (
<ExpandableButton
url={each.url}
key={each.title}
title={each.title}
openDrawer={openDrawer}
drawIsTempOpen={drawIsTempOpen}
closeDrawer={closeDrawer}
drawIsFixed={drawIsFixed}
fixDrawerClose={fixDrawerClose}
isMobile={isMobile}
isChild
isExternalLink={each.isExternal}
/>
))}
</>
)
}
export const Nav: FCWithChildren = ({ children }) => {
const { environment } = useMainContext()
const [drawerIsOpen, setDrawerToOpen] = React.useState(false)
const [fixedOpen, setFixedOpen] = React.useState(false)
// Set maintenance banner to false by default to don't display it
const [openMaintenance, setOpenMaintenance] = React.useState(false)
const theme = useTheme()
const explorerName = environment
? `${environment} Explorer`
: 'Mainnet Explorer'
const switchNetworkText =
environment === 'mainnet' ? 'Switch to Testnet' : 'Switch to Mainnet'
const switchNetworkLink =
environment === 'mainnet'
? 'https://sandbox-explorer.nymtech.net'
: 'https://explorer.nymtech.net'
const fixDrawerOpen = () => {
setFixedOpen(true)
setDrawerToOpen(true)
}
const fixDrawerClose = () => {
setFixedOpen(false)
setDrawerToOpen(false)
}
const tempDrawerOpen = () => {
if (!fixedOpen) {
setDrawerToOpen(true)
}
}
const tempDrawerClose = () => {
if (!fixedOpen) {
setDrawerToOpen(false)
}
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar
sx={{
background: theme.palette.nym.networkExplorer.topNav.appBar,
borderRadius: 0,
}}
>
<Toolbar
disableGutters
sx={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
ml: 0.5,
}}
>
<IconButton component="a" href={NYM_WEBSITE} target="_blank">
{/* <NymLogo /> */}
</IconButton>
<Typography
variant="h6"
noWrap
sx={{
color: theme.palette.nym.networkExplorer.nav.text,
fontSize: '18px',
fontWeight: 600,
}}
>
<MuiLink
href="/"
underline="none"
color="inherit"
textTransform="capitalize"
>
{explorerName}
</MuiLink>
<Button
size="small"
variant="outlined"
color="inherit"
href={switchNetworkLink}
sx={{
borderRadius: 2,
textTransform: 'none',
width: 150,
ml: 4,
fontSize: 14,
fontWeight: 600,
}}
>
{switchNetworkText}
</Button>
</Typography>
</Box>
<Box
sx={{
mr: 2,
alignItems: 'center',
display: 'flex',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: 'auto',
pr: 0,
pl: 2,
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<Box sx={{ mr: 1 }}>
<ConnectKeplrWallet />
</Box>
<DarkLightSwitchDesktop defaultChecked />
</Box>
</Box>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
open={true}
PaperProps={{
style: {
background: theme.palette.nym.networkExplorer.nav.background,
borderRadius: 0,
top: openMaintenance ? bannerHeight : 0,
},
}}
>
<DrawerHeader
sx={{
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
justifyContent: 'flex-start',
paddingLeft: 0,
display: 'none',
}}
>
<IconButton
onClick={drawerIsOpen ? fixDrawerClose : fixDrawerOpen}
sx={{
padding: 1,
ml: 1,
color: theme.palette.nym.networkExplorer.nav.text,
}}
>
{drawerIsOpen ? <MobileDrawerClose /> : <Menu />}
</IconButton>
</DrawerHeader>
<List
sx={{ pb: 0 }}
onMouseEnter={tempDrawerOpen}
onMouseLeave={tempDrawerClose}
>
{originalNavOptions.map((props) => (
<ExpandableButton
key={props.url}
closeDrawer={tempDrawerClose}
drawIsTempOpen={drawerIsOpen}
drawIsFixed={fixedOpen}
fixDrawerClose={fixDrawerClose}
openDrawer={tempDrawerOpen}
isMobile={false}
{...props}
/>
))}
</List>
</Drawer>
<Box
style={{ width: `calc(100% - ${drawerWidth}px` }}
sx={{ py: 5, px: 6, mt: 7 }}
>
{children}
<Footer />
</Box>
</Box>
)
}
@@ -0,0 +1,136 @@
'use client'
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
Toolbar,
} from '@mui/material'
import { Menu } from '@mui/icons-material'
import { MaintenanceBanner } from '@nymproject/react/banners/MaintenanceBanner'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MobileDrawerClose } from '@/app/icons/MobileDrawerClose'
import { Footer } from '../Footer'
import { ExpandableButton } from './DesktopNav'
import { ConnectKeplrWallet } from '../Wallet/ConnectKeplrWallet'
import { NetworkTitle } from '../NetworkTitle'
import { originalNavOptions } from '@/app/context/nav'
export const MobileNav: FCWithChildren = ({ children }) => {
const theme = useTheme()
const [drawerOpen, setDrawerOpen] = React.useState(false)
// Set maintenance banner to false by default to don't display it
const [openMaintenance, setOpenMaintenance] = React.useState(false)
const isSmallMobile = useIsMobile(400)
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen)
}
const openDrawer = () => {
setDrawerOpen(true)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<AppBar
sx={{
background: theme.palette.nym.networkExplorer.topNav.appBar,
borderRadius: 0,
}}
>
<MaintenanceBanner
open={openMaintenance}
onClick={() => setOpenMaintenance(false)}
/>
<Toolbar
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<IconButton onClick={toggleDrawer}>
<Menu sx={{ color: 'primary.contrastText' }} />
</IconButton>
{!isSmallMobile && <NetworkTitle />}
</Box>
<ConnectKeplrWallet />
</Toolbar>
</AppBar>
<Drawer
anchor="left"
open={drawerOpen}
onClose={toggleDrawer}
PaperProps={{
style: {
background: theme.palette.nym.networkExplorer.nav.background,
},
}}
>
<Box role="presentation">
<List sx={{ pt: 0, pb: 0 }}>
<ListItem
disablePadding
disableGutters
sx={{
height: 64,
background: theme.palette.nym.networkExplorer.nav.background,
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ListItemButton
onClick={toggleDrawer}
sx={{
pt: 2,
pb: 2,
background: theme.palette.nym.networkExplorer.nav.background,
display: 'flex',
justifyContent: 'flex-start',
}}
>
<ListItemIcon>
<MobileDrawerClose />
</ListItemIcon>
</ListItemButton>
</ListItem>
{originalNavOptions.map((props) => (
<ExpandableButton
key={props.url}
title={props.title}
openDrawer={openDrawer}
url={props.url}
drawIsTempOpen={false}
drawIsFixed={false}
Icon={props.Icon}
nested={props.nested}
closeDrawer={toggleDrawer}
isMobile
/>
))}
</List>
</Box>
</Drawer>
<Box sx={{ width: '100%', p: 4, mt: 7 }}>
{children}
<Footer />
</Box>
</Box>
)
}
@@ -0,0 +1,18 @@
'use client'
import React from 'react'
import { useIsMobile } from '@/app/hooks'
import { MobileNav } from './MobileNav'
import { Nav } from './DesktopNav'
const Navbar = ({ children }: { children: React.ReactNode }) => {
const isMobile = useIsMobile()
if (isMobile) {
return <MobileNav>{children}</MobileNav>
}
return <Nav>{children}</Nav>
}
export { Navbar }
@@ -0,0 +1,57 @@
import React from 'react'
import { Button, Typography, Link as MuiLink } from '@mui/material'
import { useMainContext } from '@/app/context/main'
type NetworkTitleProps = {
showToggleNetwork?: boolean
}
const NetworkTitle = ({ showToggleNetwork }: NetworkTitleProps) => {
const { environment } = useMainContext()
const explorerName =
`${
environment && environment.charAt(0).toUpperCase() + environment.slice(1)
} Explorer` || 'Mainnet Explorer'
const switchNetworkText =
environment === 'mainnet' ? 'Switch to Testnet' : 'Switch to Mainnet'
const switchNetworkLink =
environment === 'mainnet'
? 'https://sandbox-explorer.nymtech.net'
: 'https://explorer.nymtech.net'
return (
<Typography
variant="h6"
noWrap
sx={{
color: 'nym.networkExplorer.nav.text',
fontSize: '18px',
fontWeight: 600,
}}
>
<MuiLink href="/" underline="none" color="inherit" fontWeight={700}>
{explorerName}
</MuiLink>
{showToggleNetwork && (
<Button
variant="outlined"
color="inherit"
href={switchNetworkLink}
sx={{
textTransform: 'none',
width: 114,
fontSize: '12px',
fontWeight: 600,
ml: 1,
}}
>
{switchNetworkText}
</Button>
)}
</Typography>
)
}
export { NetworkTitle }
@@ -0,0 +1,58 @@
import * as React from 'react'
import { Box, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { TelegramIcon } from '../icons/socials/TelegramIcon'
import { GitHubIcon } from '../icons/socials/GitHubIcon'
import { TwitterIcon } from '../icons/socials/TwitterIcon'
import { DiscordIcon } from '../icons/socials/DiscordIcon'
// socials
export const TELEGRAM_LINK = 'https://t.me/nymchan'
export const TWITTER_LINK = 'https://twitter.com/nymproject'
export const GITHUB_LINK = 'https://github.com/nymtech'
export const DISCORD_LINK = 'https://discord.gg/nym'
export const Socials: FCWithChildren<{ isFooter?: boolean }> = ({
isFooter = false,
}) => {
const theme = useTheme()
const color = isFooter
? theme.palette.nym.networkExplorer.footer.socialIcons
: theme.palette.nym.networkExplorer.topNav.socialIcons
return (
<Box>
<IconButton
component="a"
href={TELEGRAM_LINK}
target="_blank"
data-testid="telegram"
>
<TelegramIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={DISCORD_LINK}
target="_blank"
data-testid="discord"
>
<DiscordIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={TWITTER_LINK}
target="_blank"
data-testid="twitter"
>
<TwitterIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={GITHUB_LINK}
target="_blank"
data-testid="github"
>
<GitHubIcon color={color} size={24} />
</IconButton>
</Box>
)
}
@@ -0,0 +1,73 @@
import * as React from 'react'
import { Box, Card, CardContent, IconButton, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import EastIcon from '@mui/icons-material/East'
interface StatsCardProps {
icon: React.ReactNode
title: string
count?: string | number
errorMsg?: Error | string
onClick?: () => void
color?: string
}
export const StatsCard: FCWithChildren<StatsCardProps> = ({
icon,
title,
count,
onClick,
errorMsg,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<Card onClick={onClick} sx={{ height: '100%' }}>
<CardContent
sx={{
padding: 1.5,
paddingLeft: 3,
'&:last-child': {
paddingBottom: 1.5,
},
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
}}
>
<Box display="flex" alignItems="center" color={color}>
<Box display="flex">
{icon}
<Typography
ml={3}
mr={0.75}
fontSize="inherit"
fontWeight="inherit"
data-testid={`${title}-amount`}
>
{count === undefined || count === null ? '' : count}
</Typography>
<Typography
mr={1}
fontSize="inherit"
fontWeight="inherit"
data-testid={title}
>
{title}
</Typography>
</Box>
<IconButton color="inherit" sx={{ fontSize: '16px' }}>
<EastIcon fontSize="inherit" />
</IconButton>
</Box>
{errorMsg && (
<Typography variant="body2" sx={{ color: 'danger', padding: 2 }}>
{typeof errorMsg === 'string'
? errorMsg
: errorMsg.message || 'Oh no! An error occurred'}
</Typography>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,34 @@
import React from 'react'
import { Link as MuiLink, SxProps, Typography } from '@mui/material'
import Link from 'next/link'
type StyledLinkProps = {
to: string
children: string
target?: React.HTMLAttributeAnchorTarget
dataTestId?: string
color?: string
sx?: SxProps
}
const StyledLink = ({
to,
children,
dataTestId,
target,
color,
sx,
}: StyledLinkProps) => (
<Link
href={to}
target={target}
data-testid={dataTestId}
style={{ textDecoration: 'none' }}
>
<Typography component="a" sx={{ ...sx }} color={color}>
{children}
</Typography>
</Link>
)
export default StyledLink
+70
View File
@@ -0,0 +1,70 @@
import * as React from 'react';
import { styled } from '@mui/material/styles';
import Switch from '@mui/material/Switch';
import { Button } from '@mui/material';
import { useMainContext } from '../context/main';
import { LightSwitchSVG } from '../icons/LightSwitchSVG';
export const DarkLightSwitch = styled(Switch)(({ theme }) => ({
width: 55,
height: 34,
padding: 7,
'& .MuiSwitch-switchBase': {
margin: 1,
padding: 2,
transform: 'translateX(4px)',
'&.Mui-checked': {
color: '#fff',
transform: 'translateX(22px)',
'& .MuiSwitch-thumb:before': {
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="black" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>\')',
},
'& + .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
},
},
},
'& .MuiSwitch-thumb': {
backgroundColor: theme.palette.nym.networkExplorer.nav.text,
width: 25,
height: 25,
marginTop: '2px',
'&:before': {
content: "''",
position: 'absolute',
width: '100%',
height: '100%',
left: 0,
top: 0,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="black" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>\')',
},
},
'& .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
borderRadius: 20 / 2,
},
}));
export const DarkLightSwitchMobile: FCWithChildren = () => {
const { toggleMode } = useMainContext();
return (
<Button onClick={() => toggleMode()} data-testid="switch-button" sx={{ p: 0, minWidth: 0 }}>
<LightSwitchSVG />
</Button>
);
};
export const DarkLightSwitchDesktop: FCWithChildren<{ defaultChecked: boolean }> = ({ defaultChecked }) => {
const { toggleMode } = useMainContext();
return (
<Button sx={{ paddingLeft: 0 }} onClick={() => toggleMode()} data-testid="switch-button">
<DarkLightSwitch defaultChecked={defaultChecked} />
</Button>
);
};
@@ -0,0 +1,61 @@
'use client'
import React from 'react'
import { Box, SelectChangeEvent } from '@mui/material'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { Filters } from './Filters/Filters'
const fieldsHeight = '42.25px'
type TableToolBarProps = {
childrenBefore?: React.ReactNode
childrenAfter?: React.ReactNode
}
export const TableToolbar: FCWithChildren<TableToolBarProps> = ({
childrenBefore,
childrenAfter,
}) => {
const isMobile = useIsMobile()
return (
<Box
sx={{
width: '100%',
marginBottom: 2,
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column-reverse' : 'row',
alignItems: 'middle',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
height: fieldsHeight,
}}
>
{childrenBefore}
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
gap: 1,
marginTop: isMobile ? 2 : 0,
}}
>
{childrenAfter}
</Box>
</Box>
)
}
+14
View File
@@ -0,0 +1,14 @@
import * as React from 'react';
import { Typography } from '@mui/material';
export const Title: FCWithChildren<{ text: string }> = ({ text }) => (
<Typography
variant="h5"
sx={{
fontWeight: 600,
}}
data-testid={text}
>
{text}
</Typography>
);
@@ -0,0 +1,36 @@
import React, { ReactElement } from 'react';
import { Tooltip as MUITooltip, TooltipComponentsPropsOverrides, TooltipProps } from '@mui/material';
type ValueType<T> = T[keyof T];
type Props = {
text: string;
id: string;
placement?: ValueType<Pick<TooltipProps, 'placement'>>;
tooltipSx?: TooltipComponentsPropsOverrides;
children: React.ReactNode;
};
export const Tooltip = ({ text, id, placement, tooltipSx, children }: Props) => (
<MUITooltip
title={text}
id={id}
placement={placement || 'top-start'}
componentsProps={{
tooltip: {
sx: {
maxWidth: 200,
background: (t) => t.palette.nym.networkExplorer.tooltip.background,
color: (t) => t.palette.nym.networkExplorer.tooltip.color,
'& .MuiTooltip-arrow': {
color: (t) => t.palette.nym.networkExplorer.tooltip.background,
},
},
...tooltipSx,
},
}}
arrow
>
{children as ReactElement<any, any>}
</MUITooltip>
);
@@ -0,0 +1,78 @@
import * as React from 'react';
import { CircularProgress, Typography } from '@mui/material';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import CheckCircleSharpIcon from '@mui/icons-material/CheckCircleSharp';
import ErrorIcon from '@mui/icons-material/Error';
interface TableProps {
title?: string;
icons?: boolean[];
keys: string[];
values: number[];
marginBottom?: boolean;
error?: string;
loading: boolean;
}
export const TwoColSmallTable: FCWithChildren<TableProps> = ({
loading,
title,
icons,
keys,
values,
marginBottom,
error,
}) => (
<>
{title && <Typography sx={{ marginTop: 2 }}>{title}</Typography>}
<TableContainer component={Paper} sx={marginBottom ? { marginBottom: 4, marginTop: 2 } : { marginTop: 2 }}>
<Table aria-label="two col small table">
<TableBody>
{keys.map((each: string, i: number) => (
<TableRow key={each}>
{icons && <TableCell>{icons[i] ? <CheckCircleSharpIcon /> : <ErrorIcon />}</TableCell>}
<TableCell sx={error ? { opacity: 0.4 } : null} data-testid={each.replace(/ /g, '')}>
{each}
</TableCell>
<TableCell
sx={error ? { opacity: 0.4 } : null}
align="right"
data-testid={`${each.replace(/ /g, '-')}-value`}
>
{values[i]}
</TableCell>
{error && (
<TableCell align="right" sx={{ opacity: 0.4 }}>
{values[i]}
</TableCell>
)}
{!error && loading && (
<TableCell align="right">
<CircularProgress />
</TableCell>
)}
{error && !icons && (
<TableCell sx={{ opacity: 0.2 }} align="right">
<ErrorIcon />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
TwoColSmallTable.defaultProps = {
title: undefined,
icons: undefined,
marginBottom: false,
error: undefined,
};
@@ -0,0 +1,96 @@
'use client'
import * as React from 'react'
import { makeStyles } from '@mui/styles'
import {
DataGrid,
GridColDef,
GridEventListener,
useGridApiContext,
} from '@mui/x-data-grid'
import Pagination from '@mui/material/Pagination'
import { LinearProgress } from '@mui/material'
import { GridInitialStateCommunity } from '@mui/x-data-grid/models/gridStateCommunity'
const useStyles = makeStyles({
root: {
display: 'flex',
},
})
const CustomPagination = () => {
const apiRef = useGridApiContext()
const classes = useStyles()
console.log(apiRef.current.state)
return (
<Pagination
className={classes.root}
sx={{ mt: 2 }}
color="primary"
count={apiRef.current.state.pagination.paginationModel.pageSize}
page={apiRef.current.state.pagination.paginationModel.page + 1}
onChange={(_, value) => apiRef.current.setPage(value - 1)}
/>
)
}
type DataGridProps = {
columns: GridColDef[]
pagination?: true | undefined
pageSize?: string | undefined
rows: any
loading?: boolean
initialState?: GridInitialStateCommunity
onRowClick?: GridEventListener<'rowClick'> | undefined
}
export const UniversalDataGrid: FCWithChildren<DataGridProps> = ({
rows,
columns,
loading,
pagination,
pageSize,
initialState,
onRowClick,
}) => {
if (loading) return <LinearProgress />
return (
<DataGrid
onRowClick={onRowClick}
pagination={pagination}
rows={rows}
slots={{
pagination: CustomPagination,
}}
columns={columns}
autoHeight
hideFooter={!pagination}
initialState={initialState}
style={{
width: '100%',
border: 'none',
}}
sx={{
'*::-webkit-scrollbar': {
width: '1em',
},
'*::-webkit-scrollbar-track': {
background: (t) => t.palette.nym.networkExplorer.scroll.backgroud,
outline: (t) =>
`1px solid ${t.palette.nym.networkExplorer.scroll.border}`,
boxShadow: 'auto',
borderRadius: 'auto',
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: (t) => t.palette.nym.networkExplorer.scroll.color,
borderRadius: '20px',
width: '.4em',
border: (t) =>
`3px solid ${t.palette.nym.networkExplorer.scroll.backgroud}`,
shadow: 'auto',
},
}}
/>
)
}
@@ -0,0 +1,115 @@
import * as React from 'react';
import { CircularProgress, Typography } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { Chart } from 'react-google-charts';
import { format } from 'date-fns';
import { ApiState, UptimeStoryResponse } from '../typeDefs/explorer-api';
interface ChartProps {
title?: string;
xLabel: string;
yLabel?: string;
uptimeStory: ApiState<UptimeStoryResponse>;
loading: boolean;
}
type FormattedDateRecord = [string, number];
type FormattedChartHeadings = string[];
type FormattedChartData = [FormattedChartHeadings | FormattedDateRecord];
export const UptimeChart: FCWithChildren<ChartProps> = ({ title, xLabel, yLabel, uptimeStory, loading }) => {
const [formattedChartData, setFormattedChartData] = React.useState<FormattedChartData>();
const theme = useTheme();
const color = theme.palette.text.primary;
React.useEffect(() => {
if (uptimeStory.data?.history) {
const allFormattedChartData: FormattedChartData = [['Date', 'Score']];
uptimeStory.data.history.forEach((eachDate) => {
const formattedDateUptimeRecord: FormattedDateRecord = [
format(new Date(eachDate.date), 'MMM dd'),
eachDate.uptime,
];
allFormattedChartData.push(formattedDateUptimeRecord);
});
setFormattedChartData(allFormattedChartData);
} else {
const emptyData: any = [
['Date', 'Score'],
['Jul 27', 10],
];
setFormattedChartData(emptyData);
}
}, [uptimeStory]);
return (
<>
{title && <Typography>{title}</Typography>}
{loading && <CircularProgress />}
{!loading && uptimeStory && (
<Chart
style={{ minHeight: 480 }}
chartType="LineChart"
loader={<p>...</p>}
data={
uptimeStory.data
? formattedChartData
: [
['Date', 'Routing Score'],
[format(new Date(Date.now()), 'MMM dd'), 0],
]
}
options={{
backgroundColor:
theme.palette.mode === 'dark' ? theme.palette.nym.networkExplorer.background.tertiary : undefined,
color: uptimeStory.error ? 'rgba(255, 255, 255, 0.4)' : 'rgba(255, 255, 255, 1)',
colors: ['#FB7A21'],
legend: {
textStyle: {
color,
opacity: uptimeStory.error ? 0.4 : 1,
},
},
intervals: { style: 'sticks' },
hAxis: {
// horizontal / date
title: xLabel,
titleTextStyle: {
color,
},
textStyle: {
color,
// fontSize: 11
},
gridlines: {
count: -1,
},
},
vAxis: {
// vertical / % Routing Score
viewWindow: {
min: 0,
max: 100,
},
title: yLabel,
titleTextStyle: {
color,
opacity: uptimeStory.error ? 0.4 : 1,
},
textStyle: {
color,
fontSize: 11,
opacity: uptimeStory.error ? 0.4 : 1,
},
},
}}
/>
)}
</>
);
};
UptimeChart.defaultProps = {
title: undefined,
};
@@ -0,0 +1,50 @@
import React from 'react'
import { Button, IconButton, Stack, CircularProgress } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { useWalletContext } from '@/app/context/wallet'
import { WalletAddress, WalletBalance } from '@/app/components/Wallet'
export const ConnectKeplrWallet = () => {
const {
connectWallet,
disconnectWallet,
isWalletConnected,
isWalletConnecting,
} = useWalletContext()
const isMobile = useIsMobile(1200)
if (!connectWallet || !disconnectWallet) {
return null
}
if (isWalletConnected) {
return (
<Stack direction="row" spacing={1}>
<WalletBalance />
<WalletAddress />
<IconButton
size="small"
onClick={async () => {
await disconnectWallet()
}}
>
<CloseIcon fontSize="small" sx={{ color: 'white' }} />
</IconButton>
</Stack>
)
}
return (
<Button
variant="outlined"
onClick={() => connectWallet()}
disabled={isWalletConnecting}
endIcon={
isWalletConnecting && <CircularProgress size={14} color="inherit" />
}
>
Connect {isMobile ? '' : ' Wallet'}
</Button>
)
}
@@ -0,0 +1,20 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { ElipsSVG } from '@/app/icons/ElipsSVG'
import { trimAddress } from '@/app/utils'
import { useWalletContext } from '@/app/context/wallet'
export const WalletAddress = () => {
const { address } = useWalletContext()
const displayAddress = trimAddress(address, 7)
return (
<Box display="flex" alignItems="center" gap={0.5}>
<ElipsSVG />
<Typography variant="body1" fontWeight={600}>
{displayAddress}
</Typography>
</Box>
)
}
@@ -0,0 +1,25 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { useWalletContext } from '@/app/context/wallet'
import { useIsMobile } from '@/app/hooks'
import { TokenSVG } from '@/app/icons/TokenSVG'
export const WalletBalance = () => {
const { balance } = useWalletContext()
const isMobile = useIsMobile(1200)
const showBalance = !isMobile && balance.status === 'success'
if (!showBalance) {
return null
}
return (
<Box display="flex" alignItems="center" gap={1}>
<TokenSVG />
<Typography variant="body1" fontWeight={600}>
{balance.data} NYM
</Typography>
</Box>
)
}
@@ -0,0 +1,2 @@
export * from './WalletBalance';
export * from './WalletAddress';
+129
View File
@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import { scaleLinear } from 'd3-scale'
import {
ComposableMap,
Geographies,
Geography,
Marker,
ZoomableGroup,
} from 'react-simple-maps'
import ReactTooltip from 'react-tooltip'
import { CircularProgress } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { ApiState, CountryDataResponse } from '../typeDefs/explorer-api'
import MAP_TOPOJSON from '../assets/world-110m.json'
type MapProps = {
userLocation?: [number, number]
countryData?: ApiState<CountryDataResponse>
loading: boolean
}
export const WorldMap: FCWithChildren<MapProps> = ({
countryData,
userLocation,
loading,
}) => {
const { palette } = useTheme()
const colorScale = React.useMemo(() => {
if (countryData?.data) {
const heighestNumberOfNodes = Math.max(
...Object.values(countryData.data).map((country) => country.nodes)
)
return scaleLinear<string, string>()
.domain([
0,
1,
heighestNumberOfNodes / 4,
heighestNumberOfNodes / 2,
heighestNumberOfNodes,
])
.range(palette.nym.networkExplorer.map.fills)
.unknown(palette.nym.networkExplorer.map.fills[0])
}
return () => palette.nym.networkExplorer.map.fills[0]
}, [countryData, palette])
const [tooltipContent, setTooltipContent] = React.useState<string | null>(
null
)
if (loading) {
return <CircularProgress />
}
return (
<>
<ComposableMap
data-tip=""
style={{
backgroundColor: palette.nym.networkExplorer.background.tertiary,
width: '100%',
height: 'auto',
}}
viewBox="0, 50, 800, 350"
projection="geoMercator"
projectionConfig={{
scale: userLocation ? 200 : 100,
center: userLocation,
}}
>
<ZoomableGroup>
<Geographies geography={MAP_TOPOJSON}>
{({ geographies }) =>
geographies.map((geo) => {
const d = (countryData?.data || {})[geo.properties.ISO_A3]
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={colorScale(d?.nodes || 0)}
stroke={palette.nym.networkExplorer.map.stroke}
strokeWidth={0.2}
onMouseEnter={() => {
const { NAME_LONG } = geo.properties
if (!userLocation) {
setTooltipContent(`${NAME_LONG} | ${d?.nodes || 0}`)
}
}}
onMouseLeave={() => {
setTooltipContent('')
}}
style={{
hover:
!userLocation && countryData
? {
fill: palette.nym.highlight,
outline: 'white',
}
: undefined,
}}
/>
)
})
}
</Geographies>
{userLocation && (
<Marker coordinates={userLocation}>
<g
fill="grey"
stroke="#FF5533"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transform="translate(-12, -10)"
>
<circle cx="12" cy="10" r="5" />
</g>
</Marker>
)}
</ZoomableGroup>
</ComposableMap>
<ReactTooltip>{tooltipContent}</ReactTooltip>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
export * from './CustomColumnHeading';
export * from './Title';
export * from './Universal-DataGrid';
export * from './Tooltip';
export { default as StyledLink } from './StyledLink';
export * from './Delegations';
export * from './MixNodes';
export * from './TableToolbar';
export * from './Icons';
@@ -0,0 +1,73 @@
'use client'
import React from 'react'
import { ChainProvider } from '@cosmos-kit/react'
import { wallets as keplr } from '@cosmos-kit/keplr-extension'
import { assets, chains } from 'chain-registry'
import { Chain, AssetList } from '@chain-registry/types'
import { VALIDATOR_BASE_URL } from '@/app/api/constants'
const nymSandbox: Chain = {
chain_name: 'sandbox',
chain_id: 'sandbox',
bech32_prefix: 'n',
network_type: 'devnet',
pretty_name: 'Nym Sandbox',
status: 'active',
slip44: 118,
apis: {
rpc: [
{
address: 'https://rpc.sandbox.nymtech.net',
},
],
},
}
const nymSandboxAssets = {
chain_name: 'sandbox',
assets: [
{
name: 'Nym',
base: 'unym',
symbol: 'NYM',
display: 'NYM',
denom_units: [],
},
],
}
const CosmosKitProvider = ({ children }: { children: React.ReactNode }) => {
// Only use the nyx chains
const chainsFixedUp = React.useMemo(() => {
const nyx = chains.find((chain) => chain.chain_id === 'nyx')
return nyx ? [nymSandbox, nyx] : [nymSandbox]
}, [chains])
// Only use the nyx assets
const assetsFixedUp = React.useMemo(() => {
const nyx = assets.find((asset) => asset.chain_name === 'nyx')
return nyx ? [nymSandboxAssets, nyx] : [nymSandboxAssets]
}, [assets]) as AssetList[]
return (
<ChainProvider
chains={chainsFixedUp}
assetLists={assetsFixedUp}
wallets={[...keplr]}
endpointOptions={{
endpoints: {
nyx: {
rpc: [VALIDATOR_BASE_URL],
},
},
}}
>
{children}
</ChainProvider>
)
}
export default CosmosKitProvider
+252
View File
@@ -0,0 +1,252 @@
'use client'
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import {
Delegation,
PendingEpochEvent,
PendingEpochEventKind,
} from '@nymproject/contract-clients/Mixnet.types'
import { ExecuteResult } from '@cosmjs/cosmwasm-stargate'
import { useWalletContext } from './wallet'
import { useMainContext } from './main'
const fee = { gas: '1000000', amount: [{ amount: '1000000', denom: 'unym' }] }
export type PendingEvent = ReturnType<typeof getEventsByAddress>
export type DelegationWithRewards = Delegation & {
rewards: string
identityKey: string
pending: PendingEvent
}
const getEventsByAddress = (kind: PendingEpochEventKind, address: String) => {
if ('delegate' in kind && kind.delegate.owner === address) {
return {
kind: 'delegate' as const,
mixId: kind.delegate.mix_id,
amount: kind.delegate.amount,
}
}
if ('undelegate' in kind && kind.undelegate.owner === address) {
return {
kind: 'undelegate' as const,
mixId: kind.undelegate.mix_id,
}
}
return undefined
}
interface DelegationsState {
delegations?: DelegationWithRewards[]
handleGetDelegations: () => Promise<void>
handleDelegate: (
mixId: number,
amount: string
) => Promise<ExecuteResult | undefined>
handleUndelegate: (mixId: number) => Promise<ExecuteResult | undefined>
}
export const DelegationsContext = createContext<DelegationsState>({
delegations: undefined,
handleGetDelegations: async () => {
throw new Error('Please connect your wallet')
},
handleDelegate: async () => {
throw new Error('Please connect your wallet')
},
handleUndelegate: async () => {
throw new Error('Please connect your wallet')
},
})
export const DelegationsProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [delegations, setDelegations] = useState<DelegationWithRewards[]>()
const { address, nymQueryClient, nymClient } = useWalletContext()
const { fetchMixnodes } = useMainContext()
const handleGetPendingEvents = async () => {
if (!nymQueryClient) {
return undefined
}
if (!address) {
return undefined
}
const response = await nymQueryClient.getPendingEpochEvents({})
const pendingEvents: PendingEvent[] = []
response.events.forEach((e: PendingEpochEvent) => {
const event = getEventsByAddress(e.event.kind, address)
if (event) {
pendingEvents.push(event)
}
})
return pendingEvents
}
const handleGetDelegationRewards = async (mixId: number) => {
if (!nymQueryClient) {
return undefined
}
if (!address) {
return undefined
}
const response = await nymQueryClient.getPendingDelegatorReward({
address,
mixId,
})
return response
}
const handleGetDelegations = useCallback(async () => {
if (!nymQueryClient) {
setDelegations(undefined)
return undefined
}
if (!address) {
setDelegations(undefined)
return undefined
}
// Get all mixnodes - Required to get the identity key for each delegation
const mixnodes = await fetchMixnodes()
// Get delegations
const delegationsResponse = await nymQueryClient.getDelegatorDelegations({
delegator: address,
})
// Get rewards for each delegation
const rewardsResponse = await Promise.all(
delegationsResponse.delegations.map((d: Delegation) =>
handleGetDelegationRewards(d.mix_id)
)
)
// Get all pending events
const pendingEvents = await handleGetPendingEvents()
const delegationsWithRewards: DelegationWithRewards[] = []
// Merge delegations with rewards and pending events
delegationsResponse.delegations.forEach((d: Delegation, index: number) => {
delegationsWithRewards.push({
...d,
pending: pendingEvents?.find((e: PendingEvent) =>
e?.mixId === d.mix_id ? e.kind : undefined
),
identityKey:
mixnodes?.find((m) => m.mix_id === d.mix_id)?.mix_node.identity_key ||
'',
rewards: rewardsResponse[index]?.amount_earned_detailed || '0',
})
})
// Add pending events that are not in the delegations list
pendingEvents?.forEach((e) => {
if (
e &&
!delegationsWithRewards.find(
(d: DelegationWithRewards) => d.mix_id === e.mixId
)
) {
delegationsWithRewards.push({
mix_id: e.mixId,
height: 0,
cumulative_reward_ratio: '0',
owner: address,
amount: {
amount: '0',
denom: 'unym',
},
rewards: '0',
identityKey:
mixnodes?.find((m) => m.mix_id === e.mixId)?.mix_node
.identity_key || '',
pending: e,
})
}
})
setDelegations(delegationsWithRewards)
return undefined
}, [address, nymQueryClient])
const handleDelegate = async (mixId: number, amount: string) => {
if (!address) {
throw new Error('Please connect your wallet')
}
const amountToDelegate = (Number(amount) * 1000000).toString()
const uNymFunds = [{ amount: amountToDelegate, denom: 'unym' }]
try {
const tx = await nymClient?.delegateToMixnode(
{ mixId },
fee,
'Delegation from Nym Explorer',
uNymFunds
)
return tx as unknown as ExecuteResult
} catch (e) {
console.error('Failed to delegate to mixnode', e)
throw e
}
}
const handleUndelegate = async (mixId: number) => {
const tx = await nymClient?.undelegateFromMixnode(
{ mixId },
fee,
'Undelegation from Nym Explorer'
)
return tx as unknown as ExecuteResult
}
const contextValue: DelegationsState = useMemo(
() => ({
delegations,
handleGetDelegations,
handleDelegate,
handleUndelegate,
}),
[delegations, handleGetDelegations]
)
return (
<DelegationsContext.Provider value={contextValue}>
{children}
</DelegationsContext.Provider>
)
}
export const useDelegationsContext = () => {
const context = useContext(DelegationsContext)
if (!context) {
throw new Error(
'useDelegationsContext must be used within a DelegationsProvider'
)
}
return context
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import * as React from 'react'
import {
ApiState,
GatewayReportResponse,
UptimeStoryResponse,
} from '@/app/typeDefs/explorer-api'
import { Api } from '@/app/api'
import { useApiState } from './hooks'
/**
* This context provides the state for a single gateway by identity key.
*/
interface GatewayState {
uptimeReport?: ApiState<GatewayReportResponse>
uptimeStory?: ApiState<UptimeStoryResponse>
}
export const GatewayContext = React.createContext<GatewayState>({})
export const useGatewayContext = (): React.ContextType<typeof GatewayContext> =>
React.useContext<GatewayState>(GatewayContext)
/**
* Provides a state context for a gateway by identity
* @param gatewayIdentityKey The identity key of the gateway
*/
export const GatewayContextProvider = ({
gatewayIdentityKey,
children,
}: {
gatewayIdentityKey: string
children: JSX.Element
}) => {
const [uptimeReport, fetchUptimeReportById, clearUptimeReportById] =
useApiState<GatewayReportResponse>(
gatewayIdentityKey,
Api.fetchGatewayReportById,
'Failed to fetch gateway uptime report by id'
)
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] =
useApiState<UptimeStoryResponse>(
gatewayIdentityKey,
Api.fetchGatewayUptimeStoryById,
'Failed to fetch gateway uptime history'
)
React.useEffect(() => {
// when the identity key changes, remove all previous data
clearUptimeReportById()
clearUptimeHistory()
Promise.all([fetchUptimeReportById(), fetchUptimeHistory()])
}, [gatewayIdentityKey])
const state = React.useMemo<GatewayState>(
() => ({
uptimeReport,
uptimeStory,
}),
[uptimeReport, uptimeStory]
)
return (
<GatewayContext.Provider value={state}>{children}</GatewayContext.Provider>
)
}
+49
View File
@@ -0,0 +1,49 @@
'use client'
import * as React from 'react';
import { ApiState } from '@/app/typeDefs/explorer-api';
/**
* Custom hook to get data from the API by passing an id to a delegate method that fetches the data asynchronously
* @param id The id to fetch
* @param fn Delegate the fetching to this method (must take `(id: string)` as a parameter)
* @param errorMessage A static error message, to use when no dynamic error message is returned
*/
export const useApiState = <T>(
id: string,
fn: (argId: string) => Promise<T>,
errorMessage: string,
): [ApiState<T> | undefined, () => Promise<ApiState<T>>, () => void] => {
// stores the state
const [value, setValue] = React.useState<ApiState<T>>();
// clear the value
const clearValueFn = () => setValue(undefined);
// this provides a method to trigger the delegate to fetch data
const wrappedFetchFn = React.useCallback(async () => {
setValue({ isLoading: true });
try {
// keep previous state and set to loading
setValue((prevState) => ({ ...prevState, isLoading: true }));
// delegate to user function to get data and set if successful
const data = await fn(id);
const newValue: ApiState<T> = {
isLoading: false,
data,
};
setValue(newValue);
return newValue;
} catch (error) {
// return the caught error or create a new error with the static error message
const newValue: ApiState<T> = {
error: error instanceof Error ? error : new Error(errorMessage),
isLoading: false,
};
setValue(newValue);
return newValue;
}
}, [setValue, fn, id, errorMessage]);
return [value, wrappedFetchFn, clearValueFn];
};
+256
View File
@@ -0,0 +1,256 @@
'use client'
import * as React from 'react'
import { PaletteMode } from '@mui/material'
import {
ApiState,
BlockResponse,
CountryDataResponse,
GatewayResponse,
MixNodeResponse,
MixnodeStatus,
SummaryOverviewResponse,
ValidatorsResponse,
Environment,
DirectoryServiceProvider,
} from '@/app/typeDefs/explorer-api'
import { EnumFilterKey } from '@/app/typeDefs/filters'
import { Api, getEnvironment } from '@/app/api'
import { toPercentIntegerString } from '@/app/utils'
import { NavOptionType, originalNavOptions } from './nav'
interface StateData {
summaryOverview?: ApiState<SummaryOverviewResponse>
block?: ApiState<BlockResponse>
countryData?: ApiState<CountryDataResponse>
gateways?: ApiState<GatewayResponse>
globalError?: string | undefined
mixnodes?: ApiState<MixNodeResponse>
mode: PaletteMode
validators?: ApiState<ValidatorsResponse>
environment?: Environment
serviceProviders?: ApiState<DirectoryServiceProvider[]>
}
interface StateApi {
fetchMixnodes: (
status?: MixnodeStatus
) => Promise<MixNodeResponse | undefined>
filterMixnodes: (filters: any, status: any) => void
toggleMode: () => void
}
type State = StateData & StateApi
export const MainContext = React.createContext<State>({
mode: 'dark',
toggleMode: () => undefined,
filterMixnodes: () => null,
fetchMixnodes: () => Promise.resolve(undefined),
})
export const useMainContext = (): React.ContextType<typeof MainContext> =>
React.useContext<State>(MainContext)
export const MainContextProvider: FCWithChildren = ({ children }) => {
// network explorer environment
const [environment, setEnvironment] = React.useState<Environment>()
// light/dark mode
const [mode, setMode] = React.useState<PaletteMode>('dark')
// global / banner error messaging
const [globalError] = React.useState<string>()
// various APIs for Overview page
const [summaryOverview, setSummaryOverview] =
React.useState<ApiState<SummaryOverviewResponse>>()
const [mixnodes, setMixnodes] = React.useState<ApiState<MixNodeResponse>>()
const [gateways, setGateways] = React.useState<ApiState<GatewayResponse>>()
const [validators, setValidators] =
React.useState<ApiState<ValidatorsResponse>>()
const [block, setBlock] = React.useState<ApiState<BlockResponse>>()
const [countryData, setCountryData] =
React.useState<ApiState<CountryDataResponse>>()
const [serviceProviders, setServiceProviders] =
React.useState<ApiState<DirectoryServiceProvider[]>>()
const toggleMode = () => setMode((m) => (m !== 'light' ? 'light' : 'dark'))
const fetchOverviewSummary = async () => {
try {
const data = await Api.fetchOverviewSummary()
setSummaryOverview({ data, isLoading: false })
} catch (error) {
setSummaryOverview({
error:
error instanceof Error
? error
: new Error('Overview summary api fail'),
isLoading: false,
})
}
}
const fetchMixnodes = async (status?: MixnodeStatus) => {
let data
setMixnodes((d) => ({ ...d, isLoading: true }))
try {
data = status
? await Api.fetchMixnodesActiveSetByStatus(status)
: await Api.fetchMixnodes()
setMixnodes({ data, isLoading: false })
} catch (error) {
setMixnodes({
error: error instanceof Error ? error : new Error('Mixnode api fail'),
isLoading: false,
})
}
return data
}
const filterMixnodes = async (
filters: { [key in EnumFilterKey]: number[] },
status?: MixnodeStatus
) => {
setMixnodes((d) => ({ ...d, isLoading: true }))
const mxns = status
? await Api.fetchMixnodesActiveSetByStatus(status)
: await Api.fetchMixnodes()
const filtered = mxns?.filter(
(m) =>
+m.profit_margin_percent >= filters.profitMargin[0] / 100 &&
+m.profit_margin_percent <= filters.profitMargin[1] / 100 &&
m.stake_saturation >= filters.stakeSaturation[0] &&
m.stake_saturation <= filters.stakeSaturation[1] &&
m.avg_uptime >= filters.routingScore[0] &&
m.avg_uptime <= filters.routingScore[1]
)
setMixnodes({ data: filtered, isLoading: false })
}
const fetchGateways = async () => {
setGateways((d) => ({ ...d, isLoading: true }))
try {
const data = await Api.fetchGateways()
setGateways({ data, isLoading: false })
} catch (error) {
setGateways({
error: error instanceof Error ? error : new Error('Gateways api fail'),
isLoading: false,
})
}
}
const fetchValidators = async () => {
try {
const data = await Api.fetchValidators()
setValidators({ data, isLoading: false })
} catch (error) {
setValidators({
error:
error instanceof Error ? error : new Error('Validators api fail'),
isLoading: false,
})
}
}
const fetchBlock = async () => {
try {
const data = await Api.fetchBlock()
setBlock({ data, isLoading: false })
} catch (error) {
setBlock({
error: error instanceof Error ? error : new Error('Block api fail'),
isLoading: false,
})
}
}
const fetchCountryData = async () => {
setCountryData({ data: undefined, isLoading: true })
try {
const res = await Api.fetchCountryData()
setCountryData({ data: res, isLoading: false })
} catch (error) {
setCountryData({
error:
error instanceof Error ? error : new Error('Country Data api fail'),
isLoading: false,
})
}
}
const fetchServiceProviders = async () => {
setServiceProviders({ data: undefined, isLoading: true })
try {
const res = await Api.fetchServiceProviders()
const resWithRoutingScorePercentage = res.map((item) => ({
...item,
routing_score: item.routing_score
? `${toPercentIntegerString(item.routing_score.toString())}%`
: item.routing_score,
}))
setServiceProviders({
data: resWithRoutingScorePercentage,
isLoading: false,
})
} catch (error) {
setServiceProviders({
error:
error instanceof Error
? error
: new Error('Service provider api fail'),
isLoading: false,
})
}
}
React.useEffect(() => {
if (environment === 'mainnet') {
fetchServiceProviders()
}
}, [environment])
React.useEffect(() => {
setEnvironment(getEnvironment())
Promise.all([
fetchOverviewSummary(),
fetchGateways(),
fetchValidators(),
fetchBlock(),
fetchCountryData(),
])
}, [])
const state = React.useMemo<State>(
() => ({
environment,
block,
countryData,
gateways,
globalError,
mixnodes,
mode,
summaryOverview,
validators,
serviceProviders,
toggleMode,
fetchMixnodes,
filterMixnodes,
}),
[
environment,
block,
countryData,
gateways,
globalError,
mixnodes,
mode,
summaryOverview,
validators,
serviceProviders,
]
)
return <MainContext.Provider value={state}>{children}</MainContext.Provider>
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import * as React from 'react'
import {
ApiState,
DelegationsResponse,
UniqDelegationsResponse,
MixNodeDescriptionResponse,
MixNodeEconomicDynamicsStatsResponse,
MixNodeResponseItem,
StatsResponse,
StatusResponse,
UptimeStoryResponse,
} from '../typeDefs/explorer-api'
import { Api } from '../api'
import { useApiState } from './hooks'
import {
mixNodeResponseItemToMixnodeRowType,
MixnodeRowType,
} from '../components/MixNodes'
/**
* This context provides the state for a single mixnode by identity key.
*/
interface MixnodeState {
delegations?: ApiState<DelegationsResponse>
uniqDelegations?: ApiState<UniqDelegationsResponse>
description?: ApiState<MixNodeDescriptionResponse>
economicDynamicsStats?: ApiState<MixNodeEconomicDynamicsStatsResponse>
mixNode?: ApiState<MixNodeResponseItem | undefined>
mixNodeRow?: MixnodeRowType
stats?: ApiState<StatsResponse>
status?: ApiState<StatusResponse>
uptimeStory?: ApiState<UptimeStoryResponse>
}
export const MixnodeContext = React.createContext<MixnodeState>({})
export const useMixnodeContext = (): React.ContextType<typeof MixnodeContext> =>
React.useContext<MixnodeState>(MixnodeContext)
interface MixnodeContextProviderProps {
mixId: string
children: React.ReactNode
}
/**
* Provides a state context for a mixnode by identity
* @param mixId The mixID of the mixnode
*/
export const MixnodeContextProvider: FCWithChildren<
MixnodeContextProviderProps
> = ({ mixId, children }) => {
const [mixNode, fetchMixnodeById, clearMixnodeById] = useApiState<
MixNodeResponseItem | undefined
>(mixId, Api.fetchMixnodeByID, 'Failed to fetch mixnode by id')
const [mixNodeRow, setMixnodeRow] = React.useState<
MixnodeRowType | undefined
>()
const [delegations, fetchDelegations, clearDelegations] =
useApiState<DelegationsResponse>(
mixId,
Api.fetchDelegationsById,
'Failed to fetch delegations for mixnode'
)
const [uniqDelegations, fetchUniqDelegations, clearUniqDelegations] =
useApiState<UniqDelegationsResponse>(
mixId,
Api.fetchUniqDelegationsById,
'Failed to fetch delegations for mixnode'
)
const [status, fetchStatus, clearStatus] = useApiState<StatusResponse>(
mixId,
Api.fetchStatusById,
'Failed to fetch mixnode status'
)
const [stats, fetchStats, clearStats] = useApiState<StatsResponse>(
mixId,
Api.fetchStatsById,
'Failed to fetch mixnode stats'
)
const [description, fetchDescription, clearDescription] =
useApiState<MixNodeDescriptionResponse>(
mixId,
Api.fetchMixnodeDescriptionById,
'Failed to fetch mixnode description'
)
const [
economicDynamicsStats,
fetchEconomicDynamicsStats,
clearEconomicDynamicsStats,
] = useApiState<MixNodeEconomicDynamicsStatsResponse>(
mixId,
Api.fetchMixnodeEconomicDynamicsStatsById,
'Failed to fetch mixnode dynamics stats by id'
)
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] =
useApiState<UptimeStoryResponse>(
mixId,
Api.fetchUptimeStoryById,
'Failed to fetch mixnode uptime history'
)
React.useEffect(() => {
// when the identity key changes, remove all previous data
clearMixnodeById()
clearDelegations()
clearUniqDelegations()
clearStatus()
clearStats()
clearDescription()
clearEconomicDynamicsStats()
clearUptimeHistory()
// fetch the mixnode, then get all the other stuff
fetchMixnodeById().then((value) => {
if (!value.data || value.error) {
setMixnodeRow(undefined)
return
}
setMixnodeRow(mixNodeResponseItemToMixnodeRowType(value.data))
Promise.all([
fetchDelegations(),
fetchUniqDelegations(),
fetchStatus(),
fetchStats(),
fetchDescription(),
fetchEconomicDynamicsStats(),
fetchUptimeHistory(),
])
})
}, [mixId])
const state = React.useMemo<MixnodeState>(
() => ({
delegations,
uniqDelegations,
mixNode,
mixNodeRow,
description,
economicDynamicsStats,
stats,
status,
uptimeStory,
}),
[
{
delegations,
uniqDelegations,
mixNode,
mixNodeRow,
description,
economicDynamicsStats,
stats,
status,
uptimeStory,
},
]
)
return (
<MixnodeContext.Provider value={state}>{children}</MixnodeContext.Provider>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import * as React from 'react'
import { DelegateIcon } from '@/app/icons/DelevateSVG'
import { BIG_DIPPER } from '@/app/api/constants'
import { OverviewSVG } from '@/app/icons/OverviewSVG'
import { NodemapSVG } from '@/app/icons/NodemapSVG'
import { NetworkComponentsSVG } from '@/app/icons/NetworksSVG'
export type NavOptionType = {
url: string
title: string
Icon?: React.ReactNode
nested?: NavOptionType[]
isExpandedChild?: boolean
isExternal?: boolean
}
export const originalNavOptions: NavOptionType[] = [
{
url: '/',
title: 'Overview',
Icon: <OverviewSVG />,
},
{
url: '/network-components',
title: 'Network Components',
Icon: <NetworkComponentsSVG />,
nested: [
{
url: '/network-components/mixnodes',
title: 'Mixnodes',
},
{
url: '/network-components/gateways',
title: 'Gateways',
},
{
url: `${BIG_DIPPER}/validators`,
title: 'Validators',
isExternal: true,
},
{
url: '/network-components/service-providers',
title: 'Service Providers',
},
],
},
{
url: '/nodemap',
title: 'Nodemap',
Icon: <NodemapSVG />,
},
{
url: '/delegations',
title: 'Delegations',
Icon: <DelegateIcon sx={{ color: 'white' }} />,
},
]
+123
View File
@@ -0,0 +1,123 @@
'use client'
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useChain } from '@cosmos-kit/react'
import { Wallet } from '@cosmos-kit/core'
import { unymToNym } from '@/app/utils/currency'
import { useNymClient } from '@/app/hooks'
import {
MixnetClient,
MixnetQueryClient,
} from '@nymproject/contract-clients/Mixnet.client'
import { COSMOS_KIT_USE_CHAIN } from '@/app/api/constants'
interface WalletState {
balance: { status: 'loading' | 'success'; data?: string }
address?: string
isWalletConnected: boolean
isWalletConnecting: boolean
wallet?: Wallet
nymClient?: MixnetClient
nymQueryClient?: MixnetQueryClient
connectWallet: () => Promise<void>
disconnectWallet: () => Promise<void>
}
export const WalletContext = createContext<WalletState>({
address: undefined,
balance: { status: 'loading', data: undefined },
isWalletConnected: false,
isWalletConnecting: false,
nymClient: undefined,
nymQueryClient: undefined,
connectWallet: async () => {
throw new Error('Please connect your wallet')
},
disconnectWallet: async () => {
throw new Error('Please connect your wallet')
},
})
export const WalletProvider = ({ children }: { children: React.ReactNode }) => {
const [balance, setBalance] = useState<WalletState['balance']>({
status: 'loading',
data: undefined,
})
const {
connect,
disconnect,
wallet,
address,
isWalletConnected,
isWalletConnecting,
getCosmWasmClient,
} = useChain(COSMOS_KIT_USE_CHAIN)
const { nymClient, nymQueryClient } = useNymClient(address)
const getBalance = async (walletAddress: string) => {
const account = await getCosmWasmClient()
const uNYMBalance = await account.getBalance(walletAddress, 'unym')
const NYMBalance = unymToNym(uNYMBalance.amount)
return NYMBalance
}
const init = async (walletAddress: string) => {
const walletBalance = await getBalance(walletAddress)
setBalance({ status: 'success', data: walletBalance })
}
useEffect(() => {
if (isWalletConnected && address) {
init(address)
}
}, [address, isWalletConnected])
const handleConnectWallet = async () => {
await connect()
}
const handleDisconnectWallet = async () => {
await disconnect()
setBalance({ status: 'loading', data: undefined })
}
const contextValue: WalletState = useMemo(
() => ({
address,
balance,
wallet,
isWalletConnected,
isWalletConnecting,
nymClient,
nymQueryClient,
connectWallet: handleConnectWallet,
disconnectWallet: handleDisconnectWallet,
}),
[
address,
balance,
wallet,
isWalletConnected,
isWalletConnecting,
nymClient,
nymQueryClient,
]
)
return (
<WalletContext.Provider value={contextValue}>
{children}
</WalletContext.Provider>
)
}
export const useWalletContext = () => useContext(WalletContext)

Some files were not shown because too many files have changed in this diff Show More