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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user