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 0e6fa01ee7..2f129d5569 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)); @@ -184,6 +196,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] @@ -418,17 +463,23 @@ 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, 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: node_details.map(|m| m.node_identity).unwrap_or_default(), + node_identity: delegation_node_identity(&node_details, d.mix_id), + historical_node_identity, amount: d.amount, block_height: d.height, uses_vesting_contract_tokens, @@ -536,3 +587,38 @@ 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 { + match node_details { + Some(node) => Some(node.is_unbonding), + None => 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), None); + } +} diff --git a/nym-wallet/src/components/Delegation/DelegationList.tsx b/nym-wallet/src/components/Delegation/DelegationList.tsx index 51ad848a49..c1dbf69856 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,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 } 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 +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 => { - 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: TDelegations[number]): boolean => shouldHideDelegationFromList(item); const SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [ { id: 'delegated_on_iso_datetime', label: 'Delegated on' }, @@ -131,10 +120,10 @@ 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]); + const displayedDelegations = React.useMemo( + () => searchDelegations(activeDelegations, identityFilter), + [activeDelegations, identityFilter], + ); const activeCount = activeDelegations.length; @@ -330,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) => ( + + ))} )} @@ -407,7 +377,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 +428,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 +477,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/PendingDelegationCard.tsx b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx index 6af5f797ec..2c4ac35422 100644 --- a/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx +++ b/nym-wallet/src/components/Delegation/PendingDelegationCard.tsx @@ -2,45 +2,63 @@ 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, + formatPendingDelegationLinkLabel, + isPendingUndelegateWithRegistryMiss, +} 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 pendingUndelegateRegistryMiss = isPendingUndelegateWithRegistryMiss(item); + const displayIdentity = formatDelegationNodeIdentityForDisplay(item.node_identity, item.event.mix_id); + const linkLabel = formatPendingDelegationLinkLabel(item.node_identity, item.event.mix_id); + + return ( + + t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', + borderColor: 'divider', + }} + > + + {pendingUndelegateRegistryMiss ? ( + + {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/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..8893fcbea6 --- /dev/null +++ b/nym-wallet/src/context/delegationQuery.test.ts @@ -0,0 +1,52 @@ +import { getAllPendingDelegations, getDelegationSummary } from 'src/requests'; +import { fetchDelegationSummaryQuery } from './delegationQuery'; + +jest.mock('src/utils', () => ({ + decCoinToDisplay: jest.fn((coin: { amount: string; denom: string }) => coin), +})); + +jest.mock('src/requests', () => ({ + getDelegationSummary: jest.fn(), + getAllPendingDelegations: jest.fn(), +})); + +const mockedGetDelegationSummary = getDelegationSummary as jest.MockedFunction; +const mockedGetAllPendingDelegations = getAllPendingDelegations as jest.MockedFunction; + +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: null, + }, + ], + 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/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/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..771a91cc56 --- /dev/null +++ b/nym-wallet/src/utils/delegationIdentity.test.ts @@ -0,0 +1,55 @@ +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', () => { + 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', + ); + }); + + 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 new file mode 100644 index 0000000000..7b09466fe1 --- /dev/null +++ b/nym-wallet/src/utils/delegationIdentity.ts @@ -0,0 +1,40 @@ +import type { DelegationWithEverything, WrappedDelegationEvent } 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; +} + +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/delegationListVisibility.ts b/nym-wallet/src/utils/delegationListVisibility.ts new file mode 100644 index 0000000000..c241507077 --- /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)) { + // Pending rows carry mix_id on the event; do not hide when bonded-registry identity lookup missed. + 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) => { + 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 { + 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..c8ea17615e --- /dev/null +++ b/nym-wallet/src/utils/unbondedDelegation.acceptance.test.ts @@ -0,0 +1,107 @@ +import type { DelegationWithEverything } from '@nymproject/types'; +import { formatDelegationNodeIdentityForDisplay, isFullyUnbondedDelegation } from './delegationIdentity'; +import { + filterVisibleDelegations, + isUndelegateOnlyDelegation, + searchDelegations, + shouldHideDelegationFromList, +} from './delegationListVisibility'; +import { + EXAMPLE_HISTORICAL_NODE_IDENTITY, + EXAMPLE_UNBONDED_MIX_ID, + buildFixedUnbondedWalletDelegation, + buildLegacyHiddenUnbondedWalletDelegation, + buildPendingUndelegateEvent, +} 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).toBeNull(); + 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('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('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, + }); + 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(); + + 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..b158943950 --- /dev/null +++ b/nym-wallet/src/utils/unbondedDelegation.fixture.ts @@ -0,0 +1,87 @@ +import type { DelegationWithEverything, WrappedDelegationEvent } 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; + historicalNodeIdentity?: string | null; +}; + +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: null, + historical_node_identity: options.historicalNodeIdentity ?? null, + 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, + }; +} + +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, + }, + }; +} + +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, + }, + }; +} 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;