From 002bb3b0f8686e873ff8e649cf702e9c22b73d0a Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 18:17:33 +0200 Subject: [PATCH] UX fixes window geometry, balance load UX, hostname fees, unbond summary - Wallet no longer forces fullscreen on launch - auth and main windows keep the same size and position when switching. - Sign-in and balance loading feel smoother, with less layout jump on the home screen. - Saving a node hostname shows the transaction fee upfront, warns when funds are low, and surfaces clear errors on failure. - Operator unbond confirmation shows pledge plus compounded operator rewards (delegator stake stays separate). --- nym-wallet/src-tauri/src/main.rs | 1 + .../src-tauri/src/operations/app/window.rs | 46 +++++++++++--- .../src/operations/simulate/mixnet.rs | 9 +++ nym-wallet/src/api/networkOverview.test.ts | 21 ++++++- nym-wallet/src/api/networkOverview.ts | 26 +++++++- nym-wallet/src/common.tsx | 11 +--- .../components/Bonding/modals/UnbondModal.tsx | 23 ++++++- nym-wallet/src/context/bonding.tsx | 11 ++-- nym-wallet/src/context/main.tsx | 50 ++++++++++----- nym-wallet/src/context/mocks/bonding.tsx | 7 ++- nym-wallet/src/hooks/useGetBalance.ts | 10 ++- nym-wallet/src/hooks/useNymUsdPrice.ts | 19 ++++-- nym-wallet/src/layouts/AppLayout.tsx | 14 ++--- nym-wallet/src/pages/balance/Balance.tsx | 26 ++++++-- .../pages/balance/NetworkOverviewSection.tsx | 33 +++++----- nym-wallet/src/pages/balance/index.tsx | 1 + .../GeneralNymNodeSettings.tsx | 62 ++++++++++++++----- nym-wallet/src/requests/simulate.ts | 4 ++ .../utils/formatOperatorUnbondReturn.test.ts | 50 +++++++++++++++ .../src/utils/formatOperatorUnbondReturn.ts | 58 +++++++++++++++++ .../src/utils/hostnameUpdateError.test.ts | 30 +++++++++ nym-wallet/src/utils/hostnameUpdateError.ts | 14 +++++ 22 files changed, 429 insertions(+), 97 deletions(-) create mode 100644 nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts create mode 100644 nym-wallet/src/utils/formatOperatorUnbondReturn.ts create mode 100644 nym-wallet/src/utils/hostnameUpdateError.test.ts create mode 100644 nym-wallet/src/utils/hostnameUpdateError.ts diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 74c76f1fa4..36adcc7541 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -185,6 +185,7 @@ fn main() { simulate::mixnet::simulate_pledge_more, simulate::mixnet::simulate_unbond_mixnode, simulate::mixnet::simulate_update_mixnode_config, + simulate::mixnet::simulate_update_nymnode_config, simulate::mixnet::simulate_update_node_cost_params, simulate::mixnet::simulate_update_gateway_config, simulate::mixnet::simulate_delegate_to_node, diff --git a/nym-wallet/src-tauri/src/operations/app/window.rs b/nym-wallet/src-tauri/src/operations/app/window.rs index a40c1d9688..4dbef828e8 100644 --- a/nym-wallet/src-tauri/src/operations/app/window.rs +++ b/nym-wallet/src-tauri/src/operations/app/window.rs @@ -3,6 +3,27 @@ use tauri::Manager; use crate::error::BackendError; use crate::webview_theme::NYM_WALLET_WEBVIEW_BG; +struct WindowGeometry { + inner_width: f64, + inner_height: f64, + outer_x: f64, + outer_y: f64, +} + +fn capture_window_geometry(app_handle: &tauri::AppHandle, label: &str) -> Option { + let window = app_handle.get_webview_window(label)?; + let scale_factor = window.scale_factor().ok()?; + let size = window.inner_size().ok()?; + let position = window.outer_position().ok()?; + + Some(WindowGeometry { + inner_width: size.width as f64 / scale_factor, + inner_height: size.height as f64 / scale_factor, + outer_x: position.x as f64 / scale_factor, + outer_y: position.y as f64 / scale_factor, + }) +} + #[tauri::command] pub async fn create_main_window(app_handle: tauri::AppHandle) -> Result<(), BackendError> { // first, try close the sign up/sign in (`main` => `index.html`) @@ -25,25 +46,36 @@ async fn create_window( new_window_url: &str, try_close_window_label: &str, ) -> Result<(), BackendError> { + let prior_geometry = capture_window_geometry(&app_handle, try_close_window_label); + // create the new window first, to stop the app process from exiting log::info!("Creating {new_window_label} window..."); - match tauri::WebviewWindowBuilder::new( + let mut builder = tauri::WebviewWindowBuilder::new( &app_handle, new_window_label, tauri::WebviewUrl::App(new_window_url.into()), ) .title("Nym Wallet") .background_color(NYM_WALLET_WEBVIEW_BG) - .use_https_scheme(true) - .build() - { + .use_https_scheme(true); + + if let Some(geometry) = &prior_geometry { + builder = builder + .visible(false) + .inner_size(geometry.inner_width, geometry.inner_height) + .position(geometry.outer_x, geometry.outer_y); + } + + match builder.build() { Ok(window) => { + if prior_geometry.is_some() { + if let Err(err) = window.show() { + log::error!("Unable to show window: {err}"); + } + } if let Err(err) = window.set_focus() { log::error!("Unable to focus window: {err}"); } - if let Err(err) = window.maximize() { - log::error!("Could not maximize window: {err}"); - } } Err(err) => { log::error!("Unable to create window: {err}"); diff --git a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs index 3866062e2f..4fa3ee21ed 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs @@ -5,6 +5,7 @@ use crate::error::BackendError; use crate::operations::simulate::FeeDetails; use crate::WalletState; use nym_contracts_common::signing::MessageSignature; +use nym_mixnet_contract_common::nym_node::NodeConfigUpdate; use nym_mixnet_contract_common::{ExecuteMsg, Gateway, MixNode, NodeId}; use nym_mixnet_contract_common::{GatewayConfigUpdate, MixNodeConfigUpdate}; use nym_types::currency::DecCoin; @@ -177,6 +178,14 @@ pub async fn simulate_update_gateway_config( .await } +#[tauri::command] +pub async fn simulate_update_nymnode_config( + update: NodeConfigUpdate, + state: tauri::State<'_, WalletState>, +) -> Result { + simulate_mixnet_operation(ExecuteMsg::UpdateNodeConfig { update }, None, &state).await +} + #[tauri::command] pub async fn simulate_delegate_to_node( node_id: NodeId, diff --git a/nym-wallet/src/api/networkOverview.test.ts b/nym-wallet/src/api/networkOverview.test.ts index 33f0c08a9d..a016740afa 100644 --- a/nym-wallet/src/api/networkOverview.test.ts +++ b/nym-wallet/src/api/networkOverview.test.ts @@ -1,4 +1,4 @@ -import { fetchNymPriceDeduped } from './networkOverview'; +import { fetchNymPriceDeduped, clearNymPriceCacheForTests } from './networkOverview'; const sampleTokenomics = { quotes: { @@ -13,6 +13,7 @@ const sampleTokenomics = { describe('fetchNymPriceDeduped', () => { afterEach(() => { jest.restoreAllMocks(); + clearNymPriceCacheForTests(); }); it('coalesces concurrent requests for the same URL', async () => { @@ -48,4 +49,22 @@ describe('fetchNymPriceDeduped', () => { await Promise.all([fetchNymPriceDeduped('https://a.test/p'), fetchNymPriceDeduped('https://b.test/p')]); expect(callCount).toBe(2); }); + + it('returns cached result without a second fetch', async () => { + let callCount = 0; + global.fetch = jest.fn(() => { + callCount += 1; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(sampleTokenomics), + } as Response); + }); + + const url = 'https://api.example.test/v1/nym-price-cached'; + await fetchNymPriceDeduped(url); + const second = await fetchNymPriceDeduped(url); + + expect(second).toStrictEqual(sampleTokenomics); + expect(callCount).toBe(1); + }); }); diff --git a/nym-wallet/src/api/networkOverview.ts b/nym-wallet/src/api/networkOverview.ts index 40c4ba1725..cb9ead48d3 100644 --- a/nym-wallet/src/api/networkOverview.ts +++ b/nym-wallet/src/api/networkOverview.ts @@ -124,16 +124,36 @@ export async function fetchNymPrice(url: string): Promise { } const nymPriceInflight = new Map>(); +const nymPriceCache = new Map(); + +export function getCachedNymPrice(url: string): NymTokenomics | undefined { + return nymPriceCache.get(url); +} + +/** @internal */ +export function clearNymPriceCacheForTests(): void { + nymPriceCache.clear(); + nymPriceInflight.clear(); +} /** Coalesces concurrent requests for the same price URL (e.g. Balance card + Network overview). */ export function fetchNymPriceDeduped(url: string): Promise { + const cached = nymPriceCache.get(url); + if (cached) { + return Promise.resolve(cached); + } const existing = nymPriceInflight.get(url); if (existing) { return existing; } - const pending = fetchNymPrice(url).finally(() => { - nymPriceInflight.delete(url); - }); + const pending = fetchNymPrice(url) + .then((data) => { + nymPriceCache.set(url, data); + return data; + }) + .finally(() => { + nymPriceInflight.delete(url); + }); nymPriceInflight.set(url, pending); return pending; } diff --git a/nym-wallet/src/common.tsx b/nym-wallet/src/common.tsx index 15f1739f34..3f81c67582 100644 --- a/nym-wallet/src/common.tsx +++ b/nym-wallet/src/common.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType, useEffect } from 'react'; +import React, { ComponentType } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ErrorBoundary } from 'react-error-boundary'; import { BrowserRouter, HashRouter } from 'react-router-dom'; @@ -6,8 +6,6 @@ import { SnackbarProvider } from 'notistack'; import { AppProvider } from './context/main'; import { ErrorFallback } from './components'; import { NymWalletTheme } from './theme'; -import { maximizeWindow } from './utils'; -import { config } from './config'; import { useTauriTextEditingClipboard } from './hooks/useTauriTextEditingClipboard'; type RouterComponent = ComponentType<{ children?: React.ReactNode }>; @@ -48,13 +46,6 @@ export const AppCommon = ({ }) => { const Router = RouterProp ?? selectRouter(); - useEffect(() => { - // do not maximise in dev mode, because it happens on hot reloading - if (!config.IS_DEV_MODE) { - maximizeWindow(); - } - }, []); - return ( diff --git a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx index 9fe8851e66..06b279028a 100644 --- a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -4,6 +4,7 @@ import { Typography } from '@mui/material'; import { TBondedNode } from 'src/context'; import { useGetFee } from 'src/hooks/useGetFee'; import { isGateway, isMixnode } from 'src/types'; +import { formatCoinDisplay, formatOperatorUnbondReturn } from 'src/utils/formatOperatorUnbondReturn'; import { ModalFee } from '../../Modals/ModalFee'; import { ModalListItem } from '../../Modals/ModalListItem'; import { SimpleModal } from '../../Modals/SimpleModal'; @@ -23,6 +24,7 @@ interface Props { export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { const { fee, isFeeLoading, getFee, feeError } = useGetFee(); + const unbondReturn = formatOperatorUnbondReturn(node); useEffect(() => { if (feeError) { @@ -59,7 +61,26 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { onOk={onConfirm} onClose={onClose} > - + {unbondReturn.hasCompoundedRewards ? ( + <> + + + + + Delegator stake is returned to delegators separately and is not included in this total. + + + ) : ( + + )} Tokens will be transferred to the account you are logged in with now diff --git a/nym-wallet/src/context/bonding.tsx b/nym-wallet/src/context/bonding.tsx index f22fa63942..1242cfb45a 100644 --- a/nym-wallet/src/context/bonding.tsx +++ b/nym-wallet/src/context/bonding.tsx @@ -42,7 +42,7 @@ export type TBondingContext = { unbond: (fee?: FeeDetails) => Promise; bond: (args: TBondNymNodeArgs) => Promise; updateBondAmount: (data: TUpdateBondArgs) => Promise; - updateNymNodeConfig: (data: NodeConfigUpdate) => Promise; + updateNymNodeConfig: (data: NodeConfigUpdate, fee?: FeeDetails) => Promise; redeemRewards: (fee?: FeeDetails) => Promise; generateNymNodeMsgPayload: (data: TNymNodeSignatureArgs) => Promise; migrateVestedMixnode: () => Promise; @@ -161,22 +161,23 @@ export const BondingContextProvider: FCWithChildren = ({ children }): React.JSX. return undefined; }; - const updateNymNodeConfig = async (data: NodeConfigUpdate) => { + const updateNymNodeConfig = async (data: NodeConfigUpdate, fee?: FeeDetails) => { let tx; setIsLoading(true); try { - tx = await updateNymNodeConfigReq(data); + tx = await updateNymNodeConfigReq(data, fee?.fee); if (clientDetails?.client_address) { await getNodeDetails(clientDetails?.client_address); } return tx; } catch (e) { Console.warn(e); - setError(`an error occurred: ${e}`); + const message = `an error occurred: ${e}`; + setError(message); + throw new Error(message); } finally { setIsLoading(false); } - return undefined; }; const redeemRewards = async (fee?: FeeDetails) => { diff --git a/nym-wallet/src/context/main.tsx b/nym-wallet/src/context/main.tsx index 89692ce133..e871ee866f 100644 --- a/nym-wallet/src/context/main.tsx +++ b/nym-wallet/src/context/main.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { forage } from '@tauri-apps/tauri-forage'; import { useNavigate } from 'react-router-dom'; import { useSnackbar } from 'notistack'; @@ -19,6 +19,7 @@ import { } from '../requests'; import { Console } from '../utils/console'; import { createSignInWindow, getReactState, setReactState } from '../requests/app'; +import { fetchNymPriceDeduped, getNetworkOverviewEndpoints } from '../api/networkOverview'; import { toDisplay } from '../utils'; export const urls = (networkName?: Network) => @@ -100,6 +101,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { const [mode, setMode] = useState<'light' | 'dark'>('dark'); const [loginType, setLoginType] = useState<'mnemonic' | 'password'>(); const [isLoading, setIsLoadingInternal] = useState(false); + const hadClientDetailsRef = useRef(false); const [loadingPresentation, setLoadingPresentation] = useState('auth-splash'); const [loadingOverlayTitle, setLoadingOverlayTitle] = useState(''); const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState(); @@ -128,10 +130,14 @@ export const AppProvider: FCWithChildren = ({ children }) => { const initFromRustState = async () => { const stateJson = await getReactState(); - if (stateJson) { - const state: RustState = JSON.parse(stateJson); - setNetwork(state.network); - setLoginType(state.loginType); + if (!stateJson) { + return; + } + const state: RustState = JSON.parse(stateJson); + setNetwork(state.network); + setLoginType(state.loginType); + if (state.network) { + await loadAccount(state.network); } }; @@ -140,7 +146,6 @@ export const AppProvider: FCWithChildren = ({ children }) => { }, []); const keepState = async () => { - // add any state from this context to store in the Rust process const state: RustState = { network, loginType, @@ -211,10 +216,15 @@ export const AppProvider: FCWithChildren = ({ children }) => { }, []); useEffect(() => { - if (!clientDetails) { - clearState(); - navigate('/'); + if (clientDetails) { + hadClientDetailsRef.current = true; + return; } + if (!hadClientDetailsRef.current) { + return; + } + clearState(); + navigate('/'); }, [clientDetails]); useEffect(() => { @@ -224,6 +234,16 @@ export const AppProvider: FCWithChildren = ({ children }) => { } }, [network]); + useEffect(() => { + if (network !== 'MAINNET' || !clientDetails?.client_address) { + return; + } + const { nymPrice } = getNetworkOverviewEndpoints('MAINNET'); + fetchNymPriceDeduped(nymPrice).catch(() => { + /* Balance card handles display errors */ + }); + }, [network, clientDetails?.client_address]); + useEffect(() => { const currency = clientDetails?.display_mix_denom.toUpperCase() || 'NYM'; if (userBalance.originalVesting) { @@ -293,27 +313,25 @@ export const AppProvider: FCWithChildren = ({ children }) => { setLoginType('password'); } setNetwork('MAINNET'); + await loadAccount('MAINNET'); navigate('/balance'); } catch (e) { setError(e as string); - } finally { publishSetIsLoading(false); } }; const logOut = async () => { - setLoadingPresentation('app-overlay'); - setLoadingOverlayTitle('Signing out'); - setLoadingOverlaySubtitle('Closing your session safely.'); - setIsLoadingInternal(true); try { await signOut(); await setReactState(undefined); setClientDetails(undefined); + hadClientDetailsRef.current = false; enqueueSnackbar('Successfully logged out', { variant: 'success' }); await createSignInWindow(); - } finally { - publishSetIsLoading(false); + } catch (e) { + Console.error(e as string); + enqueueSnackbar('Error signing out', { variant: 'error' }); } }; diff --git a/nym-wallet/src/context/mocks/bonding.tsx b/nym-wallet/src/context/mocks/bonding.tsx index 565d791cfb..d2d84e4348 100644 --- a/nym-wallet/src/context/mocks/bonding.tsx +++ b/nym-wallet/src/context/mocks/bonding.tsx @@ -1,4 +1,4 @@ -import { FeeDetails, TransactionExecuteResult } from '@nymproject/types'; +import { FeeDetails, NodeConfigUpdate, TransactionExecuteResult } from '@nymproject/types'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { Network, TNymNodeSignatureArgs } from 'src/types'; import { TBondedMixnode } from 'src/requests/mixnodeDetails'; @@ -149,7 +149,10 @@ export const MockBondingContextProvider = ({ return TxResultMock; }; - const updateNymNodeConfig = async (): Promise => { + const updateNymNodeConfig = async ( + _data?: NodeConfigUpdate, + _fee?: FeeDetails, + ): Promise => { setIsLoading(true); await mockSleep(SLEEP_MS); triggerStateUpdate(); diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index e84cdd5cfb..896e0b881c 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -241,8 +241,14 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { }; useEffect(() => { - refreshBalances(); - }, [clientDetails]); + if (!clientDetails?.client_address) { + clearAll(); + return; + } + setIsLoading(true); + setError(undefined); + void refreshBalances(); + }, [clientDetails?.client_address]); return { error, diff --git a/nym-wallet/src/hooks/useNymUsdPrice.ts b/nym-wallet/src/hooks/useNymUsdPrice.ts index ee6cdd2e5a..4bfc05a2db 100644 --- a/nym-wallet/src/hooks/useNymUsdPrice.ts +++ b/nym-wallet/src/hooks/useNymUsdPrice.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { fetchNymPriceDeduped, getNetworkOverviewEndpoints } from 'src/api/networkOverview'; +import { fetchNymPriceDeduped, getCachedNymPrice, getNetworkOverviewEndpoints } from 'src/api/networkOverview'; import type { Network } from 'src/types'; export type UseNymUsdPrice = { @@ -9,10 +9,6 @@ export type UseNymUsdPrice = { }; export function useNymUsdPrice(network: Network | undefined): UseNymUsdPrice { - const [usdPerNym, setUsdPerNym] = useState(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const url = useMemo(() => { if (network === undefined) { return undefined; @@ -20,6 +16,11 @@ export function useNymUsdPrice(network: Network | undefined): UseNymUsdPrice { return getNetworkOverviewEndpoints(network).nymPrice; }, [network]); + const cached = url ? getCachedNymPrice(url) : undefined; + const [usdPerNym, setUsdPerNym] = useState(cached?.quotes.USD.price); + const [loading, setLoading] = useState(Boolean(url && !cached)); + const [error, setError] = useState(); + useEffect(() => { if (!url) { setUsdPerNym(undefined); @@ -28,6 +29,14 @@ export function useNymUsdPrice(network: Network | undefined): UseNymUsdPrice { return undefined; } + const cached = getCachedNymPrice(url); + if (cached) { + setUsdPerNym(cached.quotes.USD.price); + setLoading(false); + setError(undefined); + return undefined; + } + let cancelled = false; setLoading(true); setError(undefined); diff --git a/nym-wallet/src/layouts/AppLayout.tsx b/nym-wallet/src/layouts/AppLayout.tsx index 86c24257c4..0f1530a092 100644 --- a/nym-wallet/src/layouts/AppLayout.tsx +++ b/nym-wallet/src/layouts/AppLayout.tsx @@ -3,20 +3,19 @@ import { NymWordmark } from '@nymproject/react/logo/NymWordmark'; import { Box, Divider, Stack, Typography } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { AppContext } from 'src/context'; -import { AppBar, AppSessionLoadingOverlay, LoadingPage, Nav } from '../components'; +import { AppBar, AppSessionLoadingOverlay, Nav } from '../components'; export const ApplicationLayout: FCWithChildren = ({ children }) => { const { isLoading, loadingPresentation, loadingOverlayTitle, loadingOverlaySubtitle, appVersion } = useContext(AppContext); + const showAppOverlay = isLoading && loadingPresentation === 'app-overlay'; + return ( <> - {isLoading && - (loadingPresentation === 'app-overlay' ? ( - - ) : ( - - ))} + {showAppOverlay && ( + + )} { overflowY: 'auto', overflowX: 'hidden', pr: { xs: 0, md: 1 }, - // Avoid horizontal layout shift when scrollbar appears between short/tall routes (e.g. delegation vs bonding). scrollbarGutter: 'stable', }} > diff --git a/nym-wallet/src/pages/balance/Balance.tsx b/nym-wallet/src/pages/balance/Balance.tsx index 62b0c60dee..4427a93e69 100644 --- a/nym-wallet/src/pages/balance/Balance.tsx +++ b/nym-wallet/src/pages/balance/Balance.tsx @@ -39,16 +39,19 @@ export const BalanceCard = ({ usdPerNym !== undefined && nymFloat !== undefined ? usdFormatter.format(nymFloat * usdPerNym) : undefined; const showUsdRow = Boolean(userBalance?.amount?.amount && userBalance.amount.amount.length > 0); + const reserveUsdRow = network === 'MAINNET' && !userBalanceError; + const awaitingBalance = Boolean(clientAddress && !userBalance && !userBalanceError); + const showBalanceLoading = Boolean(isLoading || awaitingBalance); let usdApproximationRow: React.ReactNode = null; - if (showUsdRow) { + if (showUsdRow || (reserveUsdRow && priceLoading)) { if (priceLoading) { - usdApproximationRow = ; + usdApproximationRow = ; } else if (usdApproxLabel) { usdApproximationRow = ( {`≈ ${usdApproxLabel}`} @@ -75,8 +78,17 @@ export const BalanceCard = ({ {userBalanceError} )} - {isLoading ? ( - + {showBalanceLoading ? ( + + + Available now + + + {reserveUsdRow ? : null} + ) : ( !userBalanceError && ( @@ -99,7 +111,9 @@ export const BalanceCard = ({ > {userBalance?.printable_balance || '-'} - {usdApproximationRow} + {usdApproximationRow ? ( + {usdApproximationRow} + ) : null} ) )} diff --git a/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx b/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx index d997adf698..7034262770 100644 --- a/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx +++ b/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx @@ -9,6 +9,7 @@ import { Collapse, IconButton, LinearProgress, + Skeleton, Stack, Typography, } from '@mui/material'; @@ -275,12 +276,20 @@ export const NetworkOverviewSection: React.FC = () => { bgcolor: (t) => t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', p: 2, + minHeight: 168, }} > {children} ); + const metricSkeleton = ( + + + + + ); + const subtle = theme.palette.text.secondary; const trafficColor = '#8482FD'; @@ -293,11 +302,7 @@ export const NetworkOverviewSection: React.FC = () => { ); } if (!trafficHeadline) { - return ( - - Loading… - - ); + return metricSkeleton; } return ( <> @@ -335,11 +340,7 @@ export const NetworkOverviewSection: React.FC = () => { ); } if (!epoch) { - return ( - - Loading… - - ); + return metricSkeleton; } return ( <> @@ -377,11 +378,7 @@ export const NetworkOverviewSection: React.FC = () => { ); } if (!price) { - return ( - - Loading… - - ); + return metricSkeleton; } return ( @@ -546,10 +543,12 @@ export const NetworkOverviewSection: React.FC = () => { {delegationsErr} - ) : ( + ) : delegationsCount !== undefined ? ( - {delegationsCount !== undefined ? formatCompactNumber(delegationsCount) : '…'} + {formatCompactNumber(delegationsCount)} + ) : ( + )} diff --git a/nym-wallet/src/pages/balance/index.tsx b/nym-wallet/src/pages/balance/index.tsx index 2444f5aa34..2684e0a35d 100644 --- a/nym-wallet/src/pages/balance/index.tsx +++ b/nym-wallet/src/pages/balance/index.tsx @@ -39,6 +39,7 @@ export const Balance = () => { userBalanceError={userBalance.error} clientAddress={clientDetails?.client_address} network={network} + isLoading={userBalance.isLoading} /> {network === 'MAINNET' ? : null} diff --git a/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx b/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx index 9578de69db..4f5a9c2371 100644 --- a/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx +++ b/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx @@ -3,21 +3,27 @@ import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { Box, Button, Divider, Grid, Stack, TextField, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; +import { NodeConfigUpdate } from '@nymproject/types'; import { SimpleModal } from 'src/components/Modals/SimpleModal'; import { Console } from 'src/utils/console'; import { Alert } from 'src/components/Alert'; import { ConfirmTx } from 'src/components/ConfirmTX'; import { useGetFee } from 'src/hooks/useGetFee'; import { BalanceWarning } from 'src/components/FeeWarning'; -import { AppContext, useBondingContext } from 'src/context'; +import { Error } from 'src/components/Error'; +import { LoadingModal } from 'src/components/Modals/LoadingModal'; +import { useBondingContext, AppContext } from 'src/context'; import { TBondedNymNode } from 'src/requests/nymNodeDetails'; import { settingsValidationSchema } from 'src/components/Bonding/forms/nym-node/settingsValidationSchema'; +import { simulateUpdateNymNodeConfig } from 'src/requests'; +import { getHostnameUpdateErrorMessage } from 'src/utils/hostnameUpdateError'; export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymNode }) => { const [openConfirmationModal, setOpenConfirmationModal] = useState(false); - const { fee, resetFeeState } = useGetFee(); + const [submitError, setSubmitError] = useState(); + const { fee, getFee, resetFeeState, feeError } = useGetFee(); const { userBalance } = useContext(AppContext); - const { updateNymNodeConfig } = useBondingContext(); + const { updateNymNodeConfig, isLoading: isBondingLoading } = useBondingContext(); const theme = useTheme(); @@ -34,23 +40,41 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN }, }); - const onSubmit = async ({ host, custom_http_port }: { host: string; custom_http_port: number | null }) => { - resetFeeState(); + const buildConfigUpdate = ({ + host, + custom_http_port, + }: { + host: string; + custom_http_port: number | null; + }): NodeConfigUpdate => ({ + host, + custom_http_port, + restore_default_http_port: custom_http_port === null, + }); + + const onSubmit = async (configUpdate: NodeConfigUpdate) => { + setSubmitError(undefined); try { - const NymNodeConfigParams = { - host, - custom_http_port, - restore_default_http_port: custom_http_port === null, - }; - await updateNymNodeConfig(NymNodeConfigParams); + const tx = await updateNymNodeConfig(configUpdate, fee); + const errorMessage = getHostnameUpdateErrorMessage(tx); + if (errorMessage) { + setSubmitError(errorMessage); + resetFeeState(); + return; + } + resetFeeState(); setOpenConfirmationModal(true); } catch (error) { Console.error(error); + setSubmitError(getHostnameUpdateErrorMessage(undefined, String(error))); + resetFeeState(); } }; + const displayError = submitError || feeError; + return ( {fee && ( @@ -58,17 +82,18 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN open header="Update node settings" fee={fee} - onConfirm={handleSubmit(onSubmit)} + onConfirm={handleSubmit((formData) => onSubmit(buildConfigUpdate(formData)))} onPrev={resetFeeState} onClose={resetFeeState} > - {fee.amount?.amount && userBalance?.balance?.amount.amount && ( + {fee.amount?.amount != null && ( )} )} + {(isSubmitting || isBondingLoading) && } @@ -81,6 +106,11 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN bgColor={`${theme.palette.nym.nymWallet.text.blue}0D !important`} dismissable /> + {displayError && ( + + + + )} @@ -132,7 +162,11 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN size="large" variant="contained" disabled={isSubmitting || !isDirty || !isValid} - onClick={handleSubmit(onSubmit)} + onClick={handleSubmit((formData) => { + resetFeeState(); + setSubmitError(undefined); + getFee(simulateUpdateNymNodeConfig, buildConfigUpdate(formData)); + })} sx={{ m: 3, mr: 0 }} fullWidth > diff --git a/nym-wallet/src/requests/simulate.ts b/nym-wallet/src/requests/simulate.ts index 971209b8a0..463b150845 100644 --- a/nym-wallet/src/requests/simulate.ts +++ b/nym-wallet/src/requests/simulate.ts @@ -5,6 +5,7 @@ import { NodeCostParams, MixNodeConfigUpdate, GatewayConfigUpdate, + NodeConfigUpdate, } from '@nymproject/types'; import { TBondGatewayArgs, TBondMixNodeArgs, TSimulateUpdateBondArgs } from 'src/types'; import { invokeWrapper } from './wrapper'; @@ -28,6 +29,9 @@ export const simulateUpdateMixnodeConfig = async (update: MixNodeConfigUpdate) = export const simulateUpdateGatewayConfig = async (update: GatewayConfigUpdate) => invokeWrapper('simulate_update_gateway_config', { update }); +export const simulateUpdateNymNodeConfig = async (update: NodeConfigUpdate) => + invokeWrapper('simulate_update_nymnode_config', { update }); + export const simulateDelegateToNode = async (args: { nodeId: number; amount: DecCoin }) => invokeWrapper('simulate_delegate_to_node', args); diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts new file mode 100644 index 0000000000..42317d9139 --- /dev/null +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts @@ -0,0 +1,50 @@ +import { formatCoinDisplay, formatOperatorUnbondReturn } from './formatOperatorUnbondReturn'; +import { TBondedNode } from 'src/context/bonding'; + +const mixnodeWithRewards = { + bond: { denom: 'nym', amount: '1000' }, + operatorRewards: { denom: 'nym', amount: '250.5' }, +} as TBondedNode; + +const nymNodeWithRewards = { + bond: { denom: 'nym', amount: '1000' }, + operatorRewards: { denom: 'nym', amount: '100' }, +} as TBondedNode; + +const gateway = { + bond: { denom: 'nym', amount: '500' }, +} as TBondedNode; + +describe('formatOperatorUnbondReturn', () => { + it('sums pledge and compounded operator rewards for mixnodes', () => { + const result = formatOperatorUnbondReturn(mixnodeWithRewards); + expect(result.hasCompoundedRewards).toBe(true); + expect(result.pledge.amount).toBe('1000'); + expect(result.operatorRewards?.amount).toBe('250.5'); + expect(result.total.amount).toBe('1250.5'); + expect(formatCoinDisplay(result.total)).toBe('1250.5 NYM'); + }); + + it('sums pledge and compounded operator rewards for nym nodes', () => { + const result = formatOperatorUnbondReturn(nymNodeWithRewards); + expect(result.hasCompoundedRewards).toBe(true); + expect(result.total.amount).toBe('1100'); + }); + + it('returns pledge only when operator rewards are zero', () => { + const result = formatOperatorUnbondReturn({ + ...mixnodeWithRewards, + operatorRewards: { denom: 'nym', amount: '0' }, + } as TBondedNode); + expect(result.hasCompoundedRewards).toBe(false); + expect(result.total.amount).toBe('1000'); + expect(result.operatorRewards).toBeNull(); + }); + + it('returns pledge only for gateways without operator rewards', () => { + const result = formatOperatorUnbondReturn(gateway); + expect(result.hasCompoundedRewards).toBe(false); + expect(result.total.amount).toBe('500'); + expect(result.operatorRewards).toBeNull(); + }); +}); diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.ts new file mode 100644 index 0000000000..665f09991e --- /dev/null +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.ts @@ -0,0 +1,58 @@ +import Big from 'big.js'; +import { DecCoin } from '@nymproject/types'; +import { TBondedNode } from 'src/context/bonding'; +import { isMixnode, isNymNode } from 'src/types'; + +export type OperatorUnbondReturn = { + pledge: DecCoin; + operatorRewards: DecCoin | null; + total: DecCoin; + hasCompoundedRewards: boolean; +}; + +const toDisplayAmount = (amount: string): string => { + try { + return Big(amount).toFixed(); + } catch { + return '0'; + } +}; + +const sumCoinAmounts = (a: string, b: string): string => { + try { + return Big(a).plus(b).toFixed(); + } catch { + return toDisplayAmount(a); + } +}; + +export const formatOperatorUnbondReturn = (node: TBondedNode): OperatorUnbondReturn => { + const pledge: DecCoin = { + amount: toDisplayAmount(node.bond.amount), + denom: node.bond.denom, + }; + + const operatorRewards = + (isMixnode(node) || isNymNode(node)) && node.operatorRewards + ? { + amount: toDisplayAmount(node.operatorRewards.amount), + denom: node.operatorRewards.denom, + } + : null; + + const rewardsAmount = operatorRewards && Big(operatorRewards.amount).gt(0) ? operatorRewards.amount : '0'; + + const total: DecCoin = { + amount: sumCoinAmounts(pledge.amount, rewardsAmount), + denom: pledge.denom, + }; + + return { + pledge, + operatorRewards: Big(rewardsAmount).gt(0) ? operatorRewards : null, + total, + hasCompoundedRewards: Big(rewardsAmount).gt(0), + }; +}; + +export const formatCoinDisplay = (coin: DecCoin): string => `${coin.amount} ${coin.denom.toUpperCase()}`; diff --git a/nym-wallet/src/utils/hostnameUpdateError.test.ts b/nym-wallet/src/utils/hostnameUpdateError.test.ts new file mode 100644 index 0000000000..fde45b703f --- /dev/null +++ b/nym-wallet/src/utils/hostnameUpdateError.test.ts @@ -0,0 +1,30 @@ +import { getHostnameUpdateErrorMessage } from './hostnameUpdateError'; + +describe('getHostnameUpdateErrorMessage', () => { + it('returns undefined when the transaction succeeded', () => { + expect( + getHostnameUpdateErrorMessage({ + transaction_hash: 'abc123', + logs_json: '', + msg_responses_json: '', + gas_info: { + gas_wanted: { gas_units: BigInt(1) }, + gas_used: { gas_units: BigInt(1) }, + }, + fee: { amount: '1', denom: 'nym' }, + }), + ).toBeUndefined(); + }); + + it('returns context error when provided', () => { + expect(getHostnameUpdateErrorMessage(undefined, 'an error occurred: insufficient funds')).toBe( + 'an error occurred: insufficient funds', + ); + }); + + it('returns a generic message when the update failed without context error', () => { + expect(getHostnameUpdateErrorMessage(undefined)).toBe( + 'Unable to update node settings. Check your balance and try again.', + ); + }); +}); diff --git a/nym-wallet/src/utils/hostnameUpdateError.ts b/nym-wallet/src/utils/hostnameUpdateError.ts new file mode 100644 index 0000000000..d8c8140ca8 --- /dev/null +++ b/nym-wallet/src/utils/hostnameUpdateError.ts @@ -0,0 +1,14 @@ +import { TransactionExecuteResult } from '@nymproject/types'; + +export const getHostnameUpdateErrorMessage = ( + tx: TransactionExecuteResult | undefined, + contextError?: string, +): string | undefined => { + if (tx?.transaction_hash) { + return undefined; + } + if (contextError) { + return contextError; + } + return 'Unable to update node settings. Check your balance and try again.'; +};