From 20188ff3735c243fb40592b5a5e83fb227bacc90 Mon Sep 17 00:00:00 2001 From: Yana Date: Wed, 17 Sep 2025 20:32:22 +0300 Subject: [PATCH] Refactor recommended nodes --- explorer-v2/src/app/(pages)/table/page.tsx | 4 +- explorer-v2/src/app/api/index.tsx | 86 +++++++++ explorer-v2/src/app/constants.ts | 2 - explorer-v2/src/app/lib/recommended.ts | 174 ------------------ explorer-v2/src/app/page.tsx | 5 +- .../nodeTable/NodeTableWithAction.tsx | 20 +- 6 files changed, 101 insertions(+), 190 deletions(-) delete mode 100644 explorer-v2/src/app/lib/recommended.ts 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..e10b1a8c83 100644 --- a/explorer-v2/src/app/api/index.tsx +++ b/explorer-v2/src/app/api/index.tsx @@ -306,3 +306,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/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..bec7c0a131 100644 --- a/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx +++ b/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx @@ -4,7 +4,11 @@ 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"; @@ -84,7 +88,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 +115,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 +130,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setSaturation(newSaturation); sessionStorage.setItem( "nodeTableSaturation", - JSON.stringify(newSaturation), + JSON.stringify(newSaturation) ); }; @@ -134,7 +138,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setProfitMargin(newProfitMargin); sessionStorage.setItem( "nodeTableProfitMargin", - JSON.stringify(newProfitMargin), + JSON.stringify(newProfitMargin) ); }; @@ -142,7 +146,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { setAdvancedOpen(newAdvancedOpen); sessionStorage.setItem( "nodeTableAdvancedOpen", - JSON.stringify(newAdvancedOpen), + JSON.stringify(newAdvancedOpen) ); }; @@ -174,6 +178,8 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => { refetchOnMount: false, }); + const recommendedIds = getRecommendedNodes(nsApiNodes); + // Map nodes with rewards data const nsApiNodesData = epochRewardsData ? mappedNSApiNodes(nsApiNodes || [], epochRewardsData) @@ -182,7 +188,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