From 9ff2ec249df56ed2bca08d877d5ba1178e94ffce Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 11:53:56 +0200 Subject: [PATCH] Show unbonded delegations in wallet delegation list - In `delegate.rs`, add `delegation_node_identity` and `delegation_mixnode_is_unbonding` so missing node details emit `unbonded:{mix_id}` with `mixnode_is_unbonding: true` instead of an empty `node_identity`. - Add `delegationListVisibility.ts` (`shouldHideDelegationFromList`, `filterVisibleDelegations`, `searchDelegations`) and wire `DelegationList.tsx` to the shared helpers. - Update `useSortDelegations.tsx` to pin fully unbonded delegations to the top via `isFullyUnbondedDelegation`. - In `UndelegateModal.tsx`, display `Node unbonded (mix N)` instead of raw `unbonded:{mix_id}` on the confirm screen. - Add jest tests - Add Rust unit test --- .../src/operations/mixnet/delegate.rs | 42 +++++++++++++- .../components/Delegation/DelegationList.tsx | 49 +++++++--------- .../components/Delegation/UndelegateModal.tsx | 7 ++- .../src/context/delegationQuery.test.ts | 54 +++++++++++++++++ nym-wallet/src/hooks/useSortDelegations.tsx | 9 ++- .../src/utils/delegationIdentity.test.ts | 38 ++++++++++++ nym-wallet/src/utils/delegationIdentity.ts | 27 +++++++++ .../src/utils/delegationListVisibility.ts | 48 +++++++++++++++ nym-wallet/src/utils/index.ts | 2 + .../unbondedDelegation.acceptance.test.ts | 58 +++++++++++++++++++ .../src/utils/unbondedDelegation.fixture.ts | 53 +++++++++++++++++ 11 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 nym-wallet/src/context/delegationQuery.test.ts create mode 100644 nym-wallet/src/utils/delegationIdentity.test.ts create mode 100644 nym-wallet/src/utils/delegationIdentity.ts create mode 100644 nym-wallet/src/utils/delegationListVisibility.ts create mode 100644 nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts create mode 100644 nym-wallet/src/utils/unbondedDelegation.fixture.ts diff --git a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs index 0e6fa01ee7..9e4cb383f3 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs @@ -418,7 +418,7 @@ pub async fn get_all_mix_delegations( pending_events.len() ); - let mixnode_is_unbonding = node_details.as_ref().map(|m| m.is_unbonding); + let mixnode_is_unbonding = delegation_mixnode_is_unbonding(&node_details); log::trace!( " >>> node with mix_id: {} is unbonding: {:?}", d.mix_id, @@ -428,7 +428,7 @@ pub async fn get_all_mix_delegations( with_everything.push(DelegationWithEverything { owner: d.owner, mix_id: d.mix_id, - node_identity: node_details.map(|m| m.node_identity).unwrap_or_default(), + node_identity: delegation_node_identity(&node_details, d.mix_id), amount: d.amount, block_height: d.height, uses_vesting_contract_tokens, @@ -536,3 +536,41 @@ pub async fn get_delegation_summary( total_rewards, }) } + +pub(crate) fn delegation_node_identity( + node_details: &Option, + mix_id: NodeId, +) -> String { + node_details + .as_ref() + .map(|m| m.node_identity.clone()) + .unwrap_or_else(|| format!("unbonded:{}", mix_id)) +} + +pub(crate) fn delegation_mixnode_is_unbonding( + node_details: &Option, +) -> Option { + node_details.as_ref().map(|m| m.is_unbonding).or_else(|| { + if node_details.is_none() { + Some(true) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unbonded_registry_miss_uses_synthetic_identity() { + const EXAMPLE_MIX_ID: NodeId = 1234; + + assert_eq!( + delegation_node_identity(&None, EXAMPLE_MIX_ID), + "unbonded:1234" + ); + assert_eq!(delegation_mixnode_is_unbonding(&None), Some(true)); + } +} diff --git a/nym-wallet/src/components/Delegation/DelegationList.tsx b/nym-wallet/src/components/Delegation/DelegationList.tsx index 51ad848a49..230811180a 100644 --- a/nym-wallet/src/components/Delegation/DelegationList.tsx +++ b/nym-wallet/src/components/Delegation/DelegationList.tsx @@ -4,6 +4,7 @@ import { AlertTitle, Box, Button, + Chip, Collapse, FormControl, IconButton, @@ -30,7 +31,7 @@ import { useNavigate } from 'react-router-dom'; import { TauriLink as Link } from 'src/components/TauriLinkWrapper'; import { format } from 'date-fns'; import { Undelegate } from 'src/svg-icons'; -import { toPercentIntegerString } from 'src/utils'; +import { toPercentIntegerString, isFullyUnbondedDelegation, formatUnbondedNodeLabel, shouldHideDelegationFromList, searchDelegations } from 'src/utils'; import { InfoTooltip } from '../InfoToolTip'; import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions'; import { PendingDelegationCard } from './PendingDelegationCard'; @@ -41,25 +42,7 @@ export type Order = 'asc' | 'desc'; type AdditionalTypes = { profit_margin_percent: number; operating_cost: number }; export type SortingKeys = keyof AdditionalTypes | keyof DelegationWithEverything; -const shouldBeFiltered = (item: any): boolean => { - if (isDelegation(item)) { - if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') { - return true; - } - if (typeof item.avg_uptime_percent === 'string' && item.avg_uptime_percent === '-') { - return true; - } - } - - if (isPendingDelegation(item)) { - if ((!item.node_identity || item.node_identity === '') && item.event && item.event.kind === 'Undelegate') { - return true; - } - return false; - } - - return false; -}; +const shouldBeFiltered = (item: any): boolean => shouldHideDelegationFromList(item); const SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [ { id: 'delegated_on_iso_datetime', label: 'Delegated on' }, @@ -132,9 +115,8 @@ export const DelegationList: FCWithChildren<{ const searchNeedle = identityFilter.trim().toLowerCase(); const displayedDelegations = React.useMemo(() => { - if (!searchNeedle) return activeDelegations; - return activeDelegations.filter((d) => d.node_identity.toLowerCase().includes(searchNeedle)); - }, [activeDelegations, searchNeedle]); + return searchDelegations(activeDelegations, identityFilter); + }, [activeDelegations, identityFilter]); const activeCount = activeDelegations.length; @@ -407,7 +389,7 @@ export const DelegationList: FCWithChildren<{ displayedDelegations.map((item) => { const rowKey = `${item.mix_id}-${item.node_identity}`; const isOpen = expandedKey === rowKey; - const nodeIsUnbonded = Boolean(!item.node_identity); + const nodeIsFullyUnbonded = isFullyUnbondedDelegation(item); const satNum = saturationNumeric(item); let satColor: 'text.secondary' | 'error.main' | 'success.main' = 'text.secondary'; if (satNum !== undefined) { @@ -458,9 +440,20 @@ export const DelegationList: FCWithChildren<{ )} - {nodeIsUnbonded ? ( + {nodeIsFullyUnbonded ? ( - - + + } + /> + + {formatUnbondedNodeLabel(item.mix_id)} + + ) : ( - {!item.pending_events.length && !nodeIsUnbonded && ( + {!item.pending_events.length && !nodeIsFullyUnbonded && ( onItemActionClick ? onItemActionClick(item, action) : undefined @@ -496,7 +489,7 @@ export const DelegationList: FCWithChildren<{ disableDelegateMore={item.mixnode_is_unbonding} /> )} - {!item.pending_events.length && nodeIsUnbonded && ( + {!item.pending_events.length && nodeIsFullyUnbonded && ( t.palette.nym.nymWallet.text.main }} size="small"> (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)} diff --git a/nym-wallet/src/components/Delegation/UndelegateModal.tsx b/nym-wallet/src/components/Delegation/UndelegateModal.tsx index 1038dbe341..e08f1135c7 100644 --- a/nym-wallet/src/components/Delegation/UndelegateModal.tsx +++ b/nym-wallet/src/components/Delegation/UndelegateModal.tsx @@ -3,6 +3,7 @@ import { Box, SxProps } from '@mui/material'; import { FeeDetails } from '@nymproject/types'; import { useGetFee } from 'src/hooks/useGetFee'; import { simulateUndelegateFromNode, simulateVestingUndelegateFromMixnode } from 'src/requests'; +import { formatDelegationNodeIdentityForDisplay } from 'src/utils'; import { AppContext } from 'src/context'; import { ModalFee } from '../Modals/ModalFee'; import { ModalListItem } from '../Modals/ModalListItem'; @@ -49,7 +50,11 @@ export const UndelegateModal: FCWithChildren<{ backdropProps={backdropProps} > - + diff --git a/nym-wallet/src/context/delegationQuery.test.ts b/nym-wallet/src/context/delegationQuery.test.ts new file mode 100644 index 0000000000..f560b3134a --- /dev/null +++ b/nym-wallet/src/context/delegationQuery.test.ts @@ -0,0 +1,54 @@ +jest.mock('src/utils', () => ({ + decCoinToDisplay: jest.fn((coin: { amount: string; denom: string }) => coin), +})); + +jest.mock('src/requests', () => ({ + getDelegationSummary: jest.fn(), + getAllPendingDelegations: jest.fn(), +})); + +import { fetchDelegationSummaryQuery } from './delegationQuery'; +import { getAllPendingDelegations, getDelegationSummary } from 'src/requests'; + +const mockedGetDelegationSummary = getDelegationSummary as jest.MockedFunction; +const mockedGetAllPendingDelegations = getAllPendingDelegations as jest.MockedFunction< + typeof getAllPendingDelegations +>; + +describe('fetchDelegationSummaryQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('includes unbonded delegations in totals and keeps pending events on new nodes separate', async () => { + mockedGetDelegationSummary.mockResolvedValue({ + delegations: [ + { + mix_id: 42, + node_identity: 'unbonded:42', + amount: { amount: '1000000', denom: 'unym' }, + unclaimed_rewards: { amount: '50000', denom: 'unym' }, + mixnode_is_unbonding: true, + }, + ], + total_delegations: { amount: '1000000', denom: 'unym' }, + total_rewards: { amount: '50000', denom: 'unym' }, + } as unknown as Awaited>); + + mockedGetAllPendingDelegations.mockResolvedValue([ + { + node_identity: 'new-node-identity', + event: { mix_id: 99, kind: 'Delegate', address: 'n1test' }, + }, + ] as Awaited>); + + const result = await fetchDelegationSummaryQuery(); + + expect(result.delegations).toHaveLength(2); + expect(result.delegations[0]).toMatchObject({ mix_id: 42, node_identity: 'unbonded:42' }); + expect(result.pendingDelegations).toHaveLength(1); + expect(result.totalDelegations.amount).toBe('1000000'); + expect(result.totalRewards.amount).toBe('50000'); + expect(result.totalDelegationsAndRewards.amount).toBe('1050000.000000'); + }); +}); diff --git a/nym-wallet/src/hooks/useSortDelegations.tsx b/nym-wallet/src/hooks/useSortDelegations.tsx index b2bdcce787..df7dcf6680 100644 --- a/nym-wallet/src/hooks/useSortDelegations.tsx +++ b/nym-wallet/src/hooks/useSortDelegations.tsx @@ -1,12 +1,17 @@ import { orderBy as _orderBy } from 'lodash'; import { Order, SortingKeys } from 'src/components/Delegation/DelegationList'; import { TDelegations, isDelegation } from 'src/context/delegations'; +import { isFullyUnbondedDelegation } from 'src/utils'; type MappedTypes = 'delegationValue' | 'operatorReward' | 'profitMarginValue' | 'operatorCostValue'; export const useSortDelegations = (delegationItems: TDelegations, order: Order, orderBy: SortingKeys) => { - const unbondedDelegations = delegationItems.filter((delegation) => !delegation.node_identity); - const delegations = delegationItems.filter((delegation) => delegation.node_identity); + const unbondedDelegations = delegationItems.filter( + (delegation) => isDelegation(delegation) && isFullyUnbondedDelegation(delegation), + ); + const delegations = delegationItems.filter( + (delegation) => !(isDelegation(delegation) && isFullyUnbondedDelegation(delegation)), + ); // example of a mapped type in typescript diff --git a/nym-wallet/src/utils/delegationIdentity.test.ts b/nym-wallet/src/utils/delegationIdentity.test.ts new file mode 100644 index 0000000000..c0f38d8ed5 --- /dev/null +++ b/nym-wallet/src/utils/delegationIdentity.test.ts @@ -0,0 +1,38 @@ +import { + UNBONDED_NODE_IDENTITY_PREFIX, + formatUnbondedNodeLabel, + formatDelegationNodeIdentityForDisplay, + isFullyUnbondedDelegation, + isUnbondedNodeIdentity, +} from './delegationIdentity'; + +describe('delegationIdentity', () => { + it('treats empty identity as unbonded', () => { + expect(isUnbondedNodeIdentity('')).toBe(true); + expect(isUnbondedNodeIdentity(undefined)).toBe(true); + expect(isFullyUnbondedDelegation({ node_identity: '', mixnode_is_unbonding: false })).toBe(true); + }); + + it('treats unbonded prefix as fully unbonded', () => { + const identity = `${UNBONDED_NODE_IDENTITY_PREFIX}42`; + expect(isUnbondedNodeIdentity(identity)).toBe(true); + expect(isFullyUnbondedDelegation({ node_identity: identity, mixnode_is_unbonding: false })).toBe(true); + }); + + it('does not treat bonded identity as unbonded', () => { + const identity = '2Abcdefghijklmnopqrstuvwxyz1234567890'; + expect(isUnbondedNodeIdentity(identity)).toBe(false); + expect(isFullyUnbondedDelegation({ node_identity: identity, mixnode_is_unbonding: true })).toBe(false); + }); + + it('formats unbonded node label with mix id', () => { + expect(formatUnbondedNodeLabel(123)).toBe('Node unbonded (mix 123)'); + }); + + it('formats unbonded identity for display using mix id', () => { + expect(formatDelegationNodeIdentityForDisplay('unbonded:42', 42)).toBe('Node unbonded (mix 42)'); + expect(formatDelegationNodeIdentityForDisplay('2Abcdefghijklmnopqrstuvwxyz1234567890', 42)).toBe( + '2Abcdefghijklmnopqrstuvwxyz1234567890', + ); + }); +}); diff --git a/nym-wallet/src/utils/delegationIdentity.ts b/nym-wallet/src/utils/delegationIdentity.ts new file mode 100644 index 0000000000..04cc394be2 --- /dev/null +++ b/nym-wallet/src/utils/delegationIdentity.ts @@ -0,0 +1,27 @@ +import type { DelegationWithEverything } from '@nymproject/types'; + +export const UNBONDED_NODE_IDENTITY_PREFIX = 'unbonded:'; + +export function isUnbondedNodeIdentity(nodeIdentity: string | undefined | null): boolean { + if (!nodeIdentity) { + return true; + } + return nodeIdentity.startsWith(UNBONDED_NODE_IDENTITY_PREFIX); +} + +export function isFullyUnbondedDelegation( + item: Pick, +): boolean { + return isUnbondedNodeIdentity(item.node_identity); +} + +export function formatUnbondedNodeLabel(mixId: number): string { + return `Node unbonded (mix ${mixId})`; +} + +export function formatDelegationNodeIdentityForDisplay(nodeIdentity: string, mixId: number): string { + if (isUnbondedNodeIdentity(nodeIdentity)) { + return formatUnbondedNodeLabel(mixId); + } + return nodeIdentity; +} diff --git a/nym-wallet/src/utils/delegationListVisibility.ts b/nym-wallet/src/utils/delegationListVisibility.ts new file mode 100644 index 0000000000..fae364abc6 --- /dev/null +++ b/nym-wallet/src/utils/delegationListVisibility.ts @@ -0,0 +1,48 @@ +import type { DelegationWithEverything, WrappedDelegationEvent } from '@nymproject/types'; +import { isFullyUnbondedDelegation } from './delegationIdentity'; + +export type DelegationListItem = DelegationWithEverything | WrappedDelegationEvent; + +const isPendingDelegationItem = (delegation: DelegationListItem): delegation is WrappedDelegationEvent => + 'event' in delegation; + +const isDelegationItem = (delegation: DelegationListItem): delegation is DelegationWithEverything => + 'owner' in delegation; + +export function shouldHideDelegationFromList(item: DelegationListItem): boolean { + if (isDelegationItem(item)) { + if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') { + return true; + } + } + + if (isPendingDelegationItem(item)) { + if ((!item.node_identity || item.node_identity === '') && item.event && item.event.kind === 'Undelegate') { + return true; + } + return false; + } + + return false; +} + +export function filterVisibleDelegations(items: DelegationListItem[]): DelegationListItem[] { + return items.filter((item) => !shouldHideDelegationFromList(item)); +} + +export function searchDelegations( + delegations: DelegationWithEverything[], + searchNeedle: string, +): DelegationWithEverything[] { + const needle = searchNeedle.trim().toLowerCase(); + if (!needle) { + return delegations; + } + return delegations.filter( + (d) => d.node_identity.toLowerCase().includes(needle) || String(d.mix_id).includes(needle), + ); +} + +export function isUndelegateOnlyDelegation(item: DelegationWithEverything): boolean { + return isFullyUnbondedDelegation(item); +} diff --git a/nym-wallet/src/utils/index.ts b/nym-wallet/src/utils/index.ts index b1a2ade95a..9f26adf78d 100644 --- a/nym-wallet/src/utils/index.ts +++ b/nym-wallet/src/utils/index.ts @@ -1,4 +1,6 @@ export * from './common'; +export * from './delegationIdentity'; +export * from './delegationListVisibility'; export * from './fireRequests'; export * from './console'; export { default as fireRequests } from './fireRequests'; diff --git a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts new file mode 100644 index 0000000000..0157771526 --- /dev/null +++ b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts @@ -0,0 +1,58 @@ +import type { DelegationWithEverything } from '@nymproject/types'; +import { formatDelegationNodeIdentityForDisplay } from './delegationIdentity'; +import { + filterVisibleDelegations, + isUndelegateOnlyDelegation, + searchDelegations, + shouldHideDelegationFromList, +} from './delegationListVisibility'; +import { + EXAMPLE_HISTORICAL_NODE_IDENTITY, + EXAMPLE_UNBONDED_MIX_ID, + buildFixedUnbondedWalletDelegation, + buildLegacyHiddenUnbondedWalletDelegation, +} from './unbondedDelegation.fixture'; + +describe('unbonded delegation wallet visibility acceptance', () => { + it('hides the pre-fix wallet row with empty node_identity', () => { + const legacyRow = buildLegacyHiddenUnbondedWalletDelegation(); + + expect(legacyRow.mix_id).toBe(EXAMPLE_UNBONDED_MIX_ID); + expect(legacyRow.node_identity).toBe(''); + expect(shouldHideDelegationFromList(legacyRow)).toBe(true); + expect(filterVisibleDelegations([legacyRow])).toHaveLength(0); + }); + + it('shows the post-fix wallet row for the same on-chain delegation', () => { + const fixedRow = buildFixedUnbondedWalletDelegation(); + + expect(fixedRow.node_identity).toBe(`unbonded:${EXAMPLE_UNBONDED_MIX_ID}`); + expect(fixedRow.mixnode_is_unbonding).toBe(true); + expect(shouldHideDelegationFromList(fixedRow)).toBe(false); + expect(filterVisibleDelegations([fixedRow])).toHaveLength(1); + expect(isUndelegateOnlyDelegation(fixedRow)).toBe(true); + }); + + it('does not hide the row when uptime is missing', () => { + const fixedRow = buildFixedUnbondedWalletDelegation(); + + expect(fixedRow.avg_uptime_percent).toBeNull(); + expect(shouldHideDelegationFromList(fixedRow)).toBe(false); + }); + + it('finds the row by mix_id search when historical identity is unavailable', () => { + const fixedRow = buildFixedUnbondedWalletDelegation(); + const visible = filterVisibleDelegations([fixedRow]) as DelegationWithEverything[]; + + expect(searchDelegations(visible, String(EXAMPLE_UNBONDED_MIX_ID))).toHaveLength(1); + expect(searchDelegations(visible, EXAMPLE_HISTORICAL_NODE_IDENTITY)).toHaveLength(0); + }); + + it('formats undelegate confirmation copy without exposing the synthetic prefix', () => { + const fixedRow = buildFixedUnbondedWalletDelegation(); + + expect(formatDelegationNodeIdentityForDisplay(fixedRow.node_identity, fixedRow.mix_id)).toBe( + `Node unbonded (mix ${EXAMPLE_UNBONDED_MIX_ID})`, + ); + }); +}); diff --git a/nym-wallet/src/utils/unbondedDelegation.fixture.ts b/nym-wallet/src/utils/unbondedDelegation.fixture.ts new file mode 100644 index 0000000000..c03fc344ea --- /dev/null +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -0,0 +1,53 @@ +import type { DelegationWithEverything } from '@nymproject/types'; +import { UNBONDED_NODE_IDENTITY_PREFIX } from './delegationIdentity'; + +/** Synthetic mix_id used in wallet unbonded-delegation tests. */ +export const EXAMPLE_UNBONDED_MIX_ID = 1234; + +/** Example bonded-node identity (registry miss => wallet uses unbonded prefix instead). */ +export const EXAMPLE_HISTORICAL_NODE_IDENTITY = '2ExampleHistoricalNodeIdentityKey00000000000001'; + +export const EXAMPLE_DELEGATOR_ADDRESS = 'n1exampledelegator00000000000000000000000'; + +type UnbondedWalletDelegationOptions = { + mixId?: number; + amount?: string; + blockHeight?: bigint; + owner?: string; +}; + +export function buildFixedUnbondedWalletDelegation( + options: UnbondedWalletDelegationOptions = {}, +): DelegationWithEverything { + const mixId = options.mixId ?? EXAMPLE_UNBONDED_MIX_ID; + + return { + mix_id: mixId, + node_identity: `${UNBONDED_NODE_IDENTITY_PREFIX}${mixId}`, + amount: { amount: options.amount ?? '1000000', denom: 'nym' }, + owner: options.owner ?? EXAMPLE_DELEGATOR_ADDRESS, + block_height: options.blockHeight ?? BigInt(1_000_000), + delegated_on_iso_datetime: null, + unclaimed_rewards: null, + stake_saturation: null, + avg_uptime_percent: null, + accumulated_by_delegates: null, + accumulated_by_operator: null, + cost_params: null, + uses_vesting_contract_tokens: false, + pending_events: [], + mixnode_is_unbonding: true, + errors: null, + }; +} + +/** Pre-fix wallet backend shape when node registry lookup returned none. */ +export function buildLegacyHiddenUnbondedWalletDelegation( + options: UnbondedWalletDelegationOptions = {}, +): DelegationWithEverything { + return { + ...buildFixedUnbondedWalletDelegation(options), + node_identity: '', + mixnode_is_unbonding: null, + }; +}