Merge pull request #6865 from nymtech/fix/wallet-ux-improvements

Fix wallet minor UX improvements
This commit is contained in:
Tommy Verrall
2026-06-09 09:48:24 +02:00
committed by GitHub
31 changed files with 824 additions and 141 deletions
@@ -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
+1
View File
@@ -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,
+20 -1
View File
@@ -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);
});
});
+24 -1
View File
@@ -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 -10
View File
@@ -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>
+6 -5
View File
@@ -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) => {
+49 -24
View File
@@ -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' });
}
};
+5 -2
View File
@@ -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);
}
+30 -8
View File
@@ -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,
+14 -5
View File
@@ -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);
+4 -8
View File
@@ -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',
}}
>
+19 -9
View File
@@ -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">
+1
View File
@@ -39,6 +39,7 @@ export const Balance = () => {
userBalanceError={userBalance.error}
clientAddress={clientDetails?.client_address}
network={network}
isLoading={userBalance.isLoading}
/>
<OverviewQuickActions />
{network === 'MAINNET' ? <NetworkOverviewSection /> : null}
@@ -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
>
+4
View File
@@ -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;
};