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
This commit is contained in:
Tommy Verrall
2026-06-08 11:53:56 +02:00
parent cff370a943
commit 9ff2ec249d
11 changed files with 354 additions and 33 deletions
@@ -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<NodeInformation>,
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<NodeInformation>,
) -> Option<bool> {
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));
}
}
@@ -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<{
<LockOutlined sx={{ color: 'text.secondary', fontSize: 18 }} />
</Tooltip>
)}
{nodeIsUnbonded ? (
{nodeIsFullyUnbonded ? (
<Tooltip title={unbondedTooltip} arrow>
<Typography color="text.secondary">-</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ minWidth: 0 }}>
<Chip
label="Node unbonded"
size="small"
color="warning"
variant="outlined"
icon={<WarningAmberOutlined />}
/>
<Typography variant="body2" color="text.secondary" noWrap>
{formatUnbondedNodeLabel(item.mix_id)}
</Typography>
</Stack>
</Tooltip>
) : (
<Link
@@ -487,7 +480,7 @@ export const DelegationList: FCWithChildren<{
{getRewardValue(item)}
</TableCell>
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
{!item.pending_events.length && !nodeIsUnbonded && (
{!item.pending_events.length && !nodeIsFullyUnbonded && (
<DelegationsActionsMenu
onActionClick={(action) =>
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 && (
<IconButton sx={{ color: (t) => t.palette.nym.nymWallet.text.main }} size="small">
<Undelegate
onClick={() => (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)}
@@ -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}
>
<Box sx={{ mt: 3 }}>
<ModalListItem label="Node identity" value={identityKey || '-'} divider />
<ModalListItem
label="Node identity"
value={formatDelegationNodeIdentityForDisplay(identityKey, mixId) || '-'}
divider
/>
<ModalListItem label="Delegation amount" value={`${amount} ${currency.toUpperCase()}`} divider />
<ModalFee fee={fee} isLoading={isFeeLoading} error={feeError} divider />
<ModalListItem label=" Tokens will be transferred to account you are logged in with now" value="" divider />
@@ -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<typeof getDelegationSummary>;
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<ReturnType<typeof getDelegationSummary>>);
mockedGetAllPendingDelegations.mockResolvedValue([
{
node_identity: 'new-node-identity',
event: { mix_id: 99, kind: 'Delegate', address: 'n1test' },
},
] as Awaited<ReturnType<typeof getAllPendingDelegations>>);
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');
});
});
+7 -2
View File
@@ -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
@@ -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',
);
});
});
@@ -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<DelegationWithEverything, 'node_identity' | 'mixnode_is_unbonding'>,
): 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;
}
@@ -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);
}
+2
View File
@@ -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';
@@ -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})`,
);
});
});
@@ -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,
};
}