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;