diff --git a/explorer-v2/src/app/(pages)/table/page.tsx b/explorer-v2/src/app/(pages)/table/page.tsx index ba0bffa9c6..2b9b04d77b 100644 --- a/explorer-v2/src/app/(pages)/table/page.tsx +++ b/explorer-v2/src/app/(pages)/table/page.tsx @@ -7,11 +7,9 @@ import { Wrapper } from "@/components/wrapper"; import { Box, Stack } from "@mui/material"; // import Grid from "@mui/material/Grid2"; -import { RECOMMENDED_NODES } from "@/app/constants"; // ⬅ dynamic Promise export default async function ExplorerPage() { // Resolve once on the server and pass IDs to client components - const recommendedIds = await RECOMMENDED_NODES; return ( @@ -21,7 +19,7 @@ export default async function ExplorerPage() { - + {/* diff --git a/explorer-v2/src/app/api/index.tsx b/explorer-v2/src/app/api/index.tsx index 062654abdf..c841b243cb 100644 --- a/explorer-v2/src/app/api/index.tsx +++ b/explorer-v2/src/app/api/index.tsx @@ -16,10 +16,10 @@ import type { import { CURRENT_EPOCH, CURRENT_EPOCH_REWARDS, - DATA_OBSERVATORY_BALANCES_URL, + SPECTREDAO_BALANCES_URL, NS_API_NODES, NYM_ACCOUNT_ADDRESS, - NYM_PRICES_API, + SPECTREDAO_NYM_PRICES_API, OBSERVATORY_GATEWAYS_URL, } from "./urls"; @@ -44,7 +44,7 @@ export const fetchEpochRewards = async (): Promise< // Fetch gateway status based on identity key export const fetchGatewayStatus = async ( - identityKey: string, + identityKey: string ): Promise => { const response = await fetch(`${OBSERVATORY_GATEWAYS_URL}/${identityKey}`); @@ -56,7 +56,7 @@ export const fetchGatewayStatus = async ( }; export const fetchNodeDelegations = async ( - id: number, + id: number ): Promise => { const response = await fetch(`${NS_API_NODES}/${id}/delegations`, { headers: { @@ -88,7 +88,7 @@ export const fetchCurrentEpoch = async () => { const data: CurrentEpochData = await response.json(); const epochEndTime = addSeconds( new Date(data.current_epoch_start), - data.epoch_length.secs, + data.epoch_length.secs ).toISOString(); return { ...data, current_epoch_end: epochEndTime }; @@ -96,7 +96,7 @@ export const fetchCurrentEpoch = async () => { // Fetch balances based on the address export const fetchBalances = async (address: string): Promise => { - const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, { + const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, { headers: { Accept: "application/json", "Content-Type": "application/json; charset=utf-8", @@ -117,10 +117,8 @@ export const fetchBalances = async (address: string): Promise => { }; // Fetch function to get total staker rewards -export const fetchTotalStakerRewards = async ( - address: string, -): Promise => { - const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, { +export const fetchTotalStakerRewards = async (address: string): Promise => { + const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, { headers: { Accept: "application/json", "Content-Type": "application/json; charset=utf-8", @@ -139,7 +137,7 @@ export const fetchTotalStakerRewards = async ( // Fetch function to get the original stake export const fetchOriginalStake = async (address: string): Promise => { - const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, { + const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, { headers: { Accept: "application/json", "Content-Type": "application/json; charset=utf-8", @@ -159,7 +157,7 @@ export const fetchOriginalStake = async (address: string): Promise => { export const fetchNoise = async (): Promise => { if (!process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS) { throw new Error( - "NEXT_PUBLIC_NS_API_MIXNODES_STATS environment variable is not defined", + "NEXT_PUBLIC_NS_API_MIXNODES_STATS environment variable is not defined" ); } const response = await fetch(process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS, { @@ -175,7 +173,7 @@ export const fetchNoise = async (): Promise => { // Fetch Account Balance export const fetchAccountBalance = async ( - address: string, + address: string ): Promise => { const res = await fetch(`${NYM_ACCOUNT_ADDRESS}/${address}`, { headers: { @@ -193,7 +191,7 @@ export const fetchAccountBalance = async ( // 🔹 Fetch NYM Price export const fetchNymPrice = async (): Promise => { - const res = await fetch(NYM_PRICES_API, { + const res = await fetch(SPECTREDAO_NYM_PRICES_API, { headers: { Accept: "application/json", "Content-Type": "application/json; charset=utf-8", @@ -306,3 +304,89 @@ export const fetchWorldMapCountries = async (): Promise<{ totalServers: nodes.length, }; }; + +export const getRecommendedNodes = (nodes: NS_NODE[]): number[] => { + function toNumber(x: unknown, fallback = 0): number { + const n = + typeof x === "string" || typeof x === "number" ? Number(x) : Number.NaN; + return Number.isFinite(n) ? n : fallback; + } + + const MIN_STAKE = 50_000_000_000; // 50k NYM (uNYM) + const MAX_STAKE = 150_000_000_000; // 150k NYM (uNYM) + const MAX_PM = 0.2; // ≤ 20% + const MIN_UPTIME = 0.95; // ≥ 95% + + // require gateway roles: entry + exit_ipr + exit_nr; NOT a mixnode + function hasRequiredRoles(n: NS_NODE): boolean { + const r = n.self_description?.declared_role; + if (!r) return false; + const mixnodeFalse = r.mixnode === false || r.mixnode === undefined; + return mixnodeFalse && !!r.entry && !!r.exit_ipr && !!r.exit_nr; + } + + function hasGoodPM(n: NS_NODE): boolean { + const pm = toNumber( + n.rewarding_details?.cost_params?.profit_margin_percent, + Number.NaN + ); + return !Number.isNaN(pm) && pm <= MAX_PM; + } + + function stakeInRange(n: NS_NODE): boolean { + const s = toNumber(n.total_stake, 0); + return s > MIN_STAKE && s < MAX_STAKE; + } + + function meetsUptime(n: NS_NODE): boolean { + const u = toNumber(n.uptime, -1); + return u >= MIN_UPTIME; + } + + function wireguardOn(n: NS_NODE): boolean { + return n.self_description?.wireguard != null; + } + + function sortByUptimeDescStakeAsc(a: NS_NODE, b: NS_NODE): number { + const ua = toNumber(a.uptime, 0); + const ub = toNumber(b.uptime, 0); + if (ub !== ua) return ub - ua; // higher uptime first + const sa = toNumber(a.total_stake, 0); + const sb = toNumber(b.total_stake, 0); + return sa - sb; // then lower stake first + } + const baseFilter = (n: NS_NODE) => + (n.bonded === true || n.bonded === undefined) && + hasRequiredRoles(n) && + hasGoodPM(n) && + stakeInRange(n) && + meetsUptime(n); // uptime hard floor + + // prefer wg-enabled nodes first + const wgCandidates = nodes + .filter((n) => baseFilter(n) && wireguardOn(n)) + .sort(sortByUptimeDescStakeAsc); + + let picked = wgCandidates.slice(0, 10); + + // if fewer than 10, drop wg pref but keep base filter + if (picked.length < 10) { + const relaxed = nodes.filter(baseFilter).sort(sortByUptimeDescStakeAsc); + const have = new Set(picked.map((n) => n.node_id)); + for (const n of relaxed) { + if (have.size >= 10) break; + const id = + typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0); + if (!have.has(id)) { + picked = [...picked, n]; + have.add(id); + } + } + } + + return picked + .map((n) => + typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0) + ) + .filter((id) => Number.isFinite(id) && id > 0); +}; diff --git a/explorer-v2/src/app/api/urls.ts b/explorer-v2/src/app/api/urls.ts index b0631e617f..3653bb96bd 100644 --- a/explorer-v2/src/app/api/urls.ts +++ b/explorer-v2/src/app/api/urls.ts @@ -5,10 +5,11 @@ export const CURRENT_EPOCH_REWARDS = export const NYM_ACCOUNT_ADDRESS = "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 SPECTREDAO_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"; -export const DATA_OBSERVATORY_BALANCES_URL = +export const SPECTREDAO_BALANCES_URL = "https://api.nym.spectredao.net/api/v1/balances"; export const OBSERVATORY_GATEWAYS_URL = "https://mainnet-node-status-api.nymtech.cc/v2/gateways"; diff --git a/explorer-v2/src/app/constants.ts b/explorer-v2/src/app/constants.ts index c48f3d0c1d..ee1e343744 100644 --- a/explorer-v2/src/app/constants.ts +++ b/explorer-v2/src/app/constants.ts @@ -1,5 +1,3 @@ export const TABLET_WIDTH = "(min-width:700px)"; -import { getRecommendedNodes } from "./lib/recommended"; -export const RECOMMENDED_NODES = getRecommendedNodes(); diff --git a/explorer-v2/src/app/lib/recommended.ts b/explorer-v2/src/app/lib/recommended.ts deleted file mode 100644 index b58f2143d3..0000000000 --- a/explorer-v2/src/app/lib/recommended.ts +++ /dev/null @@ -1,174 +0,0 @@ -// NOTE: removed the cache() import to avoid sticky results across requests -// import { cache } from "react"; - -type DeclaredRole = { - entry?: boolean; - exit_ipr?: boolean; - exit_nr?: boolean; - mixnode?: boolean; -}; - -type ApiNode = { - node_id?: number; - total_stake?: number | string; - uptime?: number | string; // fraction 0..1 - description?: { - wireguard?: unknown | null; - declared_role?: DeclaredRole; - }; - self_description?: { - declared_role?: DeclaredRole; - }; - rewarding_details?: { - cost_params?: { - profit_margin_percent?: string | number; - }; - }; - bonded?: boolean; -}; - -function toNumber(x: unknown, fallback = 0): number { - const n = - typeof x === "string" || typeof x === "number" ? Number(x) : Number.NaN; - return Number.isFinite(n) ? n : fallback; -} - -const MIN_STAKE = 50_000_000_000; // 50k NYM (uNYM) -const MAX_STAKE = 150_000_000_000; // 150k NYM (uNYM) -const MAX_PM = 0.2; // ≤ 20% -const MIN_UPTIME = 0.95; // ≥ 95% - -// require gateway roles: entry + exit_ipr + exit_nr; NOT a mixnode -function hasRequiredRoles(n: ApiNode): boolean { - const r = - n.self_description?.declared_role ?? n.description?.declared_role ?? {}; - const mixnodeFalse = r.mixnode === false || r.mixnode === undefined; - return mixnodeFalse && !!r.entry && !!r.exit_ipr && !!r.exit_nr; -} - -function hasGoodPM(n: ApiNode): boolean { - const pm = toNumber( - n.rewarding_details?.cost_params?.profit_margin_percent, - Number.NaN, - ); - return !Number.isNaN(pm) && pm <= MAX_PM; -} - -function stakeInRange(n: ApiNode): boolean { - const s = toNumber(n.total_stake, 0); - return s > MIN_STAKE && s < MAX_STAKE; -} - -function meetsUptime(n: ApiNode): boolean { - const u = toNumber(n.uptime, -1); - return u >= MIN_UPTIME; -} - -function wireguardOn(n: ApiNode): boolean { - return n.description?.wireguard != null; -} - -function sortByUptimeDescStakeAsc(a: ApiNode, b: ApiNode): number { - const ua = toNumber(a.uptime, 0); - const ub = toNumber(b.uptime, 0); - if (ub !== ua) return ub - ua; // higher uptime first - const sa = toNumber(a.total_stake, 0); - const sb = toNumber(b.total_stake, 0); - return sa - sb; // then lower stake first -} - -// fetch all for the nodes API -async function fetchAllNodes(): Promise { - const base = "https://api.nym.spectredao.net/api/v1/nodes"; - - // 1. Try limit/offset - { - const limit = 1000; - const all: ApiNode[] = []; - let offset = 0; - for (let i = 0; i < 200; i++) { - const res = await fetch(`${base}?limit=${limit}&offset=${offset}`, { - cache: "no-store", - }); - if (!res.ok) break; - const js = await res.json(); - const data: ApiNode[] = Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : []; - if (!data.length) break; - all.push(...data); - if (data.length < limit) return all; - offset += limit; - } - if (all.length) return all; - } - - // 2. try page/size - { - const size = 1000; - const all: ApiNode[] = []; - let page = 0; - for (let i = 0; i < 200; i++) { - const res = await fetch(`${base}?page=${page}&size=${size}`, { - cache: "no-store", - }); - if (!res.ok) break; - const js = await res.json(); - const data: ApiNode[] = Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : []; - if (!data.length) break; - all.push(...data); - const total = Number(js?.pagination?.total ?? Number.NaN); - if (Number.isFinite(total) && all.length >= total) return all; - page += 1; - } - if (all.length) return all; - } - - // 3. fallback single-shot - const res = await fetch(base, { cache: "no-store" }); - if (!res.ok) throw new Error(`Failed to fetch nodes: ${res.status}`); - const js = await res.json(); - return Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : []; -} - -async function fetchRecommendedNodes(): Promise { - const nodes = await fetchAllNodes(); - - const baseFilter = (n: ApiNode) => - (n.bonded === true || n.bonded === undefined) && - hasRequiredRoles(n) && - hasGoodPM(n) && - stakeInRange(n) && - meetsUptime(n); // uptime hard floor - - // prefer wg-enabled nodes first - const wgCandidates = nodes - .filter((n) => baseFilter(n) && wireguardOn(n)) - .sort(sortByUptimeDescStakeAsc); - - let picked = wgCandidates.slice(0, 10); - - // if fewer than 10, drop wg pref but keep base filter - if (picked.length < 10) { - const relaxed = nodes.filter(baseFilter).sort(sortByUptimeDescStakeAsc); - const have = new Set(picked.map((n) => n.node_id)); - for (const n of relaxed) { - if (have.size >= 10) break; - const id = typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0); - if (!have.has(id)) { - picked = [...picked, n]; - have.add(id); - } - } - } - - return picked - .map((n) => - typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0), - ) - .filter((id) => Number.isFinite(id) && id > 0); -} - -// keep the same exports the code expects: -export async function getRecommendedNodes(): Promise { - return fetchRecommendedNodes(); -} -export const RECOMMENDED_NODES: Promise = getRecommendedNodes(); diff --git a/explorer-v2/src/app/page.tsx b/explorer-v2/src/app/page.tsx index 9c34cbb123..0cc48d3243 100644 --- a/explorer-v2/src/app/page.tsx +++ b/explorer-v2/src/app/page.tsx @@ -11,11 +11,8 @@ import { StakersNumberCardWrapper } from "../components/landingPageComponents/St import { TokenomicsCardWrapper } from "../components/landingPageComponents/TokenomicsCardWrapper"; import NodeTable from "../components/nodeTable/NodeTableWithAction"; import NodeAndAddressSearch from "../components/search/NodeAndAddressSearch"; -import { RECOMMENDED_NODES } from "./constants"; export default async function Home() { - const recommendedIds = await RECOMMENDED_NODES; - return ( @@ -45,7 +42,7 @@ export default async function Home() { - + diff --git a/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx b/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx index 91bfb0266e..835863ced8 100644 --- a/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx +++ b/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx @@ -4,16 +4,17 @@ import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import DOMPurify from "isomorphic-dompurify"; import { useEffect, useState } from "react"; -import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api"; +import { + fetchEpochRewards, + fetchNSApiNodes, + getRecommendedNodes, +} from "../../app/api"; import type { ExplorerData, NS_NODE } from "../../app/api/types"; import { countryName } from "../../utils/countryName"; import AdvancedFilters from "./AdvancedFilters"; import NodeTable from "./NodeTable"; -type Props = { - /** Recommended node IDs provided by the server page */ - recommendedIds: number[]; -}; + function getNodeSaturationPoint( totalStake: number, @@ -84,7 +85,7 @@ const mappedNSApiNodes = ( export type MappedNymNodes = ReturnType; export type MappedNymNode = MappedNymNodes[0]; -const NodeTableWithAction = ({ recommendedIds }: Props) => { +const NodeTableWithAction = () => { // All hooks at the top! const [activeFilter, setActiveFilter] = useState< "all" | "mixnodes" | "gateways" | "recommended" @@ -111,7 +112,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { // Wrapper functions to handle filter changes and sessionStorage const handleActiveFilterChange = ( - newFilter: "all" | "mixnodes" | "gateways" | "recommended", + newFilter: "all" | "mixnodes" | "gateways" | "recommended" ) => { setActiveFilter(newFilter); sessionStorage.setItem("nodeTableActiveFilter", newFilter); @@ -126,7 +127,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setSaturation(newSaturation); sessionStorage.setItem( "nodeTableSaturation", - JSON.stringify(newSaturation), + JSON.stringify(newSaturation) ); }; @@ -134,7 +135,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setProfitMargin(newProfitMargin); sessionStorage.setItem( "nodeTableProfitMargin", - JSON.stringify(newProfitMargin), + JSON.stringify(newProfitMargin) ); }; @@ -142,7 +143,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setAdvancedOpen(newAdvancedOpen); sessionStorage.setItem( "nodeTableAdvancedOpen", - JSON.stringify(newAdvancedOpen), + JSON.stringify(newAdvancedOpen) ); }; @@ -174,6 +175,8 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { refetchOnMount: false, }); + const recommendedIds = getRecommendedNodes(nsApiNodes); + // Map nodes with rewards data const nsApiNodesData = epochRewardsData ? mappedNSApiNodes(nsApiNodes || [], epochRewardsData) @@ -182,7 +185,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { // Calculate max saturation from all nodes const maxSaturation = Math.max( 100, - ...nsApiNodesData.map((n) => n.stakeSaturation || 0), + ...nsApiNodesData.map((n) => n.stakeSaturation || 0) ); // Initialize saturation from sessionStorage or set to maxSaturation when data is loaded