diff --git a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx index f422efdc8d..18c45ec968 100644 --- a/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx +++ b/nym-wallet/src/components/Bonding/modals/UnbondModal.tsx @@ -25,6 +25,7 @@ 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) { @@ -66,14 +67,10 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => { Could not calculate exact return - check your wallet balance after unbonding. )} - {unbondReturn.hasCompoundedRewards ? ( + {compoundedRewards ? ( <> - + Delegator stake is returned to delegators separately and is not included in this total. diff --git a/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts b/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts new file mode 100644 index 0000000000..0dfc513cfb --- /dev/null +++ b/nym-wallet/src/hooks/balanceRefreshOrchestration.test.ts @@ -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); + }); +}); diff --git a/nym-wallet/src/hooks/balanceRefreshOrchestration.ts b/nym-wallet/src/hooks/balanceRefreshOrchestration.ts new file mode 100644 index 0000000000..1e29a27d9f --- /dev/null +++ b/nym-wallet/src/hooks/balanceRefreshOrchestration.ts @@ -0,0 +1,7 @@ +export async function runBalanceRefreshWithoutNestedLoading( + fetchBalance: (manageLoading?: boolean) => Promise, + fetchTokenAllocation: (isBackgroundPoll?: boolean, manageLoading?: boolean) => Promise, +): Promise { + await fetchBalance(false); + await fetchTokenAllocation(false, false); +} diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index c226793e2d..aabf6fd0fb 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -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) => { - setIsLoading(true); + const fetchTokenAllocation = async (isBackgroundPoll = false, manageLoading = true) => { + if (manageLoading) { + setIsLoading(true); + } if (!clientDetails?.client_address) { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } return; } if (vestingAccountStatusRef.current === 'absent') { if (isBackgroundPoll) { - setIsLoading(false); + 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 { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } } }; - const fetchBalance = useCallback(async () => { - setIsLoading(true); + const fetchBalance = useCallback(async (manageLoading = true) => { + if (manageLoading) { + setIsLoading(true); + } setError(undefined); try { const bal = await userBalance(); @@ -217,7 +228,9 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { } catch (err) { setError(err as string); } finally { - setIsLoading(false); + if (manageLoading) { + setIsLoading(false); + } } }, []); @@ -236,9 +249,10 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { clearAll(); return; } + setIsLoading(true); + setError(undefined); try { - await fetchBalance(); - await fetchTokenAllocation(); + await runBalanceRefreshWithoutNestedLoading(fetchBalance, fetchTokenAllocation); } finally { setIsLoading(false); } @@ -249,8 +263,6 @@ export const useGetBalance = (clientDetails?: Account): TUseuserBalance => { clearAll(); return; } - setIsLoading(true); - setError(undefined); refreshBalances().catch((e) => Console.error(String(e))); }, [clientDetails?.client_address]); diff --git a/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx b/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx index 4f5a9c2371..c48f41e02e 100644 --- a/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx +++ b/nym-wallet/src/pages/bonding/node-settings/settings-pages/general-settings/GeneralNymNodeSettings.tsx @@ -16,7 +16,7 @@ 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 { getHostnameUpdateErrorMessage } from 'src/utils/hostnameUpdateError'; +import { getNodeSettingsUpdateErrorMessage } from 'src/utils/nodeSettingsUpdateError'; export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymNode }) => { const [openConfirmationModal, setOpenConfirmationModal] = useState(false); @@ -57,7 +57,7 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN try { const tx = await updateNymNodeConfig(configUpdate, fee); - const errorMessage = getHostnameUpdateErrorMessage(tx); + const errorMessage = getNodeSettingsUpdateErrorMessage(tx); if (errorMessage) { setSubmitError(errorMessage); resetFeeState(); @@ -68,7 +68,7 @@ export const GeneralNymNodeSettings = ({ bondedNode }: { bondedNode: TBondedNymN setOpenConfirmationModal(true); } catch (error) { Console.error(error); - setSubmitError(getHostnameUpdateErrorMessage(undefined, String(error))); + setSubmitError(getNodeSettingsUpdateErrorMessage(undefined, String(error))); resetFeeState(); } }; diff --git a/nym-wallet/src/utils/hostnameUpdateError.test.ts b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts similarity index 50% rename from nym-wallet/src/utils/hostnameUpdateError.test.ts rename to nym-wallet/src/utils/nodeSettingsUpdateError.test.ts index 7313d8a9bf..292959971e 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.test.ts +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.test.ts @@ -1,12 +1,12 @@ -import { getHostnameUpdateErrorMessage } from './hostnameUpdateError'; +import { getNodeSettingsUpdateErrorMessage } from './nodeSettingsUpdateError'; -describe('getHostnameUpdateErrorMessage', () => { +describe('getNodeSettingsUpdateErrorMessage', () => { it('returns undefined when the transaction succeeded', () => { expect( - getHostnameUpdateErrorMessage({ + getNodeSettingsUpdateErrorMessage({ transaction_hash: 'abc123', - logs_json: '', - msg_responses_json: '', + logs_json: '[]', + msg_responses_json: '[]', gas_info: { gas_wanted: { gas_units: BigInt(1) }, gas_used: { gas_units: BigInt(1) }, @@ -17,23 +17,23 @@ describe('getHostnameUpdateErrorMessage', () => { }); it('returns context error when provided', () => { - expect(getHostnameUpdateErrorMessage(undefined, 'an error occurred: insufficient funds')).toBe( + 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(getHostnameUpdateErrorMessage(undefined)).toBe( + 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( - getHostnameUpdateErrorMessage({ + getNodeSettingsUpdateErrorMessage({ transaction_hash: '', - logs_json: '', - msg_responses_json: '', + logs_json: '[]', + msg_responses_json: '[]', gas_info: { gas_wanted: { gas_units: BigInt(1) }, gas_used: { gas_units: BigInt(1) }, @@ -42,4 +42,19 @@ describe('getHostnameUpdateErrorMessage', () => { }), ).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.'); + }); }); diff --git a/nym-wallet/src/utils/hostnameUpdateError.ts b/nym-wallet/src/utils/nodeSettingsUpdateError.ts similarity index 64% rename from nym-wallet/src/utils/hostnameUpdateError.ts rename to nym-wallet/src/utils/nodeSettingsUpdateError.ts index 1f323ab264..b838b09b79 100644 --- a/nym-wallet/src/utils/hostnameUpdateError.ts +++ b/nym-wallet/src/utils/nodeSettingsUpdateError.ts @@ -1,10 +1,11 @@ import { TransactionExecuteResult } from '@nymproject/types'; +import { isTransactionExecuteSuccessful } from './transactionExecuteSuccess'; -export const getHostnameUpdateErrorMessage = ( +export const getNodeSettingsUpdateErrorMessage = ( tx: TransactionExecuteResult | undefined, contextError?: string, ): string | undefined => { - if (tx?.transaction_hash && tx.transaction_hash.length > 0) { + if (isTransactionExecuteSuccessful(tx)) { return undefined; } if (contextError) { diff --git a/nym-wallet/src/utils/transactionExecuteSuccess.test.ts b/nym-wallet/src/utils/transactionExecuteSuccess.test.ts new file mode 100644 index 0000000000..9e28e424ca --- /dev/null +++ b/nym-wallet/src/utils/transactionExecuteSuccess.test.ts @@ -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); + }); +}); diff --git a/nym-wallet/src/utils/transactionExecuteSuccess.ts b/nym-wallet/src/utils/transactionExecuteSuccess.ts new file mode 100644 index 0000000000..024730cd4b --- /dev/null +++ b/nym-wallet/src/utils/transactionExecuteSuccess.ts @@ -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; +};