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 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..3130bf47a2 100644 --- a/nym-wallet/src/api/networkOverview.ts +++ b/nym-wallet/src/api/networkOverview.ts @@ -124,16 +124,39 @@ 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); +} + +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); + 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..18c45ec968 100644 --- a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -1,9 +1,10 @@ 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'; +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,8 @@ 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) { @@ -59,7 +62,23 @@ 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. + + )} + {compoundedRewards ? ( + <> + + + + + 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..73ebb3c8e0 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,9 @@ import { } from '../requests'; 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) => @@ -100,6 +103,8 @@ 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 accountLoadInflightRef = useRef>>(new Map()); const [loadingPresentation, setLoadingPresentation] = useState('auth-splash'); const [loadingOverlayTitle, setLoadingOverlayTitle] = useState(''); const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState(); @@ -128,11 +133,12 @@ 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); }; useEffect(() => { @@ -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, @@ -157,15 +162,18 @@ 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 => + dedupeInflightByKey(accountLoadInflightRef.current, n, async () => { + 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; + } + }); const loadStoredAccounts = async () => { const accounts = await listAccounts(); @@ -211,18 +219,35 @@ export const AppProvider: FCWithChildren = ({ children }) => { }, []); useEffect(() => { - if (!clientDetails) { - clearState(); - navigate('/'); + if (clientDetails) { + hadClientDetailsRef.current = true; + return; } + if (!hadClientDetailsRef.current) { + return; + } + clearState(); + navigate('/'); }, [clientDetails]); useEffect(() => { if (network) { - refreshAccount(network); + if (!clientDetails) { + refreshAccount(network); + } getEnv().then(setAppEnv); } - }, [network]); + }, [network, clientDetails?.client_address]); + + 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'; @@ -285,35 +310,35 @@ 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'); - 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); + clearNymPriceCache(); 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/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 e84cdd5cfb..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); + } } }, []); @@ -232,17 +245,26 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { const refreshBalances = async () => { vestingAccountStatusRef.current = 'unknown'; - if (clientDetails?.client_address) { - await fetchBalance(); - await fetchTokenAllocation(); - } else { + if (!clientDetails?.client_address) { clearAll(); + return; + } + setIsLoading(true); + setError(undefined); + try { + await runBalanceRefreshWithoutNestedLoading(fetchBalance, fetchTokenAllocation); + } finally { + setIsLoading(false); } }; useEffect(() => { - refreshBalances(); - }, [clientDetails]); + if (!clientDetails?.client_address) { + clearAll(); + return; + } + refreshBalances().catch((e) => Console.error(String(e))); + }, [clientDetails?.client_address]); return { error, diff --git a/nym-wallet/src/hooks/useNymUsdPrice.ts b/nym-wallet/src/hooks/useNymUsdPrice.ts index ee6cdd2e5a..3a1f2afe07 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 cachedPrice = getCachedNymPrice(url); + if (cachedPrice) { + setUsdPerNym(cachedPrice.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..5ad3821b6a 100644 --- a/nym-wallet/src/layouts/AppLayout.tsx +++ b/nym-wallet/src/layouts/AppLayout.tsx @@ -3,20 +3,17 @@ 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..26c561ac87 100644 --- a/nym-wallet/src/pages/balance/Balance.tsx +++ b/nym-wallet/src/pages/balance/Balance.tsx @@ -39,18 +39,17 @@ 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 +74,17 @@ export const BalanceCard = ({ {userBalanceError} )} - {isLoading ? ( - + {showBalanceLoading ? ( + + + Available now + + + {reserveUsdRow ? : null} + ) : ( !userBalanceError && ( @@ -99,7 +107,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..645bd8496e 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 ( @@ -421,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 ( @@ -542,15 +557,7 @@ export const NetworkOverviewSection: React.FC = () => { Number of delegations - {delegationsErr ? ( - - {delegationsErr} - - ) : ( - - {delegationsCount !== undefined ? formatCompactNumber(delegationsCount) : '…'} - - )} + {delegationsCountBody()} 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..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 @@ -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 { getNodeSettingsUpdateErrorMessage } from 'src/utils/nodeSettingsUpdateError'; 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 = getNodeSettingsUpdateErrorMessage(tx); + if (errorMessage) { + setSubmitError(errorMessage); + resetFeeState(); + return; + } + resetFeeState(); setOpenConfirmationModal(true); } catch (error) { Console.error(error); + setSubmitError(getNodeSettingsUpdateErrorMessage(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/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..0935f2c4dd --- /dev/null +++ b/nym-wallet/src/utils/dedupeInflightByKey.ts @@ -0,0 +1,13 @@ +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; +} diff --git a/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts new file mode 100644 index 0000000000..3a26125d4a --- /dev/null +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.test.ts @@ -0,0 +1,69 @@ +import { TBondedNode } from 'src/context/bonding'; +import { formatCoinDisplay, formatOperatorUnbondReturn } from './formatOperatorUnbondReturn'; + +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(); + }); + + 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 new file mode 100644 index 0000000000..113505f043 --- /dev/null +++ b/nym-wallet/src/utils/formatOperatorUnbondReturn.ts @@ -0,0 +1,66 @@ +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; + parseError?: string; +}; + +const toDisplayAmount = (amount: string): { value: string; error?: string } => { + try { + return { value: Big(amount).toFixed() }; + } catch { + return { value: amount, error: `Could not parse amount: "${amount}"` }; + } +}; + +const sumCoinAmounts = (a: string, b: string): { value: string; error?: string } => { + try { + return { value: Big(a).plus(b).toFixed() }; + } catch { + return { value: a, error: `Could not sum amounts: "${a}" + "${b}"` }; + } +}; + +export const formatOperatorUnbondReturn = (node: TBondedNode): OperatorUnbondReturn => { + const errors: string[] = []; + + const pledgeParsed = toDisplayAmount(node.bond.amount); + if (pledgeParsed.error) errors.push(pledgeParsed.error); + const pledge: DecCoin = { amount: pledgeParsed.value, denom: node.bond.denom }; + + 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 }; + } + + 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: hasCompoundedRewards ? operatorRewards : null, + total, + hasCompoundedRewards, + parseError: errors.length > 0 ? errors.join('; ') : undefined, + }; +}; + +export const formatCoinDisplay = (coin: DecCoin): string => `${coin.amount} ${coin.denom.toUpperCase()}`; diff --git a/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts new file mode 100644 index 0000000000..292959971e --- /dev/null +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts @@ -0,0 +1,60 @@ +import { getNodeSettingsUpdateErrorMessage } from './nodeSettingsUpdateError'; + +describe('getNodeSettingsUpdateErrorMessage', () => { + it('returns undefined when the transaction succeeded', () => { + expect( + getNodeSettingsUpdateErrorMessage({ + 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(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(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( + getNodeSettingsUpdateErrorMessage({ + 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.'); + }); + + 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/nodeSettingsUpdateError.ts b/nym-wallet/src/utils/nodeSettingsUpdateError.ts new file mode 100644 index 0000000000..b838b09b79 --- /dev/null +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.ts @@ -0,0 +1,15 @@ +import { TransactionExecuteResult } from '@nymproject/types'; +import { isTransactionExecuteSuccessful } from './transactionExecuteSuccess'; + +export const getNodeSettingsUpdateErrorMessage = ( + tx: TransactionExecuteResult | undefined, + contextError?: string, +): string | undefined => { + if (isTransactionExecuteSuccessful(tx)) { + return undefined; + } + if (contextError) { + return contextError; + } + return 'Unable to update node settings. Check your balance and try again.'; +}; 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'); +} 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; +};