Merge pull request #6864 from nymtech/chore/delegation-visibility-touch-ups
Show unbonded delegations in wallet delegation list
This commit is contained in:
@@ -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, };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user