Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 697d55248d | |||
| 570cc36385 | |||
| ee64762b87 | |||
| f4528bb521 | |||
| f4630e0b8a | |||
| 65f948d012 | |||
| d16a288b6d | |||
| 72c40d8576 | |||
| 34e1709b75 | |||
| a06ae48e2f | |||
| 257df97e3a | |||
| 870570d5c3 | |||
| 0000baa343 | |||
| 6a307d59b4 | |||
| a4808635f9 | |||
| 29965782a2 | |||
| e5f41731ae | |||
| a6fda391ae | |||
| 1ded24dcfc | |||
| 8c42640853 | |||
| 38aabc7983 | |||
| 4324845d29 | |||
| b9524a0f58 | |||
| e7cd417894 | |||
| ca25db845a | |||
| 64a0ce31a8 | |||
| a8fe8d9bfb | |||
| c346f145d1 | |||
| 45dd6f2632 | |||
| 22d28759ab | |||
| 890d0f7440 | |||
| b342eb870e | |||
| fc71e0cafd | |||
| 1ecb57fda0 | |||
| 3c1ec82289 | |||
| 089e403d87 | |||
| dd2b477cda | |||
| 0902539332 | |||
| 0783c532de | |||
| 8817ae7805 | |||
| 6a900c3c42 | |||
| 0ba80c9a86 | |||
| d712b65ec5 | |||
| 383b2c1351 | |||
| f0a4350e83 | |||
| 6f4b00b5c2 | |||
| d681ad20cf | |||
| 5818d58caf | |||
| da4eab8fdb |
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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 || '';
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
</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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }} />,
|
||||
},
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user