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