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'); +}