From 0dabff72bdf543476acbc369ea6a86ef0c710258 Mon Sep 17 00:00:00 2001 From: Fouad Date: Mon, 8 Aug 2022 14:09:57 +0100 Subject: [PATCH] Feature/bonding refactor (#1481) * feat(wallet-bonding): bonding page, new bond form wip * feat(wallet-bonding): add node table component feat(wallet-bonding): new dialog component * feat(wallet-bonding): node settings flow * feat(wallet-bonding): bond more flow (done) * feat(wallet): use confirmation modal component * feat(wallet-bonding): node menu ui * refactor(wallet-bonding): bonding flow with new gasFee estimation * feat(wallet-bonding): unbond with gasFee and request * refactor(wallet-bonding): switch to simpledialog component to keep modals consistency * feat(wallet-bonding): fetch mixnode status * update coin types in new bonding page * fix displayed denom * rebuild BondedNodeCard using existing shared components * create reuseable ActionMenu component * new mixnode form * add gateway bond form * check balance and fetch fee on bond mixnode request * node settings * get node description * fix up rust request * lint fixes + used NodeTypeSelector component * temporarily remove estimated operator reward * update return on rust function * dont display node name UI if name doesnt exist * rebase develop * fix uppercase address bug Co-authored-by: Mark Sinclair Co-authored-by: pierre --- nym-wallet/Cargo.lock | 97 ++++- nym-wallet/src-tauri/Cargo.toml | 4 +- nym-wallet/src-tauri/src/main.rs | 2 + .../src-tauri/src/operations/mixnet/bond.rs | 60 +++ .../components/Accounts/MultiAccountHowTo.tsx | 4 +- nym-wallet/src/components/ActionsMenu.tsx | 42 ++ nym-wallet/src/components/AppBar.tsx | 15 +- nym-wallet/src/components/AppBar/AppBar.tsx | 65 ---- nym-wallet/src/components/AppBar/index.ts | 1 - nym-wallet/src/components/Bonding/Bond.tsx | 44 +++ .../src/components/Bonding/BondedGateway.tsx | 84 ++++ .../Bonding/BondedGatewayAction.tsx | 31 ++ .../src/components/Bonding/BondedMixnode.tsx | 138 +++++++ .../Bonding/BondedMixnodeActions.tsx | 48 +++ .../src/components/Bonding/NodeTable.tsx | 50 +++ .../Bonding/forms/BondGatewayForm.tsx | 214 +++++++++++ .../Bonding/forms/BondMixnodeForm.tsx | 223 +++++++++++ .../Bonding/forms/gatewayValidationSchema.ts | 59 +++ .../Bonding/forms/mixnodeValidationSchema.ts | 51 +++ .../Bonding/modals/BondGatewayModal.tsx | 161 ++++++++ .../Bonding/modals/BondMixnodeModal.tsx | 160 ++++++++ .../Bonding/modals/BondMoreModal.tsx | 115 ++++++ .../Bonding/modals/CompoundRewardsModal.tsx | 53 +++ .../Bonding/modals/ConfirmationModal.tsx | 52 +++ .../Bonding/modals/NodeSettingsModal.tsx | 128 +++++++ .../Bonding/modals/RedeemRewardsModal.tsx | 53 +++ .../components/Bonding/modals/UnbondModal.tsx | 72 ++++ nym-wallet/src/components/CopyToClipboard.tsx | 2 +- .../components/Delegation/DelegateModal.tsx | 2 +- .../Delegation/DelegationActions.tsx | 97 ++--- nym-wallet/src/components/IdentityKey.tsx | 13 + .../components/Modals/ConfirmationModal.tsx | 2 +- .../src/components/Modals/ErrorModal.tsx | 26 ++ nym-wallet/src/components/Modals/ModalFee.tsx | 12 +- .../src/components/Modals/ModalListItem.tsx | 6 +- .../src/components/Modals/SimpleModal.tsx | 12 +- nym-wallet/src/components/Nav.tsx | 18 +- nym-wallet/src/components/NymCard.tsx | 2 +- .../src/components/Rewards/CompoundModal.tsx | 21 +- .../src/components/Rewards/RedeemModal.tsx | 22 +- .../components/Send/SendDetails.stories.tsx | 1 + .../src/components/Send/SendDetailsModal.tsx | 10 +- .../src/components/Send/SendInputModal.tsx | 2 +- nym-wallet/src/components/Tabs.tsx | 35 ++ .../src/components/TokenPoolSelector.tsx | 10 +- nym-wallet/src/context/bonding.tsx | 358 ++++++++++++++++++ nym-wallet/src/context/index.tsx | 1 + nym-wallet/src/context/main.tsx | 11 +- nym-wallet/src/context/mocks/bonding.tsx | 181 +++++++++ .../src/pages/bonding/BondingPage.stories.tsx | 13 + nym-wallet/src/pages/bonding/index.tsx | 252 ++++++++++++ nym-wallet/src/pages/bonding/types.ts | 70 ++++ nym-wallet/src/pages/delegation/index.tsx | 4 +- nym-wallet/src/pages/index.ts | 9 +- nym-wallet/src/pages/settings/index.tsx | 67 ---- nym-wallet/src/requests/actions.ts | 4 +- nym-wallet/src/requests/queries.ts | 10 +- nym-wallet/src/requests/rewards.ts | 9 +- nym-wallet/src/requests/simulate.ts | 16 +- nym-wallet/src/requests/vesting.ts | 12 +- nym-wallet/src/routes/app.tsx | 5 +- nym-wallet/src/svg-icons/bonding.tsx | 22 ++ nym-wallet/src/svg-icons/index.ts | 1 + nym-wallet/src/theme/mui-theme.d.ts | 10 +- nym-wallet/src/theme/theme.tsx | 14 +- nym-wallet/src/types/global.ts | 13 + nym-wallet/src/utils/index.ts | 2 + .../mixnodes/IdentityKeyFormField.tsx | 12 +- yarn.lock | 79 +++- 69 files changed, 3139 insertions(+), 355 deletions(-) create mode 100644 nym-wallet/src/components/ActionsMenu.tsx delete mode 100644 nym-wallet/src/components/AppBar/AppBar.tsx delete mode 100644 nym-wallet/src/components/AppBar/index.ts create mode 100644 nym-wallet/src/components/Bonding/Bond.tsx create mode 100644 nym-wallet/src/components/Bonding/BondedGateway.tsx create mode 100644 nym-wallet/src/components/Bonding/BondedGatewayAction.tsx create mode 100644 nym-wallet/src/components/Bonding/BondedMixnode.tsx create mode 100644 nym-wallet/src/components/Bonding/BondedMixnodeActions.tsx create mode 100644 nym-wallet/src/components/Bonding/NodeTable.tsx create mode 100644 nym-wallet/src/components/Bonding/forms/BondGatewayForm.tsx create mode 100644 nym-wallet/src/components/Bonding/forms/BondMixnodeForm.tsx create mode 100644 nym-wallet/src/components/Bonding/forms/gatewayValidationSchema.ts create mode 100644 nym-wallet/src/components/Bonding/forms/mixnodeValidationSchema.ts create mode 100644 nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/BondMixnodeModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/CompoundRewardsModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/ConfirmationModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/NodeSettingsModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/RedeemRewardsModal.tsx create mode 100644 nym-wallet/src/components/Bonding/modals/UnbondModal.tsx create mode 100644 nym-wallet/src/components/IdentityKey.tsx create mode 100644 nym-wallet/src/components/Modals/ErrorModal.tsx create mode 100644 nym-wallet/src/components/Tabs.tsx create mode 100644 nym-wallet/src/context/bonding.tsx create mode 100644 nym-wallet/src/context/mocks/bonding.tsx create mode 100644 nym-wallet/src/pages/bonding/BondingPage.stories.tsx create mode 100644 nym-wallet/src/pages/bonding/index.tsx create mode 100644 nym-wallet/src/pages/bonding/types.ts delete mode 100644 nym-wallet/src/pages/settings/index.tsx create mode 100644 nym-wallet/src/svg-icons/bonding.tsx diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 9f57f987f3..2561ca57b6 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -3273,7 +3273,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.3", ] [[package]] @@ -3290,6 +3300,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec 1.8.0", + "windows-sys", +] + [[package]] name = "password-hash" version = "0.3.2" @@ -4550,6 +4573,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "1.3.2" @@ -4635,9 +4667,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "state" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cf4f5369e6d3044b5e365c9690f451516ac8f0954084622b49ea3fde2f6de5" +checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" dependencies = [ "loom", ] @@ -4656,7 +4688,7 @@ checksum = "33994d0838dc2d152d17a62adf608a869b5e846b65b389af7f3dbc1de45c5b26" dependencies = [ "lazy_static", "new_debug_unreachable", - "parking_lot", + "parking_lot 0.11.2", "phf_shared 0.10.0", "precomputed-hash", "serde", @@ -4838,7 +4870,7 @@ dependencies = [ "ndk-glue", "ndk-sys", "objc", - "parking_lot", + "parking_lot 0.11.2", "raw-window-handle", "scopeguard", "serde", @@ -5241,7 +5273,9 @@ dependencies = [ "mio", "num_cpus", "once_cell", + "parking_lot 0.12.1", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "winapi", @@ -5898,11 +5932,11 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b749ebd2304aa012c5992d11a25d07b406bdbe5f79d371cb7a918ce501a19eb0" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_msvc 0.30.0", + "windows_i686_gnu 0.30.0", + "windows_i686_msvc 0.30.0", + "windows_x86_64_gnu 0.30.0", + "windows_x86_64_msvc 0.30.0", ] [[package]] @@ -5915,12 +5949,31 @@ dependencies = [ "windows_reader", ] +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + [[package]] name = "windows_aarch64_msvc" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29277a4435d642f775f63c7d1faeb927adba532886ce0287bd985bffb16b6bca" +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + [[package]] name = "windows_gen" version = "0.30.0" @@ -5937,12 +5990,24 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1145e1989da93956c68d1864f32fb97c8f561a8f89a5125f6a2b7ea75524e4b8" +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + [[package]] name = "windows_i686_msvc" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a09e3a0d4753b73019db171c1339cd4362c8c44baf1bcea336235e955954a6" +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + [[package]] name = "windows_macros" version = "0.30.0" @@ -5973,12 +6038,24 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca64fcb0220d58db4c119e050e7af03c69e6f4f415ef69ec1773d9aab422d5a" +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + [[package]] name = "windows_x86_64_msvc" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08cabc9f0066848fef4bc6a1c1668e6efce38b661d2aeec75d18d8617eebb5f1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + [[package]] name = "winreg" version = "0.7.0" diff --git a/nym-wallet/src-tauri/Cargo.toml b/nym-wallet/src-tauri/Cargo.toml index f6ea6170fe..2c28f4c2d9 100644 --- a/nym-wallet/src-tauri/Cargo.toml +++ b/nym-wallet/src-tauri/Cargo.toml @@ -32,14 +32,14 @@ log = "0.4" once_cell = "1.7.2" pretty_env_logger = "0.4" rand = "0.6.5" -reqwest = "0.11.9" +reqwest = {version = "0.11.9", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" strum = { version = "0.23", features = ["derive"] } tauri = { version = "=1.0.0-rc.2", features = ["clipboard-all", "shell-open", "updater", "window-maximize"] } tendermint-rpc = "0.23.0" thiserror = "1.0" -tokio = { version = "1.10", features = ["sync", "time"] } +tokio = { version = "1.10", features = ["full"] } toml = "0.5.8" url = "2.2" diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index ddc2830c2e..488b26e7a9 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -58,6 +58,8 @@ fn main() { mixnet::bond::unbond_gateway, mixnet::bond::unbond_mixnode, mixnet::bond::update_mixnode, + mixnet::bond::get_number_of_mixnode_delegators, + mixnet::bond::get_mix_node_description, mixnet::delegate::delegate_to_mixnode, mixnet::delegate::get_delegator_rewards, mixnet::delegate::get_pending_delegation_events, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs index c9b38ca102..e350bd49d9 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/bond.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/bond.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::error::BackendError; use crate::state::WalletState; use crate::{Gateway, MixNode}; @@ -5,8 +7,18 @@ use nym_types::currency::DecCoin; use nym_types::gateway::GatewayBond; use nym_types::mixnode::MixNodeBond; use nym_types::transaction::TransactionExecuteResult; +use reqwest::Error as ReqwestError; +use serde::{Deserialize, Serialize}; use validator_client::nymd::{Coin, Fee}; +#[derive(Debug, Serialize, Deserialize)] +pub struct NodeDescription { + name: String, + description: String, + link: String, + location: String, +} + #[tauri::command] pub async fn bond_gateway( gateway: Gateway, @@ -198,3 +210,51 @@ pub async fn get_operator_rewards( ); Ok(display_coin) } + +#[tauri::command] +pub async fn get_number_of_mixnode_delegators( + identity: String, + state: tauri::State<'_, WalletState>, +) -> Result { + let guard = state.read().await; + let client = guard.current_client()?; + let paged_delegations = client + .nymd + .get_mix_delegations_paged(identity, None, Some(20)) + .await?; + + Ok(paged_delegations.delegations.len()) +} + +async fn fetch_mix_node_description( + host: &str, + port: u16, +) -> Result { + let milli_second = Duration::from_millis(1000); + let client = reqwest::Client::builder().timeout(milli_second).build()?; + let response = client + .get(format!("http://{}:{}/description", host, port)) + .send() + .await; + + match response { + Ok(res) => { + let json = res.json::().await; + match json { + Ok(json) => Ok(json), + Err(e) => Err(e), + } + } + Err(e) => Err(e), + } +} + +#[tauri::command] +pub async fn get_mix_node_description( + host: &str, + port: u16, +) -> Result { + return fetch_mix_node_description(host, port) + .await + .map_err(|e| BackendError::ReqwestError { source: e }); +} diff --git a/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx b/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx index 0ca8524543..aca5552b2e 100644 --- a/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx +++ b/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx @@ -20,7 +20,7 @@ export const MultiAccountHowTo = ({ show, handleClose }: { show: boolean; handle - t.palette.nym.text.muted }}> + theme.palette.nym.text.muted }}> How to set up multiple accounts @@ -29,7 +29,7 @@ export const MultiAccountHowTo = ({ show, handleClose }: { show: boolean; handle (t.palette.mode === 'dark' ? { bgcolor: (t) => t.palette.background.paper } : {})} + sx={(t) => (t.palette.mode === 'dark' ? { bgcolor: (theme) => theme.palette.background.paper } : {})} > In order to create multiple accounts your wallet needs a password. Follow steps below to create password. diff --git a/nym-wallet/src/components/ActionsMenu.tsx b/nym-wallet/src/components/ActionsMenu.tsx new file mode 100644 index 0000000000..63e8193833 --- /dev/null +++ b/nym-wallet/src/components/ActionsMenu.tsx @@ -0,0 +1,42 @@ +import React, { useRef } from 'react'; +import { MoreVertSharp } from '@mui/icons-material'; +import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material'; + +export const ActionsMenu: React.FC<{ open: boolean; onOpen: () => void; onClose: () => void }> = ({ + children, + open, + onOpen, + onClose, +}) => { + const anchorEl: any = useRef(); + + return ( + <> + + + + + {children} + + + ); +}; + +export const ActionsMenuItem = ({ + title, + description, + onClick, + Icon, + disabled, +}: { + title: string; + description?: string; + onClick?: () => void; + Icon?: React.ReactNode; + disabled?: boolean; +}) => ( + + {Icon} + + +); diff --git a/nym-wallet/src/components/AppBar.tsx b/nym-wallet/src/components/AppBar.tsx index 3bc8d6bebb..9aedf14d1a 100644 --- a/nym-wallet/src/components/AppBar.tsx +++ b/nym-wallet/src/components/AppBar.tsx @@ -7,13 +7,11 @@ import ModeNightOutlinedIcon from '@mui/icons-material/ModeNightOutlined'; import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'; import { AppContext } from '../context/main'; import { NetworkSelector } from './NetworkSelector'; -import { Node as NodeIcon } from '../svg-icons/node'; import { MultiAccounts } from './Accounts'; import { config } from '../config'; export const AppBar = () => { - const { logOut, handleShowTerminal, appEnv, handleShowSettings, showSettings, mode, handleSwitchMode } = - useContext(AppContext); + const { logOut, handleShowTerminal, appEnv, mode, handleSwitchMode } = useContext(AppContext); const navigate = useNavigate(); return ( @@ -31,7 +29,7 @@ export const AppBar = () => { - {mode === 'light' ? ( + {mode === 'dark' ? ( ) : ( @@ -45,15 +43,6 @@ export const AppBar = () => { )} - - - - - { - const { showSettings, handleShowTerminal, appEnv, handleShowSettings, logOut, mode, handleSwitchMode } = - useContext(AppContext); - - return ( - - - - - - - - - - - - - - - {mode === 'light' ? ( - - ) : ( - - )} - - - {(appEnv?.SHOW_TERMINAL || config.IS_DEV_MODE) && ( - - - - - - )} - - - - - - - - - - - - - - - ); -}; diff --git a/nym-wallet/src/components/AppBar/index.ts b/nym-wallet/src/components/AppBar/index.ts deleted file mode 100644 index 60f2f0e90b..0000000000 --- a/nym-wallet/src/components/AppBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './AppBar'; diff --git a/nym-wallet/src/components/Bonding/Bond.tsx b/nym-wallet/src/components/Bonding/Bond.tsx new file mode 100644 index 0000000000..3392cde43f --- /dev/null +++ b/nym-wallet/src/components/Bonding/Bond.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import { NymCard } from '../NymCard'; + +export const Bond = ({ + onBond, + disabled, +}: { + onBond: () => void; + + disabled: boolean; +}) => ( + + + Bond a mixnode or a gateway + + + + + +); diff --git a/nym-wallet/src/components/Bonding/BondedGateway.tsx b/nym-wallet/src/components/Bonding/BondedGateway.tsx new file mode 100644 index 0000000000..2ae9b26608 --- /dev/null +++ b/nym-wallet/src/components/Bonding/BondedGateway.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Stack, Typography } from '@mui/material'; +import { Link } from '@nymproject/react/link/Link'; +import { TBondedGateway, urls } from 'src/context'; +import { NymCard } from 'src/components'; +import { Network } from 'src/types'; +import { IdentityKey } from 'src/components/IdentityKey'; +import { Cell, Header, NodeTable } from './NodeTable'; +import { BondedGatewayActions, TBondedGatwayActions } from './BondedGatewayAction'; + +const headers: Header[] = [ + { + header: 'IP', + id: 'ip', + sx: { pl: 0 }, + }, + { + header: 'Bond', + id: 'bond', + }, + { + id: 'menu-button', + sx: { width: 34, maxWidth: 34 }, + }, +]; + +export const BondedGateway = ({ + gateway, + network, + onActionSelect, +}: { + gateway: TBondedGateway; + network?: Network; + onActionSelect: (action: TBondedGatwayActions) => void; +}) => { + const { name, bond, ip, identityKey } = gateway; + const cells: Cell[] = [ + { + cell: ip, + id: 'stake-saturation-cell', + }, + { + cell: `${bond.amount} ${bond.denom}`, + id: 'stake-cell', + sx: { pl: 0 }, + }, + + { + cell: , + id: 'actions-cell', + align: 'right', + }, + ]; + + return ( + + + Gateway + + + {name && ( + + {name} + + )} + + + } + > + + {network && ( + + Check more stats of your node on the{' '} + + explorer + + + )} + + ); +}; diff --git a/nym-wallet/src/components/Bonding/BondedGatewayAction.tsx b/nym-wallet/src/components/Bonding/BondedGatewayAction.tsx new file mode 100644 index 0000000000..c511d07bf4 --- /dev/null +++ b/nym-wallet/src/components/Bonding/BondedGatewayAction.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import { ActionsMenu, ActionsMenuItem } from 'src/components/ActionsMenu'; +import { Unbond as UnbondIcon } from '../../svg-icons'; + +export type TBondedGatwayActions = 'unbond'; + +export const BondedGatewayActions = ({ + onActionSelect, +}: { + onActionSelect: (action: TBondedGatwayActions) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + const handleActionClick = (action: TBondedGatwayActions) => { + onActionSelect(action); + handleClose(); + }; + + return ( + + } + onClick={() => handleActionClick('unbond')} + /> + + ); +}; diff --git a/nym-wallet/src/components/Bonding/BondedMixnode.tsx b/nym-wallet/src/components/Bonding/BondedMixnode.tsx new file mode 100644 index 0000000000..d602eeacb8 --- /dev/null +++ b/nym-wallet/src/components/Bonding/BondedMixnode.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { Link } from '@nymproject/react/link/Link'; +import { TBondedMixnode, urls } from 'src/context'; +import { NymCard } from 'src/components'; +import { Network } from 'src/types'; +import { IdentityKey } from 'src/components/IdentityKey'; +import { NodeStatus } from 'src/components/NodeStatus'; +import { Node as NodeIcon } from '../../svg-icons/node'; +import { Cell, Header, NodeTable } from './NodeTable'; +import { BondedMixnodeActions, TBondedMixnodeActions } from './BondedMixnodeActions'; + +const headers: Header[] = [ + { + header: 'Stake', + id: 'stake', + sx: { pl: 0 }, + }, + { + header: 'Bond', + id: 'bond', + }, + { + header: 'Stake saturation', + id: 'stake-saturation', + }, + { + header: 'PM', + id: 'profit-margin', + tooltipText: + 'The percentage of the node rewards that you as the node operator will take before the rest of the reward is shared between you and the delegators.', + }, + { + header: 'Operator rewards', + id: 'operator-rewards', + tooltipText: + 'This is your (operator) new rewards including the PM and cost. You can compound your rewards manually every epoch or unbond your node to redeem them.', + }, + { + header: 'No. delegators', + id: 'delegators', + }, + { + id: 'menu-button', + sx: { width: 34, maxWidth: 34 }, + }, +]; + +export const BondedMixnode = ({ + mixnode, + network, + onActionSelect, +}: { + mixnode: TBondedMixnode; + network?: Network; + onActionSelect: (action: TBondedMixnodeActions) => void; +}) => { + const { name, stake, bond, stakeSaturation, profitMargin, operatorRewards, delegators, status, identityKey } = + mixnode; + const cells: Cell[] = [ + { + cell: `${stake.amount} ${stake.denom}`, + id: 'stake-cell', + }, + { + cell: `${bond.amount} ${bond.denom}`, + id: 'bond-cell', + }, + { + cell: `${stakeSaturation}%`, + id: 'stake-saturation-cell', + }, + { + cell: `${profitMargin}%`, + id: 'pm-cell', + }, + { + cell: `${operatorRewards.amount} ${operatorRewards.denom}`, + id: 'operator-rewards-cell', + }, + { + cell: delegators, + id: 'delegators-cell', + }, + { + cell: ( + + ), + id: 'actions-cell', + align: 'right', + }, + ]; + + return ( + + + + Mix node + + + + {name && ( + + {name} + + )} + + + } + Action={ + + } + > + + {network && ( + + Check more stats of your node on the{' '} + + explorer + + + )} + + ); +}; diff --git a/nym-wallet/src/components/Bonding/BondedMixnodeActions.tsx b/nym-wallet/src/components/Bonding/BondedMixnodeActions.tsx new file mode 100644 index 0000000000..83cc2a0198 --- /dev/null +++ b/nym-wallet/src/components/Bonding/BondedMixnodeActions.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { Typography } from '@mui/material'; +import { ActionsMenu, ActionsMenuItem } from 'src/components/ActionsMenu'; +import { Unbond as UnbondIcon } from '../../svg-icons'; + +export type TBondedMixnodeActions = 'nodeSettings' | 'bondMore' | 'unbond' | 'redeem' | 'compound'; + +export const BondedMixnodeActions = ({ + onActionSelect, + disabledRedeemAndCompound, +}: { + onActionSelect: (action: TBondedMixnodeActions) => void; + disabledRedeemAndCompound: boolean; +}) => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + + const handleActionClick = (action: TBondedMixnodeActions) => { + onActionSelect(action); + handleClose(); + }; + + return ( + + } + onClick={() => handleActionClick('unbond')} + /> + C} + description={disabledRedeemAndCompound ? 'No rewards to compound' : 'Add your rewards to you balance'} + onClick={() => handleActionClick('compound')} + disabled={disabledRedeemAndCompound} + /> + R} + description={disabledRedeemAndCompound ? 'No rewards to redeem' : 'Add your rewards to you balance'} + onClick={() => handleActionClick('redeem')} + disabled={disabledRedeemAndCompound} + /> + + ); +}; diff --git a/nym-wallet/src/components/Bonding/NodeTable.tsx b/nym-wallet/src/components/Bonding/NodeTable.tsx new file mode 100644 index 0000000000..d1e620a1ce --- /dev/null +++ b/nym-wallet/src/components/Bonding/NodeTable.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { + Stack, + SxProps, + Table, + TableBody, + TableCell, + TableCellProps, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { InfoTooltip } from '../InfoToolTip'; + +export type Header = { header?: string; id: string; tooltipText?: string; sx?: SxProps }; +export type Cell = { cell: string | React.ReactNode; id: string; align?: TableCellProps['align']; sx?: SxProps }; + +export interface TableProps { + headers: Header[]; + cells: Cell[]; +} + +export const NodeTable = ({ headers, cells }: TableProps) => ( + + + + + {headers.map(({ header, id, tooltipText }) => ( + + + {tooltipText && } + {header} + + + ))} + + + + + {cells.map(({ cell, id, align }) => ( + + {cell} + + ))} + + +
+
+); diff --git a/nym-wallet/src/components/Bonding/forms/BondGatewayForm.tsx b/nym-wallet/src/components/Bonding/forms/BondGatewayForm.tsx new file mode 100644 index 0000000000..c4f827dcd5 --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/BondGatewayForm.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useState } from 'react'; +import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; +import { Box, Checkbox, FormControlLabel, Stack, TextField } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; +import { NodeTypeSelector, TokenPoolSelector } from 'src/components'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { checkHasEnoughFunds, checkHasEnoughLockedTokens } from 'src/utils'; +import { CurrencyDenom, TNodeType } from '@nymproject/types'; +import { GatewayAmount, GatewayData } from 'src/pages/bonding/types'; +import { gatewayValidationSchema, amountSchema } from './gatewayValidationSchema'; + +const NodeFormData = ({ gatewayData, onNext }: { gatewayData: GatewayData; onNext: (data: GatewayData) => void }) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + const { + register, + formState: { errors }, + handleSubmit, + setValue, + } = useForm({ resolver: yupResolver(gatewayValidationSchema), defaultValues: gatewayData }); + + const handleRequestValidation = (event: { detail: { step: number } }) => { + if (event.detail.step === 1) { + handleSubmit(onNext)(); + } + }; + + useEffect(() => { + window.addEventListener('validate_bond_gateway_step' as any, handleRequestValidation); + return () => window.removeEventListener('validate_bond_gateway_step' as any, handleRequestValidation); + }, []); + + return ( + + setValue('identityKey', value)} + /> + + + + + + + + setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />} + label="Show advanced options" + /> + {showAdvancedOptions && ( + + + + + )} + + ); +}; + +const AmountFormData = ({ + denom, + amountData, + hasVestingTokens, + onNext, +}: { + denom: CurrencyDenom; + amountData: GatewayAmount; + hasVestingTokens: boolean; + onNext: (data: any) => void; +}) => { + const { + formState: { errors }, + handleSubmit, + setValue, + getValues, + setError, + } = useForm({ resolver: yupResolver(amountSchema), defaultValues: amountData }); + + const handleRequestValidation = async (event: { detail: { step: number } }) => { + let hasSufficientTokens = true; + const values = getValues(); + + if (values.tokenPool === 'balance') { + hasSufficientTokens = await checkHasEnoughFunds(values.amount.amount); + } + + if (values.tokenPool === 'locked') { + hasSufficientTokens = await checkHasEnoughLockedTokens(values.amount.amount); + } + + if (event.detail.step === 2 && hasSufficientTokens) { + handleSubmit(onNext)(); + } else { + setError('amount.amount', { message: 'Not enough tokens' }); + } + }; + + useEffect(() => { + window.addEventListener('validate_bond_gateway_step' as any, handleRequestValidation); + return () => window.removeEventListener('validate_bond_gateway_step' as any, handleRequestValidation); + }, []); + + return ( + + + {hasVestingTokens && setValue('tokenPool', pool)} />} + setValue('amount', newValue, { shouldValidate: true })} + validationError={errors.amount?.amount?.message} + denom={denom} + initialValue={amountData.amount.amount} + /> + + + ); +}; + +export const BondGatewayForm = ({ + step, + denom, + gatewayData, + amountData, + hasVestingTokens, + onValidateGatewayData, + onValidateAmountData, + onSelectNodeType, +}: { + step: 1 | 2 | 3; + gatewayData: GatewayData; + amountData: GatewayAmount; + denom: CurrencyDenom; + hasVestingTokens: boolean; + onValidateGatewayData: (data: GatewayData) => void; + onValidateAmountData: (data: GatewayAmount) => Promise; + onSelectNodeType: (nodeType: TNodeType) => void; +}) => ( + <> + {step === 1 && ( + <> + + + + + + )} + {step === 2 && ( + + )} + +); diff --git a/nym-wallet/src/components/Bonding/forms/BondMixnodeForm.tsx b/nym-wallet/src/components/Bonding/forms/BondMixnodeForm.tsx new file mode 100644 index 0000000000..99be47d96b --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/BondMixnodeForm.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box, Checkbox, FormControlLabel, Stack, TextField } from '@mui/material'; +import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; +import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; +import { CurrencyDenom, TNodeType } from '@nymproject/types'; +import { checkHasEnoughFunds, checkHasEnoughLockedTokens } from 'src/utils'; +import { NodeTypeSelector, TokenPoolSelector } from 'src/components'; +import { MixnodeAmount, MixnodeData } from 'src/pages/bonding/types'; +import { amountSchema, mixnodeValidationSchema } from './mixnodeValidationSchema'; + +const NodeFormData = ({ mixnodeData, onNext }: { mixnodeData: MixnodeData; onNext: (data: any) => void }) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + const { + register, + formState: { errors }, + handleSubmit, + setValue, + } = useForm({ resolver: yupResolver(mixnodeValidationSchema), defaultValues: mixnodeData }); + + const handleRequestValidation = (event: { detail: { step: number } }) => { + if (event.detail.step === 1) { + handleSubmit(onNext)(); + } + }; + + useEffect(() => { + window.addEventListener('validate_bond_mixnode_step' as any, handleRequestValidation); + return () => window.removeEventListener('validate_bond_mixnode_step' as any, handleRequestValidation); + }, []); + + return ( + + setValue('identityKey', value)} + /> + + + + + + + setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />} + label="Show advanced options" + /> + {showAdvancedOptions && ( + + + + + + )} + + ); +}; + +const AmountFormData = ({ + amountData, + hasVestingTokens, + denom, + onNext, +}: { + amountData: MixnodeAmount; + hasVestingTokens: boolean; + denom: CurrencyDenom; + onNext: (data: MixnodeAmount) => void; +}) => { + const { + register, + formState: { errors }, + handleSubmit, + setValue, + getValues, + setError, + } = useForm({ resolver: yupResolver(amountSchema), defaultValues: amountData }); + + const handleRequestValidation = async (event: { detail: { step: number } }) => { + let hasSufficientTokens = true; + const values = getValues(); + + if (values.tokenPool === 'balance') { + hasSufficientTokens = await checkHasEnoughFunds(values.amount.amount); + } + + if (values.tokenPool === 'locked') { + hasSufficientTokens = await checkHasEnoughLockedTokens(values.amount.amount); + } + + if (event.detail.step === 2 && hasSufficientTokens) { + handleSubmit(onNext)(); + } else { + setError('amount.amount', { message: 'Not enough tokens' }); + } + }; + + useEffect(() => { + window.addEventListener('validate_bond_mixnode_step' as any, handleRequestValidation); + return () => window.removeEventListener('validate_bond_mixnode_step' as any, handleRequestValidation); + }, []); + + return ( + + + {hasVestingTokens && setValue('tokenPool', pool)} />} + { + setValue('amount', newValue, { shouldValidate: true }); + }} + validationError={errors.amount?.amount?.message} + denom={denom} + initialValue={amountData.amount.amount} + /> + + + + ); +}; + +export const BondMixnodeForm = ({ + step, + denom, + mixnodeData, + amountData, + hasVestingTokens, + onValidateMixnodeData, + onValidateAmountData, + onSelectNodeType, +}: { + step: 1 | 2 | 3; + mixnodeData: MixnodeData; + amountData: MixnodeAmount; + denom: CurrencyDenom; + hasVestingTokens: boolean; + onValidateMixnodeData: (data: MixnodeData) => void; + onValidateAmountData: (data: MixnodeAmount) => Promise; + onSelectNodeType: (nodeType: TNodeType) => void; +}) => ( + <> + {step === 1 && ( + <> + + + + + + )} + {step === 2 && ( + + )} + +); diff --git a/nym-wallet/src/components/Bonding/forms/gatewayValidationSchema.ts b/nym-wallet/src/components/Bonding/forms/gatewayValidationSchema.ts new file mode 100644 index 0000000000..fb1625adc1 --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/gatewayValidationSchema.ts @@ -0,0 +1,59 @@ +import * as Yup from 'yup'; +import { + isValidHostname, + validateAmount, + validateKey, + validateLocation, + validateRawPort, + validateVersion, +} from 'src/utils'; + +export const gatewayValidationSchema = Yup.object().shape({ + identityKey: Yup.string() + .required('An indentity key is required') + .test('valid-id-key', 'A valid identity key is required', (value) => validateKey(value || '', 32)), + + sphinxKey: Yup.string() + .required('A sphinx key is required') + .test('valid-sphinx-key', 'A valid sphinx key is required', (value) => validateKey(value || '', 32)), + + ownerSignature: Yup.string() + .required('Signature is required') + .test('valid-signature', 'A valid signature is required', (value) => validateKey(value || '', 64)), + + host: Yup.string() + .required('A host is required') + .test('valid-host', 'A valid host is required', (value) => (value ? isValidHostname(value) : false)), + + version: Yup.string() + .required('A version is required') + .test('valid-version', 'A valid version is required', (value) => (value ? validateVersion(value) : false)), + + location: Yup.string() + .required('A location is required') + .test('valid-location', 'A valid version is required', (locationValueTest) => + locationValueTest ? validateLocation(locationValueTest) : false, + ), + + mixPort: Yup.number() + .required('A mixport is required') + .test('valid-mixport', 'A valid mixport is required', (value) => (value ? validateRawPort(value) : false)), + + clientsPort: Yup.number() + .required('A clients port is required') + .test('valid-clients', 'A valid clients port is required', (value) => (value ? validateRawPort(value) : false)), +}); + +export const amountSchema = Yup.object().shape({ + amount: Yup.object().shape({ + amount: Yup.string() + .required('An amount is required') + .test('valid-amount', 'Pledge error', async function isValidAmount(this, value) { + const isValid = await validateAmount(value || '', '100'); + if (!isValid) { + return this.createError({ message: 'A valid amount is required (min 100)' }); + } + return true; + }), + }), +}); diff --git a/nym-wallet/src/components/Bonding/forms/mixnodeValidationSchema.ts b/nym-wallet/src/components/Bonding/forms/mixnodeValidationSchema.ts new file mode 100644 index 0000000000..aa9e66db1c --- /dev/null +++ b/nym-wallet/src/components/Bonding/forms/mixnodeValidationSchema.ts @@ -0,0 +1,51 @@ +import * as Yup from 'yup'; +import { isValidHostname, validateAmount, validateKey, validateRawPort, validateVersion } from 'src/utils'; + +export const mixnodeValidationSchema = Yup.object().shape({ + identityKey: Yup.string() + .required('An identity key is required') + .test('valid-id-key', 'A valid identity key is required', (value) => validateKey(value || '', 32)), + + sphinxKey: Yup.string() + .required('A sphinx key is required') + .test('valid-sphinx-key', 'A valid sphinx key is required', (value) => validateKey(value || '', 32)), + + ownerSignature: Yup.string() + .required('Signature is required') + .test('valid-signature', 'A valid signature is required', (value) => validateKey(value || '', 64)), + + host: Yup.string() + .required('A host is required') + .test('valid-host', 'A valid host is required', (value) => (value ? isValidHostname(value) : false)), + + version: Yup.string() + .required('A version is required') + .test('valid-version', 'A valid version is required', (value) => (value ? validateVersion(value) : false)), + + mixPort: Yup.number() + .required('A mixport is required') + .test('valid-mixport', 'A valid mixport is required', (value) => (value ? validateRawPort(value) : false)), + + verlocPort: Yup.number() + .required('A verloc port is required') + .test('valid-verloc', 'A valid verloc port is required', (value) => (value ? validateRawPort(value) : false)), + + httpApiPort: Yup.number() + .required('A http-api port is required') + .test('valid-http', 'A valid http-api port is required', (value) => (value ? validateRawPort(value) : false)), +}); + +export const amountSchema = Yup.object().shape({ + amount: Yup.object().shape({ + amount: Yup.string() + .required('An amount is required') + .test('valid-amount', 'Pledge error', async function isValidAmount(this, value) { + const isValid = await validateAmount(value || '', '100'); + if (!isValid) { + return this.createError({ message: 'A valid amount is required (min 100)' }); + } + return true; + }), + }), + profitMargin: Yup.number().required('Profit Percentage is required').min(0).max(100), +}); diff --git a/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx b/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx new file mode 100644 index 0000000000..d54f75ce24 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/BondGatewayModal.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useState } from 'react'; +import { Box } from '@mui/material'; +import { CurrencyDenom, TNodeType } from '@nymproject/types'; +import { ConfirmTx } from 'src/components/ConfirmTX'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { TPoolOption } from 'src/components/TokenPoolSelector'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { GatewayAmount, GatewayData } from 'src/pages/bonding/types'; +import { simulateBondGateway, simulateVestingBondGateway } from 'src/requests'; +import { TBondGatewayArgs } from 'src/types'; +import { BondGatewayForm } from '../forms/BondGatewayForm'; + +const defaultMixnodeValues: GatewayData = { + identityKey: '', + sphinxKey: '', + ownerSignature: '', + location: '', + host: '', + version: '', + mixPort: 1789, + clientsPort: 1790, +}; + +const defaultAmountValues = (denom: CurrencyDenom) => ({ + amount: { amount: '100', denom }, + tokenPool: 'balance', +}); + +export const BondGatewayModal = ({ + denom, + hasVestingTokens, + onBondGateway, + onSelectNodeType, + onClose, + onError, +}: { + denom: CurrencyDenom; + hasVestingTokens: boolean; + onBondGateway: (data: TBondGatewayArgs, tokenPool: TPoolOption) => void; + onSelectNodeType: (type: TNodeType) => void; + onClose: () => void; + onError: (e: string) => void; +}) => { + const [step, setStep] = useState<1 | 2 | 3>(1); + const [gatewayData, setGatewayData] = useState(defaultMixnodeValues); + const [amountData, setAmountData] = useState(defaultAmountValues(denom)); + + const { fee, getFee, resetFeeState, feeError } = useGetFee(); + + useEffect(() => { + if (feeError) { + onError(feeError); + } + }, [feeError]); + + const validateStep = async (s: number) => { + const event = new CustomEvent('validate_bond_gateway_step', { detail: { step: s } }); + window.dispatchEvent(event); + }; + + const handleBack = () => { + setStep(1); + }; + + const handleUpdateGatwayData = (data: GatewayData) => { + setGatewayData(data); + setStep(2); + }; + + const handleUpdateAmountData = async (data: GatewayAmount) => { + setAmountData(data); + const payload = { + pledge: data.amount, + ownerSignature: gatewayData.ownerSignature, + gateway: { + ...gatewayData, + host: gatewayData.host, + version: gatewayData.version, + mix_port: gatewayData.mixPort, + clients_port: gatewayData.clientsPort, + sphinx_key: gatewayData.sphinxKey, + identity_key: gatewayData.identityKey, + location: gatewayData.location, + }, + }; + + if (data.tokenPool === 'balance') { + await getFee(simulateBondGateway, payload); + } else { + await getFee(simulateVestingBondGateway, payload); + } + }; + + const handleConfirm = async () => { + await onBondGateway( + { + pledge: amountData.amount, + ownerSignature: gatewayData.ownerSignature, + gateway: { + ...gatewayData, + host: gatewayData.host, + version: gatewayData.version, + mix_port: gatewayData.mixPort, + clients_port: gatewayData.clientsPort, + sphinx_key: gatewayData.sphinxKey, + identity_key: gatewayData.identityKey, + location: gatewayData.location, + }, + }, + amountData.tokenPool as TPoolOption, + ); + }; + + if (fee) { + return ( + + + + + ); + } + + return ( + { + await validateStep(step); + }} + onBack={step === 2 ? handleBack : undefined} + onClose={onClose} + header="Bond gateway" + subHeader={`Step ${step}/2`} + okLabel="Next" + > + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/BondMixnodeModal.tsx b/nym-wallet/src/components/Bonding/modals/BondMixnodeModal.tsx new file mode 100644 index 0000000000..fd0de36051 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/BondMixnodeModal.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useState } from 'react'; +import { Box } from '@mui/material'; +import { CurrencyDenom, TNodeType } from '@nymproject/types'; +import { ConfirmTx } from 'src/components/ConfirmTX'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { TPoolOption } from 'src/components/TokenPoolSelector'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { MixnodeAmount, MixnodeData } from 'src/pages/bonding/types'; +import { simulateBondMixnode, simulateVestingBondMixnode } from 'src/requests'; +import { TBondMixNodeArgs } from 'src/types'; +import { BondMixnodeForm } from '../forms/BondMixnodeForm'; + +const defaultMixnodeValues: MixnodeData = { + identityKey: '', + sphinxKey: '', + ownerSignature: '', + host: '', + version: '', + mixPort: 1789, + verlocPort: 1790, + httpApiPort: 8000, +}; + +const defaultAmountValues = (denom: CurrencyDenom) => ({ + amount: { amount: '100', denom }, + profitMargin: 10, + tokenPool: 'balance', +}); + +export const BondMixnodeModal = ({ + denom, + hasVestingTokens, + onBondMixnode, + onSelectNodeType, + onClose, + onError, +}: { + denom: CurrencyDenom; + hasVestingTokens: boolean; + onBondMixnode: (data: TBondMixNodeArgs, tokenPool: TPoolOption) => void; + onSelectNodeType: (type: TNodeType) => void; + onClose: () => void; + onError: (e: string) => void; +}) => { + const [step, setStep] = useState<1 | 2 | 3>(1); + const [mixnodeData, setMixnodeData] = useState(defaultMixnodeValues); + const [amountData, setAmountData] = useState(defaultAmountValues(denom)); + + const { fee, getFee, resetFeeState, feeError } = useGetFee(); + + useEffect(() => { + if (feeError) { + onError(feeError); + } + }, [feeError]); + + const validateStep = async (s: number) => { + const event = new CustomEvent('validate_bond_mixnode_step', { detail: { step: s } }); + window.dispatchEvent(event); + }; + + const handleBack = () => { + setStep(1); + }; + + const handleUpdateMixnodeData = (data: MixnodeData) => { + setMixnodeData(data); + setStep(2); + }; + + const handleUpdateAmountData = async (data: MixnodeAmount) => { + setAmountData(data); + const payload = { + pledge: data.amount, + ownerSignature: mixnodeData.ownerSignature, + mixnode: { + ...mixnodeData, + mix_port: mixnodeData.mixPort, + http_api_port: mixnodeData.httpApiPort, + verloc_port: mixnodeData.verlocPort, + sphinx_key: mixnodeData.sphinxKey, + identity_key: mixnodeData.identityKey, + profit_margin_percent: data.profitMargin, + }, + }; + + if (data.tokenPool === 'balance') { + await getFee(simulateBondMixnode, payload); + } else { + await getFee(simulateVestingBondMixnode, payload); + } + }; + + const handleConfirm = async () => { + await onBondMixnode( + { + pledge: amountData.amount, + ownerSignature: mixnodeData.ownerSignature, + mixnode: { + ...mixnodeData, + mix_port: mixnodeData.mixPort, + http_api_port: mixnodeData.httpApiPort, + verloc_port: mixnodeData.verlocPort, + sphinx_key: mixnodeData.sphinxKey, + identity_key: mixnodeData.identityKey, + profit_margin_percent: amountData.profitMargin, + }, + }, + amountData.tokenPool as TPoolOption, + ); + }; + + if (fee) { + return ( + + + + + ); + } + + return ( + { + await validateStep(step); + }} + onBack={step === 2 ? handleBack : undefined} + onClose={onClose} + header="Bond mixnode" + subHeader={`Step ${step}/2`} + okLabel="Next" + > + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx b/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx new file mode 100644 index 0000000000..235795e77a --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/BondMoreModal.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { Box, FormHelperText, Stack, TextField } from '@mui/material'; +import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { DecCoin } from '@nymproject/types'; +import { TokenPoolSelector, TPoolOption } from 'src/components/TokenPoolSelector'; +import { ConfirmTx } from 'src/components/ConfirmTX'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { validateAmount, validateKey } from 'src/utils'; + +export const BondMoreModal = ({ + currentBond, + userBalance, + hasVestingTokens, + onConfirm, + onClose, +}: { + currentBond: DecCoin; + userBalance?: string; + hasVestingTokens: boolean; + onConfirm: (args: { additionalBond: DecCoin; signature: string; tokenPool: TPoolOption }) => Promise; + onClose: () => void; +}) => { + const { fee, resetFeeState } = useGetFee(); + const [additionalBond, setAdditionalBond] = useState({ amount: '0', denom: currentBond.denom }); + const [signature, setSignature] = useState(''); + const [tokenPool, setTokenPool] = useState('balance'); + const [errorAmount, setErrorAmount] = useState(false); + const [errorSignature, setErrorSignature] = useState(false); + + const handleOnOk = async () => { + const errors = { + amount: false, + signature: false, + }; + + if (!validateKey(signature || '', 64)) { + errors.signature = true; + } + + if (!additionalBond?.amount) { + errors.amount = true; + } + + if (additionalBond && !(await validateAmount(additionalBond.amount, '1'))) { + errors.amount = true; + } + + if (!errors.amount && !errors.signature) { + onConfirm({ additionalBond, signature, tokenPool }); + } else { + setErrorAmount(errors.amount); + setErrorSignature(errors.signature); + } + }; + + useEffect(() => { + setErrorAmount(false); + }, [additionalBond]); + + if (fee) + return ( + onConfirm({ additionalBond, signature, tokenPool })} + onPrev={resetFeeState} + > + + + + ); + + return ( + + + + {hasVestingTokens && setTokenPool(pool)} />} + { + setAdditionalBond(value); + setErrorSignature(false); + }} + fullWidth + validationError={errorAmount ? 'Please enter a valid amount' : undefined} + /> + + + + setSignature(e.target.value)} /> + {errorSignature && Invalid signature} + + + + + + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/CompoundRewardsModal.tsx b/nym-wallet/src/components/Bonding/modals/CompoundRewardsModal.tsx new file mode 100644 index 0000000000..999f1c3302 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/CompoundRewardsModal.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; +import { FeeDetails } from '@nymproject/types'; +import { ModalFee } from 'src/components/Modals/ModalFee'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { TBondedMixnode } from 'src/context'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { simulateCompoundOperatorReward, simulateVestingCompoundOperatorReward } from 'src/requests'; + +export const CompoundRewardsModal = ({ + node, + onConfirm, + onClose, + onError, +}: { + node: TBondedMixnode; + onClose: () => void; + onConfirm: (fee?: FeeDetails) => void; + onError: (err: string) => void; +}) => { + const { fee, getFee, feeError, isFeeLoading } = useGetFee(); + + useEffect(() => { + if (feeError) onError(feeError); + }, [feeError]); + + useEffect(() => { + if (node.proxy) getFee(simulateVestingCompoundOperatorReward, {}); + else getFee(simulateCompoundOperatorReward, {}); + }, []); + + const handleOnOK = async () => onConfirm(fee); + + return ( + + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/ConfirmationModal.tsx b/nym-wallet/src/components/Bonding/modals/ConfirmationModal.tsx new file mode 100644 index 0000000000..9a3c683b55 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/ConfirmationModal.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Stack, Typography, SxProps } from '@mui/material'; +import { Link } from '@nymproject/react/link/Link'; +import { ConfirmationModal } from 'src/components/Modals/ConfirmationModal'; +import { ErrorModal } from 'src/components/Modals/ErrorModal'; + +export type ConfirmationDetailProps = { + status: 'success' | 'error'; + title: string; + subtitle?: string; + txUrl?: string; +}; + +export const ConfirmationDetailsModal = ({ + title, + subtitle, + txUrl, + status, + onClose, + sx, + backdropProps, +}: ConfirmationDetailProps & { + onClose: () => void; + sx?: SxProps; + backdropProps?: object; +}) => { + if (status === 'error') { + ; + } + + return ( + + + + {title} + + {subtitle} + {txUrl && } + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/NodeSettingsModal.tsx b/nym-wallet/src/components/Bonding/modals/NodeSettingsModal.tsx new file mode 100644 index 0000000000..1a1903d205 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/NodeSettingsModal.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Button, FormHelperText, TextField, Typography } from '@mui/material'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { Node as NodeIcon } from 'src/svg-icons/node'; +import { TBondedMixnode } from 'src/context'; +import { Tabs } from 'src/components/Tabs'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { isDecimal } from 'src/utils'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { ConfirmTx } from 'src/components/ConfirmTX'; +import { simulateUpdateMixnode, simulateVestingUpdateMixnode } from 'src/requests'; +import { LoadingModal } from 'src/components/Modals/LoadingModal'; +import { FeeDetails } from '@nymproject/types'; + +export const NodeSettings = ({ + currentPm, + isVesting, + onConfirm, + onClose, + onError, +}: { + currentPm: TBondedMixnode['profitMargin']; + isVesting: boolean; + onConfirm: (profitMargin: number, fee?: FeeDetails) => Promise; + onClose: () => void; + onError: (err: string) => void; +}) => { + const [pm, setPm] = useState(currentPm.toString()); + const [error, setError] = useState(false); + + const { fee, getFee, resetFeeState, isFeeLoading, feeError } = useGetFee(); + + const handleValidate = async () => { + let isValid = true; + const pmAsNumber = Number(pm); + + if (!pmAsNumber) { + isValid = false; + } + if (isDecimal(pmAsNumber)) { + isValid = false; + } + if (pmAsNumber > 100) { + isValid = false; + } + if (pmAsNumber < 0) { + isValid = false; + } + + if (!isValid) { + setError(true); + return; + } + + if (isVesting) { + await getFee(simulateVestingUpdateMixnode, { profitMarginPercent: pmAsNumber }); + } else { + await getFee(simulateUpdateMixnode, { profitMarginPercent: pmAsNumber }); + } + }; + + useEffect(() => { + setError(false); + }, [pm]); + + useEffect(() => { + if (feeError) { + onError(feeError); + } + }, [feeError]); + + if (isFeeLoading) return ; + + if (fee) + return ( + onConfirm(Number(pm), fee)} + > + + + + ); + + return ( + + + + Node Settings + + + } + okLabel="Next" + onClose={onClose} + > + + + + Set profit margin + + + setPm(e.target.value)} fullWidth /> + {error && ( + + Profit margin should be a whole number between 0 and 100 + + )} + Your new profit margin will be applied in the next epoch + + + + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/RedeemRewardsModal.tsx b/nym-wallet/src/components/Bonding/modals/RedeemRewardsModal.tsx new file mode 100644 index 0000000000..1a312cdc07 --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/RedeemRewardsModal.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; +import { FeeDetails } from '@nymproject/types'; +import { ModalListItem } from 'src/components/Modals/ModalListItem'; +import { SimpleModal } from 'src/components/Modals/SimpleModal'; +import { ModalFee } from 'src/components/Modals/ModalFee'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { simulateClaimOperatorReward, simulateVestingClaimOperatorReward } from 'src/requests'; +import { TBondedMixnode } from 'src/context'; + +export const RedeemRewardsModal = ({ + node, + onConfirm, + onError, + onClose, +}: { + node: TBondedMixnode; + onConfirm: (fee?: FeeDetails) => Promise; + onError: (err: string) => void; + onClose: () => void; +}) => { + const { fee, getFee, isFeeLoading, feeError } = useGetFee(); + + useEffect(() => { + if (feeError) onError(feeError); + }, [feeError]); + + useEffect(() => { + if (node.proxy) getFee(simulateVestingClaimOperatorReward, {}); + else getFee(simulateClaimOperatorReward, {}); + }, []); + + const handleOnOK = async () => onConfirm(fee); + + return ( + + + + + + ); +}; diff --git a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx new file mode 100644 index 0000000000..1c105139bc --- /dev/null +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Typography } from '@mui/material'; +import { useEffect } from 'react'; +import { TBondedGateway, TBondedMixnode } from 'src/context'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { isGateway, isMixnode } from 'src/types'; +import { ModalFee } from '../../Modals/ModalFee'; +import { ModalListItem } from '../../Modals/ModalListItem'; +import { SimpleModal } from '../../Modals/SimpleModal'; +import { + simulateUnbondGateway, + simulateUnbondMixnode, + simulateVestingUnbondGateway, + simulateVestingUnbondMixnode, +} from '../../../requests'; + +interface Props { + node: TBondedMixnode | TBondedGateway; + onConfirm: () => Promise; + onClose: () => void; + onError: (e: string) => void; +} + +export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { + const { fee, isFeeLoading, getFee, feeError } = useGetFee(); + + useEffect(() => { + if (feeError) { + onError(feeError); + } + }, [feeError]); + + useEffect(() => { + if (isMixnode(node) && !node.proxy) { + getFee(simulateUnbondMixnode, {}); + } + + if (isMixnode(node) && node.proxy) { + getFee(simulateVestingUnbondMixnode, {}); + } + + if (isGateway(node) && !node.proxy) { + getFee(simulateUnbondGateway, {}); + } + + if (isGateway(node) && node.proxy) { + getFee(simulateVestingUnbondGateway, {}); + } + }, [node]); + + return ( + + + {isMixnode(node) && ( + + )} + + Tokens will be transferred to the account you are logged in with now + + ); +}; diff --git a/nym-wallet/src/components/CopyToClipboard.tsx b/nym-wallet/src/components/CopyToClipboard.tsx index 453a2ce382..8df15668a8 100644 --- a/nym-wallet/src/components/CopyToClipboard.tsx +++ b/nym-wallet/src/components/CopyToClipboard.tsx @@ -36,7 +36,7 @@ export const CopyToClipboard = ({ text = '', iconButton }: { text?: string; icon color: 'text.primary', }} > - {!copied ? : } + {!copied ? : } ); diff --git a/nym-wallet/src/components/Delegation/DelegateModal.tsx b/nym-wallet/src/components/Delegation/DelegateModal.tsx index be32256409..fb1e69f1ff 100644 --- a/nym-wallet/src/components/Delegation/DelegateModal.tsx +++ b/nym-wallet/src/components/Delegation/DelegateModal.tsx @@ -231,7 +231,7 @@ export const DelegateModal: React.FC<{ {errorAmount} - +