From 002bb3b0f8686e873ff8e649cf702e9c22b73d0a Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 18:17:33 +0200 Subject: [PATCH 1/8] 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.'; +}; From b9cd2aa12e519d0069b80a3ab414ea712f101071 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 18:23:19 +0200 Subject: [PATCH 2/8] Fixes - Account loading is deduplicated so sign-in no longer fires two concurrent network switches. - Main window boot relies on the network effect only; rust state init no longer double-loads the account. - NYM price cache clears on sign-out. --- nym-wallet/src/api/networkOverview.ts | 5 +++- nym-wallet/src/context/main.tsx | 33 ++++++++++++++++++--------- nym-wallet/src/hooks/useGetBalance.ts | 10 +++++--- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/nym-wallet/src/api/networkOverview.ts b/nym-wallet/src/api/networkOverview.ts index cb9ead48d3..3130bf47a2 100644 --- a/nym-wallet/src/api/networkOverview.ts +++ b/nym-wallet/src/api/networkOverview.ts @@ -130,12 +130,15 @@ export function getCachedNymPrice(url: string): NymTokenomics | undefined { return nymPriceCache.get(url); } -/** @internal */ export function clearNymPriceCacheForTests(): void { nymPriceCache.clear(); nymPriceInflight.clear(); } +export function clearNymPriceCache(): void { + clearNymPriceCacheForTests(); +} + /** 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); diff --git a/nym-wallet/src/context/main.tsx b/nym-wallet/src/context/main.tsx index e871ee866f..e3a47d4bec 100644 --- a/nym-wallet/src/context/main.tsx +++ b/nym-wallet/src/context/main.tsx @@ -19,7 +19,7 @@ import { } from '../requests'; import { Console } from '../utils/console'; import { createSignInWindow, getReactState, setReactState } from '../requests/app'; -import { fetchNymPriceDeduped, getNetworkOverviewEndpoints } from '../api/networkOverview'; +import { fetchNymPriceDeduped, getNetworkOverviewEndpoints, clearNymPriceCache } from '../api/networkOverview'; import { toDisplay } from '../utils'; export const urls = (networkName?: Network) => @@ -102,6 +102,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { const [loginType, setLoginType] = useState<'mnemonic' | 'password'>(); const [isLoading, setIsLoadingInternal] = useState(false); const hadClientDetailsRef = useRef(false); + const accountLoadInflightRef = useRef | null>(null); const [loadingPresentation, setLoadingPresentation] = useState('auth-splash'); const [loadingOverlayTitle, setLoadingOverlayTitle] = useState(''); const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState(); @@ -136,9 +137,6 @@ export const AppProvider: FCWithChildren = ({ children }) => { const state: RustState = JSON.parse(stateJson); setNetwork(state.network); setLoginType(state.loginType); - if (state.network) { - await loadAccount(state.network); - } }; useEffect(() => { @@ -162,14 +160,25 @@ export const AppProvider: FCWithChildren = ({ children }) => { setMixnodeDetails(null); }; - const loadAccount = async (n: Network) => { - try { - const client = await selectNetwork(n); - setClientDetails(client); - } catch (e) { - enqueueSnackbar('Error loading account', { variant: 'error' }); - Console.error(e as string); + const loadAccount = async (n: Network): Promise => { + if (accountLoadInflightRef.current) { + return accountLoadInflightRef.current; } + + const pending = (async () => { + try { + const client = await selectNetwork(n); + setClientDetails(client); + } catch (e) { + enqueueSnackbar('Error loading account', { variant: 'error' }); + Console.error(e as string); + } finally { + accountLoadInflightRef.current = null; + } + })(); + + accountLoadInflightRef.current = pending; + return pending; }; const loadStoredAccounts = async () => { @@ -315,6 +324,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { setNetwork('MAINNET'); await loadAccount('MAINNET'); navigate('/balance'); + // Overlay stays up until auth window closes via switchWindows on clientDetails. } catch (e) { setError(e as string); publishSetIsLoading(false); @@ -325,6 +335,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { try { await signOut(); await setReactState(undefined); + clearNymPriceCache(); setClientDetails(undefined); hadClientDetailsRef.current = false; enqueueSnackbar('Successfully logged out', { variant: 'success' }); diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index 896e0b881c..a3c469b6c5 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -232,11 +232,15 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { const refreshBalances = async () => { vestingAccountStatusRef.current = 'unknown'; - if (clientDetails?.client_address) { + if (!clientDetails?.client_address) { + clearAll(); + return; + } + try { await fetchBalance(); await fetchTokenAllocation(); - } else { - clearAll(); + } finally { + setIsLoading(false); } }; From 500200db45917f7c3a55f1b3c09299be72faf0da Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 18:24:35 +0200 Subject: [PATCH 3/8] Resolve eslint and prettier issues on balance load changes --- nym-wallet/src/hooks/useGetBalance.ts | 2 +- nym-wallet/src/hooks/useNymUsdPrice.ts | 6 ++-- nym-wallet/src/pages/balance/Balance.tsx | 6 +--- .../pages/balance/NetworkOverviewSection.tsx | 30 ++++++++++++------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index a3c469b6c5..c226793e2d 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -251,7 +251,7 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { } setIsLoading(true); setError(undefined); - void refreshBalances(); + refreshBalances().catch((e) => Console.error(String(e))); }, [clientDetails?.client_address]); return { diff --git a/nym-wallet/src/hooks/useNymUsdPrice.ts b/nym-wallet/src/hooks/useNymUsdPrice.ts index 4bfc05a2db..3a1f2afe07 100644 --- a/nym-wallet/src/hooks/useNymUsdPrice.ts +++ b/nym-wallet/src/hooks/useNymUsdPrice.ts @@ -29,9 +29,9 @@ export function useNymUsdPrice(network: Network | undefined): UseNymUsdPrice { return undefined; } - const cached = getCachedNymPrice(url); - if (cached) { - setUsdPerNym(cached.quotes.USD.price); + const cachedPrice = getCachedNymPrice(url); + if (cachedPrice) { + setUsdPerNym(cachedPrice.quotes.USD.price); setLoading(false); setError(undefined); return undefined; diff --git a/nym-wallet/src/pages/balance/Balance.tsx b/nym-wallet/src/pages/balance/Balance.tsx index 4427a93e69..26c561ac87 100644 --- a/nym-wallet/src/pages/balance/Balance.tsx +++ b/nym-wallet/src/pages/balance/Balance.tsx @@ -49,11 +49,7 @@ export const BalanceCard = ({ usdApproximationRow = ; } else if (usdApproxLabel) { usdApproximationRow = ( - + {`≈ ${usdApproxLabel}`} ); diff --git a/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx b/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx index 7034262770..645bd8496e 100644 --- a/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx +++ b/nym-wallet/src/pages/balance/NetworkOverviewSection.tsx @@ -418,6 +418,24 @@ export const NetworkOverviewSection: React.FC = () => { ); }; + const delegationsCountBody = () => { + if (delegationsErr) { + return ( + + {delegationsErr} + + ); + } + if (delegationsCount !== undefined) { + return ( + + {formatCompactNumber(delegationsCount)} + + ); + } + return ; + }; + const showTrafficChartToggle = trafficChartData.length > 0; return ( @@ -539,17 +557,7 @@ export const NetworkOverviewSection: React.FC = () => { Number of delegations - {delegationsErr ? ( - - {delegationsErr} - - ) : delegationsCount !== undefined ? ( - - {formatCompactNumber(delegationsCount)} - - ) : ( - - )} + {delegationsCountBody()} From 7374ceae6f0a55f08c1b3406f77da0dae13d8c75 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 18:56:07 +0200 Subject: [PATCH 4/8] More fixes - Unbond totals no longer default malformed amounts to zero; a warning appears when exact totals cannot be calculated. - Hostname updates no longer treat an empty transaction hash as success. - Sign-in navigation is gated on successful account load with regression tests. --- .../components/Bonding/modals/UnbondModal.tsx | 13 +++-- nym-wallet/src/context/main.tsx | 33 ++++++----- .../utils/formatOperatorUnbondReturn.test.ts | 19 +++++++ .../src/utils/formatOperatorUnbondReturn.ts | 56 +++++++++++-------- .../src/utils/hostnameUpdateError.test.ts | 15 +++++ nym-wallet/src/utils/hostnameUpdateError.ts | 2 +- .../utils/signInAndNavigateToBalance.test.ts | 54 ++++++++++++++++++ .../src/utils/signInAndNavigateToBalance.ts | 44 +++++++++++++++ 8 files changed, 191 insertions(+), 45 deletions(-) create mode 100644 nym-wallet/src/utils/signInAndNavigateToBalance.test.ts create mode 100644 nym-wallet/src/utils/signInAndNavigateToBalance.ts diff --git a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx index 06b279028a..f422efdc8d 100644 --- a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useEffect } from 'react'; -import { Typography } from '@mui/material'; +import { Alert, Typography } from '@mui/material'; import { TBondedNode } from 'src/context'; import { useGetFee } from 'src/hooks/useGetFee'; import { isGateway, isMixnode } from 'src/types'; @@ -61,6 +61,11 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { onOk={onConfirm} onClose={onClose} > + {unbondReturn.parseError && ( + + Could not calculate exact return - check your wallet balance after unbonding. + + )} {unbondReturn.hasCompoundedRewards ? ( <> @@ -69,11 +74,7 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { value={formatCoinDisplay(unbondReturn.operatorRewards!)} divider /> - + Delegator stake is returned to delegators separately and is not included in this total. diff --git a/nym-wallet/src/context/main.tsx b/nym-wallet/src/context/main.tsx index e3a47d4bec..eef88eb645 100644 --- a/nym-wallet/src/context/main.tsx +++ b/nym-wallet/src/context/main.tsx @@ -20,6 +20,7 @@ import { import { Console } from '../utils/console'; import { createSignInWindow, getReactState, setReactState } from '../requests/app'; import { fetchNymPriceDeduped, getNetworkOverviewEndpoints, clearNymPriceCache } from '../api/networkOverview'; +import { signInAndNavigateToBalance } from '../utils/signInAndNavigateToBalance'; import { toDisplay } from '../utils'; export const urls = (networkName?: Network) => @@ -102,7 +103,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { const [loginType, setLoginType] = useState<'mnemonic' | 'password'>(); const [isLoading, setIsLoadingInternal] = useState(false); const hadClientDetailsRef = useRef(false); - const accountLoadInflightRef = useRef | null>(null); + const accountLoadInflightRef = useRef | null>(null); const [loadingPresentation, setLoadingPresentation] = useState('auth-splash'); const [loadingOverlayTitle, setLoadingOverlayTitle] = useState(''); const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState(); @@ -160,7 +161,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { setMixnodeDetails(null); }; - const loadAccount = async (n: Network): Promise => { + const loadAccount = async (n: Network): Promise => { if (accountLoadInflightRef.current) { return accountLoadInflightRef.current; } @@ -169,9 +170,11 @@ export const AppProvider: FCWithChildren = ({ children }) => { try { const client = await selectNetwork(n); setClientDetails(client); + return client; } catch (e) { enqueueSnackbar('Error loading account', { variant: 'error' }); Console.error(e as string); + return undefined; } finally { accountLoadInflightRef.current = null; } @@ -238,10 +241,12 @@ export const AppProvider: FCWithChildren = ({ children }) => { useEffect(() => { if (network) { - refreshAccount(network); + if (!clientDetails) { + refreshAccount(network); + } getEnv().then(setAppEnv); } - }, [network]); + }, [network, clientDetails?.client_address]); useEffect(() => { if (network !== 'MAINNET' || !clientDetails?.client_address) { @@ -314,17 +319,17 @@ export const AppProvider: FCWithChildren = ({ children }) => { : 'Unlocking your wallet and connecting to the network.', ); setIsLoadingInternal(true); - if (type === 'mnemonic') { - await signInWithMnemonic(value); - setLoginType('mnemonic'); - } else { - await signInWithPassword(value); - setLoginType('password'); - } + await signInAndNavigateToBalance({ + type, + value, + network: 'MAINNET', + signInWithMnemonic, + signInWithPassword, + loadAccount, + setLoginType, + navigate, + }); setNetwork('MAINNET'); - await loadAccount('MAINNET'); - navigate('/balance'); - // Overlay stays up until auth window closes via switchWindows on clientDetails. } catch (e) { setError(e as string); publishSetIsLoading(false); diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts index 42317d9139..80231a9430 100644 --- a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts @@ -47,4 +47,23 @@ describe('formatOperatorUnbondReturn', () => { expect(result.total.amount).toBe('500'); expect(result.operatorRewards).toBeNull(); }); + + it('preserves the raw amount string and sets parseError when bond amount is malformed', () => { + const result = formatOperatorUnbondReturn({ + bond: { denom: 'nym', amount: 'not-a-number' }, + } as TBondedNode); + expect(result.pledge.amount).toBe('not-a-number'); + expect(result.parseError).toMatch(/Could not parse amount/); + expect(result.hasCompoundedRewards).toBe(false); + }); + + it('sets parseError and treats malformed reward as zero when reward amount is unparseable', () => { + const result = formatOperatorUnbondReturn({ + ...mixnodeWithRewards, + operatorRewards: { denom: 'nym', amount: 'NaN' }, + } as TBondedNode); + expect(result.operatorRewards).toBeNull(); + expect(result.hasCompoundedRewards).toBe(false); + expect(result.parseError).toMatch(/Could not/); + }); }); diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.ts index 665f09991e..113505f043 100644 --- a/nym-wallet/src/utils/formatOperatorUnbondReturn.ts +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.ts @@ -8,50 +8,58 @@ export type OperatorUnbondReturn = { operatorRewards: DecCoin | null; total: DecCoin; hasCompoundedRewards: boolean; + parseError?: string; }; -const toDisplayAmount = (amount: string): string => { +const toDisplayAmount = (amount: string): { value: string; error?: string } => { try { - return Big(amount).toFixed(); + return { value: Big(amount).toFixed() }; } catch { - return '0'; + return { value: amount, error: `Could not parse amount: "${amount}"` }; } }; -const sumCoinAmounts = (a: string, b: string): string => { +const sumCoinAmounts = (a: string, b: string): { value: string; error?: string } => { try { - return Big(a).plus(b).toFixed(); + return { value: Big(a).plus(b).toFixed() }; } catch { - return toDisplayAmount(a); + return { value: a, error: `Could not sum amounts: "${a}" + "${b}"` }; } }; export const formatOperatorUnbondReturn = (node: TBondedNode): OperatorUnbondReturn => { - const pledge: DecCoin = { - amount: toDisplayAmount(node.bond.amount), - denom: node.bond.denom, - }; + const errors: string[] = []; - const operatorRewards = - (isMixnode(node) || isNymNode(node)) && node.operatorRewards - ? { - amount: toDisplayAmount(node.operatorRewards.amount), - denom: node.operatorRewards.denom, - } - : null; + const pledgeParsed = toDisplayAmount(node.bond.amount); + if (pledgeParsed.error) errors.push(pledgeParsed.error); + const pledge: DecCoin = { amount: pledgeParsed.value, denom: node.bond.denom }; - const rewardsAmount = operatorRewards && Big(operatorRewards.amount).gt(0) ? operatorRewards.amount : '0'; + const rawRewards = (isMixnode(node) || isNymNode(node)) && node.operatorRewards ? node.operatorRewards : null; + let operatorRewards: DecCoin | null = null; + if (rawRewards) { + const rewardsParsed = toDisplayAmount(rawRewards.amount); + if (rewardsParsed.error) errors.push(rewardsParsed.error); + operatorRewards = { amount: rewardsParsed.value, denom: rawRewards.denom }; + } - const total: DecCoin = { - amount: sumCoinAmounts(pledge.amount, rewardsAmount), - denom: pledge.denom, - }; + let hasCompoundedRewards = false; + try { + hasCompoundedRewards = Boolean(operatorRewards) && Big(operatorRewards!.amount).gt(0); + } catch { + errors.push(`Could not evaluate rewards amount: "${operatorRewards?.amount}"`); + } + + const rewardsAmount = hasCompoundedRewards ? operatorRewards!.amount : '0'; + const totalParsed = sumCoinAmounts(pledge.amount, rewardsAmount); + if (totalParsed.error) errors.push(totalParsed.error); + const total: DecCoin = { amount: totalParsed.value, denom: pledge.denom }; return { pledge, - operatorRewards: Big(rewardsAmount).gt(0) ? operatorRewards : null, + operatorRewards: hasCompoundedRewards ? operatorRewards : null, total, - hasCompoundedRewards: Big(rewardsAmount).gt(0), + hasCompoundedRewards, + parseError: errors.length > 0 ? errors.join('; ') : undefined, }; }; diff --git a/nym-wallet/src/utils/hostnameUpdateError.test.ts b/nym-wallet/src/utils/hostnameUpdateError.test.ts index fde45b703f..7313d8a9bf 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.test.ts +++ b/nym-wallet/src/utils/hostnameUpdateError.test.ts @@ -27,4 +27,19 @@ describe('getHostnameUpdateErrorMessage', () => { 'Unable to update node settings. Check your balance and try again.', ); }); + + it('returns an error message when transaction_hash is an empty string', () => { + expect( + getHostnameUpdateErrorMessage({ + transaction_hash: '', + logs_json: '', + msg_responses_json: '', + gas_info: { + gas_wanted: { gas_units: BigInt(1) }, + gas_used: { gas_units: BigInt(1) }, + }, + fee: { amount: '1', denom: 'nym' }, + }), + ).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 index d8c8140ca8..1f323ab264 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.ts +++ b/nym-wallet/src/utils/hostnameUpdateError.ts @@ -4,7 +4,7 @@ export const getHostnameUpdateErrorMessage = ( tx: TransactionExecuteResult | undefined, contextError?: string, ): string | undefined => { - if (tx?.transaction_hash) { + if (tx?.transaction_hash && tx.transaction_hash.length > 0) { return undefined; } if (contextError) { diff --git a/nym-wallet/src/utils/signInAndNavigateToBalance.test.ts b/nym-wallet/src/utils/signInAndNavigateToBalance.test.ts new file mode 100644 index 0000000000..6209bcf45a --- /dev/null +++ b/nym-wallet/src/utils/signInAndNavigateToBalance.test.ts @@ -0,0 +1,54 @@ +import { Account } from '@nymproject/types'; +import { signInAndNavigateToBalance } from './signInAndNavigateToBalance'; + +describe('signInAndNavigateToBalance', () => { + it('does not navigate when loading the account fails', async () => { + const navigate = jest.fn(); + const loadAccount = jest.fn(async () => undefined); + const signInWithPassword = jest.fn(async () => ({ client_address: 'nym1fail' } as Account)); + const setLoginType = jest.fn(); + + await expect( + signInAndNavigateToBalance({ + type: 'password', + value: 'secret', + network: 'MAINNET', + signInWithMnemonic: jest.fn(async () => ({ client_address: 'nym1mnemonic' } as Account)), + signInWithPassword, + loadAccount, + setLoginType, + navigate, + }), + ).rejects.toThrow('Unable to load account'); + + expect(signInWithPassword).toHaveBeenCalledWith('secret'); + expect(loadAccount).toHaveBeenCalledWith('MAINNET'); + expect(setLoginType).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('navigates after the account loads successfully', async () => { + const navigate = jest.fn(); + const loadAccount = jest.fn(async () => ({ client_address: 'nym1abc' } as Account)); + const signInWithMnemonic = jest.fn(async () => ({ client_address: 'nym1mnemonic' } as Account)); + const setLoginType = jest.fn(); + + await expect( + signInAndNavigateToBalance({ + type: 'mnemonic', + value: 'mnemonic phrase', + network: 'MAINNET', + signInWithMnemonic, + signInWithPassword: jest.fn(async () => ({ client_address: 'nym1password' } as Account)), + loadAccount, + setLoginType, + navigate, + }), + ).resolves.toBeUndefined(); + + expect(signInWithMnemonic).toHaveBeenCalledWith('mnemonic phrase'); + expect(loadAccount).toHaveBeenCalledWith('MAINNET'); + expect(setLoginType).toHaveBeenCalledWith('mnemonic'); + expect(navigate).toHaveBeenCalledWith('/balance'); + }); +}); diff --git a/nym-wallet/src/utils/signInAndNavigateToBalance.ts b/nym-wallet/src/utils/signInAndNavigateToBalance.ts new file mode 100644 index 0000000000..81fc786689 --- /dev/null +++ b/nym-wallet/src/utils/signInAndNavigateToBalance.ts @@ -0,0 +1,44 @@ +import { Account } from '@nymproject/types'; +import { Network } from 'src/types'; + +export type SignInType = 'mnemonic' | 'password'; + +export type SignInAndNavigateToBalanceDeps = { + type: SignInType; + value: string; + network: Network; + signInWithMnemonic: (mnemonic: string) => Promise; + signInWithPassword: (password: string) => Promise; + loadAccount: (network: Network) => Promise; + setLoginType: (loginType: SignInType) => void; + navigate: (path: string) => void; +}; + +export async function signInAndNavigateToBalance({ + type, + value, + network, + signInWithMnemonic, + signInWithPassword, + loadAccount, + setLoginType, + navigate, +}: SignInAndNavigateToBalanceDeps): Promise { + if (value.length === 0) { + throw new Error(`A ${type} must be provided`); + } + + if (type === 'mnemonic') { + await signInWithMnemonic(value); + } else { + await signInWithPassword(value); + } + + const client = await loadAccount(network); + if (!client?.client_address) { + throw new Error('Unable to load account'); + } + + setLoginType(type); + navigate('/balance'); +} From 13d48b4bb6d573feb3eef2dc234482dd8ff20cf2 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 19:06:13 +0200 Subject: [PATCH 5/8] Another round of fixes - Transaction success is now checked through a shared helper that validates hash, gas usage, and response payloads, not hash presence alone. - Node settings error helper renamed to match its broader scope. - Balance refresh now owns the loading flag so nested balance and vesting fetches do not race each other. - Unbond modal removes the non-null assertion on compounded rewards. --- .../components/Bonding/modals/UnbondModal.tsx | 9 ++-- .../hooks/balanceRefreshOrchestration.test.ts | 13 +++++ .../src/hooks/balanceRefreshOrchestration.ts | 7 +++ nym-wallet/src/hooks/useGetBalance.ts | 36 ++++++++----- .../GeneralNymNodeSettings.tsx | 6 +-- ...est.ts => nodeSettingsUpdateError.test.ts} | 35 ++++++++---- ...ateError.ts => nodeSettingsUpdateError.ts} | 5 +- .../utils/transactionExecuteSuccess.test.ts | 53 +++++++++++++++++++ .../src/utils/transactionExecuteSuccess.ts | 40 ++++++++++++++ 9 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts create mode 100644 nym-wallet/src/hooks/balanceRefreshOrchestration.ts rename nym-wallet/src/utils/{hostnameUpdateError.test.ts => nodeSettingsUpdateError.test.ts} (50%) rename nym-wallet/src/utils/{hostnameUpdateError.ts => nodeSettingsUpdateError.ts} (64%) create mode 100644 nym-wallet/src/utils/transactionExecuteSuccess.test.ts create mode 100644 nym-wallet/src/utils/transactionExecuteSuccess.ts diff --git a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx index f422efdc8d..18c45ec968 100644 --- a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -25,6 +25,7 @@ interface Props { export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { const { fee, isFeeLoading, getFee, feeError } = useGetFee(); const unbondReturn = formatOperatorUnbondReturn(node); + const compoundedRewards = unbondReturn.hasCompoundedRewards ? unbondReturn.operatorRewards : null; useEffect(() => { if (feeError) { @@ -66,14 +67,10 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { Could not calculate exact return - check your wallet balance after unbonding. )} - {unbondReturn.hasCompoundedRewards ? ( + {compoundedRewards ? ( <> - + Delegator stake is returned to delegators separately and is not included in this total. diff --git a/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts b/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts new file mode 100644 index 0000000000..0dfc513cfb --- /dev/null +++ b/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts @@ -0,0 +1,13 @@ +import { runBalanceRefreshWithoutNestedLoading } from './balanceRefreshOrchestration'; + +describe('runBalanceRefreshWithoutNestedLoading', () => { + it('delegates loading ownership to the caller by disabling nested loading toggles', async () => { + const fetchBalance = jest.fn(async () => undefined); + const fetchTokenAllocation = jest.fn(async () => undefined); + + await runBalanceRefreshWithoutNestedLoading(fetchBalance, fetchTokenAllocation); + + expect(fetchBalance).toHaveBeenCalledWith(false); + expect(fetchTokenAllocation).toHaveBeenCalledWith(false, false); + }); +}); diff --git a/nym-wallet/src/hooks/balanceRefreshOrchestration.ts b/nym-wallet/src/hooks/balanceRefreshOrchestration.ts new file mode 100644 index 0000000000..1e29a27d9f --- /dev/null +++ b/nym-wallet/src/hooks/balanceRefreshOrchestration.ts @@ -0,0 +1,7 @@ +export async function runBalanceRefreshWithoutNestedLoading( + fetchBalance: (manageLoading?: boolean) => Promise, + fetchTokenAllocation: (isBackgroundPoll?: boolean, manageLoading?: boolean) => Promise, +): Promise { + await fetchBalance(false); + await fetchTokenAllocation(false, false); +} diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index c226793e2d..aabf6fd0fb 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -13,6 +13,7 @@ import { userBalance, } from '../requests'; import { Console } from '../utils/console'; +import { runBalanceRefreshWithoutNestedLoading } from './balanceRefreshOrchestration'; type TTokenAllocation = { [key in @@ -98,16 +99,22 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { setVestingAccountInfo(vestingAccountDetail); }; - const fetchTokenAllocation = async (isBackgroundPoll = false) => { - setIsLoading(true); + const fetchTokenAllocation = async (isBackgroundPoll = false, manageLoading = true) => { + if (manageLoading) { + setIsLoading(true); + } if (!clientDetails?.client_address) { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } return; } if (vestingAccountStatusRef.current === 'absent') { if (isBackgroundPoll) { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } return; } vestingAccountStatusRef.current = 'unknown'; @@ -204,12 +211,16 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { clearVestingUiState(); Console.error(e as string); } finally { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } } }; - const fetchBalance = useCallback(async () => { - setIsLoading(true); + const fetchBalance = useCallback(async (manageLoading = true) => { + if (manageLoading) { + setIsLoading(true); + } setError(undefined); try { const bal = await userBalance(); @@ -217,7 +228,9 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { } catch (err) { setError(err as string); } finally { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } } }, []); @@ -236,9 +249,10 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { clearAll(); return; } + setIsLoading(true); + setError(undefined); try { - await fetchBalance(); - await fetchTokenAllocation(); + await runBalanceRefreshWithoutNestedLoading(fetchBalance, fetchTokenAllocation); } finally { setIsLoading(false); } @@ -249,8 +263,6 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { clearAll(); return; } - setIsLoading(true); - setError(undefined); refreshBalances().catch((e) => Console.error(String(e))); }, [clientDetails?.client_address]); 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 4f5a9c2371..c48f41e02e 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 @@ -16,7 +16,7 @@ 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'; +import { getNodeSettingsUpdateErrorMessage } from 'src/utils/nodeSettingsUpdateError'; export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymNode }) => { const [openConfirmationModal, setOpenConfirmationModal] = useState(false); @@ -57,7 +57,7 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN try { const tx = await updateNymNodeConfig(configUpdate, fee); - const errorMessage = getHostnameUpdateErrorMessage(tx); + const errorMessage = getNodeSettingsUpdateErrorMessage(tx); if (errorMessage) { setSubmitError(errorMessage); resetFeeState(); @@ -68,7 +68,7 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN setOpenConfirmationModal(true); } catch (error) { Console.error(error); - setSubmitError(getHostnameUpdateErrorMessage(undefined, String(error))); + setSubmitError(getNodeSettingsUpdateErrorMessage(undefined, String(error))); resetFeeState(); } }; diff --git a/nym-wallet/src/utils/hostnameUpdateError.test.ts b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts similarity index 50% rename from nym-wallet/src/utils/hostnameUpdateError.test.ts rename to nym-wallet/src/utils/nodeSettingsUpdateError.test.ts index 7313d8a9bf..292959971e 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.test.ts +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts @@ -1,12 +1,12 @@ -import { getHostnameUpdateErrorMessage } from './hostnameUpdateError'; +import { getNodeSettingsUpdateErrorMessage } from './nodeSettingsUpdateError'; -describe('getHostnameUpdateErrorMessage', () => { +describe('getNodeSettingsUpdateErrorMessage', () => { it('returns undefined when the transaction succeeded', () => { expect( - getHostnameUpdateErrorMessage({ + getNodeSettingsUpdateErrorMessage({ transaction_hash: 'abc123', - logs_json: '', - msg_responses_json: '', + logs_json: '[]', + msg_responses_json: '[]', gas_info: { gas_wanted: { gas_units: BigInt(1) }, gas_used: { gas_units: BigInt(1) }, @@ -17,23 +17,23 @@ describe('getHostnameUpdateErrorMessage', () => { }); it('returns context error when provided', () => { - expect(getHostnameUpdateErrorMessage(undefined, 'an error occurred: insufficient funds')).toBe( + expect(getNodeSettingsUpdateErrorMessage(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( + expect(getNodeSettingsUpdateErrorMessage(undefined)).toBe( 'Unable to update node settings. Check your balance and try again.', ); }); it('returns an error message when transaction_hash is an empty string', () => { expect( - getHostnameUpdateErrorMessage({ + getNodeSettingsUpdateErrorMessage({ transaction_hash: '', - logs_json: '', - msg_responses_json: '', + logs_json: '[]', + msg_responses_json: '[]', gas_info: { gas_wanted: { gas_units: BigInt(1) }, gas_used: { gas_units: BigInt(1) }, @@ -42,4 +42,19 @@ describe('getHostnameUpdateErrorMessage', () => { }), ).toBe('Unable to update node settings. Check your balance and try again.'); }); + + it('returns an error when gas_used is zero despite a non-empty hash', () => { + expect( + getNodeSettingsUpdateErrorMessage({ + transaction_hash: 'abc123', + logs_json: '[]', + msg_responses_json: '[]', + gas_info: { + gas_wanted: { gas_units: BigInt(1) }, + gas_used: { gas_units: BigInt(0) }, + }, + fee: { amount: '1', denom: 'nym' }, + }), + ).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/nodeSettingsUpdateError.ts similarity index 64% rename from nym-wallet/src/utils/hostnameUpdateError.ts rename to nym-wallet/src/utils/nodeSettingsUpdateError.ts index 1f323ab264..b838b09b79 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.ts +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.ts @@ -1,10 +1,11 @@ import { TransactionExecuteResult } from '@nymproject/types'; +import { isTransactionExecuteSuccessful } from './transactionExecuteSuccess'; -export const getHostnameUpdateErrorMessage = ( +export const getNodeSettingsUpdateErrorMessage = ( tx: TransactionExecuteResult | undefined, contextError?: string, ): string | undefined => { - if (tx?.transaction_hash && tx.transaction_hash.length > 0) { + if (isTransactionExecuteSuccessful(tx)) { return undefined; } if (contextError) { diff --git a/nym-wallet/src/utils/transactionExecuteSuccess.test.ts b/nym-wallet/src/utils/transactionExecuteSuccess.test.ts new file mode 100644 index 0000000000..9e28e424ca --- /dev/null +++ b/nym-wallet/src/utils/transactionExecuteSuccess.test.ts @@ -0,0 +1,53 @@ +import { TransactionExecuteResult } from '@nymproject/types'; +import { isTransactionExecuteSuccessful } from './transactionExecuteSuccess'; + +const baseTx = { + transaction_hash: 'abc123', + logs_json: '[]', + msg_responses_json: '[]', + gas_info: { + gas_wanted: { gas_units: BigInt(100) }, + gas_used: { gas_units: BigInt(50) }, + }, + fee: { amount: '1', denom: 'nym' }, +} as TransactionExecuteResult; + +describe('isTransactionExecuteSuccessful', () => { + it('returns true for a structurally valid execution result', () => { + expect(isTransactionExecuteSuccessful(baseTx)).toBe(true); + }); + + it('returns true when log and response JSON are empty strings', () => { + expect( + isTransactionExecuteSuccessful({ + ...baseTx, + logs_json: '', + msg_responses_json: '', + }), + ).toBe(true); + }); + + it('returns false when transaction_hash is empty', () => { + expect(isTransactionExecuteSuccessful({ ...baseTx, transaction_hash: '' })).toBe(false); + }); + + it('returns false when gas_used is zero', () => { + expect( + isTransactionExecuteSuccessful({ + ...baseTx, + gas_info: { + gas_wanted: { gas_units: BigInt(100) }, + gas_used: { gas_units: BigInt(0) }, + }, + }), + ).toBe(false); + }); + + it('returns false when msg_responses_json is not valid JSON', () => { + expect(isTransactionExecuteSuccessful({ ...baseTx, msg_responses_json: 'not-json' })).toBe(false); + }); + + it('returns false when logs_json is not a JSON array', () => { + expect(isTransactionExecuteSuccessful({ ...baseTx, logs_json: '{"error":true}' })).toBe(false); + }); +}); diff --git a/nym-wallet/src/utils/transactionExecuteSuccess.ts b/nym-wallet/src/utils/transactionExecuteSuccess.ts new file mode 100644 index 0000000000..024730cd4b --- /dev/null +++ b/nym-wallet/src/utils/transactionExecuteSuccess.ts @@ -0,0 +1,40 @@ +import { TransactionExecuteResult } from '@nymproject/types'; + +const parseJsonArray = (raw: string): unknown[] | null => { + const trimmed = raw.trim(); + if (!trimmed) { + return []; + } + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}; + +/** + * Rust IPC only returns `TransactionExecuteResult` after DeliverTx succeeds (code 0). + * This helper centralizes the TS-side interpretation: non-empty hash plus structurally + * valid execution metadata, not hash presence alone. + */ +export const isTransactionExecuteSuccessful = (tx: TransactionExecuteResult | undefined): boolean => { + if (!tx?.transaction_hash || tx.transaction_hash.length === 0) { + return false; + } + + const gasUsed = tx.gas_info?.gas_used?.gas_units; + if (gasUsed === undefined || gasUsed <= 0n) { + return false; + } + + if (parseJsonArray(tx.msg_responses_json) === null) { + return false; + } + + if (parseJsonArray(tx.logs_json) === null) { + return false; + } + + return true; +}; From 6f5e83112728417da664dec331a6f8bc7fb80366 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 19:10:36 +0200 Subject: [PATCH 6/8] PR comments - Account loading now dedupes in-flight requests per network instead of sharing one global promise across all networks. - Regression tests cover same-network reuse and cross-network isolation. --- nym-wallet/src/context/main.tsx | 19 +++----- .../src/utils/dedupeInflightByKey.test.ts | 43 +++++++++++++++++++ nym-wallet/src/utils/dedupeInflightByKey.ts | 17 ++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 nym-wallet/src/utils/dedupeInflightByKey.test.ts create mode 100644 nym-wallet/src/utils/dedupeInflightByKey.ts diff --git a/nym-wallet/src/context/main.tsx b/nym-wallet/src/context/main.tsx index eef88eb645..73ebb3c8e0 100644 --- a/nym-wallet/src/context/main.tsx +++ b/nym-wallet/src/context/main.tsx @@ -21,6 +21,7 @@ import { Console } from '../utils/console'; import { createSignInWindow, getReactState, setReactState } from '../requests/app'; import { fetchNymPriceDeduped, getNetworkOverviewEndpoints, clearNymPriceCache } from '../api/networkOverview'; import { signInAndNavigateToBalance } from '../utils/signInAndNavigateToBalance'; +import { dedupeInflightByKey } from '../utils/dedupeInflightByKey'; import { toDisplay } from '../utils'; export const urls = (networkName?: Network) => @@ -103,7 +104,7 @@ export const AppProvider: FCWithChildren = ({ children }) => { const [loginType, setLoginType] = useState<'mnemonic' | 'password'>(); const [isLoading, setIsLoadingInternal] = useState(false); const hadClientDetailsRef = useRef(false); - const accountLoadInflightRef = useRef | null>(null); + const accountLoadInflightRef = useRef>>(new Map()); const [loadingPresentation, setLoadingPresentation] = useState('auth-splash'); const [loadingOverlayTitle, setLoadingOverlayTitle] = useState(''); const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState(); @@ -161,12 +162,8 @@ export const AppProvider: FCWithChildren = ({ children }) => { setMixnodeDetails(null); }; - const loadAccount = async (n: Network): Promise => { - if (accountLoadInflightRef.current) { - return accountLoadInflightRef.current; - } - - const pending = (async () => { + const loadAccount = async (n: Network): Promise => + dedupeInflightByKey(accountLoadInflightRef.current, n, async () => { try { const client = await selectNetwork(n); setClientDetails(client); @@ -175,14 +172,8 @@ export const AppProvider: FCWithChildren = ({ children }) => { enqueueSnackbar('Error loading account', { variant: 'error' }); Console.error(e as string); return undefined; - } finally { - accountLoadInflightRef.current = null; } - })(); - - accountLoadInflightRef.current = pending; - return pending; - }; + }); const loadStoredAccounts = async () => { const accounts = await listAccounts(); diff --git a/nym-wallet/src/utils/dedupeInflightByKey.test.ts b/nym-wallet/src/utils/dedupeInflightByKey.test.ts new file mode 100644 index 0000000000..78c5b23800 --- /dev/null +++ b/nym-wallet/src/utils/dedupeInflightByKey.test.ts @@ -0,0 +1,43 @@ +import { dedupeInflightByKey } from './dedupeInflightByKey'; + +describe('dedupeInflightByKey', () => { + it('reuses the in-flight promise for the same key', async () => { + const inflight = new Map>(); + let calls = 0; + const load = () => { + calls += 1; + return new Promise((resolve) => { + setTimeout(() => resolve('done'), 20); + }); + }; + + const first = dedupeInflightByKey(inflight, 'MAINNET', load); + const second = dedupeInflightByKey(inflight, 'MAINNET', load); + + expect(first).toBe(second); + await first; + expect(calls).toBe(1); + }); + + it('does not reuse promises across different keys', async () => { + const inflight = new Map>(); + let calls = 0; + const load = (value: string) => () => { + calls += 1; + return Promise.resolve(value); + }; + + const mainnet = dedupeInflightByKey(inflight, 'MAINNET', load('mainnet')); + const sandbox = dedupeInflightByKey(inflight, 'SANDBOX', load('sandbox')); + + await expect(mainnet).resolves.toBe('mainnet'); + await expect(sandbox).resolves.toBe('sandbox'); + expect(calls).toBe(2); + }); + + it('clears the key after the promise settles', async () => { + const inflight = new Map>(); + await dedupeInflightByKey(inflight, 'MAINNET', async () => 'done'); + expect(inflight.has('MAINNET')).toBe(false); + }); +}); diff --git a/nym-wallet/src/utils/dedupeInflightByKey.ts b/nym-wallet/src/utils/dedupeInflightByKey.ts new file mode 100644 index 0000000000..dafec135ad --- /dev/null +++ b/nym-wallet/src/utils/dedupeInflightByKey.ts @@ -0,0 +1,17 @@ +export function dedupeInflightByKey( + inflight: Map>, + key: K, + load: () => Promise, +): Promise { + const existing = inflight.get(key); + if (existing) { + return existing; + } + + const pending = load().finally(() => { + inflight.delete(key); + }); + + inflight.set(key, pending); + return pending; +} From 54779175f1f7a85eb82d7c78018d5ee243d684f4 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 19:19:59 +0200 Subject: [PATCH 7/8] Fix CI linting --- nym-wallet/src/layouts/AppLayout.tsx | 4 +--- nym-wallet/src/utils/dedupeInflightByKey.ts | 6 +----- nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/nym-wallet/src/layouts/AppLayout.tsx b/nym-wallet/src/layouts/AppLayout.tsx index 0f1530a092..5ad3821b6a 100644 --- a/nym-wallet/src/layouts/AppLayout.tsx +++ b/nym-wallet/src/layouts/AppLayout.tsx @@ -13,9 +13,7 @@ export const ApplicationLayout: FCWithChildren = ({ children }) => { return ( <> - {showAppOverlay && ( - - )} + {showAppOverlay && } ( - inflight: Map>, - key: K, - load: () => Promise, -): Promise { +export function dedupeInflightByKey(inflight: Map>, key: K, load: () => Promise): Promise { const existing = inflight.get(key); if (existing) { return existing; diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts index 80231a9430..3a26125d4a 100644 --- a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts @@ -1,5 +1,5 @@ -import { formatCoinDisplay, formatOperatorUnbondReturn } from './formatOperatorUnbondReturn'; import { TBondedNode } from 'src/context/bonding'; +import { formatCoinDisplay, formatOperatorUnbondReturn } from './formatOperatorUnbondReturn'; const mixnodeWithRewards = { bond: { denom: 'nym', amount: '1000' }, From 906a93719ffe3d4297197819b44aa503b11abd95 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 20:02:37 +0200 Subject: [PATCH 8/8] Fix CI Ubuntu --- .github/workflows/publish-nym-wallet-ubuntu.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-nym-wallet-ubuntu.yml b/.github/workflows/publish-nym-wallet-ubuntu.yml index f1c95d1f4f..8cff672d2a 100644 --- a/.github/workflows/publish-nym-wallet-ubuntu.yml +++ b/.github/workflows/publish-nym-wallet-ubuntu.yml @@ -48,7 +48,7 @@ jobs: run: cd .. && pnpm i - name: Install app dependencies - run: pnpm + run: pnpm i - name: Create env file uses: timheuer/base64-to-file@v1.2