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.
This commit is contained in:
Tommy Verrall
2026-06-08 18:56:07 +02:00
parent 500200db45
commit 7374ceae6f
8 changed files with 191 additions and 45 deletions
@@ -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 && (
<Alert severity="warning" sx={{ mb: 1 }}>
Could not calculate exact return - check your wallet balance after unbonding.
</Alert>
)}
{unbondReturn.hasCompoundedRewards ? (
<>
<ModalListItem label="Original pledge" value={formatCoinDisplay(unbondReturn.pledge)} divider />
@@ -69,11 +74,7 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => {
value={formatCoinDisplay(unbondReturn.operatorRewards!)}
divider
/>
<ModalListItem
label="Total returned to your account"
value={formatCoinDisplay(unbondReturn.total)}
divider
/>
<ModalListItem label="Total returned to your account" value={formatCoinDisplay(unbondReturn.total)} divider />
<Typography fontSize="small" sx={{ mb: 1 }}>
Delegator stake is returned to delegators separately and is not included in this total.
</Typography>
+18 -13
View File
@@ -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<Promise<void> | null>(null);
const accountLoadInflightRef = useRef<Promise<Account | undefined> | null>(null);
const [loadingPresentation, setLoadingPresentation] = useState<AppLoadingPresentation>('auth-splash');
const [loadingOverlayTitle, setLoadingOverlayTitle] = useState('');
const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState<string | undefined>();
@@ -160,7 +161,7 @@ export const AppProvider: FCWithChildren = ({ children }) => {
setMixnodeDetails(null);
};
const loadAccount = async (n: Network): Promise<void> => {
const loadAccount = async (n: Network): Promise<Account | undefined> => {
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) {
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);
@@ -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/);
});
});
@@ -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,
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 };
}
: null;
const rewardsAmount = operatorRewards && Big(operatorRewards.amount).gt(0) ? operatorRewards.amount : '0';
let hasCompoundedRewards = false;
try {
hasCompoundedRewards = Boolean(operatorRewards) && Big(operatorRewards!.amount).gt(0);
} catch {
errors.push(`Could not evaluate rewards amount: "${operatorRewards?.amount}"`);
}
const total: DecCoin = {
amount: sumCoinAmounts(pledge.amount, rewardsAmount),
denom: pledge.denom,
};
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,
};
};
@@ -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.');
});
});
+1 -1
View File
@@ -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) {
@@ -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');
});
});
@@ -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<Account>;
signInWithPassword: (password: string) => Promise<Account>;
loadAccount: (network: Network) => Promise<Account | undefined>;
setLoginType: (loginType: SignInType) => void;
navigate: (path: string) => void;
};
export async function signInAndNavigateToBalance({
type,
value,
network,
signInWithMnemonic,
signInWithPassword,
loadAccount,
setLoginType,
navigate,
}: SignInAndNavigateToBalanceDeps): Promise<void> {
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');
}