Merge pull request #6049 from nymtech/refactor/recommended-nodes

Refactor/recommended nodes
This commit is contained in:
Yana Matrosova
2025-09-18 13:34:58 +03:00
committed by GitHub
7 changed files with 117 additions and 210 deletions
+1 -3
View File
@@ -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<number[]>
export default async function ExplorerPage() {
// Resolve once on the server and pass IDs to client components
const recommendedIds = await RECOMMENDED_NODES;
return (
<ContentLayout>
@@ -21,7 +19,7 @@ export default async function ExplorerPage() {
<NodeAndAddressSearch />
</Stack>
<Box sx={{ mt: 5 }}>
<NodeTableWithAction recommendedIds={recommendedIds} />
<NodeTableWithAction />
</Box>
{/* <Grid container columnSpacing={5} rowSpacing={5} mt={10}>
<Grid size={12}>
+98 -14
View File
@@ -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<GatewayStatus | null> => {
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<NodeRewardDetails[]> => {
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<number> => {
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<number> => {
};
// Fetch function to get total staker rewards
export const fetchTotalStakerRewards = async (
address: string,
): Promise<number> => {
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
export const fetchTotalStakerRewards = async (address: string): Promise<number> => {
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<number> => {
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<number> => {
export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
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<IPacketsAndStakingData[]> => {
// Fetch Account Balance
export const fetchAccountBalance = async (
address: string,
address: string
): Promise<IAccountBalancesInfo> => {
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<NymTokenomics> => {
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);
};
+3 -2
View File
@@ -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";
-2
View File
@@ -1,5 +1,3 @@
export const TABLET_WIDTH = "(min-width:700px)";
import { getRecommendedNodes } from "./lib/recommended";
export const RECOMMENDED_NODES = getRecommendedNodes();
-174
View File
@@ -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<ApiNode[]> {
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<number[]> {
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<number[]> {
return fetchRecommendedNodes();
}
export const RECOMMENDED_NODES: Promise<number[]> = getRecommendedNodes();
+1 -4
View File
@@ -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 (
<ContentLayout>
<Stack gap={5}>
@@ -45,7 +42,7 @@ export default async function Home() {
<SectionHeading title="Nym Servers" />
</Grid>
<Grid size={12}>
<NodeTable recommendedIds={recommendedIds} />
<NodeTable />
</Grid>
</Grid>
<Grid container columnSpacing={5} rowSpacing={5}>
@@ -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<typeof mappedNSApiNodes>;
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