9aed938c79
* Add pending events to stake table * Add additional tanstack refetch on user stake/unstake/redeem rewards * refactor repeated fetch functions * clean up fetching functions * refactor & clean up * Add epoch waiting message on epoch change delay * fine tune epoch change * clean up * refactor imports * Add transaction hash to successful redeem rewards InfoModal * fix epoch time check and mobile header * Fix Loading modal width on mobile * fix epoch logic * clean up * clean up logs * add waiting for epoch to start to landing page * clean up * Add refetch of all queries on epoch change * Finalise state change on epoch change * clean up * fix build * Fix NodesTable mobile view * Fix stake table mobile view * fix typo * Fix blog articles height * Add loading skeletons to landing page cards * clean up * Add skeletons to cards * Add skeletons, and loading/error refetch on wallet balnce * clean up * Add active stakers card * clean up * change NGM to mixnet * Add TVL to Tokenomics card * Add last total stake to Stake Card * clean up * Fix stake sorting function in Stake Table * Add wrap of identity key and address to Basic Info Card * Add counter to epoch time on staking page * clean up * update epoch labels * Add circular loading on Toggle Button * Update Toggle button loading functionality * Add skeletons to account cards * Add search functionality on Enter * clean up * DOMpurify node name and description * Add column with id and identity key, wrap names to 2 lines * Set width of column headers to 110px * fix pending events for delegations * Fix Stake button proppagation * Add full country name to tooltips * Take out connect wallet from mobile menu toggle * finetune epoch change intervals * Add error text to Magic Search * fix build * Add react-markdown for Blog articles * fix graph's width and Table column headings * fix Magic Search loading * Fix grid on account page * fix account card address width * Fix permanent loading spinner on ToggleButton * clean up URL's, fix copy address on the Basic Card * replace mintscan with ping, open tx link on new page * Take out toggle button if no node bonded by address * Set fixed column width on tables * Add not-found page to account, when no node bonded * Add full country name to tables and node profile card * clean up * Table fixes * Fix sorting in Delegations table Node page * clean up * Fix line chart view * refactor epoch progress bar * remove unused imports * remove tanstack delclaration module * create epoch data provider * remove logic from togglebutton component * use epoch provider in components * invalidateQueries should be awaited * tidy up QualityIndicatorsCard component formatting * fix infinite loop in epoch provider --------- Co-authored-by: Yana <yanok87@users.noreply.github.com> Co-authored-by: fmtabbara <fmtabbara@hotmail.co.uk>
659 lines
18 KiB
TypeScript
659 lines
18 KiB
TypeScript
"use client";
|
|
import { useChain } from "@cosmos-kit/react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
Stack,
|
|
Tooltip,
|
|
Typography,
|
|
useMediaQuery,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useLocalStorage } from "@uidotdev/usehooks";
|
|
import {
|
|
type MRT_ColumnDef,
|
|
MaterialReactTable,
|
|
useMaterialReactTable,
|
|
} from "material-react-table";
|
|
import { useRouter } from "next/navigation";
|
|
import usePendingEvents, {
|
|
type PendingEvent,
|
|
} from "../../../src/hooks/useGetPendingEvents";
|
|
import { COSMOS_KIT_USE_CHAIN } from "../../config";
|
|
import { useNymClient } from "../../hooks/useNymClient";
|
|
import { formatBigNum } from "../../utils/formatBigNumbers";
|
|
import CountryFlag from "../countryFlag/CountryFlag";
|
|
import { Favorite } from "../favorite/Favorite";
|
|
import Loading from "../loading";
|
|
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
|
|
import { Link } from "../muiLink";
|
|
import ConnectWallet from "../wallet/ConnectWallet";
|
|
import StakeActions from "./StakeActions";
|
|
import StakeModal from "./StakeModal";
|
|
import type { MappedNymNode, MappedNymNodes } from "./StakeTableWithAction";
|
|
import { fee } from "./schemas";
|
|
|
|
type DelegationWithNodeDetails = {
|
|
node: MappedNymNode | undefined;
|
|
delegation: Delegation;
|
|
pendingEvent?: PendingEvent;
|
|
};
|
|
|
|
const ColumnHeading = ({
|
|
children,
|
|
}: {
|
|
children: string | React.ReactNode;
|
|
}) => {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
|
return (
|
|
<Box
|
|
sx={{
|
|
width: isMobile ? "80px" : "unset",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: "flex-start",
|
|
alignItems: "baseline",
|
|
p: 0,
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
py: 2,
|
|
textAlign: "center",
|
|
whiteSpace: isMobile ? "normal" : "unset", // Ensure text can wrap
|
|
wordWrap: isMobile ? "break-word" : "unset", // Break long words
|
|
overflowWrap: isMobile ? "break-word" : "unset", // Ensure text breaks inside the cell
|
|
textTransform: "uppercase",
|
|
}}
|
|
variant={isMobile ? "caption" : "h5"}
|
|
>
|
|
{children}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
|
|
const { nymClient, address, nymQueryClient } = useNymClient();
|
|
const [delegations, setDelegations] = useState<DelegationWithNodeDetails[]>(
|
|
[],
|
|
);
|
|
const [isDataLoading, setIsLoading] = useState(false);
|
|
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
|
|
open: false,
|
|
});
|
|
const [selectedNodeForStaking, setSelectedNodeForStaking] = useState<{
|
|
nodeId: number;
|
|
identityKey: string;
|
|
}>();
|
|
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
|
|
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
|
|
const { data: pendingEvents } = usePendingEvents(nymQueryClient, address);
|
|
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
|
|
|
const router = useRouter();
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Custom Hook for fetching pending events
|
|
|
|
const handleRefetch = useCallback(async () => {
|
|
await queryClient.invalidateQueries();
|
|
}, [queryClient]);
|
|
|
|
useEffect(() => {
|
|
if (!nymClient || !address || !nymQueryClient) return;
|
|
|
|
// Fetch staking data
|
|
const fetchDelegations = async () => {
|
|
const data = await nymClient?.getDelegatorDelegations({
|
|
delegator: address,
|
|
});
|
|
return data.delegations;
|
|
};
|
|
|
|
// Combine delegations with node details and pending events
|
|
const combineDelegationsWithNodeAndPendingEvents = (
|
|
delegations: Delegation[],
|
|
nodes: MappedNymNode[],
|
|
pendingEvents: PendingEvent[] | undefined,
|
|
) => {
|
|
// Combine delegations with node details
|
|
const delegationsWithNodeDetails = delegations.map((delegation) => {
|
|
const node = nodes.find((node) => node.nodeId === delegation.node_id);
|
|
const pendingEvent = pendingEvents?.find(
|
|
(event) => event?.mixId === delegation.node_id,
|
|
);
|
|
console.log("node,delegation,pendingEvent,:>> ", {
|
|
node,
|
|
delegation,
|
|
pendingEvent,
|
|
});
|
|
return {
|
|
node,
|
|
delegation,
|
|
pendingEvent,
|
|
};
|
|
});
|
|
|
|
// Add pending events that are not in the delegations list
|
|
if (pendingEvents) {
|
|
for (const e of pendingEvents) {
|
|
if (
|
|
e &&
|
|
!delegationsWithNodeDetails.find(
|
|
(item) =>
|
|
item.node?.nodeId === e.mixId ||
|
|
item.delegation.node_id === e.mixId,
|
|
)
|
|
) {
|
|
delegationsWithNodeDetails.push({
|
|
node: {
|
|
name: "-",
|
|
nodeId: e.mixId,
|
|
identity_key: "-",
|
|
countryCode: null,
|
|
countryName: null,
|
|
profitMarginPercentage: 0,
|
|
owner: "-",
|
|
stakeSaturation: 0,
|
|
},
|
|
pendingEvent: e,
|
|
delegation: {
|
|
amount: {
|
|
amount: e.amount?.amount || "0",
|
|
denom: "unym",
|
|
},
|
|
cumulative_reward_ratio: "0",
|
|
height: 0,
|
|
node_id: e.mixId,
|
|
owner: "-",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return delegationsWithNodeDetails;
|
|
};
|
|
|
|
// Fetch and map delegations
|
|
const fetchAndMapDelegations = async () => {
|
|
const delegations = await fetchDelegations();
|
|
const delegationsWithNodeDetails =
|
|
combineDelegationsWithNodeAndPendingEvents(
|
|
delegations,
|
|
nodes,
|
|
pendingEvents,
|
|
);
|
|
|
|
setDelegations(delegationsWithNodeDetails);
|
|
};
|
|
|
|
fetchAndMapDelegations();
|
|
}, [address, nodes, nymClient, nymQueryClient, pendingEvents]);
|
|
|
|
const handleStakeOnNode = useCallback(
|
|
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
|
|
const amountToDelegate = (Number(amount) * 1_000_000).toString();
|
|
const uNymFunds = [{ amount: amountToDelegate, denom: "unym" }];
|
|
|
|
setIsLoading(true);
|
|
setSelectedNodeForStaking(undefined);
|
|
try {
|
|
const tx = await nymClient?.delegate(
|
|
{ nodeId },
|
|
fee,
|
|
"Delegation from Nym Explorer V2",
|
|
uNymFunds,
|
|
);
|
|
setSelectedNodeForStaking(undefined);
|
|
|
|
setInfoModalProps({
|
|
open: true,
|
|
title: "Success",
|
|
message: "This operation can take up to one hour to process",
|
|
tx: tx?.transactionHash,
|
|
|
|
onClose: async () => {
|
|
await handleRefetch();
|
|
setInfoModalProps({ open: false });
|
|
},
|
|
});
|
|
} catch (e) {
|
|
const errorMessage =
|
|
e instanceof Error ? e.message : "An error occurred while staking";
|
|
setInfoModalProps({
|
|
open: true,
|
|
title: "Error",
|
|
message: errorMessage,
|
|
onClose: () => {
|
|
setInfoModalProps({ open: false });
|
|
},
|
|
});
|
|
}
|
|
setIsLoading(false);
|
|
},
|
|
[nymClient, handleRefetch],
|
|
);
|
|
|
|
const handleOnSelectStake = useCallback(
|
|
(nodeId: number, nodeIdentityKey: string | undefined) => {
|
|
if (!isWalletConnected) {
|
|
setInfoModalProps({
|
|
open: true,
|
|
title: "Connect Wallet",
|
|
message: "Connect your wallet to stake",
|
|
Action: (
|
|
<ConnectWallet
|
|
fullWidth
|
|
onClick={() =>
|
|
setInfoModalProps({
|
|
open: false,
|
|
})
|
|
}
|
|
/>
|
|
),
|
|
onClose: () => setInfoModalProps({ open: false }),
|
|
});
|
|
return;
|
|
}
|
|
if (nodeIdentityKey) {
|
|
setSelectedNodeForStaking({
|
|
nodeId: nodeId,
|
|
identityKey: nodeIdentityKey,
|
|
});
|
|
}
|
|
},
|
|
[isWalletConnected],
|
|
);
|
|
|
|
const handleUnstake = useCallback(
|
|
async (nodeId?: number) => {
|
|
try {
|
|
if (!nodeId || !address) {
|
|
return;
|
|
}
|
|
console.log("Unstaking node", nodeId);
|
|
setIsLoading(true);
|
|
await nymClient?.undelegate(
|
|
{
|
|
nodeId,
|
|
},
|
|
fee,
|
|
`Explorer V2: Unstaking node ${nodeId}`,
|
|
);
|
|
setIsLoading(false);
|
|
await handleRefetch();
|
|
setInfoModalProps({
|
|
open: true,
|
|
title: "Success",
|
|
message: "This operation can take up to one hour to process",
|
|
onClose: () => setInfoModalProps({ open: false }),
|
|
});
|
|
} catch (e) {
|
|
setInfoModalProps({
|
|
open: true,
|
|
title: "Error",
|
|
message:
|
|
e instanceof Error
|
|
? e.message
|
|
: "An error occurred while unstaking",
|
|
onClose: () => {
|
|
setInfoModalProps({ open: false });
|
|
},
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[address, nymClient, handleRefetch],
|
|
);
|
|
|
|
const handleActionSelect = useCallback(
|
|
(action: string, nodeId: number, nodeIdentityKey: string | undefined) => {
|
|
switch (action) {
|
|
case "stake":
|
|
handleOnSelectStake(nodeId, nodeIdentityKey);
|
|
break;
|
|
case "unstake":
|
|
handleUnstake(nodeId);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
[handleUnstake, handleOnSelectStake],
|
|
);
|
|
|
|
const getTooltipTitle = useCallback(
|
|
(pending: PendingEvent) => {
|
|
if (pending?.kind === "undelegate") {
|
|
return "You have an undelegation pending";
|
|
}
|
|
|
|
if (pending?.kind === "delegate") {
|
|
return `You have a delegation pending worth ${formatBigNum(
|
|
+pending.amount.amount / 1_000_000,
|
|
)} NYM`;
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
[], // Add dependencies if necessary
|
|
);
|
|
|
|
// get full country name
|
|
const countryName = useCallback((countryCode: string) => {
|
|
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
|
|
|
|
return <span>{regionNames.of(countryCode)}</span>;
|
|
}, []);
|
|
|
|
const columns: MRT_ColumnDef<DelegationWithNodeDetails>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
header: "",
|
|
Header: <ColumnHeading>Name</ColumnHeading>,
|
|
accessorKey: "node.name",
|
|
Cell: ({ row }) =>
|
|
row.original.node?.name ? (
|
|
<Stack spacing={1}>
|
|
<Typography variant="body4">{row.original.node.name}</Typography>
|
|
</Stack>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
{
|
|
id: "id",
|
|
header: "",
|
|
Header: <ColumnHeading>Node ID</ColumnHeading>,
|
|
accessorKey: "delegation.node_id",
|
|
size: 90,
|
|
|
|
Cell: ({ row }) =>
|
|
row.original.delegation?.node_id ? (
|
|
<Typography variant="body4">
|
|
{row.original.delegation.node_id || "-"}
|
|
</Typography>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
{
|
|
id: "identity_key",
|
|
header: "",
|
|
Header: <ColumnHeading>Identity Key</ColumnHeading>,
|
|
accessorKey: "delegation.node.identity_key",
|
|
Cell: ({ row }) =>
|
|
row.original.node?.identity_key ? (
|
|
<Typography variant="body4">
|
|
<Stack spacing={1}>
|
|
{row.original.node?.identity_key || "-"}
|
|
</Stack>
|
|
</Typography>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
|
|
{
|
|
id: "location",
|
|
header: "Location",
|
|
accessorKey: "node.countryCode",
|
|
size: 160,
|
|
Header: <ColumnHeading>Location</ColumnHeading>,
|
|
Cell: ({ row }) =>
|
|
row.original.node?.countryCode && row.original.node?.countryName ? (
|
|
<Box>
|
|
<CountryFlag
|
|
countryCode={row.original.node.countryCode}
|
|
countryName={countryName(row.original.node?.countryName) || ""}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
{
|
|
id: "stake",
|
|
header: "Staked amount",
|
|
accessorKey: "delegation.amount.amount",
|
|
Header: <ColumnHeading>Stake</ColumnHeading>,
|
|
size: 80,
|
|
|
|
sortingFn: (rowA, rowB) => {
|
|
const stakeA = Number.parseFloat(
|
|
rowA.original.delegation.amount.amount,
|
|
);
|
|
const stakeB = Number.parseFloat(
|
|
rowB.original.delegation.amount.amount,
|
|
);
|
|
return stakeA - stakeB;
|
|
},
|
|
Cell: ({ row }) => (
|
|
<Typography variant="body4">
|
|
{formatBigNum(+row.original.delegation.amount.amount / 1_000_000)}{" "}
|
|
NYM
|
|
</Typography>
|
|
),
|
|
},
|
|
{
|
|
id: "stakeSaturation",
|
|
header: "Stake saturation",
|
|
accessorKey: "node.stakeSaturation",
|
|
size: 200,
|
|
Header: <ColumnHeading>Stake saturation</ColumnHeading>,
|
|
sortingFn: (rowA, rowB) => {
|
|
const saturationA = rowA.original.node?.stakeSaturation || 0;
|
|
const saturationB = rowB.original.node?.stakeSaturation || 0;
|
|
console.log("sorting :>> ", saturationA, saturationB);
|
|
return saturationA - saturationB;
|
|
},
|
|
Cell: ({ row }) =>
|
|
row.original.node?.stakeSaturation ? (
|
|
<Typography variant="body4">
|
|
{row.original.node.stakeSaturation}%
|
|
</Typography>
|
|
) : (
|
|
<Typography variant="body4">{0}%</Typography>
|
|
),
|
|
},
|
|
{
|
|
id: "Favorite",
|
|
header: "Favorite",
|
|
accessorKey: "Favorite",
|
|
enableColumnFilter: false,
|
|
size: 80,
|
|
|
|
Header: (
|
|
<Stack direction="row" alignItems="center">
|
|
<ColumnHeading>Favorite</ColumnHeading>
|
|
</Stack>
|
|
),
|
|
sortingFn: (rowA, rowB) => {
|
|
const isFavoriteA = favorites.includes(
|
|
rowA.original.node?.owner || "-",
|
|
);
|
|
const isFavoriteB = favorites.includes(
|
|
rowB.original.node?.owner || "-",
|
|
);
|
|
|
|
// Sort favorites first
|
|
if (isFavoriteA && !isFavoriteB) return -1;
|
|
if (!isFavoriteA && isFavoriteB) return 1;
|
|
|
|
// If both are favorites or neither, keep the original order
|
|
return 0;
|
|
},
|
|
Cell: ({ row }) => (
|
|
<Favorite address={row.original.node?.owner || ""} />
|
|
),
|
|
},
|
|
{
|
|
id: "action",
|
|
header: "Action",
|
|
Header: <ColumnHeading>Action</ColumnHeading>,
|
|
size: 80,
|
|
|
|
enableColumnFilter: false,
|
|
Cell: ({ row }) => {
|
|
return (
|
|
<Box>
|
|
{row.original.pendingEvent ? (
|
|
<Tooltip
|
|
placement="left"
|
|
title={getTooltipTitle(row.original.pendingEvent)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Chip size="small" label="Pending events" />
|
|
</Tooltip>
|
|
) : (
|
|
<StakeActions
|
|
nodeId={row.original.delegation?.node_id}
|
|
nodeIdentityKey={row.original.node?.identity_key}
|
|
onActionSelect={(action) => {
|
|
handleActionSelect(
|
|
action,
|
|
row.original.delegation?.node_id,
|
|
row.original.node?.identity_key || undefined,
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[handleActionSelect, favorites, getTooltipTitle, countryName],
|
|
);
|
|
|
|
const table = useMaterialReactTable({
|
|
columns,
|
|
data: delegations,
|
|
enableRowSelection: false,
|
|
enableColumnOrdering: false,
|
|
enableColumnActions: false,
|
|
enableFullScreenToggle: false,
|
|
enableHiding: false,
|
|
paginationDisplayMode: "pages",
|
|
renderEmptyRowsFallback: () => (
|
|
<Stack
|
|
gap={3}
|
|
sx={{ p: 5 }}
|
|
justifyContent={isMobile ? "flex-start" : "center"}
|
|
alignItems={isMobile ? "flex-start" : "center"}
|
|
>
|
|
<Typography variant="body3" width={isMobile ? 300 : "unset"}>
|
|
You haven't staked on any nodes yet. Stake on a node to start
|
|
earning rewards.
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Link href="/explorer" underline="none" color="inherit">
|
|
Stake
|
|
</Link>
|
|
</Button>
|
|
</Stack>
|
|
),
|
|
muiPaginationProps: {
|
|
showRowsPerPage: false,
|
|
SelectProps: {
|
|
sx: {
|
|
fontFamily: "labGrotesqueMono",
|
|
fontSize: "14px",
|
|
},
|
|
},
|
|
color: "primary",
|
|
shape: "circular",
|
|
},
|
|
|
|
initialState: {
|
|
columnPinning: isMobile ? {} : { right: ["Action", "Favorite"] }, // No pinning on mobile
|
|
},
|
|
|
|
muiColumnActionsButtonProps: {
|
|
sx: {
|
|
color: "red",
|
|
},
|
|
size: "small",
|
|
},
|
|
muiTablePaperProps: {
|
|
elevation: 0,
|
|
},
|
|
muiTableHeadCellProps: {
|
|
sx: {
|
|
alignItems: "center",
|
|
},
|
|
},
|
|
muiTableHeadRowProps: {
|
|
sx: {
|
|
bgcolor: "background.paper",
|
|
},
|
|
},
|
|
|
|
muiTableBodyCellProps: {
|
|
sx: {
|
|
border: "none",
|
|
whiteSpace: "unset", // Allow text wrapping in body cells
|
|
wordBreak: "break-word",
|
|
},
|
|
},
|
|
muiTableBodyRowProps: ({ row }) => ({
|
|
onClick: () => {
|
|
router.push(`/nym-node/${row.original.node?.nodeId || "not-found"}`);
|
|
},
|
|
hover: true,
|
|
sx: {
|
|
":nth-child(odd)": {
|
|
bgcolor: "#F3F7FB !important",
|
|
},
|
|
":nth-child(even)": {
|
|
bgcolor: "white !important",
|
|
},
|
|
cursor: "pointer",
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!nymClient || !address) {
|
|
return (
|
|
<Stack spacing={2} alignItems="center">
|
|
<Typography variant="body4">
|
|
Please connect your wallet to view your stake
|
|
</Typography>
|
|
<ConnectWallet hideAddressAndBalance />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{isDataLoading && <Loading />}
|
|
<StakeModal
|
|
nodeId={selectedNodeForStaking?.nodeId}
|
|
identityKey={selectedNodeForStaking?.identityKey}
|
|
onStake={handleStakeOnNode}
|
|
onClose={() => setSelectedNodeForStaking(undefined)}
|
|
/>
|
|
<InfoModal {...infoModalProps} />
|
|
<MaterialReactTable table={table} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StakeTable;
|