From c4410bd187e5b455daeb3be7879de399ba0155c7 Mon Sep 17 00:00:00 2001 From: serinko <97586125+serinko@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:51:59 +0200 Subject: [PATCH] update recommended selection algo --- explorer-v2/src/app/lib/recommended.ts | 98 +++++++++++++++++--------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/explorer-v2/src/app/lib/recommended.ts b/explorer-v2/src/app/lib/recommended.ts index 075cd1d7c5..83ffd56347 100644 --- a/explorer-v2/src/app/lib/recommended.ts +++ b/explorer-v2/src/app/lib/recommended.ts @@ -1,5 +1,12 @@ 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; @@ -7,11 +14,10 @@ type ApiNode = { description?: { mixnet_websockets?: { ws_port?: number | null }; wireguard?: unknown | null; - declared_role?: { - entry?: boolean; - exit_ipr?: boolean; - exit_nr?: boolean; - }; + declared_role?: DeclaredRole; + }; + self_description?: { + declared_role?: DeclaredRole; }; rewarding_details?: { cost_params?: { @@ -26,47 +32,75 @@ function toNumber(x: unknown, fallback = 0): number { } const MIN_STAKE = 50_000_000_000; // 50B +const MAX_PM = 0.2; + +function hasRequiredRoles(n: ApiNode): boolean { + const r = n.self_description?.declared_role ?? n.description?.declared_role ?? {}; + return !!(r.entry && r.exit_ipr && r.exit_nr); +} + +function hasWs9000(n: ApiNode): boolean { + return n.description?.mixnet_websockets?.ws_port === 9000; +} + +function hasGoodPM(n: ApiNode): boolean { + const pm = toNumber(n.rewarding_details?.cost_params?.profit_margin_percent, NaN); + return !Number.isNaN(pm) && pm <= MAX_PM; +} + +function stakeOk(n: ApiNode): boolean { + return toNumber(n.total_stake, 0) > MIN_STAKE; +} + +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; + const sa = toNumber(a.total_stake, 0); + const sb = toNumber(b.total_stake, 0); + return sa - sb; +} async function fetchRecommendedNodes(): Promise { const url = "https://api.nym.spectredao.net/api/v1/nodes?size=3000"; const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`Failed to fetch nodes: ${res.status}`); - const data = (await res.json()) as unknown; - - if (!Array.isArray(data)) { + const json = (await res.json()) as unknown; + if (!Array.isArray(json)) { throw new Error("Unexpected API shape: expected an array"); } + const nodes = json as ApiNode[]; - const nodes = data as ApiNode[]; + const baseFilter = (n: ApiNode) => + hasWs9000(n) && hasRequiredRoles(n) && hasGoodPM(n) && stakeOk(n); - const filtered = nodes.filter((n) => { - const ws9000 = n.description?.mixnet_websockets?.ws_port === 9000; - const wgOn = n.description?.wireguard != null; + const wgCandidates = nodes + .filter((n) => baseFilter(n) && wireguardOn(n)) + .sort(sortByUptimeDescStakeAsc); - const pm = toNumber(n.rewarding_details?.cost_params?.profit_margin_percent, NaN); - const pmOk = !Number.isNaN(pm) && pm <= 0.2; + let picked = wgCandidates.slice(0, 10); - const r = n.description?.declared_role; - const rolesOk = !!(r?.entry && r?.exit_ipr && r?.exit_nr); + if (picked.length < 10) { + const relaxed = nodes + .filter((n) => baseFilter(n)) // WG relaxed + .sort(sortByUptimeDescStakeAsc); - const stakeOk = toNumber(n.total_stake, 0) > MIN_STAKE; + const set = new Set(picked.map((n) => n.node_id)); + for (const n of relaxed) { + if (set.size >= 10) break; + if (!set.has(n.node_id)) { + picked.push(n); + set.add(n.node_id); + } + } + } - return ws9000 && wgOn && pmOk && rolesOk && stakeOk; - }); - - filtered.sort((a, b) => { - const ua = toNumber(a.uptime, 0); - const ub = toNumber(b.uptime, 0); - if (ub !== ua) return ub - ua; // uptime DESC - - const sa = toNumber(a.total_stake, 0); - const sb = toNumber(b.total_stake, 0); - return sa - sb; // stake ASC - }); - - return filtered - .slice(0, 10) + return picked .map((n) => (typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0))) .filter((id) => Number.isFinite(id) && id > 0); }