Merge pull request #6865 from nymtech/fix/wallet-ux-improvements
Fix wallet minor UX improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WindowGeometry> {
|
||||
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}");
|
||||
|
||||
@@ -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<FeeDetails, BackendError> {
|
||||
simulate_mixnet_operation(ExecuteMsg::UpdateNodeConfig { update }, None, &state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn simulate_delegate_to_node(
|
||||
node_id: NodeId,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,14 +124,37 @@ export async function fetchNymPrice(url: string): Promise<NymTokenomics> {
|
||||
}
|
||||
|
||||
const nymPriceInflight = new Map<string, Promise<NymTokenomics>>();
|
||||
const nymPriceCache = new Map<string, NymTokenomics>();
|
||||
|
||||
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<NymTokenomics> {
|
||||
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(() => {
|
||||
const pending = fetchNymPrice(url)
|
||||
.then((data) => {
|
||||
nymPriceCache.set(url, data);
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
nymPriceInflight.delete(url);
|
||||
});
|
||||
nymPriceInflight.set(url, pending);
|
||||
|
||||
@@ -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 (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Router>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<ModalListItem label="Total to unbond" value={`${node.bond.amount} ${node.bond.denom.toUpperCase()}`} divider />
|
||||
{unbondReturn.parseError && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
Could not calculate exact return - check your wallet balance after unbonding.
|
||||
</Alert>
|
||||
)}
|
||||
{compoundedRewards ? (
|
||||
<>
|
||||
<ModalListItem label="Original pledge" value={formatCoinDisplay(unbondReturn.pledge)} divider />
|
||||
<ModalListItem label="Compounded operator rewards" value={formatCoinDisplay(compoundedRewards)} 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>
|
||||
</>
|
||||
) : (
|
||||
<ModalListItem label="Total to unbond" value={formatCoinDisplay(unbondReturn.total)} divider />
|
||||
)}
|
||||
<ModalFee isLoading={isFeeLoading} fee={fee} divider />
|
||||
<Typography fontSize="small">Tokens will be transferred to the account you are logged in with now</Typography>
|
||||
</SimpleModal>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type TBondingContext = {
|
||||
unbond: (fee?: FeeDetails) => Promise<TransactionExecuteResult | undefined>;
|
||||
bond: (args: TBondNymNodeArgs) => Promise<TransactionExecuteResult | undefined>;
|
||||
updateBondAmount: (data: TUpdateBondArgs) => Promise<TransactionExecuteResult | undefined>;
|
||||
updateNymNodeConfig: (data: NodeConfigUpdate) => Promise<TransactionExecuteResult | undefined>;
|
||||
updateNymNodeConfig: (data: NodeConfigUpdate, fee?: FeeDetails) => Promise<TransactionExecuteResult | undefined>;
|
||||
redeemRewards: (fee?: FeeDetails) => Promise<TransactionExecuteResult | undefined>;
|
||||
generateNymNodeMsgPayload: (data: TNymNodeSignatureArgs) => Promise<string | undefined>;
|
||||
migrateVestedMixnode: () => Promise<TransactionExecuteResult | undefined>;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Map<Network, Promise<Account | undefined>>>(new Map());
|
||||
const [loadingPresentation, setLoadingPresentation] = useState<AppLoadingPresentation>('auth-splash');
|
||||
const [loadingOverlayTitle, setLoadingOverlayTitle] = useState('');
|
||||
const [loadingOverlaySubtitle, setLoadingOverlaySubtitle] = useState<string | undefined>();
|
||||
@@ -128,11 +133,12 @@ export const AppProvider: FCWithChildren = ({ children }) => {
|
||||
|
||||
const initFromRustState = async () => {
|
||||
const stateJson = await getReactState();
|
||||
if (stateJson) {
|
||||
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) => {
|
||||
const loadAccount = async (n: Network): Promise<Account | undefined> =>
|
||||
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) {
|
||||
if (clientDetails) {
|
||||
hadClientDetailsRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!hadClientDetailsRef.current) {
|
||||
return;
|
||||
}
|
||||
clearState();
|
||||
navigate('/');
|
||||
}
|
||||
}, [clientDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TransactionExecuteResult> => {
|
||||
const updateNymNodeConfig = async (
|
||||
_data?: NodeConfigUpdate,
|
||||
_fee?: FeeDetails,
|
||||
): Promise<TransactionExecuteResult> => {
|
||||
setIsLoading(true);
|
||||
await mockSleep(SLEEP_MS);
|
||||
triggerStateUpdate();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export async function runBalanceRefreshWithoutNestedLoading(
|
||||
fetchBalance: (manageLoading?: boolean) => Promise<void>,
|
||||
fetchTokenAllocation: (isBackgroundPoll?: boolean, manageLoading?: boolean) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await fetchBalance(false);
|
||||
await fetchTokenAllocation(false, false);
|
||||
}
|
||||
@@ -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) => {
|
||||
const fetchTokenAllocation = async (isBackgroundPoll = false, manageLoading = true) => {
|
||||
if (manageLoading) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
if (!clientDetails?.client_address) {
|
||||
if (manageLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vestingAccountStatusRef.current === 'absent') {
|
||||
if (isBackgroundPoll) {
|
||||
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 {
|
||||
if (manageLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBalance = useCallback(async () => {
|
||||
const fetchBalance = useCallback(async (manageLoading = true) => {
|
||||
if (manageLoading) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(undefined);
|
||||
try {
|
||||
const bal = await userBalance();
|
||||
@@ -217,8 +228,10 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => {
|
||||
} catch (err) {
|
||||
setError(err as string);
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearAll = () => {
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
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<number | undefined>(cached?.quotes.USD.price);
|
||||
const [loading, setLoading] = useState(Boolean(url && !cached));
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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' ? (
|
||||
<AppSessionLoadingOverlay title={loadingOverlayTitle} subtitle={loadingOverlaySubtitle} />
|
||||
) : (
|
||||
<LoadingPage />
|
||||
))}
|
||||
{showAppOverlay && <AppSessionLoadingOverlay title={loadingOverlayTitle} subtitle={loadingOverlaySubtitle} />}
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
@@ -98,7 +95,6 @@ export const ApplicationLayout: FCWithChildren = ({ children }) => {
|
||||
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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 = <Skeleton width={140} height={22} sx={{ mt: 0.5 }} />;
|
||||
usdApproximationRow = <Skeleton width={140} height={22} />;
|
||||
} else if (usdApproxLabel) {
|
||||
usdApproximationRow = (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'nym.text.muted', fontWeight: 500, mt: 0.25 }}
|
||||
data-testid="balance-usd-approx"
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'nym.text.muted', fontWeight: 500 }} data-testid="balance-usd-approx">
|
||||
{`≈ ${usdApproxLabel}`}
|
||||
</Typography>
|
||||
);
|
||||
@@ -75,8 +74,17 @@ export const BalanceCard = ({
|
||||
{userBalanceError}
|
||||
</Alert>
|
||||
)}
|
||||
{isLoading ? (
|
||||
{showBalanceLoading ? (
|
||||
<Stack spacing={1}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'nym.text.muted', textTransform: 'uppercase', letterSpacing: 1 }}
|
||||
>
|
||||
Available now
|
||||
</Typography>
|
||||
<Skeleton width={160} height={42} />
|
||||
{reserveUsdRow ? <Skeleton width={140} height={22} /> : null}
|
||||
</Stack>
|
||||
) : (
|
||||
!userBalanceError && (
|
||||
<Stack spacing={1}>
|
||||
@@ -99,7 +107,9 @@ export const BalanceCard = ({
|
||||
>
|
||||
{userBalance?.printable_balance || '-'}
|
||||
</Typography>
|
||||
{usdApproximationRow}
|
||||
{usdApproximationRow ? (
|
||||
<Box sx={{ minHeight: 22, display: 'flex', alignItems: 'center' }}>{usdApproximationRow}</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const metricSkeleton = (
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
<Skeleton width="55%" height={32} />
|
||||
<Skeleton width="38%" height={20} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const subtle = theme.palette.text.secondary;
|
||||
const trafficColor = '#8482FD';
|
||||
|
||||
@@ -293,11 +302,7 @@ export const NetworkOverviewSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
if (!trafficHeadline) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Loading…
|
||||
</Typography>
|
||||
);
|
||||
return metricSkeleton;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -335,11 +340,7 @@ export const NetworkOverviewSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
if (!epoch) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading…
|
||||
</Typography>
|
||||
);
|
||||
return metricSkeleton;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -377,11 +378,7 @@ export const NetworkOverviewSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
if (!price) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Loading…
|
||||
</Typography>
|
||||
);
|
||||
return metricSkeleton;
|
||||
}
|
||||
return (
|
||||
<Stack spacing={1.25} sx={{ mt: 1 }}>
|
||||
@@ -421,6 +418,24 @@ export const NetworkOverviewSection: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const delegationsCountBody = () => {
|
||||
if (delegationsErr) {
|
||||
return (
|
||||
<Typography color="error" variant="body2">
|
||||
{delegationsErr}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (delegationsCount !== undefined) {
|
||||
return (
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{formatCompactNumber(delegationsCount)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return <Skeleton width={72} height={32} />;
|
||||
};
|
||||
|
||||
const showTrafficChartToggle = trafficChartData.length > 0;
|
||||
|
||||
return (
|
||||
@@ -542,15 +557,7 @@ export const NetworkOverviewSection: React.FC = () => {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Number of delegations
|
||||
</Typography>
|
||||
{delegationsErr ? (
|
||||
<Typography color="error" variant="body2">
|
||||
{delegationsErr}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{delegationsCount !== undefined ? formatCompactNumber(delegationsCount) : '…'}
|
||||
</Typography>
|
||||
)}
|
||||
{delegationsCountBody()}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
|
||||
@@ -39,6 +39,7 @@ export const Balance = () => {
|
||||
userBalanceError={userBalance.error}
|
||||
clientAddress={clientDetails?.client_address}
|
||||
network={network}
|
||||
isLoading={userBalance.isLoading}
|
||||
/>
|
||||
<OverviewQuickActions />
|
||||
{network === 'MAINNET' ? <NetworkOverviewSection /> : null}
|
||||
|
||||
+47
-13
@@ -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<boolean>(false);
|
||||
const { fee, resetFeeState } = useGetFee();
|
||||
const [submitError, setSubmitError] = useState<string>();
|
||||
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();
|
||||
|
||||
try {
|
||||
const NymNodeConfigParams = {
|
||||
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,
|
||||
};
|
||||
await updateNymNodeConfig(NymNodeConfigParams);
|
||||
});
|
||||
|
||||
const onSubmit = async (configUpdate: NodeConfigUpdate) => {
|
||||
setSubmitError(undefined);
|
||||
|
||||
try {
|
||||
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 (
|
||||
<Grid container xs>
|
||||
{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 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<BalanceWarning fee={fee.amount.amount} />
|
||||
</Box>
|
||||
)}
|
||||
</ConfirmTx>
|
||||
)}
|
||||
{(isSubmitting || isBondingLoading) && <LoadingModal />}
|
||||
<Alert
|
||||
title={
|
||||
<Stack>
|
||||
@@ -81,6 +106,11 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN
|
||||
bgColor={`${theme.palette.nym.nymWallet.text.blue}0D !important`}
|
||||
dismissable
|
||||
/>
|
||||
{displayError && (
|
||||
<Box sx={{ px: 3, pt: 2, width: '100%' }}>
|
||||
<Error message={displayError} />
|
||||
</Box>
|
||||
)}
|
||||
<Grid container mt={2}>
|
||||
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
|
||||
<Grid item>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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<FeeDetails>('simulate_update_gateway_config', { update });
|
||||
|
||||
export const simulateUpdateNymNodeConfig = async (update: NodeConfigUpdate) =>
|
||||
invokeWrapper<FeeDetails>('simulate_update_nymnode_config', { update });
|
||||
|
||||
export const simulateDelegateToNode = async (args: { nodeId: number; amount: DecCoin }) =>
|
||||
invokeWrapper<FeeDetails>('simulate_delegate_to_node', args);
|
||||
|
||||
|
||||
@@ -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<string, Promise<string>>();
|
||||
let calls = 0;
|
||||
const load = () => {
|
||||
calls += 1;
|
||||
return new Promise<string>((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<string, Promise<string>>();
|
||||
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<string, Promise<string>>();
|
||||
await dedupeInflightByKey(inflight, 'MAINNET', async () => 'done');
|
||||
expect(inflight.has('MAINNET')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export function dedupeInflightByKey<K, T>(inflight: Map<K, Promise<T>>, key: K, load: () => Promise<T>): Promise<T> {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = load().finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
|
||||
inflight.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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.';
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user