Yana/pending staking events (#5400)

* 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>
This commit is contained in:
Yana Matrosova
2025-02-12 15:15:45 +02:00
committed by Yana
parent 077a64b076
commit 9e4e8a9b2b
72 changed files with 2582 additions and 1224 deletions
+3
View File
@@ -28,11 +28,13 @@
"@nymproject/react": "1.0.0",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2",
"@tanstack/react-query-next-experimental": "^5.66.0",
"@tanstack/react-table": "^8.20.6",
"@uidotdev/usehooks": "^2.4.1",
"chain-registry": "^1.69.64",
"cldr-compact-number": "^0.4.0",
"date-fns": "^4.1.0",
"isomorphic-dompurify": "^2.21.0",
"material-react-table": "^3.0.3",
"next": "15.0.3",
"qrcode.react": "^4.1.0",
@@ -45,6 +47,7 @@
"@mui/x-date-pickers": "7.1.1",
"@mui/x-data-grid": "7.1.1",
"@mui/x-charts": "^7.22.3",
"react-markdown": "^9.0.3",
"react-random-avatars": "^1.3.1",
"react-world-flags": "^1.6.0",
"zod": "^3.24.1"
@@ -1,15 +0,0 @@
import type { IObservatoryNode } from "@/app/api/types";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
const getNymNodes = async (): Promise<IObservatoryNode[]> => {
const response = await fetch(`${DATA_OBSERVATORY_NODES_URL}`, {
next: {
revalidate: 900,
},
});
const data = await response.json();
return data;
};
export default getNymNodes;
@@ -0,0 +1,57 @@
import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
export default async function Account({
params,
}: {
params: Promise<{ address: string }>;
}) {
try {
const address = (await params).address;
return (
<ContentLayout>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<ExplorerButtonGroup
options={[
{
label: "Nym Node",
isSelected: true,
link: `/account/${address}/not-found/`,
},
{
label: "Account",
isSelected: false,
link: `/account/${address}`,
},
]}
/>
</Box>
</Grid>
</Grid>
<Typography variant="h5">
Is this your accont? Set up your node!
</Typography>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
</Grid>
<BlogArticlesCards limit={4} />
</Grid>
</ContentLayout>
);
} catch (error) {
let errorMessage = "An error occurred";
if (error instanceof Error) {
errorMessage = error.message;
}
throw new Error(errorMessage);
}
}
@@ -1,14 +1,12 @@
import type { IAccountBalancesInfo, NymTokenomics } from "@/app/api/types";
import type NodeData from "@/app/api/types";
import { NYM_ACCOUNT_ADDRESS, NYM_NODES, NYM_PRICES_API } from "@/app/api/urls";
import { AccountBalancesCard } from "@/components/accountPageComponents/AccountBalancesCard";
import { AccountInfoCard } from "@/components/accountPageComponents/AccountInfoCard";
import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { fetchNodes } from "@/app/api";
import type { NodeData } from "@/app/api/types";
import { Box, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { AccountBalancesCard } from "../../../../components/accountPageComponents/AccountBalancesCard";
import { AccountInfoCard } from "../../../../components/accountPageComponents/AccountInfoCard";
import { ContentLayout } from "../../../../components/contentLayout/ContentLayout";
import SectionHeading from "../../../../components/headings/SectionHeading";
import ExplorerButtonGroup from "../../../../components/toggleButton/ToggleButton";
export default async function Account({
params,
@@ -16,56 +14,21 @@ export default async function Account({
params: Promise<{ address: string }>;
}) {
try {
const { address } = await params;
const address = (await params).address;
const accountData = await fetch(`${NYM_ACCOUNT_ADDRESS}${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const response = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymNodes: NodeData[] = await response.json();
const nymNodes: NodeData[] = await fetchNodes();
const nymNode = nymNodes.find(
(node) => node.bond_information.owner === address,
);
const nymAccountBalancesData: IAccountBalancesInfo =
await accountData.json();
if (!nymAccountBalancesData) {
return <Typography>Account not found</Typography>;
}
const nymPrice = await fetch(NYM_PRICES_API, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymPriceData: NymTokenomics = await nymPrice.json();
return (
<ContentLayout>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={6}>
<SectionHeading title="Account Details" />
</Grid>
<Grid size={6} justifyContent="flex-end">
<Box sx={{ display: "flex", justifyContent: "end" }}>
<ExplorerButtonGroup
@@ -75,7 +38,7 @@ export default async function Account({
isSelected: false,
link: nymNode
? `/nym-node/${nymNode.node_id}`
: "/nym-node/not-found",
: `/account/${address}/not-found`,
},
{
label: "Account",
@@ -86,22 +49,14 @@ export default async function Account({
/>
</Box>
</Grid>
<Grid size={4}>
<AccountInfoCard accountInfo={nymAccountBalancesData} />
<Grid size={{ xs: 12, md: 4 }}>
<AccountInfoCard address={address} />
</Grid>
<Grid size={8}>
<AccountBalancesCard
accountInfo={nymAccountBalancesData}
nymPrice={nymPriceData.quotes.USD.price}
/>
<Grid size={{ xs: 12, md: 8 }}>
<AccountBalancesCard address={address} />
</Grid>
</Grid>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
</Grid>
<BlogArticlesCards limit={4} />
</Grid>
</ContentLayout>
);
} catch (error) {
@@ -1,20 +1,19 @@
import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import CardSkeleton from "@/components/cards/Skeleton";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import NodeTableWithAction from "@/components/nodeTable/NodeTableWithAction";
import { Wrapper } from "@/components/wrapper";
import { Box } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { Suspense } from "react";
export default function ExplorerPage() {
return (
<ContentLayout>
<Wrapper>
<SectionHeading title="Explorer" />
<Suspense fallback={<CardSkeleton sx={{ mt: 5 }} />}>
<Box sx={{ mt: 5 }}>
<NodeTableWithAction />
</Suspense>
</Box>
<Grid container columnSpacing={5} rowSpacing={5} mt={10}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
@@ -1,6 +1,4 @@
import type { IObservatoryNode } from "@/app/api/types";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import { fetchNodeInfo } from "@/app/api";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { BasicInfoCard } from "@/components/nymNodePageComponents/BasicInfoCard";
@@ -22,25 +20,7 @@ export default async function NymNode({
try {
const id = Number((await params).id);
const observatoryResponse = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const observatoryNymNodes: IObservatoryNode[] =
await observatoryResponse.json();
if (!observatoryNymNodes) {
return null;
}
const observatoryNymNode = observatoryNymNodes.find(
(node) => node.node_id === id,
);
const observatoryNymNode = await fetchNodeInfo(id);
if (!observatoryNymNode) {
return null;
@@ -126,12 +106,6 @@ export default async function NymNode({
<NodeChatCard />
</Grid>
</Grid>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Onboarding" />
</Grid>
<BlogArticlesCards limit={4} />
</Grid>
</ContentLayout>
);
} catch (error) {
@@ -1,14 +0,0 @@
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { Typography } from "@mui/material";
const NotFound = () => {
return (
<ContentLayout>
<SectionHeading title="Nym Node" />
<Typography variant="body3">This Nym Node could not be found</Typography>
</ContentLayout>
);
};
export default NotFound;
@@ -9,6 +9,7 @@ import { Box, Stack, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { format } from "date-fns";
import Image from "next/image";
import Markdown from "react-markdown";
export default async function BlogPage({
params,
@@ -104,7 +105,7 @@ export default async function BlogPage({
<SectionHeading title={section.heading} />
{section.text.map(({ text }) => (
<Typography key={text} variant="body2" sx={{ mt: 3 }}>
{text}
<Markdown>{text}</Markdown>
</Typography>
))}
</Box>
@@ -1,8 +1,8 @@
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import OverviewCards from "@/components/staking/OverviewCards";
import StakeTableWithAction from "@/components/staking/StakeTableWithAction";
import SubHeaderRow from "@/components/staking/SubHeaderRow";
import { ContentLayout } from "../../../components/contentLayout/ContentLayout";
import SectionHeading from "../../../components/headings/SectionHeading";
import OverviewCards from "../../../components/staking/OverviewCards";
import StakeTableWithAction from "../../../components/staking/StakeTableWithAction";
import SubHeaderRow from "../../../components/staking/SubHeaderRow";
export default async function StakingPage() {
return (
+203 -205
View File
@@ -1,218 +1,38 @@
import { addSeconds } from "date-fns";
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CurrentEpochData,
ExplorerData,
GatewayStatus,
IAccountBalancesInfo,
IObservatoryNode,
IPacketsAndStakingData,
NodeData,
NymTokenomics,
ObservatoryBalance,
} from "./types";
import {
CIRCULATING_NYM_SUPPLY,
CURRENT_EPOCH,
CURRENT_EPOCH_REWARDS,
DATA_OBSERVATORY_BALANCES_URL,
DATA_OBSERVATORY_NODES_URL,
HARBOURMASTER_API_MIXNODES_STATS,
NYM_NODES_DESCRIBED,
NYM_NODE_DESCRIPTION,
NYM_ACCOUNT_ADDRESS,
NYM_NODES,
NYM_PRICES_API,
OBSERVATORY_GATEWAYS_URL,
} from "./urls";
type Denom = "unym" | "nym";
export interface IPacketsAndStakingData {
date_utc: string;
total_packets_received: number;
total_packets_sent: number;
total_packets_dropped: number;
total_stake: number;
}
export interface CurrentEpochData {
id: number;
current_epoch_id: number;
current_epoch_start: string;
epoch_length: { secs: number; nanos: number };
epochs_in_interval: number;
total_elapsed_epochs: number;
}
export interface ExplorerData {
circulatingNymSupplyData: {
circulating_supply: { denom: Denom; amount: string };
mixmining_reserve: { denom: Denom; amount: string };
total_supply: { denom: Denom; amount: string };
vesting_tokens: { denom: Denom; amount: string };
};
nymNodesData: {
gateways: {
bonded: { count: number; last_updated_utc: string };
blacklisted: { count: number; last_updated_utc: string };
historical: { count: number; last_updated_utc: string };
explorer: { count: number; last_updated_utc: string };
};
mixnodes: {
bonded: {
count: number;
active: number;
inactive: number;
reserve: number;
last_updated_utc: string;
};
blacklisted: {
count: number;
last_updated_utc: string;
};
historical: { count: number; last_updated_utc: string };
};
};
packetsAndStakingData: IPacketsAndStakingData[];
currentEpochRewardsData: {
interval: {
reward_pool: string;
staking_supply: string;
staking_supply_scale_factor: string;
epoch_reward_budget: string;
stake_saturation_point: string;
active_set_work_factor: string;
interval_pool_emission: string;
sybil_resistance: string;
};
rewarded_set: {
entry_gateways: number;
exit_gateways: number;
mixnodes: number;
standby: number;
};
};
}
const CACHE_TIME_SECONDS = 60 * 5; // 5 minutes
export interface ExplorerCache {
explorerCache?: {
data?: ExplorerData;
lastUpdated?: Date;
};
}
const getExplorerData = async () => {
// FETCH NYMNODES
const fetchNymNodes = await fetch(NYM_NODES_DESCRIBED, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CIRCULATING NYM SUPPLY
const fetchCirculatingNymSupply = await fetch(CIRCULATING_NYM_SUPPLY, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH PACKETS AND STAKING
const fetchPacketsAndStaking = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const fetchNymNodeDescription = await fetch(NYM_NODE_DESCRIPTION, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const [
circulatingNymSupplyRes,
nymNodesRes,
packetsAndStakingRes,
nymNodeDescriptionRes,
] = await Promise.all([
fetchCirculatingNymSupply,
fetchNymNodes,
fetchPacketsAndStaking,
fetchNymNodeDescription,
]);
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
nymNodeDescriptionData,
] = await Promise.all([
circulatingNymSupplyRes.json(),
nymNodesRes.json(),
packetsAndStakingRes.json(),
nymNodeDescriptionRes.json(),
]);
return [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
nymNodeDescriptionData,
];
};
export async function ensureCacheExists() {
// makes sure the cache exists in global memory
let doUpdate = false;
const now = new Date();
if (!(global as ExplorerCache).explorerCache) {
(global as any).explorerCache = {};
doUpdate = true;
}
if (
(global as ExplorerCache)?.explorerCache?.lastUpdated &&
now.getDate() - (global as any).explorerCache.lastUpdated.getDate() >
CACHE_TIME_SECONDS
) {
doUpdate = true;
}
// if the cache has expired or never existed, get it from API's
if (doUpdate) {
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
] = await getExplorerData();
packetsAndStakingData.pop();
(global as any).explorerCache.data = {
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
};
(global as any).explorerCache.lastUpdated = now;
}
}
export async function getCacheExplorerData() {
await ensureCacheExists();
if (!(global as ExplorerCache).explorerCache?.data) {
return null;
}
return (global as ExplorerCache)?.explorerCache?.data || null;
}
export const fetchEpochRewards = async () => {
// Fetch function for epoch rewards
export const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
cache: "no-store", // Ensures fresh data on every request
});
if (!response.ok) {
@@ -222,7 +42,22 @@ export const fetchEpochRewards = async () => {
return response.json();
};
export const fetchObservatoryNodes = async () => {
// Fetch gateway status based on identity key
export const fetchGatewayStatus = async (
identityKey: string,
): Promise<GatewayStatus | null> => {
const response = await fetch(`${OBSERVATORY_GATEWAYS_URL}/${identityKey}`);
if (!response.ok) {
throw new Error("Failed to fetch gateway status");
}
return response.json();
};
export const fetchNodeInfo = async (
id: number,
): Promise<IObservatoryNode | undefined> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
@@ -234,7 +69,8 @@ export const fetchObservatoryNodes = async () => {
throw new Error("Failed to fetch observatory nodes");
}
return response.json();
const nodes: IObservatoryNode[] = await response.json();
return nodes.find((node) => node.node_id === id);
};
export const fetchNodeDelegations = async (id: number) => {
@@ -254,3 +90,165 @@ export const fetchNodeDelegations = async (id: number) => {
return response.json();
};
export const fetchCurrentEpoch = async () => {
const response = await fetch(CURRENT_EPOCH, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
cache: "no-store", // Ensures fresh data on every request
});
if (!response.ok) {
throw new Error("Failed to fetch current epoch data");
}
const data: CurrentEpochData = await response.json();
const epochEndTime = addSeconds(
new Date(data.current_epoch_start),
data.epoch_length.secs,
).toISOString();
return { ...data, current_epoch_end: epochEndTime };
};
// Fetch balances based on the address
export const fetchBalances = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Calculate total stake
return (
Number(balances.rewards.staking_rewards.amount) +
Number(balances.delegated.amount)
);
};
// Fetch function to get total staker rewards
export const fetchTotalStakerRewards = async (
address: string,
): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Return the staking rewards amount
return Number(balances.rewards.staking_rewards.amount);
};
// Fetch function to get the original stake
export const fetchOriginalStake = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Return the delegated amount
return Number(balances.delegated.amount);
};
export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
const response = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
const data: IPacketsAndStakingData[] = await response.json();
return data;
};
// Fetch Account Balance
export const fetchAccountBalance = async (
address: string,
): Promise<IAccountBalancesInfo> => {
const res = await fetch(`${NYM_ACCOUNT_ADDRESS}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!res.ok) {
throw new Error("Failed to fetch account balance error from api");
}
const data: IAccountBalancesInfo = await res.json();
return data;
};
// 🔹 Fetch Nodes
export const fetchNodes = async (): Promise<NodeData[]> => {
const res = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!res.ok) {
throw new Error("Failed to fetch nodes");
}
const data: NodeData[] = await res.json();
return data;
};
export const fetchObservatoryNodes = async (): Promise<
IObservatoryNode[] | null
> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const nodes: IObservatoryNode[] = await response.json();
return nodes;
};
// 🔹 Fetch NYM Price
export const fetchNymPrice = async (): Promise<NymTokenomics> => {
const res = await fetch(NYM_PRICES_API, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!res.ok) {
throw new Error("Failed to fetch NYM price");
}
const data: NymTokenomics = await res.json();
return data;
};
+70 -3
View File
@@ -2,6 +2,75 @@ export type API_RESPONSE<T> = {
data: T[];
};
export type Denom = "unym" | "nym";
export interface IPacketsAndStakingData {
date_utc: string;
total_packets_received: number;
total_packets_sent: number;
total_packets_dropped: number;
total_stake: number;
}
export interface CurrentEpochData {
id: number;
current_epoch_id: number;
current_epoch_start: string;
epoch_length: { secs: number; nanos: number };
epochs_in_interval: number;
total_elapsed_epochs: number;
}
export interface ExplorerData {
circulatingNymSupplyData: {
circulating_supply: { denom: Denom; amount: string };
mixmining_reserve: { denom: Denom; amount: string };
total_supply: { denom: Denom; amount: string };
vesting_tokens: { denom: Denom; amount: string };
};
nymNodesData: {
gateways: {
bonded: { count: number; last_updated_utc: string };
blacklisted: { count: number; last_updated_utc: string };
historical: { count: number; last_updated_utc: string };
explorer: { count: number; last_updated_utc: string };
};
mixnodes: {
bonded: {
count: number;
active: number;
inactive: number;
reserve: number;
last_updated_utc: string;
};
blacklisted: {
count: number;
last_updated_utc: string;
};
historical: { count: number; last_updated_utc: string };
};
};
packetsAndStakingData: IPacketsAndStakingData[];
currentEpochRewardsData: {
interval: {
reward_pool: string;
staking_supply: string;
staking_supply_scale_factor: string;
epoch_reward_budget: string;
stake_saturation_point: string;
active_set_work_factor: string;
interval_pool_emission: string;
sybil_resistance: string;
};
rewarded_set: {
entry_gateways: number;
exit_gateways: number;
mixnodes: number;
standby: number;
};
};
}
export type NodeDescription = {
last_polled: string;
host_information: {
@@ -96,7 +165,7 @@ export type Location = {
longitude?: number;
};
type NodeData = {
export type NodeData = {
node_id: number;
contract_node_type: string;
description: NodeDescription;
@@ -105,8 +174,6 @@ type NodeData = {
location: Location;
};
export default NodeData;
// ACCOUNT BALANCES
export interface IRewardDetails {
+4 -20
View File
@@ -1,33 +1,15 @@
export const HARBOURMASTER_API_SUMMARY =
"https://harbourmaster.nymtech.net/v2/summary";
export const NYM_NODES_DESCRIBED =
"https://validator.nymtech.net/api/v1/nym-nodes/described";
export const BONDED_NODES =
"https://validator.nymtech.net/api/v1/nym-nodes/bonded";
export const NYM_NODES =
"https://explorer.nymtech.net/api/v1/tmp/unstable/nym-nodes";
export const EXPLORER_API = "https://explorer.nymtech.net/api/v1/countries";
export const VALIDATOR_API_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
export const COSMOS_API =
"https://api.nymtech.net/cosmos/bank/v1beta1/balances/n1a53udazy8ayufvy0s434pfwjcedzqv34yg485t";
export const VALIDATOR_API_EPOCH =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const HARBOURMASTER_API_MIXNODES_STATS =
"https://harbourmaster.nymtech.net/v2/mixnodes/stats";
export const HARBOURMASTER_API_BASE = "https://harbourmaster.nymtech.net";
export const CURRENT_EPOCH =
" https://validator.nymtech.net/api/v1/epoch/current";
"https://validator.nymtech.net/api/v1/epoch/current";
export const CURRENT_EPOCH_REWARDS =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const CIRCULATING_NYM_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
export const NYM_NODE_DESCRIPTION =
"https://validator.nymtech.net/api/v1/nym-nodes/described";
export const NYM_NODE_BONDED =
"https://validator.nymtech.net/api/v1/nym-nodes/bonded";
export const NYM_ACCOUNT_ADDRESS =
"https://explorer.nymtech.net/api/v1/tmp/unstable/account/";
"https://explorer.nymtech.net/api/v1/tmp/unstable/account";
export const NYM_PRICES_API = "https://api.nym.spectredao.net/api/v1/nym-price";
export const VALIDATOR_BASE_URL =
process.env.NEXT_PUBLIC_VALIDATOR_URL || "https://rpc.nymtech.net";
@@ -37,3 +19,5 @@ export const DATA_OBSERVATORY_DELEGATIONS_URL =
"https://api.nym.spectredao.net/api/v1/delegations";
export const DATA_OBSERVATORY_BALANCES_URL =
"https://api.nym.spectredao.net/api/v1/balances";
export const OBSERVATORY_GATEWAYS_URL =
"https://mainnet-node-status-api.nymtech.cc/v2/gateways";
+16 -30
View File
@@ -1,17 +1,15 @@
import BlogArticlesCards from "@/components/blogs/BlogArticleCards";
import CardSkeleton from "@/components/cards/Skeleton";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { CurrentEpochCard } from "@/components/landingPageComponents/CurrentEpochCard";
import { NetworkStakeCard } from "@/components/landingPageComponents/NetworkStakeCard";
import { NoiseCard } from "@/components/landingPageComponents/NoiseCard";
import { RewardsCard } from "@/components/landingPageComponents/RewardsCard";
import { TokenomicsCard } from "@/components/landingPageComponents/TokenomicsCard";
import NodeTable from "@/components/nodeTable/NodeTableWithAction";
import NodeAndAddressSearch from "@/components/search/NodeAndAddressSearch";
import { Stack, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { Suspense } from "react";
import BlogArticlesCards from "../components/blogs/BlogArticleCards";
import { ContentLayout } from "../components/contentLayout/ContentLayout";
import SectionHeading from "../components/headings/SectionHeading";
import { CurrentEpochCard } from "../components/landingPageComponents/CurrentEpochCard";
import { NetworkStakeCard } from "../components/landingPageComponents/NetworkStakeCard";
import { NoiseCard } from "../components/landingPageComponents/NoiseCard";
import { RewardsCard } from "../components/landingPageComponents/StakersNumberCard";
import { TokenomicsCard } from "../components/landingPageComponents/TokenomicsCard";
import NodeTable from "../components/nodeTable/NodeTableWithAction";
import NodeAndAddressSearch from "../components/search/NodeAndAddressSearch";
export default async function Home() {
return (
@@ -27,9 +25,7 @@ export default async function Home() {
<SectionHeading title="Noise Generating Mixnet Overview" />
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Suspense fallback={<CardSkeleton />}>
<NoiseCard />
</Suspense>
<NoiseCard />
</Grid>
<Grid
container
@@ -38,26 +34,18 @@ export default async function Home() {
size={{ xs: 12, md: 3 }}
>
<Grid size={12}>
<Suspense fallback={<CardSkeleton sx={{ width: "100%" }} />}>
<RewardsCard />
</Suspense>
<RewardsCard />
</Grid>
<Grid size={12}>
<Suspense fallback={<CardSkeleton sx={{ width: "100%" }} />}>
<CurrentEpochCard />
</Suspense>
<CurrentEpochCard />
</Grid>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Suspense fallback={<CardSkeleton sx={{ height: "100%" }} />}>
<NetworkStakeCard />
</Suspense>
<NetworkStakeCard />
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Suspense fallback={<CardSkeleton sx={{ height: "100%" }} />}>
<TokenomicsCard />
</Suspense>
<TokenomicsCard />
</Grid>
</Grid>
<Grid container>
@@ -65,9 +53,7 @@ export default async function Home() {
<SectionHeading title="Nym Nodes" />
</Grid>
<Grid size={12}>
<Suspense fallback={<CardSkeleton />}>
<NodeTable />
</Suspense>
<NodeTable />
</Grid>
</Grid>
<Grid container columnSpacing={5} rowSpacing={5}>
@@ -1,5 +1,8 @@
"use client";
import type { IAccountBalancesInfo, IRewardDetails } from "@/app/api/types";
import { fetchAccountBalance, fetchNymPrice } from "@/app/api";
import { Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { IRewardDetails } from "../../app/api/types";
import { AccountBalancesTable } from "../cards/AccountBalancesTable";
import ExplorerCard from "../cards/ExplorerCard";
@@ -14,8 +17,7 @@ export interface IAccontStatsRowProps {
}
interface IAccountBalancesCardProps {
accountInfo: IAccountBalancesInfo;
nymPrice: number;
address: string;
}
const getNymsFormated = (unyms: number) => {
@@ -46,16 +48,59 @@ const calculateStakingRewards = (
};
export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const { accountInfo, nymPrice } = props;
const { address } = props;
const {
data: accountInfo,
isLoading,
isError,
} = useQuery({
queryKey: ["accountBalance", address],
queryFn: () => fetchAccountBalance(address),
enabled: !!address,
});
const {
data: nymPrice,
isLoading: isLoadingPrice,
error: priceError,
} = useQuery({
queryKey: ["nymPrice"],
queryFn: fetchNymPrice,
});
if (isLoading || isLoadingPrice) {
return (
<ExplorerCard label="Total value">
<Stack gap={1}>
<Skeleton variant="text" height={38} />
<Skeleton variant="text" height={380} />
</Stack>
</ExplorerCard>
);
}
if (isError || priceError || !accountInfo || !nymPrice) {
return (
<ExplorerCard label="Total value">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
}
const nymPriceData = nymPrice.quotes.USD.price;
const totalBalanceUSD = getPriceInUSD(
Number(accountInfo.total_value.amount),
nymPrice,
nymPriceData,
);
const spendableNYM = getNymsFormated(Number(accountInfo.balances[0].amount));
const spendableUSD = getPriceInUSD(
Number(accountInfo.balances[0].amount),
nymPrice,
nymPriceData,
);
const spendableAllocation = getAllocation(
Number(accountInfo.balances[0].amount),
@@ -67,7 +112,7 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
);
const delegationsUSD = getPriceInUSD(
Number(accountInfo.total_delegations.amount),
nymPrice,
nymPriceData,
);
const delegationsAllocation = getAllocation(
Number(accountInfo.total_delegations.amount),
@@ -79,7 +124,7 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
);
const claimableUSD = getPriceInUSD(
Number(accountInfo.claimable_rewards.amount),
nymPrice,
nymPriceData,
);
const claimableAllocation = getAllocation(
Number(accountInfo.claimable_rewards.amount),
@@ -1,19 +1,49 @@
"use client";
import type { IAccountBalancesInfo } from "@/app/api/types";
import { Box, Stack, Typography } from "@mui/material";
import { fetchAccountBalance } from "@/app/api";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
import { CardQRCode } from "../qrCode/QrCode";
interface IAccountInfoCardProps {
accountInfo: IAccountBalancesInfo;
address: string;
}
export const AccountInfoCard = (props: IAccountInfoCardProps) => {
const { accountInfo } = props;
const { address } = props;
const balance = Number(accountInfo.balances[0].amount) / 1000000;
const { data, isLoading, isError } = useQuery({
queryKey: ["accountBalance", address],
queryFn: () => fetchAccountBalance(address),
enabled: !!address,
});
if (isLoading) {
return (
<ExplorerCard label="">
<Stack gap={1}>
<Skeleton variant="text" height={38} />
<Skeleton variant="rectangular" height={128} width={128} />
<Skeleton variant="text" height={300} />
</Stack>
</ExplorerCard>
);
}
if (isError || !data) {
return (
<ExplorerCard label="">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
}
const balance = Number(data.balances[0].amount) / 1000000;
const balanceFormated = `${balance} NYM`;
return (
@@ -24,7 +54,7 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
>
<Stack gap={5}>
<Box display={"flex"} justifyContent={"flex-start"}>
<CardQRCode url={accountInfo.address} />
<CardQRCode url={data.address} />
</Box>
<ExplorerListItem
@@ -37,8 +67,13 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">{accountInfo.address}</Typography>
<CopyToClipboard text={accountInfo.address} />
<Typography
variant="body4"
sx={{ wordWrap: "break-word", maxWidth: "85%" }}
>
{data.address}
</Typography>
<CopyToClipboard text={data.address} />
</Stack>
}
/>
@@ -1,10 +1,10 @@
"use client";
import { colours } from "@/theme/colours";
import { Button as MUIButton } from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
import Link from "next/link";
import { colours } from "../../theme/colours";
import type { ButtonProps } from "./types";
export const Button = ({
@@ -1,6 +1,5 @@
"use client";
import { TABLET_WIDTH } from "@/app/constants";
import CircleIcon from "@mui/icons-material/Circle";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
@@ -15,6 +14,7 @@ import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { TABLET_WIDTH } from "../../app/constants";
import { MultiSegmentProgressBar } from "../progressBars/MultiSegmentProgressBar";
import { StaticProgressBar } from "../progressBars/StaticProgressBar";
@@ -1,4 +1,3 @@
import ArrowUpRight from "@/components/icons/ArrowUpRight";
import {
Card,
CardContent,
@@ -8,6 +7,7 @@ import {
Typography,
} from "@mui/material";
import Image from "next/image";
import ArrowUpRight from "../../components/icons/ArrowUpRight";
import { Link } from "../muiLink";
const cardStyles = {
@@ -40,7 +40,7 @@ const ExplorerHeroCard = ({
sx?: SxProps;
}) => {
return (
<Link href={link} sx={{ textDecoration: "none" }}>
<Link href={link} sx={{ textDecoration: "none", height: "100%" }}>
<Card sx={{ ...cardStyles, ...sx }} elevation={0}>
<CardHeader
title={
@@ -1,17 +0,0 @@
import { Skeleton, Stack, type SxProps } from "@mui/material";
import ExplorerCard from "./ExplorerCard";
const CardSkeleton = ({ sx }: { sx?: SxProps }) => {
return (
<ExplorerCard label={<Skeleton variant="text" width={200} />} sx={sx}>
<Stack gap={1}>
<Skeleton variant="text" />
<Skeleton variant="rounded" height={75} />
<Skeleton variant="text" />
<Skeleton variant="text" />
</Stack>
</ExplorerCard>
);
};
export default CardSkeleton;
@@ -3,7 +3,7 @@ import Flag from "react-world-flags";
interface ICountryFlag {
countryCode: string;
countryName?: string;
countryName?: string | JSX.Element;
}
const CountryFlag = ({ countryCode, countryName }: ICountryFlag) => {
@@ -1,49 +1,46 @@
"use client";
import { CURRENT_EPOCH } from "@/app/api/urls";
import {
type EpochResponseData,
useEpochContext,
} from "@/providers/EpochProvider";
import { AccessTime } from "@mui/icons-material";
import { Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { addSeconds } from "date-fns";
import { format } from "date-fns";
import { differenceInMinutes } from "date-fns";
import { useCallback, useEffect, useState } from "react";
// Fetch function for the next epoch
const fetchNextEpoch = async () => {
const res = await fetch(CURRENT_EPOCH, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!res.ok) {
throw new Error("Failed to fetch current epoch");
}
const data = await res.json();
const dateTime = addSeconds(
new Date(data.current_epoch_start),
data.epoch_length.secs,
);
return { data, dateTime };
const calculateMinutesRemaining = (epochEndTime: string) => {
const endDate = new Date(epochEndTime);
const difference = differenceInMinutes(endDate, new Date());
return difference;
};
const NextEpochTime = () => {
// Use React Query to fetch next epoch data
const { data, isLoading, isError } = useQuery({
queryKey: ["nextEpoch"], // Unique key for this query
queryFn: fetchNextEpoch, // Fetch function
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is considered fresh for 60 seconds
});
const { data, isLoading, isError, epochStatus } = useEpochContext();
const [minutesRemaining, setMinutesRemaining] = useState(0);
const updateState = useCallback((data: EpochResponseData) => {
if (!data) return;
const minutesRemaining = calculateMinutesRemaining(data.current_epoch_end);
setMinutesRemaining(minutesRemaining);
}, []);
useEffect(() => {
updateState(data);
const interval = setInterval(() => {
updateState(data);
}, 30_000);
return () => clearInterval(interval);
}, [data, updateState]);
if (isLoading) {
return (
<Stack direction="row" spacing={1}>
<AccessTime />
<Typography variant="h5" fontWeight="light">
Loading next epoch...
Loading next mixnet epoch...
</Typography>
</Stack>
);
@@ -60,14 +57,18 @@ const NextEpochTime = () => {
);
}
const formattedDate = format(data.dateTime, "HH:mm:ss");
return (
<Stack direction="row" spacing={1}>
<AccessTime />
<Typography variant="h5" fontWeight="light">
Next epoch: {formattedDate}
</Typography>
{epochStatus === "pending" ? (
<Typography variant="h5" fontWeight="light">
Waiting for next mixnet epoch to start...
</Typography>
) : (
<Typography variant="h5" fontWeight="light">
Next mixnet epoch starts in: {minutesRemaining} min
</Typography>
)}
</Stack>
);
};
@@ -1,7 +1,7 @@
import NymLogo from "@/components/icons/NymLogo";
import { Link } from "@/components/muiLink";
import { Wrapper } from "@/components/wrapper";
import { Box, Divider } from "@mui/material";
import NymLogo from "../../components/icons/NymLogo";
import { Link } from "../../components/muiLink";
import { Wrapper } from "../../components/wrapper";
import ConnectWallet from "../wallet/ConnectWallet";
import HeaderItem from "./HeaderItem";
import MENU_DATA from "./menuItems";
@@ -1,9 +1,9 @@
"use client";
import { Link } from "@/components/muiLink";
import { Wrapper } from "@/components/wrapper";
import { Close as CloseIcon, Menu as MenuIcon } from "@mui/icons-material";
import { Box, Drawer, IconButton, Typography } from "@mui/material";
import { useState } from "react";
import { Link } from "../../components/muiLink";
import { Wrapper } from "../../components/wrapper";
import NymLogo from "../icons/NymLogo";
import ConnectWallet from "../wallet/ConnectWallet";
import MENU_DATA from "./menuItems";
@@ -148,7 +148,8 @@ const MobileMenuHeader = ({
</IconButton>
</Box>
</Box>
<ConnectWallet size="small" />
{!drawerOpen && <ConnectWallet size="small" />}
<Box height={40} />
</Wrapper>
);
};
@@ -1,4 +1,5 @@
import { type SxProps, TextField } from "@mui/material";
import type { KeyboardEventHandler } from "react";
const Input = ({
placeholder,
@@ -6,6 +7,7 @@ const Input = ({
value,
rounded = false,
onChange,
onKeyDown,
}: {
placeholder?: string;
fullWidth?: boolean;
@@ -13,6 +15,7 @@ const Input = ({
sx?: SxProps;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: KeyboardEventHandler<HTMLDivElement> | undefined;
}) => {
return (
<TextField
@@ -20,6 +23,7 @@ const Input = ({
fullWidth={fullWidth}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
slotProps={{
input: {
sx: {
@@ -1,51 +1,104 @@
"use client";
import type { CurrentEpochData } from "@/app/api";
import { CURRENT_EPOCH } from "@/app/api/urls";
import { useQuery } from "@tanstack/react-query";
import {
type EpochResponseData,
useEpochContext,
} from "@/providers/EpochProvider";
import { Skeleton, Typography } from "@mui/material";
import { differenceInMinutes, format } from "date-fns";
import { useCallback, useEffect, useState } from "react";
import ExplorerCard from "../cards/ExplorerCard";
import EpochProgressBar from "../progressBars/EpochProgressBar";
// Fetch function
const fetchCurrentEpoch = async (): Promise<CurrentEpochData> => {
const response = await fetch(CURRENT_EPOCH, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
const calulateProgress = (end: string) => {
const endDate = new Date(end);
const difference = differenceInMinutes(endDate, new Date());
const progress = Math.max(0, 100 - (difference / 60) * 100);
if (!response.ok) {
throw new Error("Failed to fetch current epoch data");
}
return progress;
};
return response.json();
const getStartEndTime = (start: string, end: string) => {
const startDate = new Date(start);
const endDate = new Date(end);
const startTime = format(startDate, "HH:mm:ss");
const endTime = format(endDate, "HH:mm:ss");
return { startTime, endTime };
};
export const CurrentEpochCard = () => {
// Use React Query to fetch data
const { data, isError, isLoading } = useQuery({
queryKey: ["currentEpoch"], // Unique query key
queryFn: fetchCurrentEpoch, // Fetch function
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is considered fresh for 60 seconds
});
const { data, isError, isLoading, epochStatus } = useEpochContext();
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const [progress, setProgress] = useState(0);
const updateState = useCallback((data: NonNullable<EpochResponseData>) => {
const { startTime, endTime } = getStartEndTime(
data.current_epoch_start,
data.current_epoch_end,
);
const progress = calulateProgress(data.current_epoch_end);
setStartTime(startTime);
setEndTime(endTime);
setProgress(progress);
}, []);
useEffect(() => {
if (!data) return;
updateState(data);
const intervalId = setInterval(() => {
updateState(data);
}, 30_000);
return () => clearInterval(intervalId);
}, [data, updateState]);
if (isLoading) {
return <ExplorerCard label="Current NGM epoch">Loading...</ExplorerCard>;
}
if (isError || !data) {
return (
<ExplorerCard label="Current NGM epoch">Failed to load data</ExplorerCard>
<ExplorerCard label="Current mixnet epoch">
<Skeleton variant="text" height={80} />
</ExplorerCard>
);
}
const currentEpochStart = data.current_epoch_start || "";
if (isError) {
return (
<ExplorerCard label="Current mixnet epoch">
Failed to load data
</ExplorerCard>
);
}
if (!data) {
return (
<ExplorerCard label="Current mixnet epoch">
No data available
</ExplorerCard>
);
}
if (epochStatus === "pending") {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light">
Waiting for next epoch to start...
</Typography>
</ExplorerCard>
);
}
return (
<ExplorerCard label="Current NGM epoch">
<EpochProgressBar start={currentEpochStart} showEpoch={true} />
<ExplorerCard label="Current mixnet epoch">
<EpochProgressBar
startTime={startTime}
endTime={endTime}
progress={progress}
/>
</ExplorerCard>
);
};
@@ -1,40 +1,49 @@
import type { ExplorerData, IPacketsAndStakingData } from "@/app/api";
import {
CURRENT_EPOCH_REWARDS,
HARBOURMASTER_API_MIXNODES_STATS,
} from "@/app/api/urls";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Stack, Typography } from "@mui/material";
"use client";
import { fetchNoise } from "@/app/api";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { ExplorerData, IPacketsAndStakingData } from "../../app/api/types";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
export const NetworkStakeCard = async () => {
const epochRewards = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
export const NetworkStakeCard = () => {
const {
data: packetsAndStaking,
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
});
const packetsAndStaking = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
const epochRewardsData: ExplorerData["currentEpochRewardsData"] =
await epochRewards.json();
const packetsAndStakingData: ExplorerData["packetsAndStakingData"] =
await packetsAndStaking.json();
if (!epochRewardsData || !packetsAndStakingData) {
return null;
if (isStakingLoading) {
return (
<ExplorerCard label="Current network stake">
<Stack gap={1}>
<Skeleton variant="text" />
<Skeleton variant="text" height={238} />
</Stack>
</ExplorerCard>
);
}
const currentStake =
Number(epochRewardsData.interval.staking_supply) / 1000000 || 0;
if (isStakingError || !packetsAndStaking) {
return (
<ExplorerCard label="Current network stake">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
}
const packetsAndStakingData: ExplorerData["packetsAndStakingData"] =
packetsAndStaking;
const lastTotalStake =
packetsAndStaking[packetsAndStaking.length - 1]?.total_stake / 1000000;
const data = packetsAndStakingData.map((item: IPacketsAndStakingData) => {
return {
date_utc: item.date_utc,
@@ -48,7 +57,7 @@ export const NetworkStakeCard = async () => {
data,
};
const title = `${formatBigNum(currentStake)} NYM`;
const title = `${formatBigNum(lastTotalStake)} NYM`;
return (
<ExplorerCard label="Current network stake" sx={{ height: "100%" }}>
@@ -1,23 +1,40 @@
import type { IPacketsAndStakingData } from "@/app/api";
import { HARBOURMASTER_API_MIXNODES_STATS } from "@/app/api/urls";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Stack, Typography } from "@mui/material";
"use client";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { IPacketsAndStakingData } from "../../app/api/types";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
import { UpDownPriceIndicator } from "../price/UpDownPriceIndicator";
export const NoiseCard = async () => {
const response = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
import { fetchNoise } from "@/app/api";
export const NoiseCard = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
});
const data: IPacketsAndStakingData[] = await response.json();
if (isLoading) {
return (
<ExplorerCard label="Noise generated last 24h">
<Stack gap={1}>
<Skeleton variant="text" />
<Skeleton variant="text" height={238} />
</Stack>
</ExplorerCard>
);
}
if (!data) {
return null;
if (isError || !data) {
return (
<ExplorerCard label="Noise generated last 24h">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
}
const todaysData = data[data.length - 1];
@@ -1,16 +0,0 @@
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
export const RewardsCard = async () => {
return (
<ExplorerCard label="Operator rewards this month">
<Typography
variant="h3"
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{`${formatBigNum(10_000_111)} NYM`}
</Typography>
</ExplorerCard>
);
};
@@ -0,0 +1,50 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
export const RewardsCard = () => {
const {
data: observatoryNodes,
isLoading,
isError,
} = useQuery({
queryKey: ["observatoryNodes"],
queryFn: () => fetchObservatoryNodes(),
});
if (isLoading) {
return (
<ExplorerCard label="Active stakers number">
<Skeleton variant="text" height={90} />
</ExplorerCard>
);
}
if (isError || !observatoryNodes) {
return (
<ExplorerCard label="Active stakers number">
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
const getActiveStakersNumber = (nodes: IObservatoryNode[]): number => {
return nodes.reduce(
(sum, node) => sum + node.rewarding_details.unique_delegations,
0,
);
};
const allStakers = getActiveStakersNumber(observatoryNodes);
return (
<ExplorerCard label="Active stakers number">
<Typography variant="h3" sx={{ color: "pine.950" }}>
{allStakers}
</Typography>
</ExplorerCard>
);
};
@@ -1,21 +1,71 @@
import type { NymTokenomics } from "@/app/api/types";
import { NYM_PRICES_API } from "@/app/api/urls";
import { Box, Stack } from "@mui/material";
"use client";
import { fetchEpochRewards, fetchNoise, fetchNymPrice } from "@/app/api";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { ExplorerData, NymTokenomics } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import { TitlePrice } from "../price/TitlePrice";
export const TokenomicsCard = async () => {
const nymPrice = await fetch(NYM_PRICES_API, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
export const TokenomicsCard = () => {
const {
data: nymPrice,
isLoading,
isError,
} = useQuery({
queryKey: ["nymPrice"],
queryFn: fetchNymPrice,
});
const nymPriceData: NymTokenomics = await nymPrice.json();
const {
data: epochRewards,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
});
const {
data: packetsAndStaking,
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
});
if (isLoading || isEpochLoading || isStakingLoading) {
return (
<ExplorerCard label="Tokenomics overview">
<Stack gap={1}>
<Skeleton variant="text" />
<Skeleton variant="text" height={238} />
</Stack>
</ExplorerCard>
);
}
if (
isStakingError ||
isEpochError ||
isError ||
!nymPrice ||
!epochRewards ||
!packetsAndStaking
) {
return (
<ExplorerCard label="Tokenomics overview">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Failed to load tokenomics overview.
</Typography>
<Skeleton variant="text" height={80} />
</ExplorerCard>
);
}
const nymPriceData: NymTokenomics = nymPrice;
const nymPriceDataFormated = Number(nymPriceData.quotes.USD.price.toFixed(2));
const titlePrice = {
@@ -25,11 +75,35 @@ export const TokenomicsCard = async () => {
// numberWentUp: true,
// },
};
const marketCap = nymPriceData.quotes.USD.market_cap;
const volume24H = nymPriceData.quotes.USD.volume_24h.toFixed(2);
const marketCap = formatBigNum(nymPriceData.quotes.USD.market_cap);
const volume24H = formatBigNum(nymPriceData.quotes.USD.volume_24h);
const epochRewardsData: ExplorerData["currentEpochRewardsData"] =
epochRewards;
const packetsAndStakingData: ExplorerData["packetsAndStakingData"] =
packetsAndStaking;
function calculateTVL(
epochRewards: ExplorerData["currentEpochRewardsData"],
nymPriceData: NymTokenomics,
packetsAndStaking: ExplorerData["packetsAndStakingData"],
): number {
const lastTotalStake =
packetsAndStaking[packetsAndStaking.length - 1]?.total_stake || 0;
return (
(Number.parseFloat(epochRewards.interval.reward_pool) / 1000000 +
lastTotalStake / 1000000) *
nymPriceData.quotes.USD.price
);
}
const TVL = formatBigNum(
calculateTVL(epochRewardsData, nymPrice, packetsAndStakingData),
);
const dataRows = [
{ key: "Market cap", value: `$ ${marketCap}` },
{ key: "24H VOL", value: `$ ${volume24H}` },
{ key: "TVL", value: `$ ${TVL}` },
];
return (
@@ -66,8 +66,8 @@ export const LineChart = ({
enableSlices="x"
margin={{
bottom: 24,
left: 30,
right: 12,
left: 36,
right: 18,
top: 20,
}}
theme={{
@@ -106,7 +106,7 @@ export const LineChart = ({
legendOffset: 12,
tickSize: 3,
format: yformat,
tickValues: 5,
tickValues: 8,
}}
axisBottom={{
format: "%b %d",
@@ -12,7 +12,7 @@ const modalStyle: SxProps = {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 500,
width: 300,
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "16px",
@@ -22,7 +22,7 @@ const InfoModal = (props: InfoModalProps) => {
}
const { open, onClose, title, message, tx, Action } = props;
const mintscanURL = tx ? `https://www.mintscan.io/nyx/tx/${tx}` : "/";
const pingURL = tx ? `https://www.ping.pub/nyx/tx/${tx}` : "/";
return (
<SimpleModal
@@ -41,7 +41,7 @@ const InfoModal = (props: InfoModalProps) => {
<Typography variant="h3">{title}</Typography>
<Typography variant="body3">{message}</Typography>
{tx && (
<Link href={mintscanURL}>
<Link href={pingURL} rel="noopener noreferrer" target="_blank">
<Typography variant="h5">Block explorer link</Typography>
</Link>
)}
@@ -1,4 +1,3 @@
import Cross from "@/components/icons/Cross";
import {
Dialog,
DialogActions,
@@ -7,6 +6,7 @@ import {
Stack,
Typography,
} from "@mui/material";
import Cross from "../../components/icons/Cross";
type SimpleModalPropsClosed = {
open: false;
@@ -1,4 +1,3 @@
import { TABLET_WIDTH } from "@/app/constants";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutline from "@mui/icons-material/ErrorOutline";
@@ -13,6 +12,7 @@ import {
useMediaQuery,
} from "@mui/material";
import type React from "react";
import { TABLET_WIDTH } from "../../app/constants";
export const modalStyle = (width: number | string = 600) => ({
position: "absolute",
@@ -1,6 +1,6 @@
// Imports
import { Link as MuiLink, type LinkProps as MuiLinkProps } from "@mui/material";
import NextLink, { type LinkProps as NextLinkProps } from "next/link";
// Imports
import type { ReactNode } from "react";
interface CustomLinkProps extends NextLinkProps, Omit<MuiLinkProps, "href"> {
@@ -1,9 +1,14 @@
"use client";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useNymClient } from "@/hooks/useNymClient";
import { useChain } from "@cosmos-kit/react";
import { Box, Button, Stack, Tooltip, Typography } from "@mui/material";
import {
Box,
Button,
Stack,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
import {
type MRT_ColumnDef,
@@ -12,9 +17,13 @@ import {
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite } from "../favorite/Favorite";
import Loading from "../loading";
// import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import StakeModal from "../staking/StakeModal";
import { fee } from "../staking/schemas";
@@ -26,16 +35,42 @@ const ColumnHeading = ({
}: {
children: string | React.ReactNode;
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Typography sx={{ py: 2, textAlign: "center" }} variant="h5">
{children}
</Typography>
<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 NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const router = useRouter();
const { nymClient } = useNymClient();
const queryClient = useQueryClient();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
@@ -48,6 +83,10 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const handleRefetch = useCallback(async () => {
await queryClient.invalidateQueries();
}, [queryClient]);
const handleStakeOnNode = useCallback(
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
const amountToDelegate = (Number(amount) * 1_000_000).toString();
@@ -69,8 +108,12 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
message: "This operation can take up to one hour to process",
tx: tx?.transactionHash,
onClose: () => setInfoModalProps({ open: false }),
onClose: async () => {
await handleRefetch();
setInfoModalProps({ open: false });
},
});
await handleRefetch();
} catch (e) {
const errorMessage =
e instanceof Error ? e.message : "An error occurred while staking";
@@ -85,7 +128,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
}
setIsLoading(false);
},
[nymClient],
[nymClient, handleRefetch],
);
const handleOnSelectStake = useCallback(
@@ -116,6 +159,12 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
[isWalletConnected],
);
// 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<MappedNymNode>[] = useMemo(
() => [
@@ -131,23 +180,36 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
),
},
{
id: "node",
id: "id",
header: "",
Header: <ColumnHeading>Node</ColumnHeading>,
accessorKey: "identity_key",
Header: <ColumnHeading>Node ID</ColumnHeading>,
accessorKey: "nodeId",
size: 90,
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.nodeId}</Typography>
</Stack>
),
},
{
id: "identity_key",
header: "",
Header: <ColumnHeading>Identity Key</ColumnHeading>,
accessorKey: "identity_key",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body5">{row.original.identity_key}</Typography>
</Stack>
),
},
{
id: "qos",
header: "Quality of Service",
header: "Qlt of Service",
align: "center",
accessorKey: "qualityOfService",
Header: <ColumnHeading>Quality of Service</ColumnHeading>,
size: 100,
Header: <ColumnHeading>Qlt of Service</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.qualityOfService}%
@@ -158,17 +220,16 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
id: "location",
header: "Location",
accessorKey: "countryName",
size: 160,
Header: <ColumnHeading>Location</ColumnHeading>,
Cell: ({ row }) =>
row.original.countryCode && row.original.countryName ? (
<Tooltip title={row.original.countryName}>
<Box>
<CountryFlag
countryCode={row.original.countryCode || ""}
countryName={row.original.countryCode || ""}
/>
</Box>
</Tooltip>
<Box width="100%">
<CountryFlag
countryCode={row.original.countryCode || ""}
countryName={countryName(row.original.countryName) || ""}
/>
</Box>
) : (
"-"
),
@@ -199,6 +260,8 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
id: "Action",
header: "Action",
accessorKey: "Action",
size: 120,
Header: <ColumnHeading>Action</ColumnHeading>,
hidden: !isWalletConnected,
enableColumnFilter: false,
@@ -221,6 +284,8 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
enableColumnFilter: false,
header: "Favorite",
accessorKey: "Favorite",
size: 110,
Header: (
<Stack direction="row" alignItems="center">
<ColumnHeading>Favorite</ColumnHeading>
@@ -241,7 +306,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
Cell: ({ row }) => <Favorite address={row.original.owner} />,
},
],
[isWalletConnected, handleOnSelectStake, favorites],
[isWalletConnected, handleOnSelectStake, favorites, countryName],
);
const table = useMaterialReactTable({
columns,
@@ -264,7 +329,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
shape: "circular",
},
initialState: {
columnPinning: { right: ["Action", "Favorite"] },
columnPinning: isMobile ? {} : { right: ["Action", "Favorite"] }, // No pinning on mobile
},
muiColumnActionsButtonProps: {
sx: {
@@ -280,9 +345,17 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
bgcolor: "background.paper",
},
},
muiTableHeadCellProps: {
sx: {
alignItems: "center",
},
},
muiTableBodyCellProps: {
sx: {
border: "none",
whiteSpace: "unset", // Allow text wrapping in body cells
wordBreak: "break-word", // Ensure long text breaks correctly
},
},
muiTableBodyRowProps: ({ row }) => ({
@@ -301,9 +374,11 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
}),
});
return (
<>
{isLoading && <Loading />}
<StakeModal
nodeId={selectedNodeForStaking?.nodeId}
identityKey={selectedNodeForStaking?.identityKey}
@@ -312,6 +387,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
/>
<InfoModal {...infoModalProps} />
<MaterialReactTable table={table} />
</>
);
@@ -1,30 +1,12 @@
"use client";
import getNymNodes from "@/actions/getNymNodes";
import type { ExplorerData } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { CURRENT_EPOCH_REWARDS } from "@/app/api/urls";
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { ExplorerData, IObservatoryNode } from "../../app/api/types";
import NodeTable from "./NodeTable";
// Fetch function for epoch rewards
const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch epoch rewards");
}
return response.json();
};
// Utility function to calculate node saturation point
function getNodeSaturationPoint(
totalStake: number,
@@ -52,8 +34,12 @@ const mappedNymNodes = (
epochRewardsData.interval.stake_saturation_point,
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
return {
name: node.self_description.moniker,
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.description.auxiliary_details.location || null,
@@ -78,8 +64,6 @@ const NodeTableWithAction = () => {
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 60000, // Data is fresh for 60 seconds
refetchInterval: 60000, // Refetch every 60 seconds
});
// Use React Query to fetch Nym nodes
@@ -89,23 +73,41 @@ const NodeTableWithAction = () => {
isError: isNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: getNymNodes,
staleTime: 60000,
refetchInterval: 60000,
queryFn: fetchObservatoryNodes,
});
// Handle loading state
if (isEpochLoading || isNodesLoading) {
return <div>Loading...</div>;
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
</CardContent>
</Card>
);
}
// Handle error state
if (isEpochError || isNodesError) {
return <div>Error loading data. Please try again later.</div>;
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Error loading data. Please try again later.
</Typography>
</Stack>
);
}
// Map nodes with rewards data
const data = mappedNymNodes(nymNodes, epochRewardsData);
if (!epochRewardsData) {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
return <NodeTable nodes={data} />;
};
@@ -1,70 +1,51 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Stack, Typography } from "@mui/material";
import { Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { fetchNodeInfo } from "../../app/api";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
interface IBasicInfoCardProps {
id: number; // Node ID
id: number;
}
// Fetch function to get the node data
const fetchNodeInfo = async (id: number): Promise<IObservatoryNode | null> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const observatoryNymNodes: IObservatoryNode[] = await response.json();
return observatoryNymNodes.find((node) => node.node_id === id) || null;
};
export const BasicInfoCard = ({ id }: IBasicInfoCardProps) => {
// Use React Query to fetch the node info
const {
data: nodeInfo,
isLoading,
isError,
} = useQuery({
queryKey: ["nodeInfo", id], // Unique query key based on the node ID
queryFn: () => fetchNodeInfo(id), // Fetch function
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is considered fresh for 60 seconds
queryKey: ["nodeInfo", id],
queryFn: () => fetchNodeInfo(id),
});
// Loading state
if (isLoading) {
return (
<ExplorerCard label="Basic info">
<Typography>Loading...</Typography>
<Skeleton variant="text" height={90} />
<Skeleton variant="text" height={90} />
<Skeleton variant="text" height={70} />
<Skeleton variant="text" height={70} />
<Skeleton variant="text" height={70} />
<Skeleton variant="text" height={70} />
</ExplorerCard>
);
}
// Error state
if (isError || !nodeInfo) {
return (
<ExplorerCard label="Basic info">
<Typography>Failed to load node information.</Typography>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
// Derived data from nodeInfo
const timeBonded = format(
new Date(nodeInfo.description.build_information.build_timestamp),
"dd/MM/yyyy",
@@ -92,7 +73,10 @@ export const BasicInfoCard = ({ id }: IBasicInfoCardProps) => {
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">
<Typography
variant="body4"
sx={{ wordWrap: "break-word", maxWidth: "90%" }}
>
{nodeInfo.bonding_address}
</Typography>
<CopyToClipboard text={nodeInfo.bonding_address} />
@@ -110,7 +94,12 @@ export const BasicInfoCard = ({ id }: IBasicInfoCardProps) => {
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">{nodeInfo.identity_key}</Typography>
<Typography
variant="body4"
sx={{ wordWrap: "break-word", maxWidth: "85%" }}
>
{nodeInfo.identity_key}
</Typography>
<CopyToClipboard text={nodeInfo.identity_key} />
</Stack>
}
@@ -1,6 +1,5 @@
"use client";
import type { NodeRewardDetails } from "@/app/api/types";
import { Stack, Typography } from "@mui/material";
import {
type MRT_ColumnDef,
@@ -9,6 +8,7 @@ import {
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import type { NodeRewardDetails } from "../../app/api/types";
const ColumnHeading = ({
children,
@@ -62,6 +62,11 @@ const DelegationsTable = ({
header: "Amount",
accessorKey: "amount",
Header: <ColumnHeading>Amount</ColumnHeading>,
sortingFn: (rowA, rowB) => {
const stakeA = Number.parseFloat(rowA.original.amount.amount);
const stakeB = Number.parseFloat(rowB.original.amount.amount);
return stakeA - stakeB;
},
Cell: ({ row }) => (
<Typography variant="body4">
{getNymsFormated(row.original.amount.amount)} NYM
@@ -1,7 +1,7 @@
"use client";
import type { NodeRewardDetails } from "@/app/api/types";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
import { fetchNodeDelegations } from "@/app/api";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import DelegationsTable from "./DelegationsTable";
@@ -10,30 +10,7 @@ interface NodeDelegationsCardProps {
id: number; // Node ID
}
// Fetch delegations dynamically based on ID
const fetchNodeDelegations = async (
id: number,
): Promise<NodeRewardDetails[]> => {
const response = await fetch(
`${DATA_OBSERVATORY_NODES_URL}/${id}/delegations`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
},
);
if (!response.ok) {
throw new Error("Failed to fetch delegations");
}
return response.json();
};
const NodeDelegationsCard = ({ id }: NodeDelegationsCardProps) => {
// Use React Query to fetch delegations
const {
data: delegations = [],
isLoading,
@@ -41,17 +18,32 @@ const NodeDelegationsCard = ({ id }: NodeDelegationsCardProps) => {
} = useQuery({
queryKey: ["nodeDelegations", id],
queryFn: () => fetchNodeDelegations(id),
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (isLoading) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
</ExplorerCard>
);
}
if (isError) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load delegations. Please try again later.
</Typography>
</ExplorerCard>
);
}
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
{isLoading && <div>Loading delegations...</div>}
{isError && (
<div>Failed to load delegations. Please try again later.</div>
)}
{!isLoading && !isError && <DelegationsTable delegations={delegations} />}
<DelegationsTable delegations={delegations} />
</ExplorerCard>
);
};
@@ -1,12 +1,9 @@
"use client";
import type { ExplorerData } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import {
CURRENT_EPOCH_REWARDS,
DATA_OBSERVATORY_NODES_URL,
} from "@/app/api/urls";
import { useQuery } from "@tanstack/react-query";
import { fetchEpochRewards, fetchNodeInfo } from "../../app/api";
import { Skeleton, Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
@@ -14,42 +11,7 @@ interface INodeMetricsCardProps {
id: number; // Node ID
}
// Fetch functions
const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch epoch rewards");
}
return response.json();
};
const fetchNodeInfo = async (id: number): Promise<IObservatoryNode | null> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const nodes: IObservatoryNode[] = await response.json();
return nodes.find((node) => node.node_id === id) || null;
};
export const NodeMetricsCard = ({ id }: INodeMetricsCardProps) => {
// Fetch epoch rewards
const {
data: epochRewardsData,
isLoading: isEpochLoading,
@@ -57,8 +19,6 @@ export const NodeMetricsCard = ({ id }: INodeMetricsCardProps) => {
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
// Fetch node information
@@ -69,14 +29,15 @@ export const NodeMetricsCard = ({ id }: INodeMetricsCardProps) => {
} = useQuery({
queryKey: ["nodeInfo", id],
queryFn: () => fetchNodeInfo(id),
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (isEpochLoading || isNodeLoading) {
return (
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<div>Loading...</div>
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
</ExplorerCard>
);
}
@@ -84,7 +45,9 @@ export const NodeMetricsCard = ({ id }: INodeMetricsCardProps) => {
if (isEpochError || isNodeError || !nodeInfo || !epochRewardsData) {
return (
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<div>Failed to load data</div>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
@@ -1,13 +1,14 @@
"use client";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useNymClient } from "@/hooks/useNymClient";
import { useChain } from "@cosmos-kit/react";
import { Box, Button, Stack, Typography } from "@mui/material";
import { Box, Button, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { useCallback, useState } from "react";
import { RandomAvatar } from "react-random-avatars";
import { fetchNodeInfo } from "../../app/api";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import ExplorerCard from "../cards/ExplorerCard";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite } from "../favorite/Favorite";
@@ -21,23 +22,6 @@ interface INodeProfileCardProps {
id: number; // Node ID
}
// Fetch node info
const fetchNodeInfo = async (id: number) => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const nodes = await response.json();
return nodes.find((node: { node_id: number }) => node.node_id === id) || null;
};
export const NodeProfileCard = ({ id }: INodeProfileCardProps) => {
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { nymClient } = useNymClient();
@@ -58,8 +42,6 @@ export const NodeProfileCard = ({ id }: INodeProfileCardProps) => {
} = useQuery({
queryKey: ["nodeInfo", id],
queryFn: () => fetchNodeInfo(id),
refetchInterval: 60000,
staleTime: 60000,
});
const handleStakeOnNode = async ({
@@ -135,7 +117,9 @@ export const NodeProfileCard = ({ id }: INodeProfileCardProps) => {
if (isNodeLoading) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<div>Loading...</div>
<Skeleton variant="rectangular" height={80} width={80} />
<Skeleton variant="text" />
<Skeleton variant="text" height={200} />
</ExplorerCard>
);
}
@@ -143,10 +127,26 @@ export const NodeProfileCard = ({ id }: INodeProfileCardProps) => {
if (isNodeError || !nodeInfo) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<div>Failed to load node information.</div>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
const cleanMoniker = DOMPurify.sanitize(
nodeInfo?.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanDescription = DOMPurify.sanitize(
nodeInfo?.self_description.details,
).replace(/&amp;/g, "&");
// get full country name
const countryName = (countryCode: string) => {
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
return <span>{regionNames.of(countryCode)}</span>;
};
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
@@ -160,20 +160,25 @@ export const NodeProfileCard = ({ id }: INodeProfileCardProps) => {
mb={1}
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{nodeInfo?.self_description.moniker || "Moniker"}
{cleanMoniker || "Moniker"}
</Typography>
{nodeInfo.description.auxiliary_details.location && (
<Box display={"flex"} gap={1}>
<Typography variant="h6">Location:</Typography>
<CountryFlag
countryCode={nodeInfo.description.auxiliary_details.location}
countryName={nodeInfo.description.auxiliary_details.location}
/>
<Box>
<CountryFlag
countryCode={nodeInfo.description.auxiliary_details.location}
countryName={countryName(
nodeInfo.description.auxiliary_details.location,
)}
/>
</Box>
</Box>
)}
{nodeInfo && (
<Typography variant="body4" sx={{ color: "pine.950" }} mt={2}>
{nodeInfo.self_description.details}
{cleanDescription}
</Typography>
)}
<Box mt={3} display={"flex"} gap={1}>
@@ -1,12 +1,9 @@
"use client";
import type { ExplorerData } from "@/app/api";
import type { IObservatoryNode, RewardingDetails } from "@/app/api/types";
import {
CURRENT_EPOCH_REWARDS,
DATA_OBSERVATORY_NODES_URL,
} from "@/app/api/urls";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchEpochRewards, fetchNodeInfo } from "../../app/api";
import type { RewardingDetails } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
@@ -14,40 +11,6 @@ interface INodeRewardsCardProps {
id: number; // Node ID
}
// Fetch functions
const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch epoch rewards");
}
return response.json();
};
const fetchNodeInfo = async (id: number): Promise<IObservatoryNode | null> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const nodes: IObservatoryNode[] = await response.json();
return nodes.find((node) => node.node_id === id) || null;
};
export const NodeRewardsCard = ({ id }: INodeRewardsCardProps) => {
// Fetch epoch rewards
const {
@@ -57,8 +20,6 @@ export const NodeRewardsCard = ({ id }: INodeRewardsCardProps) => {
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
// Fetch node information
@@ -69,8 +30,6 @@ export const NodeRewardsCard = ({ id }: INodeRewardsCardProps) => {
} = useQuery({
queryKey: ["nodeInfo", id],
queryFn: () => fetchNodeInfo(id),
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (isEpochLoading || isNodeLoading) {
@@ -79,7 +38,10 @@ export const NodeRewardsCard = ({ id }: INodeRewardsCardProps) => {
label="Node rewards (last epoch/hour)"
sx={{ height: "100%" }}
>
<div>Loading...</div>
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
<Skeleton variant="text" height={50} />
</ExplorerCard>
);
}
@@ -90,7 +52,9 @@ export const NodeRewardsCard = ({ id }: INodeRewardsCardProps) => {
label="Node rewards (last epoch/hour)"
sx={{ height: "100%" }}
>
<div>Failed to load data</div>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
@@ -1,14 +1,9 @@
"use client";
import type {
GatewayStatus,
IObservatoryNode,
LastProbeResult,
NodeDescription,
} from "@/app/api/types";
import { DATA_OBSERVATORY_NODES_URL } from "@/app/api/urls";
import { Chip, Stack } from "@mui/material";
import { Chip, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchGatewayStatus, fetchNodeInfo } from "../../app/api";
import type { LastProbeResult, NodeDescription } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import StarRating from "../starRating/StarRating";
@@ -28,38 +23,6 @@ const roleMapping: Record<DeclaredRoleKey, RoleString> = {
mixnode: "Mix Node",
};
// Fetch node data based on ID
const fetchNodeInfo = async (id: number): Promise<IObservatoryNode | null> => {
const response = await fetch(DATA_OBSERVATORY_NODES_URL, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch observatory nodes");
}
const nodes: IObservatoryNode[] = await response.json();
return nodes.find((node) => node.node_id === id) || null;
};
// Fetch gateway status based on identity key
const fetchGatewayStatus = async (
identityKey: string,
): Promise<GatewayStatus | null> => {
const response = await fetch(
`https://mainnet-node-status-api.nymtech.cc/v2/gateways/${identityKey}`,
);
if (!response.ok) {
throw new Error("Failed to fetch gateway status");
}
return response.json();
};
const getNodeRoles = (
declaredRoles: NodeDescriptionNotNull["declared_role"],
): RoleString[] => {
@@ -180,39 +143,50 @@ export const QualityIndicatorsCard = ({ id }: IQualityIndicatorsCardProps) => {
// Fetch node info
const {
data: nodeInfo,
isLoading: isNodeLoading,
isError: isNodeError,
isLoading,
isError,
} = useQuery({
queryKey: ["nodeInfo", id],
queryFn: () => fetchNodeInfo(id),
refetchInterval: 60000,
staleTime: 60000,
});
// Fetch gateway status if nodeInfo is available
// Extract node roles once `nodeInfo` is available
const nodeRoles = nodeInfo
? getNodeRoles(nodeInfo.description.declared_role)
: [];
// Define whether to fetch gateway status
const shouldFetchGatewayStatus = nodeRoles.some((role) =>
["Entry Node", "Exit IPR Node", "Exit NR Node"].includes(role),
);
// Fetch gateway status only if `shouldFetchGatewayStatus` is true
const { data: gatewayStatus } = useQuery({
queryKey: ["gatewayStatus", nodeInfo?.identity_key],
queryFn: () => fetchGatewayStatus(nodeInfo?.identity_key),
enabled: !!nodeInfo?.identity_key, // Only fetch if identity key is available
queryFn: () => fetchGatewayStatus(nodeInfo?.identity_key || ""),
enabled: !!nodeInfo?.identity_key && shouldFetchGatewayStatus, // Only fetch if needed
});
if (isNodeLoading) {
if (isLoading) {
return (
<ExplorerCard label="Quality indicators">
<div>Loading...</div>
<Skeleton variant="text" height={70} />
<Skeleton variant="text" height={70} />
<Skeleton variant="text" height={300} />
</ExplorerCard>
);
}
if (isNodeError || !nodeInfo) {
if (isError || !nodeInfo) {
return (
<ExplorerCard label="Quality indicators">
<div>Failed to load node data.</div>
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
const nodeRoles = getNodeRoles(nodeInfo.description.declared_role);
const NodeRoles = nodeRoles.map((role) => (
<Stack key={role} direction="row" gap={1}>
<Chip key={role} label={role} size="small" />
@@ -254,7 +228,7 @@ export const QualityIndicatorsCard = ({ id }: IQualityIndicatorsCardProps) => {
label="Quality of service"
value={<StarRating value={qualityOfServiceStars} />}
/>
{!nodeIsMixNodeOnly && (
{!nodeIsMixNodeOnly && gatewayStatus && (
<ExplorerListItem
row
divider
@@ -262,7 +236,7 @@ export const QualityIndicatorsCard = ({ id }: IQualityIndicatorsCardProps) => {
value={<StarRating value={configScoreStars} />}
/>
)}
{!nodeIsMixNodeOnly && (
{!nodeIsMixNodeOnly && gatewayStatus && (
<ExplorerListItem
row
divider
@@ -1,9 +1,9 @@
import { colours } from "@/theme/colours";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import { Box, Typography } from "@mui/material";
import type React from "react";
import type { ReactElement } from "react";
import { colours } from "../../theme/colours";
export interface IUpDownPriceIndicatorProps {
percentage: number;
@@ -1,73 +1,31 @@
"use client";
import { Stack } from "@mui/material";
import Box from "@mui/material/Box";
import { addHours, format } from "date-fns";
import React from "react";
import ListItem from "../list/ListItem";
import ProgressBar from "./ProgressBar";
export interface IDynamicProgressBarProps {
start: string; // Start timestamp as ISO 8601 string
showEpoch: boolean;
startTime: string;
endTime: string;
progress: number;
}
const EpochProgressBar = ({ start, showEpoch }: IDynamicProgressBarProps) => {
const [progress, setProgress] = React.useState(0);
const startDate = new Date(start);
const endDate = addHours(new Date(start), 1);
const startTime = format(startDate, "HH:mm dd-MM-yyyy");
const endTime = format(endDate, "HH:mm dd-MM-yyyy");
React.useEffect(() => {
// Parse the start timestamp
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000; // Add 1 hour to the start time
// Validate start timestamp
if (Number.isNaN(startTime)) {
console.error("Invalid start timestamp:", { start });
return;
}
// Function to calculate progress
const calculateProgress = () => {
const currentTime = Date.now();
if (currentTime < startTime) {
return 0;
}
if (currentTime >= endTime) {
return 100;
}
const elapsed = currentTime - startTime;
const total = endTime - startTime;
return (elapsed / total) * 100;
};
// Set initial progress and start timer
setProgress(calculateProgress());
const timer = setInterval(() => {
setProgress(calculateProgress());
}, 60000); // Update every minute (60000 milliseconds)
// Cleanup on unmount
return () => {
clearInterval(timer);
};
}, [start]);
const EpochProgressBar = ({
startTime,
endTime,
progress,
}: IDynamicProgressBarProps) => {
return (
<Box sx={{ width: "100%" }}>
<ProgressBar value={progress} color="secondary" />
{showEpoch && (
<Box mt={3}>
<Stack gap={0}>
<ListItem row label="START" value={startTime} />
<ListItem row label="END" value={endTime} />
</Stack>
</Box>
)}
<Box mt={3}>
<Stack gap={0}>
<ListItem row label="START" value={startTime} />
<ListItem row label="END" value={endTime} />
</Stack>
</Box>
</Box>
);
};
@@ -1,6 +1,6 @@
import SimpleModal from "@/components/modal/SimpleModal";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Button, Stack, Typography } from "@mui/material";
import SimpleModal from "../../components/modal/SimpleModal";
import { formatBigNum } from "../../utils/formatBigNumbers";
const RedeemRewardsModal = ({
totalRewardsAmount,
@@ -1,10 +1,10 @@
"use client";
import type NodeData from "@/app/api/types";
import { NYM_NODE_BONDED } from "@/app/api/urls";
import type { NodeData } from "@/app/api/types";
import { Search } from "@mui/icons-material";
import { Button, CircularProgress, Stack, Typography } from "@mui/material";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { NYM_NODE_BONDED } from "../../app/api/urls";
import Input from "../input/Input";
const NodeAndAddressSearch = () => {
@@ -32,11 +32,19 @@ const NodeAndAddressSearch = () => {
return;
}
} catch {
setErrorText("Such Nym address doesn't exist");
setErrorText(
"It seems that this node or account does not exist. Please enter a complete Node ID or an existing Nym wallet address.",
);
setIsLoading(false); // Stop loading
return;
}
} else {
setErrorText("Such Nym address doesn't exist");
setErrorText(
"It seems that this node or account does not exist. Please enter a complete Node ID or an existing Nym wallet address.",
);
setIsLoading(false); // Stop loading
return;
}
} else {
@@ -55,12 +63,16 @@ const NodeAndAddressSearch = () => {
return;
}
}
setErrorText("Such Nym Node identity key doesn't exist");
setErrorText(
"It seems that this node or account does not exist. Please enter a complete Node ID or an existing Nym wallet address.",
);
setIsLoading(false); // Stop loading
}
} catch (error) {
setErrorText("An unexpected error occurred. Please try again.");
setErrorText(
"It seems that this node or account does not exist. Please enter a complete Node ID or an existing Nym wallet address.",
);
console.error(error);
} finally {
setIsLoading(false); // Stop loading
}
};
@@ -73,6 +85,11 @@ const NodeAndAddressSearch = () => {
fullWidth
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
rounded
/>
<Button
@@ -1,33 +1,12 @@
"use client";
import type { ObservatoryBalance } from "@/app/api/types";
import { DATA_OBSERVATORY_BALANCES_URL } from "@/app/api/urls";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Typography } from "@mui/material";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchOriginalStake } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
// Fetch function to get the original stake
const fetchOriginalStake = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Return the delegated amount
return Number(balances.delegated.amount);
};
const OriginalStakeCard = () => {
const { address } = useNymClient();
@@ -40,8 +19,6 @@ const OriginalStakeCard = () => {
queryKey: ["originalStake", address],
queryFn: () => fetchOriginalStake(address || ""),
enabled: !!address, // Only fetch if address exists
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (!address) {
@@ -51,7 +28,7 @@ const OriginalStakeCard = () => {
if (isLoading) {
return (
<ExplorerCard label="Original Stake">
<Typography variant="body2">Loading...</Typography>
<Skeleton variant="text" />
</ExplorerCard>
);
}
@@ -59,7 +36,7 @@ const OriginalStakeCard = () => {
if (isError) {
return (
<ExplorerCard label="Original Stake">
<Typography variant="body2" color="error">
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load original stake.
</Typography>
</ExplorerCard>
@@ -1,7 +1,7 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { Grid2 } from "@mui/material";
import { useNymClient } from "../../hooks/useNymClient";
import OriginalStakeCard from "./OriginalStakeCard";
import TotalRewardsCard from "./TotalRewardsCard";
import TotalStakeCard from "./TotalStakeCard";
@@ -1,10 +1,10 @@
import SimpleModal from "@/components/modal/SimpleModal";
import useGetWalletBalance from "@/hooks/useGetWalletBalance";
import { Button, Stack, Typography } from "@mui/material";
import { CurrencyFormField } from "@nymproject/react/currency/CurrencyFormField.js";
import { IdentityKeyFormField } from "@nymproject/react/mixnodes/IdentityKeyFormField.js";
import type { DecCoin } from "@nymproject/types";
import { useCallback, useEffect, useState } from "react";
import SimpleModal from "../../components/modal/SimpleModal";
import useGetWalletBalance from "../../hooks/useGetWalletBalance";
import ExplorerListItem from "../list/ListItem";
import stakingSchema, { MIN_AMOUNT_TO_DELEGATE } from "./schemas";
@@ -1,12 +1,19 @@
"use client";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
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 { COSMOS_KIT_USE_CHAIN } from "@/config";
import { Box, Button, Stack, Tooltip, Typography } from "@mui/material";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
import {
type MRT_ColumnDef,
@@ -14,7 +21,12 @@ import {
useMaterialReactTable,
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
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";
@@ -29,6 +41,7 @@ import { fee } from "./schemas";
type DelegationWithNodeDetails = {
node: MappedNymNode | undefined;
delegation: Delegation;
pendingEvent?: PendingEvent;
};
const ColumnHeading = ({
@@ -36,19 +49,42 @@ const ColumnHeading = ({
}: {
children: string | React.ReactNode;
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Typography sx={{ py: 2, textAlign: "center" }} variant="h5">
{children}
</Typography>
<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 } = useNymClient();
const { nymClient, address, nymQueryClient } = useNymClient();
const [delegations, setDelegations] = useState<DelegationWithNodeDetails[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [isDataLoading, setIsLoading] = useState(false);
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
open: false,
});
@@ -58,11 +94,23 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
}>();
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) return;
if (!nymClient || !address || !nymQueryClient) return;
// Fetch staking data
const fetchDelegations = async () => {
@@ -72,16 +120,68 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
return data.delegations;
};
// Combine delegations with node details
const combineDelegationsWithNode = (delegations: Delegation[]) => {
// 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;
};
@@ -89,12 +189,17 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const fetchAndMapDelegations = async () => {
const delegations = await fetchDelegations();
const delegationsWithNodeDetails =
combineDelegationsWithNode(delegations);
combineDelegationsWithNodeAndPendingEvents(
delegations,
nodes,
pendingEvents,
);
setDelegations(delegationsWithNodeDetails);
};
fetchAndMapDelegations();
}, [address, nodes, nymClient]);
}, [address, nodes, nymClient, nymQueryClient, pendingEvents]);
const handleStakeOnNode = useCallback(
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
@@ -111,13 +216,17 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
uNymFunds,
);
setSelectedNodeForStaking(undefined);
setInfoModalProps({
open: true,
title: "Success",
message: "This operation can take up to one hour to process",
tx: tx?.transactionHash,
onClose: () => setInfoModalProps({ open: false }),
onClose: async () => {
await handleRefetch();
setInfoModalProps({ open: false });
},
});
} catch (e) {
const errorMessage =
@@ -133,7 +242,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
}
setIsLoading(false);
},
[nymClient],
[nymClient, handleRefetch],
);
const handleOnSelectStake = useCallback(
@@ -183,6 +292,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
`Explorer V2: Unstaking node ${nodeId}`,
);
setIsLoading(false);
await handleRefetch();
setInfoModalProps({
open: true,
title: "Success",
@@ -204,7 +314,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
setIsLoading(false);
}
},
[address, nymClient],
[address, nymClient, handleRefetch],
);
const handleActionSelect = useCallback(
@@ -223,6 +333,30 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
[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(
() => [
{
@@ -240,39 +374,52 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
),
},
{
id: "node",
id: "id",
header: "",
Header: <ColumnHeading>Node</ColumnHeading>,
Header: <ColumnHeading>Node ID</ColumnHeading>,
accessorKey: "delegation.node_id",
size: 90,
Cell: ({ row }) =>
row.original.delegation?.node_id ? (
<Stack spacing={1}>
<Typography variant="body4">
{row.original.delegation.node_id || "-"}
</Typography>
<Typography variant="body5">
{row.original.node?.identity_key || "-"}
</Typography>
</Stack>
<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 ? (
<Tooltip title={row.original.node?.countryName}>
<Box>
<CountryFlag
countryCode={row.original.node.countryCode}
countryName={row.original.node.countryCode}
/>
</Box>
</Tooltip>
<Box>
<CountryFlag
countryCode={row.original.node.countryCode}
countryName={countryName(row.original.node?.countryName) || ""}
/>
</Box>
) : (
"-"
),
@@ -282,6 +429,17 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
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)}{" "}
@@ -293,7 +451,14 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
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">
@@ -308,6 +473,8 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
header: "Favorite",
accessorKey: "Favorite",
enableColumnFilter: false,
size: 80,
Header: (
<Stack direction="row" alignItems="center">
<ColumnHeading>Favorite</ColumnHeading>
@@ -335,24 +502,40 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
{
id: "action",
header: "Action",
enableColumnFilter: false,
Header: <ColumnHeading>Action</ColumnHeading>,
Cell: ({ row }) => (
<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,
);
}}
/>
),
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],
[handleActionSelect, favorites, getTooltipTitle, countryName],
);
const table = useMaterialReactTable({
@@ -365,12 +548,21 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
enableHiding: false,
paginationDisplayMode: "pages",
renderEmptyRowsFallback: () => (
<Stack gap={3} sx={{ p: 5 }} justifyContent="center" alignItems="center">
<Typography variant="body3">
<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&apos;t staked on any nodes yet. Stake on a node to start
earning rewnotards.
earning rewards.
</Typography>
<Button variant="contained" size="large">
<Button
variant="contained"
size="large"
onClick={(e) => e.stopPropagation()}
>
<Link href="/explorer" underline="none" color="inherit">
Stake
</Link>
@@ -390,7 +582,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
initialState: {
columnPinning: { right: ["Action", "Favorite"] },
columnPinning: isMobile ? {} : { right: ["Action", "Favorite"] }, // No pinning on mobile
},
muiColumnActionsButtonProps: {
@@ -402,6 +594,11 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
muiTablePaperProps: {
elevation: 0,
},
muiTableHeadCellProps: {
sx: {
alignItems: "center",
},
},
muiTableHeadRowProps: {
sx: {
bgcolor: "background.paper",
@@ -411,6 +608,8 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
muiTableBodyCellProps: {
sx: {
border: "none",
whiteSpace: "unset", // Allow text wrapping in body cells
wordBreak: "break-word",
},
},
muiTableBodyRowProps: ({ row }) => ({
@@ -443,7 +642,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
return (
<div>
{isLoading && <Loading />}
{isDataLoading && <Loading />}
<StakeModal
nodeId={selectedNodeForStaking?.nodeId}
identityKey={selectedNodeForStaking?.identityKey}
@@ -1,30 +1,12 @@
"use client";
import getNymNodes from "@/actions/getNymNodes";
import type { ExplorerData } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { CURRENT_EPOCH_REWARDS } from "@/app/api/urls";
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { ExplorerData, IObservatoryNode } from "../../app/api/types";
import StakeTable from "./StakeTable";
// Fetch function for epoch rewards
const fetchEpochRewards = async (): Promise<
ExplorerData["currentEpochRewardsData"]
> => {
const response = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
});
if (!response.ok) {
throw new Error("Failed to fetch epoch rewards");
}
return response.json();
};
// Utility function to calculate node saturation point
function getNodeSaturationPoint(
totalStake: number,
@@ -37,7 +19,7 @@ function getNodeSaturationPoint(
}
const ratio = (totalStake / saturation) * 100;
return Number(ratio.toFixed());
return Number.parseFloat(ratio.toFixed());
}
// Map nodes with rewards data
@@ -51,8 +33,12 @@ const mappedNymNodes = (
epochRewardsData.interval.stake_saturation_point,
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
return {
name: node.self_description.moniker,
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.description.auxiliary_details.location || null,
@@ -60,7 +46,7 @@ const mappedNymNodes = (
profitMarginPercentage:
+node.rewarding_details.cost_params.profit_margin_percent * 100,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint || 0,
stakeSaturation: +nodeSaturationPoint || 0,
};
});
@@ -76,8 +62,6 @@ const StakeTableWithAction = () => {
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 60000, // Data is fresh for 60 seconds
refetchInterval: 60000, // Refetch every 60 seconds
});
// Use React Query to fetch Nym nodes
@@ -87,23 +71,41 @@ const StakeTableWithAction = () => {
isError: isNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: getNymNodes,
staleTime: 60000,
refetchInterval: 60000,
queryFn: fetchObservatoryNodes,
});
// Handle loading state
if (isEpochLoading || isNodesLoading) {
return <div>Loading stake table...</div>;
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
<Skeleton variant="text" height={100} />
</CardContent>
</Card>
);
}
// Handle error state
if (isEpochError || isNodesError) {
return <div>Error loading stake table data. Please try again later.</div>;
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
Error loading stake table data. Please try again later.
</Typography>
</Stack>
);
}
// Map nodes with rewards data
const data = mappedNymNodes(nymNodes, epochRewardsData);
if (!epochRewardsData) {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
return <StakeTable nodes={data} />;
};
@@ -1,5 +1,5 @@
import NextEpochTime from "@/components/epochtime/EpochTime";
import Grid2 from "@mui/material/Grid2";
import NextEpochTime from "../../components/epochtime/EpochTime";
import SubHeaderRowActions from "./SubHeaderRowActions";
const SubHeaderRow = () => {
@@ -1,14 +1,14 @@
"use client";
import type { NodeRewardDetails, ObservatoryBalance } from "@/app/api/types";
import { DATA_OBSERVATORY_BALANCES_URL } from "@/app/api/urls";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "@/config";
import { useNymClient } from "@/hooks/useNymClient";
import { useChain } from "@cosmos-kit/react";
import { Button, Stack } from "@mui/material";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { fetchTotalStakerRewards } from "../../app/api";
import type { NodeRewardDetails } from "../../app/api/types";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import Loading from "../loading";
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
import RedeemRewardsModal from "../redeemRewards/RedeemRewardsModal";
@@ -25,24 +25,6 @@ const fetchDelegations = async (
return data.delegations;
};
// Fetch total staker rewards
const fetchTotalRewards = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
return Number(balances.rewards.staking_rewards.amount);
};
const SubHeaderRowActions = () => {
const [openRedeemRewardsModal, setOpenRedeemRewardsModal] =
useState<boolean>(false);
@@ -54,6 +36,8 @@ const SubHeaderRowActions = () => {
const { address, nymClient } = useNymClient();
const { getSigningCosmWasmClient } = useChain(COSMOS_KIT_USE_CHAIN);
const queryClient = useQueryClient();
// Fetch delegations using React Query
const {
data: delegations = [],
@@ -63,8 +47,6 @@ const SubHeaderRowActions = () => {
queryKey: ["delegations", address],
queryFn: () => fetchDelegations(address || "", nymClient),
enabled: !!address && !!nymClient, // Only fetch if address and nymClient are available
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000,
});
// Fetch total rewards using React Query
@@ -72,14 +54,18 @@ const SubHeaderRowActions = () => {
data: totalStakerRewards = 0,
isLoading: isRewardsLoading,
isError: isRewardsError,
refetch,
} = useQuery({
queryKey: ["totalRewards", address],
queryFn: () => fetchTotalRewards(address || ""),
queryKey: ["totalStakerRewards", address],
queryFn: () => fetchTotalStakerRewards(address || ""),
enabled: !!address, // Only fetch if address is available
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000,
});
const handleRefetch = useCallback(async () => {
refetch();
queryClient.invalidateQueries(); // This will refetch ALL active queries
}, [queryClient, refetch]);
const handleRedeemRewards = useCallback(async () => {
setIsLoading(true);
setOpenRedeemRewardsModal(false);
@@ -107,13 +93,18 @@ const SubHeaderRowActions = () => {
fee,
"Redeeming all rewards",
);
console.log("Rewards redeemed successfully:", result);
// Success state
setIsLoading(false);
setInfoModalProps({
open: true,
title: "Success",
message: "All rewards have been redeemed successfully!",
onClose: () => setInfoModalProps({ open: false }),
tx: result?.transactionHash,
onClose: async () => {
await handleRefetch();
setInfoModalProps({ open: false });
},
});
} catch (error) {
console.error("Error redeeming rewards:", error);
@@ -127,7 +118,13 @@ const SubHeaderRowActions = () => {
} finally {
setIsLoading(false);
}
}, [address, nymClient, delegations, getSigningCosmWasmClient]);
}, [
address,
nymClient,
delegations,
getSigningCosmWasmClient,
handleRefetch,
]);
const handleRedeemRewardsButtonClick = () => {
setOpenRedeemRewardsModal(true);
@@ -153,7 +150,11 @@ const SubHeaderRowActions = () => {
return (
<Stack direction="row" spacing={3} justifyContent={"end"}>
<Button variant="contained" onClick={handleRedeemRewardsButtonClick}>
<Button
variant="contained"
onClick={handleRedeemRewardsButtonClick}
disabled={totalStakerRewards === 0}
>
Redeem NYM
</Button>
{isLoading && <Loading />}
@@ -1,33 +1,12 @@
"use client";
import type { ObservatoryBalance } from "@/app/api/types";
import { DATA_OBSERVATORY_BALANCES_URL } from "@/app/api/urls";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Typography } from "@mui/material";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchTotalStakerRewards } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
// Fetch function to get total staker rewards
const fetchTotalStakerRewards = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Return the staking rewards amount
return Number(balances.rewards.staking_rewards.amount);
};
const TotalRewardsCard = () => {
const { address } = useNymClient();
@@ -40,8 +19,6 @@ const TotalRewardsCard = () => {
queryKey: ["totalStakerRewards", address],
queryFn: () => fetchTotalStakerRewards(address || ""),
enabled: !!address, // Only fetch if address exists
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (!address) {
@@ -51,7 +28,7 @@ const TotalRewardsCard = () => {
if (isLoading) {
return (
<ExplorerCard label="Total Rewards">
<Typography variant="body2">Loading...</Typography>
<Skeleton variant="text" />
</ExplorerCard>
);
}
@@ -59,7 +36,7 @@ const TotalRewardsCard = () => {
if (isError) {
return (
<ExplorerCard label="Total Rewards">
<Typography variant="body2" color="error">
<Typography variant="h3" sx={{ color: "pine.950" }}>
Failed to load total rewards.
</Typography>
</ExplorerCard>
@@ -1,36 +1,12 @@
"use client";
import type { ObservatoryBalance } from "@/app/api/types";
import { DATA_OBSERVATORY_BALANCES_URL } from "@/app/api/urls";
import { useNymClient } from "@/hooks/useNymClient";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Typography } from "@mui/material";
import { Skeleton, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchBalances } from "../../app/api";
import { useNymClient } from "../../hooks/useNymClient";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
// Fetch balances based on the address
const fetchBalances = async (address: string): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error("Failed to fetch balances");
}
const balances: ObservatoryBalance = await response.json();
// Calculate total stake
return (
Number(balances.rewards.staking_rewards.amount) +
Number(balances.delegated.amount)
);
};
const TotalStakeCard = () => {
const { address } = useNymClient();
@@ -43,8 +19,6 @@ const TotalStakeCard = () => {
queryKey: ["totalStake", address],
queryFn: () => fetchBalances(address || ""),
enabled: !!address, // Only fetch if address exists
refetchInterval: 60000, // Refetch every 60 seconds
staleTime: 60000, // Data is fresh for 60 seconds
});
if (!address) {
@@ -54,7 +28,7 @@ const TotalStakeCard = () => {
if (isLoading) {
return (
<ExplorerCard label="Total Stake">
<Typography variant="body2">Loading...</Typography>
<Skeleton variant="text" />
</ExplorerCard>
);
}
@@ -62,7 +36,7 @@ const TotalStakeCard = () => {
if (isError) {
return (
<ExplorerCard label="Total Stake">
<Typography variant="body2" color="error">
<Typography variant="h3" color="error">
Failed to load total stake.
</Typography>
</ExplorerCard>
@@ -1,5 +1,5 @@
import { validateAmount } from "@/utils/currency";
import { z } from "zod";
import { validateAmount } from "../../utils/currency";
const MIN_AMOUNT_TO_DELEGATE = "10";
const fee = { gas: "1000000", amount: [{ amount: "1000000", denom: "unym" }] };
@@ -36,7 +36,6 @@ const stakingSchema = z
(data) => {
const balance = Number.parseFloat(data.balance);
const amount = Number.parseFloat(data.amount);
console.log(balance);
return balance - amount >= 0;
},
{
@@ -1,6 +1,5 @@
"use client";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useChain } from "@cosmos-kit/react";
import {
Box,
@@ -9,6 +8,7 @@ import {
IconButton,
Typography,
} from "@mui/material";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import Cross from "../icons/Cross";
import { WalletAddress } from "./WalletAddress";
import { WalletBalance } from "./WalletBalance";
@@ -1,6 +1,6 @@
import { Elips } from "@/components/icons/Elips";
import { Stack, Typography } from "@mui/material";
import React from "react";
import { Elips } from "../../components/icons/Elips";
export const trimAddress = (address = "", trimBy = 6) =>
`${address.slice(0, trimBy)}...${address.slice(-trimBy)}`;
@@ -1,10 +1,32 @@
import { Token } from "@/components/icons/Token";
import useGetWalletBalance from "@/hooks/useGetWalletBalance";
"use client";
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { useChain } from "@cosmos-kit/react";
import { Stack, Typography } from "@mui/material";
import React from "react";
import { Token } from "../../components/icons/Token";
import useGetWalletBalance from "../../hooks/useGetWalletBalance";
export const WalletBalance = () => {
const { formattedBalance } = useGetWalletBalance();
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
const { formattedBalance, isLoading, isError, refetch } =
useGetWalletBalance();
if (isLoading) {
return (
<Stack direction="row" alignItems="center" gap={1}>
<Token />
<Typography variant="h5" fontWeight={400}>
Loading...
</Typography>
</Stack>
);
}
if (isError) {
if (isWalletConnected) {
refetch();
}
return;
}
return (
<Stack direction="row" alignItems="center" gap={1}>
@@ -1,5 +1,5 @@
{
"title": "Welcome to the allNym Network Explorer",
"title": "Welcome to the Nym Network Explorer",
"label": "Onboarding",
"description": "Let's unpack the key features of the long awaited features together!",
"attributes": {
@@ -36,7 +36,7 @@
},
{
"type": "paragraph",
"text": "**Designed in a mobile first way, with integrated onboarding articles and live network data. The application is intentionally kept lightweight and barebones, while opening up a new social interaction layer of the mixnet. The NGM Explorer is currently in open beta release, which means that each released version of the app is live-tested by the core community of Nym node operators, where future features and updates are informed by feedback on the Node Operators forum."
"text": "Designed in a mobile first way, with integrated onboarding articles and live network data. The application is intentionally kept lightweight and barebones, while opening up a new social interaction layer of the mixnet. The NGM Explorer is currently in open beta release, which means that each released version of the app is live-tested by the core community of Nym node operators, where future features and updates are informed by feedback on the Node Operators forum."
}
]
},
@@ -0,0 +1,53 @@
import type { PendingEpochEventKind } from "@nymproject/contract-clients/Mixnet.types";
import { useQuery } from "@tanstack/react-query";
export const getEventsByAddress = (
kind: PendingEpochEventKind,
address: string,
) => {
if ("delegate" in kind && kind.delegate.owner === address) {
return {
kind: "delegate" as const,
mixId: kind.delegate.node_id,
amount: kind.delegate.amount,
};
}
if ("undelegate" in kind && kind.undelegate.owner === address) {
return {
kind: "undelegate" as const,
mixId: kind.undelegate.node_id,
};
}
return undefined;
};
export type PendingEvent = ReturnType<typeof getEventsByAddress>;
// Custom Hook for fetching pending events
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const usePendingEvents = (nymQueryClient: any, address: string | undefined) => {
return useQuery({
queryKey: ["pendingEvents", address], // Query key to uniquely identify this query
queryFn: async () => {
if (!nymQueryClient || !address) {
throw new Error("Missing required dependencies");
}
const response = await nymQueryClient.getPendingEpochEvents({});
const pendingEvents: PendingEvent[] = [];
for (const e of response.events) {
const event = getEventsByAddress(e.event.kind, address);
if (event) {
pendingEvents.push(event);
}
}
return pendingEvents;
},
enabled: !!nymQueryClient && !!address, // Prevents execution if dependencies are missing
});
};
export default usePendingEvents;
@@ -1,46 +1,42 @@
import { COSMOS_KIT_USE_CHAIN } from "@/config";
import { unymToNym } from "@/utils/currency";
import { useChain } from "@cosmos-kit/react";
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { COSMOS_KIT_USE_CHAIN } from "../config";
import { unymToNym } from "../utils/currency";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetchNYMBalance = async (address: string, getCosmWasmClient: any) => {
if (!address) return { NYMBalance: "0", formattedBalance: "-" };
const account = await getCosmWasmClient();
const uNYMBalance = await account.getBalance(address, "unym");
const NYMBalance = unymToNym(uNYMBalance.amount);
if (!NYMBalance) return;
const formattedBalance = Intl.NumberFormat().format(+NYMBalance);
return { NYMBalance, formattedBalance };
};
const useGetWalletBalance = () => {
const [balance, setBalance] = useState<string>("0");
const [formattedBalance, setFormattedBalance] = useState<string>("-");
const { getCosmWasmClient, address } = useChain(COSMOS_KIT_USE_CHAIN);
const getNYMBalance = useCallback(
async (address: string) => {
const account = await getCosmWasmClient();
const uNYMBalance = await account.getBalance(address, "unym");
const NYMBalance = unymToNym(uNYMBalance.amount);
if (!NYMBalance) {
return undefined;
}
const formattedBalance = Intl.NumberFormat().format(+NYMBalance);
return {
NYMBalance,
formattedBalance,
};
},
[getCosmWasmClient],
);
const {
data = { NYMBalance: "0", formattedBalance: "-" },
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["nymBalance", address],
queryFn: () => fetchNYMBalance(address || "", getCosmWasmClient),
enabled: !!address, // Only fetch if address exists
});
useEffect(() => {
if (!address) {
return;
}
getNYMBalance(address)
.then((balance) => {
setFormattedBalance(balance?.formattedBalance || "-");
setBalance(balance?.NYMBalance || "0");
})
.catch((e) => {
console.error("Failed to get balance", e);
});
}, [address, getNYMBalance]);
return { balance, formattedBalance };
return {
balance: data.NYMBalance,
formattedBalance: data.formattedBalance,
isLoading,
isError,
refetch, // Expose refetch function if needed
};
};
export default useGetWalletBalance;
+1 -1
View File
@@ -1,6 +1,5 @@
"use client";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "@/config";
import { useChain } from "@cosmos-kit/react";
import { contracts } from "@nymproject/contract-clients";
import type {
@@ -8,6 +7,7 @@ import type {
MixnetQueryClient,
} from "@nymproject/contract-clients/Mixnet.client";
import { useEffect, useState } from "react";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "../config";
export const useNymClient = () => {
const [nymClient, setNymClient] = useState<MixnetClient>();
@@ -0,0 +1,109 @@
"use client";
import { fetchCurrentEpoch } from "@/app/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { differenceInMilliseconds } from "date-fns";
import { createContext, useContext, useEffect, useState } from "react";
type EpochStatus = "active" | "pending";
export type EpochResponseData =
| Awaited<ReturnType<typeof fetchCurrentEpoch>>
| undefined;
type EpochContext = {
epochStatus: EpochStatus;
data: EpochResponseData;
isError: boolean;
isLoading: boolean;
};
const initialState = {
epochStatus: "pending" as const,
data: undefined,
isError: false,
isLoading: false,
};
const EpochContext = createContext<EpochContext>(initialState);
const checkIsEpochTimeValid = (epochEndTime: string) =>
new Date(epochEndTime) >= new Date();
const calculateRefetchInterval = (epochEndTime: string) => {
return differenceInMilliseconds(new Date(epochEndTime), new Date());
};
const useEpochContext = () => {
const context = useContext(EpochContext);
if (context === undefined) {
throw new Error("useEpochContext must be used within a EpochProvider");
}
return context;
};
const EpochProvider = ({ children }: { children: React.ReactNode }) => {
const [epochStatus, setEpochStatus] = useState<EpochStatus>("pending");
const QueryClient = useQueryClient();
const { data, isError, isLoading } = useQuery({
refetchOnWindowFocus: false,
queryKey: ["currentEpoch"],
queryFn: fetchCurrentEpoch,
refetchInterval: ({ state }) => {
// refetchInterval can be set dynamically based on the current state
// if there is no data, refetch in 30 secs
if (!state.data) {
return 30_000;
}
const isEpochTimeValid = checkIsEpochTimeValid(
state.data.current_epoch_end.toString(),
);
// if epoch time is not valid (i.e current_time > epoch_start_time) refetch in 30 secs
if (!isEpochTimeValid) {
setEpochStatus("pending");
return 30_000;
}
// if epoch time is valid, refetch based on the epoch end time
const newRefetchInterval = calculateRefetchInterval(
state.data.current_epoch_end.toString(),
);
setEpochStatus("active");
return newRefetchInterval;
},
});
useEffect(() => {
const refreshQueries = async () => {
await QueryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] !== "currentEpoch",
});
};
// when new epoch starts, refresh all data
if (epochStatus === "active") {
refreshQueries();
}
}, [epochStatus, QueryClient]);
const value = {
epochStatus,
data,
isError,
isLoading,
};
return (
<EpochContext.Provider value={value}>{children}</EpochContext.Provider>
);
};
export { EpochProvider, useEpochContext };
@@ -1,8 +1,8 @@
"use client";
import { lightTheme } from "@/theme/theme";
import { CssBaseline } from "@mui/material";
import { ThemeProvider as MUIThemeProvider } from "@mui/material";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { lightTheme } from "../theme/theme";
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
+6 -3
View File
@@ -1,13 +1,16 @@
import CosmosKitProvider from "./CosmosKitProvider";
import { EpochProvider } from "./EpochProvider";
import { QueryProvider } from "./QueryProvider";
import ThemeProvider from "./ThemeProvider";
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<ThemeProvider>
<CosmosKitProvider>
<QueryProvider>{children}</QueryProvider>
</CosmosKitProvider>
<QueryProvider>
<EpochProvider>
<CosmosKitProvider>{children}</CosmosKitProvider>
</EpochProvider>
</QueryProvider>
</ThemeProvider>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { labGrotesque, labGrotesqueMono } from "@/fonts";
import { labGrotesque, labGrotesqueMono } from "../fonts";
export const headings = {
display: {
-1
View File
@@ -1 +0,0 @@
declare module "@tanstack/react-query";
+856 -5
View File
File diff suppressed because it is too large Load Diff