From 9ff2ec249df56ed2bca08d877d5ba1178e94ffce Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 11:53:56 +0200 Subject: [PATCH 1/6] 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, + }; +} From 959a986e2c7323e2e85752aca6b1d3216eebb812 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 12:23:00 +0200 Subject: [PATCH 2/6] PR review comments - Add `historical_node_identity` to `DelegationWithEverything` and populate via `lookup_historical_node_identity` in `delegate.rs` so search works after unbond. - `searchDelegations` searches `historical_node_identity` and guards null/empty `node_identity` with optional chaining. - Acceptance tests: historical identity search, bonded-unbonding vs synthetic branch semantics, empty-identity search safety. - Fix linting --- .../types/rust/DelegationWithEverything.ts | 6 ++- common/types/src/delegation.rs | 2 + .../src/operations/mixnet/delegate.rs | 50 ++++++++++++++++--- .../components/Delegation/DelegationList.tsx | 17 +++++-- .../src/context/delegationQuery.test.ts | 10 ++-- nym-wallet/src/context/mocks/delegations.tsx | 2 + .../src/utils/delegationListVisibility.ts | 8 +-- .../unbondedDelegation.acceptance.test.ts | 41 ++++++++++++++- .../src/utils/unbondedDelegation.fixture.ts | 2 + .../types/rust/DelegationWithEverything.ts | 1 + 10 files changed, 116 insertions(+), 23 deletions(-) diff --git a/common/types/bindings/ts-packages/types/src/types/rust/DelegationWithEverything.ts b/common/types/bindings/ts-packages/types/src/types/rust/DelegationWithEverything.ts index ed8a84b8d7..1ba2be7775 100644 --- a/common/types/bindings/ts-packages/types/src/types/rust/DelegationWithEverything.ts +++ b/common/types/bindings/ts-packages/types/src/types/rust/DelegationWithEverything.ts @@ -3,4 +3,8 @@ import type { DecCoin } from "./DecCoin"; import type { DelegationEvent } from "./DelegationEvent"; import type { NodeCostParams } from "./MixNodeCostParams"; -export type DelegationWithEverything = { owner: string, mix_id: number, node_identity: string, amount: DecCoin, accumulated_by_delegates: DecCoin | null, accumulated_by_operator: DecCoin | null, block_height: bigint, delegated_on_iso_datetime: string | null, cost_params: NodeCostParams | null, avg_uptime_percent: number | null, stake_saturation: string | null, uses_vesting_contract_tokens: boolean, unclaimed_rewards: DecCoin | null, errors: string | null, pending_events: Array, mixnode_is_unbonding: boolean | null, }; +export type DelegationWithEverything = { owner: string, mix_id: number, node_identity: string, +/** + * Prior node identity when `node_identity` is synthetic (registry miss after unbond). + */ +historical_node_identity: string | null, amount: DecCoin, accumulated_by_delegates: DecCoin | null, accumulated_by_operator: DecCoin | null, block_height: bigint, delegated_on_iso_datetime: string | null, cost_params: NodeCostParams | null, avg_uptime_percent: number | null, stake_saturation: string | null, uses_vesting_contract_tokens: boolean, unclaimed_rewards: DecCoin | null, errors: string | null, pending_events: Array, mixnode_is_unbonding: boolean | null, }; diff --git a/common/types/src/delegation.rs b/common/types/src/delegation.rs index 3060a02e3a..bfaff3a2d6 100644 --- a/common/types/src/delegation.rs +++ b/common/types/src/delegation.rs @@ -49,6 +49,8 @@ pub struct DelegationWithEverything { pub owner: String, pub mix_id: NodeId, pub node_identity: String, + /// Prior node identity when `node_identity` is synthetic (registry miss after unbond). + pub historical_node_identity: Option, pub amount: DecCoin, pub accumulated_by_delegates: Option, pub accumulated_by_operator: Option, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs index 9e4cb383f3..81ec42da5c 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs @@ -184,6 +184,39 @@ pub(crate) async fn get_node_information( Ok(None) } +pub(crate) async fn lookup_historical_node_identity( + client: &DirectSigningHttpRpcValidatorClient, + node_id: NodeId, + error_strings: &mut Vec, +) -> Option { + match client.nyxd.get_unbonded_nymnode_information(node_id).await { + Ok(response) => { + if let Some(details) = response.details { + return Some(details.identity_key); + } + } + Err(err) => { + let str_err = format!( + "Failed to get unbonded nymnode information for node_id = {node_id}. Error: {err}", + ); + log::error!(" <<< {str_err}"); + error_strings.push(str_err); + } + } + + match client.nyxd.get_unbonded_mixnode_information(node_id).await { + Ok(response) => response.unbonded_info.map(|info| info.identity_key), + Err(err) => { + let str_err = format!( + "Failed to get unbonded mixnode information for mix_id = {node_id}. Error: {err}", + ); + log::error!(" <<< {str_err}"); + error_strings.push(str_err); + None + } + } +} + // TODO: fix later (yeah...) #[allow(deprecated)] #[tauri::command] @@ -425,10 +458,16 @@ pub async fn get_all_mix_delegations( mixnode_is_unbonding ); + let historical_node_identity = match &node_details { + Some(node) => Some(node.node_identity.clone()), + None => lookup_historical_node_identity(client, d.mix_id, &mut error_strings).await, + }; + with_everything.push(DelegationWithEverything { owner: d.owner, mix_id: d.mix_id, node_identity: delegation_node_identity(&node_details, d.mix_id), + historical_node_identity, amount: d.amount, block_height: d.height, uses_vesting_contract_tokens, @@ -550,13 +589,10 @@ pub(crate) fn delegation_node_identity( 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 - } - }) + match node_details { + Some(node) => Some(node.is_unbonding), + None => Some(true), + } } #[cfg(test)] diff --git a/nym-wallet/src/components/Delegation/DelegationList.tsx b/nym-wallet/src/components/Delegation/DelegationList.tsx index 230811180a..b239a16283 100644 --- a/nym-wallet/src/components/Delegation/DelegationList.tsx +++ b/nym-wallet/src/components/Delegation/DelegationList.tsx @@ -31,7 +31,13 @@ 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, isFullyUnbondedDelegation, formatUnbondedNodeLabel, shouldHideDelegationFromList, searchDelegations } 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'; @@ -42,7 +48,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 => shouldHideDelegationFromList(item); +const shouldBeFiltered = (item: TDelegations[number]): boolean => shouldHideDelegationFromList(item); const SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [ { id: 'delegated_on_iso_datetime', label: 'Delegated on' }, @@ -114,9 +120,10 @@ export const DelegationList: FCWithChildren<{ const searchNeedle = identityFilter.trim().toLowerCase(); - const displayedDelegations = React.useMemo(() => { - return searchDelegations(activeDelegations, identityFilter); - }, [activeDelegations, identityFilter]); + const displayedDelegations = React.useMemo( + () => searchDelegations(activeDelegations, identityFilter), + [activeDelegations, identityFilter], + ); const activeCount = activeDelegations.length; diff --git a/nym-wallet/src/context/delegationQuery.test.ts b/nym-wallet/src/context/delegationQuery.test.ts index f560b3134a..c06f6a3e3e 100644 --- a/nym-wallet/src/context/delegationQuery.test.ts +++ b/nym-wallet/src/context/delegationQuery.test.ts @@ -1,3 +1,6 @@ +import { getAllPendingDelegations, getDelegationSummary } from 'src/requests'; +import { fetchDelegationSummaryQuery } from './delegationQuery'; + jest.mock('src/utils', () => ({ decCoinToDisplay: jest.fn((coin: { amount: string; denom: string }) => coin), })); @@ -7,13 +10,8 @@ jest.mock('src/requests', () => ({ 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 ->; +const mockedGetAllPendingDelegations = getAllPendingDelegations as jest.MockedFunction; describe('fetchDelegationSummaryQuery', () => { beforeEach(() => { diff --git a/nym-wallet/src/context/mocks/delegations.tsx b/nym-wallet/src/context/mocks/delegations.tsx index 27c15d3e1d..fe0dcddcf1 100644 --- a/nym-wallet/src/context/mocks/delegations.tsx +++ b/nym-wallet/src/context/mocks/delegations.tsx @@ -37,6 +37,7 @@ let mockDelegations: DelegationWithEverything[] = [ uses_vesting_contract_tokens: false, pending_events: [], mixnode_is_unbonding: false, + historical_node_identity: null, errors: null, }, { @@ -61,6 +62,7 @@ let mockDelegations: DelegationWithEverything[] = [ uses_vesting_contract_tokens: true, pending_events: [], mixnode_is_unbonding: false, + historical_node_identity: null, errors: null, }, ]; diff --git a/nym-wallet/src/utils/delegationListVisibility.ts b/nym-wallet/src/utils/delegationListVisibility.ts index fae364abc6..548f6f0312 100644 --- a/nym-wallet/src/utils/delegationListVisibility.ts +++ b/nym-wallet/src/utils/delegationListVisibility.ts @@ -38,9 +38,11 @@ export function searchDelegations( if (!needle) { return delegations; } - return delegations.filter( - (d) => d.node_identity.toLowerCase().includes(needle) || String(d.mix_id).includes(needle), - ); + return delegations.filter((d) => { + const identity = d.node_identity?.toLowerCase() ?? ''; + const historical = d.historical_node_identity?.toLowerCase() ?? ''; + return identity.includes(needle) || historical.includes(needle) || String(d.mix_id).includes(needle); + }); } export function isUndelegateOnlyDelegation(item: DelegationWithEverything): boolean { diff --git a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts index 0157771526..c7dd2df2a3 100644 --- a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts +++ b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts @@ -1,5 +1,5 @@ import type { DelegationWithEverything } from '@nymproject/types'; -import { formatDelegationNodeIdentityForDisplay } from './delegationIdentity'; +import { formatDelegationNodeIdentityForDisplay, isFullyUnbondedDelegation } from './delegationIdentity'; import { filterVisibleDelegations, isUndelegateOnlyDelegation, @@ -48,6 +48,45 @@ describe('unbonded delegation wallet visibility acceptance', () => { expect(searchDelegations(visible, EXAMPLE_HISTORICAL_NODE_IDENTITY)).toHaveLength(0); }); + it('does not throw when search runs on unfiltered rows with empty node_identity', () => { + const legacyRow = buildLegacyHiddenUnbondedWalletDelegation(); + + expect(searchDelegations([legacyRow], String(EXAMPLE_UNBONDED_MIX_ID))).toHaveLength(1); + expect(searchDelegations([legacyRow], 'nonexistent-needle')).toHaveLength(0); + }); + + it('finds the row by historical identity when the backend preserved it', () => { + const fixedRow = buildFixedUnbondedWalletDelegation({ + historicalNodeIdentity: EXAMPLE_HISTORICAL_NODE_IDENTITY, + }); + const visible = filterVisibleDelegations([fixedRow]) as DelegationWithEverything[]; + + expect(searchDelegations(visible, EXAMPLE_HISTORICAL_NODE_IDENTITY)).toHaveLength(1); + expect(searchDelegations(visible, String(EXAMPLE_UNBONDED_MIX_ID))).toHaveLength(1); + }); + + it('keeps bonded-but-unbonding rows linked and not undelegate-only', () => { + const bondedUnbondingRow: DelegationWithEverything = { + ...buildFixedUnbondedWalletDelegation(), + node_identity: EXAMPLE_HISTORICAL_NODE_IDENTITY, + historical_node_identity: EXAMPLE_HISTORICAL_NODE_IDENTITY, + mixnode_is_unbonding: true, + }; + + expect(isFullyUnbondedDelegation(bondedUnbondingRow)).toBe(false); + expect(isUndelegateOnlyDelegation(bondedUnbondingRow)).toBe(false); + expect(searchDelegations([bondedUnbondingRow], EXAMPLE_HISTORICAL_NODE_IDENTITY)).toHaveLength(1); + }); + + it('treats synthetic registry-miss rows as undelegate-only even with historical identity', () => { + const syntheticRow = buildFixedUnbondedWalletDelegation({ + historicalNodeIdentity: EXAMPLE_HISTORICAL_NODE_IDENTITY, + }); + + expect(isFullyUnbondedDelegation(syntheticRow)).toBe(true); + expect(isUndelegateOnlyDelegation(syntheticRow)).toBe(true); + }); + it('formats undelegate confirmation copy without exposing the synthetic prefix', () => { const fixedRow = buildFixedUnbondedWalletDelegation(); diff --git a/nym-wallet/src/utils/unbondedDelegation.fixture.ts b/nym-wallet/src/utils/unbondedDelegation.fixture.ts index c03fc344ea..83fee7cd07 100644 --- a/nym-wallet/src/utils/unbondedDelegation.fixture.ts +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -14,6 +14,7 @@ type UnbondedWalletDelegationOptions = { amount?: string; blockHeight?: bigint; owner?: string; + historicalNodeIdentity?: string | null; }; export function buildFixedUnbondedWalletDelegation( @@ -37,6 +38,7 @@ export function buildFixedUnbondedWalletDelegation( uses_vesting_contract_tokens: false, pending_events: [], mixnode_is_unbonding: true, + historical_node_identity: options.historicalNodeIdentity ?? null, errors: null, }; } diff --git a/ts-packages/types/src/types/rust/DelegationWithEverything.ts b/ts-packages/types/src/types/rust/DelegationWithEverything.ts index a37755f1a7..4adde3e782 100644 --- a/ts-packages/types/src/types/rust/DelegationWithEverything.ts +++ b/ts-packages/types/src/types/rust/DelegationWithEverything.ts @@ -7,6 +7,7 @@ export type DelegationWithEverything = { owner: string; mix_id: number; node_identity: string; + historical_node_identity: string | null; amount: DecCoin; accumulated_by_delegates: DecCoin | null; accumulated_by_operator: DecCoin | null; From c52fc0c9af1874dff55d36684670f78f133f44d2 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 12:29:19 +0200 Subject: [PATCH 3/6] Last round of fixes - Align `get_pending_delegation_events` with active delegation identity resolution: `get_node_information` + `delegation_node_identity` synthetic fallback on registry miss. - Stop hiding pending delegation rows in `shouldHideDelegationFromList` when bonded-registry identity lookup missed. --- .../src/operations/mixnet/delegate.rs | 26 +++-- .../Delegation/PendingDelegationCard.tsx | 95 +++++++++++-------- .../src/utils/delegationListVisibility.ts | 4 +- .../unbondedDelegation.acceptance.test.ts | 10 ++ .../src/utils/unbondedDelegation.fixture.ts | 18 +++- 5 files changed, 101 insertions(+), 52 deletions(-) diff --git a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs index 81ec42da5c..bc36a6a350 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs @@ -48,13 +48,25 @@ pub async fn get_pending_delegation_events( let mut client_specific_events = Vec::new(); for delegation_event in delegation_events { if delegation_event.address_matches(client.nyxd.address().as_ref()) { - let node_identity = client - .nyxd - .get_mixnode_details(delegation_event.mix_id) - .await? - .mixnode_details - .map(|d| d.bond_information.mix_node.identity_key) - .unwrap_or_default(); + let mut error_strings = Vec::new(); + let node_identity = match get_node_information( + client, + delegation_event.mix_id, + &mut error_strings, + ) + .await + { + Ok(node_details) => { + delegation_node_identity(&node_details, delegation_event.mix_id) + } + Err(err) => { + log::error!( + "Failed to resolve node identity for pending event mix_id = {}. Error: {err}", + delegation_event.mix_id + ); + delegation_node_identity(&None, delegation_event.mix_id) + } + }; client_specific_events .push(WrappedDelegationEvent::new(delegation_event, node_identity)); diff --git a/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx index 6af5f797ec..7c4eac65d7 100644 --- a/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx +++ b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx @@ -2,45 +2,58 @@ import React from 'react'; import { Box, Chip, Paper, Stack, Tooltip, Typography } from '@mui/material'; import { WrappedDelegationEvent } from '@nymproject/types'; import { TauriLink as Link } from 'src/components/TauriLinkWrapper'; +import { formatDelegationNodeIdentityForDisplay, isUnbondedNodeIdentity } from 'src/utils/delegationIdentity'; -export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => ( - (t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle'), - borderColor: 'divider', - }} - > - - - - {item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'} - - - - Your delegation of {item.event.amount?.amount} {item.event.amount?.denom} will take effect when the new - epoch starts. There is a new epoch every hour. - - } - arrow - PopperProps={{ - sx: { - '& .MuiTooltip-tooltip': { textAlign: 'left' }, - }, - }} - > - - - - -); +export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => { + const displayIdentity = formatDelegationNodeIdentityForDisplay(item.node_identity, item.event.mix_id); + const nodeIsUnbonded = isUnbondedNodeIdentity(item.node_identity); + + return ( + + t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', + borderColor: 'divider', + }} + > + + {nodeIsUnbonded ? ( + + {displayIdentity} + + ) : ( + + )} + + {item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'} + + + + Your delegation of {item.event.amount?.amount} {item.event.amount?.denom} will take effect when the new + epoch starts. There is a new epoch every hour. + + } + arrow + PopperProps={{ + sx: { + '& .MuiTooltip-tooltip': { textAlign: 'left' }, + }, + }} + > + + + + + ); +}; diff --git a/nym-wallet/src/utils/delegationListVisibility.ts b/nym-wallet/src/utils/delegationListVisibility.ts index 548f6f0312..c241507077 100644 --- a/nym-wallet/src/utils/delegationListVisibility.ts +++ b/nym-wallet/src/utils/delegationListVisibility.ts @@ -17,9 +17,7 @@ export function shouldHideDelegationFromList(item: DelegationListItem): boolean } if (isPendingDelegationItem(item)) { - if ((!item.node_identity || item.node_identity === '') && item.event && item.event.kind === 'Undelegate') { - return true; - } + // Pending rows carry mix_id on the event; do not hide when bonded-registry identity lookup missed. return false; } diff --git a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts index c7dd2df2a3..d85f461974 100644 --- a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts +++ b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts @@ -11,6 +11,7 @@ import { EXAMPLE_UNBONDED_MIX_ID, buildFixedUnbondedWalletDelegation, buildLegacyHiddenUnbondedWalletDelegation, + buildPendingUndelegateEvent, } from './unbondedDelegation.fixture'; describe('unbonded delegation wallet visibility acceptance', () => { @@ -55,6 +56,15 @@ describe('unbonded delegation wallet visibility acceptance', () => { expect(searchDelegations([legacyRow], 'nonexistent-needle')).toHaveLength(0); }); + it('shows pending undelegate events when node identity lookup missed', () => { + const emptyIdentityPending = buildPendingUndelegateEvent(''); + const syntheticPending = buildPendingUndelegateEvent(`unbonded:${EXAMPLE_UNBONDED_MIX_ID}`); + + expect(shouldHideDelegationFromList(emptyIdentityPending)).toBe(false); + expect(shouldHideDelegationFromList(syntheticPending)).toBe(false); + expect(filterVisibleDelegations([emptyIdentityPending, syntheticPending])).toHaveLength(2); + }); + it('finds the row by historical identity when the backend preserved it', () => { const fixedRow = buildFixedUnbondedWalletDelegation({ historicalNodeIdentity: EXAMPLE_HISTORICAL_NODE_IDENTITY, diff --git a/nym-wallet/src/utils/unbondedDelegation.fixture.ts b/nym-wallet/src/utils/unbondedDelegation.fixture.ts index 83fee7cd07..4b2befb28d 100644 --- a/nym-wallet/src/utils/unbondedDelegation.fixture.ts +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -1,4 +1,4 @@ -import type { DelegationWithEverything } from '@nymproject/types'; +import type { DelegationWithEverything, WrappedDelegationEvent } from '@nymproject/types'; import { UNBONDED_NODE_IDENTITY_PREFIX } from './delegationIdentity'; /** Synthetic mix_id used in wallet unbonded-delegation tests. */ @@ -53,3 +53,19 @@ export function buildLegacyHiddenUnbondedWalletDelegation( mixnode_is_unbonding: null, }; } + +export function buildPendingUndelegateEvent( + nodeIdentity: string, + mixId: number = EXAMPLE_UNBONDED_MIX_ID, +): WrappedDelegationEvent { + return { + node_identity: nodeIdentity, + event: { + kind: 'Undelegate', + mix_id: mixId, + address: EXAMPLE_DELEGATOR_ADDRESS, + amount: { amount: '1000000', denom: 'nym' }, + proxy: null, + }, + }; +} From 2c1b5f59a38c8e31e61a3fa9de7f6baeeeab08c7 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 12:38:09 +0200 Subject: [PATCH 4/6] Last fixes - Pending Delegate events with registry-miss identity keep explorer navigation; unbonded label limited to pending Undelegate rows (`isPendingUndelegateWithRegistryMiss`, `formatPendingDelegationLinkLabel`). - Tests in `delegationIdentity.test.ts`. --- .../components/Delegation/DelegationList.tsx | 25 +++---------------- .../Delegation/PendingDelegationCard.tsx | 13 +++++++--- .../src/utils/delegationIdentity.test.ts | 19 ++++++++++++++ nym-wallet/src/utils/delegationIdentity.ts | 15 ++++++++++- .../src/utils/unbondedDelegation.fixture.ts | 16 ++++++++++++ 5 files changed, 61 insertions(+), 27 deletions(-) diff --git a/nym-wallet/src/components/Delegation/DelegationList.tsx b/nym-wallet/src/components/Delegation/DelegationList.tsx index b239a16283..c1dbf69856 100644 --- a/nym-wallet/src/components/Delegation/DelegationList.tsx +++ b/nym-wallet/src/components/Delegation/DelegationList.tsx @@ -319,28 +319,9 @@ export const DelegationList: FCWithChildren<{ Pending - {pendingItems.map((item: any, index: number) => { - if ( - item.event && - item.event.kind === 'Delegate' && - (!item.node_identity || item.node_identity === '') - ) { - return ( - - ); - } - - return ( - - ); - })} + {pendingItems.map((item: any, index: number) => ( + + ))} )} diff --git a/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx index 7c4eac65d7..2c4ac35422 100644 --- a/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx +++ b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx @@ -2,11 +2,16 @@ import React from 'react'; import { Box, Chip, Paper, Stack, Tooltip, Typography } from '@mui/material'; import { WrappedDelegationEvent } from '@nymproject/types'; import { TauriLink as Link } from 'src/components/TauriLinkWrapper'; -import { formatDelegationNodeIdentityForDisplay, isUnbondedNodeIdentity } from 'src/utils/delegationIdentity'; +import { + formatDelegationNodeIdentityForDisplay, + formatPendingDelegationLinkLabel, + isPendingUndelegateWithRegistryMiss, +} from 'src/utils/delegationIdentity'; export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => { + const pendingUndelegateRegistryMiss = isPendingUndelegateWithRegistryMiss(item); const displayIdentity = formatDelegationNodeIdentityForDisplay(item.node_identity, item.event.mix_id); - const nodeIsUnbonded = isUnbondedNodeIdentity(item.node_identity); + const linkLabel = formatPendingDelegationLinkLabel(item.node_identity, item.event.mix_id); return ( - {nodeIsUnbonded ? ( + {pendingUndelegateRegistryMiss ? ( {displayIdentity} @@ -28,7 +33,7 @@ export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDele diff --git a/nym-wallet/src/utils/delegationIdentity.test.ts b/nym-wallet/src/utils/delegationIdentity.test.ts index c0f38d8ed5..9a83b52791 100644 --- a/nym-wallet/src/utils/delegationIdentity.test.ts +++ b/nym-wallet/src/utils/delegationIdentity.test.ts @@ -2,9 +2,12 @@ import { UNBONDED_NODE_IDENTITY_PREFIX, formatUnbondedNodeLabel, formatDelegationNodeIdentityForDisplay, + formatPendingDelegationLinkLabel, isFullyUnbondedDelegation, + isPendingUndelegateWithRegistryMiss, isUnbondedNodeIdentity, } from './delegationIdentity'; +import { buildPendingDelegateEvent, buildPendingUndelegateEvent } from './unbondedDelegation.fixture'; describe('delegationIdentity', () => { it('treats empty identity as unbonded', () => { @@ -35,4 +38,20 @@ describe('delegationIdentity', () => { '2Abcdefghijklmnopqrstuvwxyz1234567890', ); }); + + it('uses unbonded label only for pending undelegate registry misses', () => { + const pendingDelegate = buildPendingDelegateEvent(`unbonded:${42}`); + const pendingUndelegate = buildPendingUndelegateEvent(`unbonded:${42}`); + + expect(isPendingUndelegateWithRegistryMiss(pendingDelegate)).toBe(false); + expect(isPendingUndelegateWithRegistryMiss(pendingUndelegate)).toBe(true); + }); + + it('formats pending delegate explorer link label by mix id when identity lookup missed', () => { + expect(formatPendingDelegationLinkLabel('', 788)).toBe('Mix 788'); + expect(formatPendingDelegationLinkLabel('unbonded:788', 788)).toBe('Mix 788'); + expect(formatPendingDelegationLinkLabel('2Abcdefghijklmnopqrstuvwxyz1234567890', 788)).toBe( + '2Abcde...567890', + ); + }); }); diff --git a/nym-wallet/src/utils/delegationIdentity.ts b/nym-wallet/src/utils/delegationIdentity.ts index 04cc394be2..7b09466fe1 100644 --- a/nym-wallet/src/utils/delegationIdentity.ts +++ b/nym-wallet/src/utils/delegationIdentity.ts @@ -1,4 +1,4 @@ -import type { DelegationWithEverything } from '@nymproject/types'; +import type { DelegationWithEverything, WrappedDelegationEvent } from '@nymproject/types'; export const UNBONDED_NODE_IDENTITY_PREFIX = 'unbonded:'; @@ -25,3 +25,16 @@ export function formatDelegationNodeIdentityForDisplay(nodeIdentity: string, mix } return nodeIdentity; } + +export function isPendingUndelegateWithRegistryMiss( + item: Pick, +): boolean { + return item.event.kind === 'Undelegate' && isUnbondedNodeIdentity(item.node_identity); +} + +export function formatPendingDelegationLinkLabel(nodeIdentity: string, mixId: number): string { + if (!nodeIdentity || isUnbondedNodeIdentity(nodeIdentity)) { + return `Mix ${mixId}`; + } + return `${nodeIdentity.slice(0, 6)}...${nodeIdentity.slice(-6)}`; +} diff --git a/nym-wallet/src/utils/unbondedDelegation.fixture.ts b/nym-wallet/src/utils/unbondedDelegation.fixture.ts index 4b2befb28d..5cdb9a949b 100644 --- a/nym-wallet/src/utils/unbondedDelegation.fixture.ts +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -69,3 +69,19 @@ export function buildPendingUndelegateEvent( }, }; } + +export function buildPendingDelegateEvent( + nodeIdentity: string, + mixId: number = EXAMPLE_UNBONDED_MIX_ID, +): WrappedDelegationEvent { + return { + node_identity: nodeIdentity, + event: { + kind: 'Delegate', + mix_id: mixId, + address: EXAMPLE_DELEGATOR_ADDRESS, + amount: { amount: '1000000', denom: 'nym' }, + proxy: null, + }, + }; +} From d609d30f3ade5720a28509364f8582381773e452 Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 12:47:01 +0200 Subject: [PATCH 5/6] Formatting fixes --- nym-wallet/src/utils/delegationIdentity.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nym-wallet/src/utils/delegationIdentity.test.ts b/nym-wallet/src/utils/delegationIdentity.test.ts index 9a83b52791..771a91cc56 100644 --- a/nym-wallet/src/utils/delegationIdentity.test.ts +++ b/nym-wallet/src/utils/delegationIdentity.test.ts @@ -50,8 +50,6 @@ describe('delegationIdentity', () => { it('formats pending delegate explorer link label by mix id when identity lookup missed', () => { expect(formatPendingDelegationLinkLabel('', 788)).toBe('Mix 788'); expect(formatPendingDelegationLinkLabel('unbonded:788', 788)).toBe('Mix 788'); - expect(formatPendingDelegationLinkLabel('2Abcdefghijklmnopqrstuvwxyz1234567890', 788)).toBe( - '2Abcde...567890', - ); + expect(formatPendingDelegationLinkLabel('2Abcdefghijklmnopqrstuvwxyz1234567890', 788)).toBe('2Abcde...567890'); }); }); From f7fbf942f9a91ecdac2188a19e242e8c4c13a4af Mon Sep 17 00:00:00 2001 From: Tommy Verrall Date: Mon, 8 Jun 2026 13:20:15 +0200 Subject: [PATCH 6/6] PR comments - Fix addressing concern around unbonding - Amend existing tests to reflect this --- nym-wallet/src-tauri/src/operations/mixnet/delegate.rs | 4 ++-- nym-wallet/src/context/delegationQuery.test.ts | 2 +- nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts | 2 +- nym-wallet/src/utils/unbondedDelegation.fixture.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs index bc36a6a350..2f129d5569 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/delegate.rs @@ -603,7 +603,7 @@ pub(crate) fn delegation_mixnode_is_unbonding( ) -> Option { match node_details { Some(node) => Some(node.is_unbonding), - None => Some(true), + None => None, } } @@ -619,6 +619,6 @@ mod tests { delegation_node_identity(&None, EXAMPLE_MIX_ID), "unbonded:1234" ); - assert_eq!(delegation_mixnode_is_unbonding(&None), Some(true)); + assert_eq!(delegation_mixnode_is_unbonding(&None), None); } } diff --git a/nym-wallet/src/context/delegationQuery.test.ts b/nym-wallet/src/context/delegationQuery.test.ts index c06f6a3e3e..8893fcbea6 100644 --- a/nym-wallet/src/context/delegationQuery.test.ts +++ b/nym-wallet/src/context/delegationQuery.test.ts @@ -26,7 +26,7 @@ describe('fetchDelegationSummaryQuery', () => { node_identity: 'unbonded:42', amount: { amount: '1000000', denom: 'unym' }, unclaimed_rewards: { amount: '50000', denom: 'unym' }, - mixnode_is_unbonding: true, + mixnode_is_unbonding: null, }, ], total_delegations: { amount: '1000000', denom: 'unym' }, diff --git a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts index d85f461974..c8ea17615e 100644 --- a/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts +++ b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts @@ -28,7 +28,7 @@ describe('unbonded delegation wallet visibility acceptance', () => { const fixedRow = buildFixedUnbondedWalletDelegation(); expect(fixedRow.node_identity).toBe(`unbonded:${EXAMPLE_UNBONDED_MIX_ID}`); - expect(fixedRow.mixnode_is_unbonding).toBe(true); + expect(fixedRow.mixnode_is_unbonding).toBeNull(); expect(shouldHideDelegationFromList(fixedRow)).toBe(false); expect(filterVisibleDelegations([fixedRow])).toHaveLength(1); expect(isUndelegateOnlyDelegation(fixedRow)).toBe(true); diff --git a/nym-wallet/src/utils/unbondedDelegation.fixture.ts b/nym-wallet/src/utils/unbondedDelegation.fixture.ts index 5cdb9a949b..b158943950 100644 --- a/nym-wallet/src/utils/unbondedDelegation.fixture.ts +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -37,7 +37,7 @@ export function buildFixedUnbondedWalletDelegation( cost_params: null, uses_vesting_contract_tokens: false, pending_events: [], - mixnode_is_unbonding: true, + mixnode_is_unbonding: null, historical_node_identity: options.historicalNodeIdentity ?? null, errors: null, };