import React from 'react'; import { Alert, AlertTitle, Box, Button, Chip, Collapse, FormControl, IconButton, InputLabel, MenuItem, Select, Skeleton, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Tooltip, Typography, } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { KeyboardArrowDown, KeyboardArrowUp, LockOutlined, WarningAmberOutlined } from '@mui/icons-material'; import { decimalToFloatApproximation, decimalToPercentage, DelegationWithEverything } from '@nymproject/types'; import { useSortDelegations } from 'src/hooks/useSortDelegations'; 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 { InfoTooltip } from '../InfoToolTip'; import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions'; import { PendingDelegationCard } from './PendingDelegationCard'; import { isDelegation, isPendingDelegation, TDelegations, useDelegationContext } from '../../context/delegations'; import { ErrorModal } from '../Modals/ErrorModal'; export type Order = 'asc' | 'desc'; type AdditionalTypes = { profit_margin_percent: number; operating_cost: number }; export type SortingKeys = keyof AdditionalTypes | keyof DelegationWithEverything; const shouldBeFiltered = (item: TDelegations[number]): boolean => shouldHideDelegationFromList(item); const SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [ { id: 'delegated_on_iso_datetime', label: 'Delegated on' }, { id: 'node_identity', label: 'Node ID' }, { id: 'avg_uptime_percent', label: 'Routing score' }, { id: 'profit_margin_percent', label: 'Profit margin' }, { id: 'operating_cost', label: 'Operating cost' }, { id: 'stake_saturation', label: 'Stake saturation' }, { id: 'amount', label: 'Delegation' }, { id: 'unclaimed_rewards', label: 'Reward' }, ]; const hasPruningError = (item: any): boolean => { if (!isDelegation(item) || !item.errors) return false; return ( (item.errors.includes('height') && item.errors.includes('not available')) || item.errors.includes('Due to pruning strategies') ); }; const getStakeSaturation = (item: DelegationWithEverything) => !item.stake_saturation ? '-' : `${decimalToPercentage(item.stake_saturation)}%`; const getRewardValue = (item: DelegationWithEverything) => { const { unclaimed_rewards } = item; return !unclaimed_rewards ? '-' : `${unclaimed_rewards.amount} ${unclaimed_rewards.denom}`; }; const saturationNumeric = (item: DelegationWithEverything): number | undefined => { if (!item.stake_saturation) return undefined; return decimalToFloatApproximation(item.stake_saturation); }; export const DelegationList: FCWithChildren<{ items: TDelegations; onItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void; explorerUrl: string; nextEpoch?: string | Error; }> = ({ items, onItemActionClick, explorerUrl, nextEpoch }) => { const [order, setOrder] = React.useState('asc'); const [orderBy, setOrderBy] = React.useState('delegated_on_iso_datetime'); const [identityFilter, setIdentityFilter] = React.useState(''); const [expandedKey, setExpandedKey] = React.useState(null); const navigate = useNavigate(); const { delegationItemErrors, setDelegationItemErrors, totalDelegations, totalRewards, totalDelegationsAndRewards, isLoading: delegationsSummaryLoading, } = useDelegationContext(); const sorted = useSortDelegations(items, order, orderBy); const filteredItems = React.useMemo(() => { if (!sorted) return []; return sorted.filter((item) => !shouldBeFiltered(item)); }, [sorted]); const activeDelegations = React.useMemo( () => filteredItems.filter((item): item is DelegationWithEverything => isDelegation(item)), [filteredItems], ); const pendingItems = React.useMemo(() => filteredItems.filter((item) => isPendingDelegation(item)), [filteredItems]); const searchNeedle = identityFilter.trim().toLowerCase(); const displayedDelegations = React.useMemo( () => searchDelegations(activeDelegations, identityFilter), [activeDelegations, identityFilter], ); const activeCount = activeDelegations.length; const hasPruningErrors = React.useMemo(() => filteredItems?.some((item) => hasPruningError(item)), [filteredItems]); const navigateToSettings = () => { navigate('/settings'); }; const formatErrorMessage = (message: string) => { if (message.includes('height') && message.includes('not available')) { return 'Due to pruning strategies from validators, please navigate to the Settings tab and change your RPC node for your validator to retrieve your delegations.'; } return message; }; const pendingKey = (item: any, suffix: string) => `pending-${item.event?.mix_id}-${item.event?.address ?? ''}-${item.event?.kind ?? ''}-${ item.node_identity ?? '' }-${suffix}`; const nextEpochLine = nextEpoch instanceof Error || !nextEpoch ? null : ( Next epoch starts at {nextEpoch} ); const emptyTableMessage = searchNeedle ? 'No delegations match your search.' : 'No delegations to show.'; return ( <> {hasPruningErrors && ( Go to Settings } > Data Pruning Detected Some delegation details cannot be retrieved because of data pruning on the validator. Please navigate to the Settings tab and change your RPC node to fix this issue. )} Delegations {activeCount} active setIdentityFilter(e.target.value)} sx={{ minWidth: { xs: '100%', md: 220 }, flex: { md: '1 1 200px' }, '& .MuiOutlinedInput-root': { borderRadius: 2 }, }} /> Sort by Order {nextEpochLine ? {nextEpochLine} : null} `1px solid ${t.palette.divider}`, bgcolor: (t) => t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', p: 2.5, }} > Total delegations {delegationsSummaryLoading ? : totalDelegationsAndRewards ?? '-'} `1px solid ${t.palette.divider}`, bgcolor: (t) => t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', p: 2.5, }} > Original delegations {delegationsSummaryLoading ? : totalDelegations ?? '-'} `1px solid ${t.palette.divider}`, bgcolor: (t) => t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', p: 2.5, }} > Total rewards {delegationsSummaryLoading ? : totalRewards ?? '-'} {pendingItems.length > 0 && ( Pending {pendingItems.map((item: any, index: number) => { if ( item.event && item.event.kind === 'Delegate' && (!item.node_identity || item.node_identity === '') ) { return ( ); } return ( ); })} )} `1px solid ${t.palette.divider}`, bgcolor: (t) => t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle', }} > setDelegationItemErrors(undefined)} /> Node Amount Saturation Reward Actions {displayedDelegations.length === 0 ? ( {emptyTableMessage} ) : ( displayedDelegations.map((item) => { const rowKey = `${item.mix_id}-${item.node_identity}`; const isOpen = expandedKey === rowKey; const nodeIsFullyUnbonded = isFullyUnbondedDelegation(item); const satNum = saturationNumeric(item); let satColor: 'text.secondary' | 'error.main' | 'success.main' = 'text.secondary'; if (satNum !== undefined) { satColor = satNum > 1 ? 'error.main' : 'success.main'; } const operatingCost = item.cost_params?.interval_operating_cost; const uptime = item.avg_uptime_percent; const routingDisplay = uptime != null && String(uptime) !== '-' ? `${uptime}%` : '-'; const marginDisplay = item.cost_params?.profit_margin_percent ? `${toPercentIntegerString(item.cost_params.profit_margin_percent)}%` : '-'; const costDisplay = operatingCost ? `${operatingCost.amount} ${operatingCost.denom}` : '-'; const delegatedDisplay = item.delegated_on_iso_datetime ? format(new Date(item.delegated_on_iso_datetime), 'dd/MM/yyyy') : '-'; const unbondedTooltip = 'This node has unbonded and it does not exist anymore. You need to undelegate from it to get your stake and outstanding rewards (if any) back.'; return ( *': { borderBottom: 'unset' } }}> setExpandedKey(isOpen ? null : rowKey)} > {isOpen ? : } {item.errors && ( setDelegationItemErrors({ nodeId: item.node_identity, errors: item.errors! }) } > )} {item.uses_vesting_contract_tokens && ( )} {nodeIsFullyUnbonded ? ( } /> {formatUnbondedNodeLabel(item.mix_id)} ) : ( )} {item.amount.amount} {item.amount.denom} {getStakeSaturation(item)} {getRewardValue(item)} {!item.pending_events.length && !nodeIsFullyUnbonded && ( onItemActionClick ? onItemActionClick(item, action) : undefined } disableRedeemingRewards={!item.unclaimed_rewards || item.unclaimed_rewards.amount === '0'} disableDelegateMore={item.mixnode_is_unbonding} /> )} {!item.pending_events.length && nodeIsFullyUnbonded && ( t.palette.nym.nymWallet.text.main }} size="small"> (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)} /> )} {item.pending_events.length > 0 && ( Pending events )} `1px solid ${alpha(t.palette.divider, t.palette.mode === 'dark' ? 0.35 : 0.5)}`, bgcolor: (t) => t.palette.mode === 'dark' ? alpha(t.palette.common.white, 0.04) : alpha(t.palette.common.black, 0.03), }} >
Routing Margin NYM cost Delegated on {routingDisplay} {marginDisplay} {costDisplay} {delegatedDisplay}
); }) )}
); };