Merge pull request #6864 from nymtech/chore/delegation-visibility-touch-ups

Show unbonded delegations in wallet delegation list
This commit is contained in:
Tommy Verrall
2026-06-08 17:33:16 +02:00
committed by GitHub
16 changed files with 600 additions and 105 deletions
@@ -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<DelegationEvent>, 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<DelegationEvent>, mixnode_is_unbonding: boolean | null, };
+2
View File
@@ -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<String>,
pub amount: DecCoin,
pub accumulated_by_delegates: Option<DecCoin>,
pub accumulated_by_operator: Option<DecCoin>,
@@ -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<String>,
) -> Option<String> {
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<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> {
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);
}
}
@@ -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
</Typography>
<Stack spacing={2}>
{pendingItems.map((item: any, index: number) => {
if (
item.event &&
item.event.kind === 'Delegate' &&
(!item.node_identity || item.node_identity === '')
) {
return (
<PendingDelegationCard
key={pendingKey(item, `d-${index}`)}
item={{
...item,
node_identity: `Mix Identity Key ${item.event.mix_id}`,
}}
explorerUrl={explorerUrl}
/>
);
}
return (
<PendingDelegationCard key={pendingKey(item, `p-${index}`)} item={item} explorerUrl={explorerUrl} />
);
})}
{pendingItems.map((item: any, index: number) => (
<PendingDelegationCard key={pendingKey(item, `p-${index}`)} item={item} explorerUrl={explorerUrl} />
))}
</Stack>
</Stack>
)}
@@ -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<{
<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 +468,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 +477,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)}
@@ -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 }) => (
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
bgcolor: (t) => (t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle'),
borderColor: 'divider',
}}
>
<Stack spacing={1.5} direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} flexWrap="wrap">
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
color="text.primary"
noIcon
/>
<Typography variant="body2" color="text.secondary">
{item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip
title={
<Box sx={{ textAlign: 'left' }}>
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.
</Box>
}
arrow
PopperProps={{
sx: {
'& .MuiTooltip-tooltip': { textAlign: 'left' },
},
}}
>
<Chip label="Pending" size="small" color="primary" variant="outlined" />
</Tooltip>
</Stack>
</Paper>
);
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 (
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
bgcolor: (t) =>
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
borderColor: 'divider',
}}
>
<Stack spacing={1.5} direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} flexWrap="wrap">
{pendingUndelegateRegistryMiss ? (
<Typography variant="body2" color="text.secondary">
{displayIdentity}
</Typography>
) : (
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
text={linkLabel}
color="text.primary"
noIcon
/>
)}
<Typography variant="body2" color="text.secondary">
{item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip
title={
<Box sx={{ textAlign: 'left' }}>
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.
</Box>
}
arrow
PopperProps={{
sx: {
'& .MuiTooltip-tooltip': { textAlign: 'left' },
},
}}
>
<Chip label="Pending" size="small" color="primary" variant="outlined" />
</Tooltip>
</Stack>
</Paper>
);
};
@@ -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,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<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: null,
},
],
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');
});
});
@@ -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,
},
];
+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,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');
});
});
@@ -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<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;
}
export function isPendingUndelegateWithRegistryMiss(
item: Pick<WrappedDelegationEvent, 'node_identity' | 'event'>,
): 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)}`;
}
@@ -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);
}
+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,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})`,
);
});
});
@@ -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,
},
};
}
@@ -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;