/** * Авто-выбор «хорошего» Solana-валидатора для нативного стейкинга — чтобы работало из коробки * без операторской настройки. Каскад с кэшем (KeyDB 24ч): * 1. env override SOL_STAKE_VALIDATOR_VOTE (оператор может закрепить). * 2. Stakewiz API (без ключа): фильтр delinquent/commission/skip_rate/uptime, сорт по wiz_score. * 3. RPC getVoteAccounts: фильтр epochVoteAccount/commission/stake, сорт по (commission, credit growth). * 4. Hard-coded last-resort (проверенный активный валидатор, 0% комиссия). * * Менять валидатор на новых стейках безопасно: stake-аккаунт запоминает свой voter в delegation, * unstake/withdraw читают аккаунт, а не этот резолвер. */ import { Connection, PublicKey } from '@solana/web3.js'; import { env } from '../config/env'; import { getRedis } from '../config/redis'; import { logger } from './logger'; /** Last-resort fallback — Yurbason (0% commission, давний, проверен активным). */ const FALLBACK_VOTE = '9gANMngbGUmAaLXL1RC3JdiaLjRowJXNbzCTh53ht7mq'; const CACHE_KEY = 'sol:validator:default'; const CACHE_TTL_SEC = 24 * 60 * 60; // 24h const STAKEWIZ_URL = 'https://api.stakewiz.com/validators'; const HTTP_TIMEOUT_MS = 6000; let _conn: Connection | null = null; function defaultConn(): Connection { if (!_conn) _conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed'); return _conn; } function isValidVote(addr: unknown): addr is string { if (typeof addr !== 'string' || !addr) return false; try { new PublicKey(addr); return true; } catch { return false; } } /** * Резолвит vote-адрес валидатора. Никогда не throws — деградирует до hard-coded fallback. */ export async function resolveStakeValidator(conn?: Connection): Promise { // 1) env override if (isValidVote(env.defi.solStakeValidatorVote)) { return env.defi.solStakeValidatorVote; } // cache try { const cached = await getRedis().get(CACHE_KEY); if (cached && isValidVote(cached)) return cached; } catch { /* ignore */ } let chosen: string | null = null; // 2) Stakewiz (внешний, untrusted) — выбор кросс-проверяется против on-chain перед доверием chosen = await fromStakewiz().catch(() => null); if (chosen) { const ok = await verifyOnChain(conn ?? defaultConn(), chosen).catch(() => false); if (!ok) { logger.warn(`SOL validator: Stakewiz pick ${chosen} failed on-chain verification — falling back to RPC`); chosen = null; } } // 3) RPC fallback (on-chain authoritative) if (!chosen) { chosen = await fromRpc(conn ?? defaultConn()).catch(() => null); } // 4) last-resort if (!chosen || !isValidVote(chosen)) { logger.warn('SOL validator auto-select failed (Stakewiz + RPC) — using hard-coded fallback'); chosen = FALLBACK_VOTE; } try { await getRedis().set(CACHE_KEY, chosen, 'EX', CACHE_TTL_SEC); } catch { /* ignore */ } return chosen; } async function fromStakewiz(): Promise { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); try { const res = await fetch(STAKEWIZ_URL, { headers: { Accept: 'application/json' }, signal: controller.signal }); if (!res.ok) return null; const list = (await res.json()) as any[]; if (!Array.isArray(list)) return null; const candidates = list .filter((v) => v && v.delinquent === false) .filter((v) => Number(v.commission) <= 2) .filter((v) => Number(v.skip_rate) < 1) .filter((v) => Number(v.uptime) >= 99.8) .filter((v) => isValidVote(v.vote_identity)) .sort((a, b) => Number(b.wiz_score) - Number(a.wiz_score)); return candidates.length ? String(candidates[0].vote_identity) : null; } catch { return null; } finally { clearTimeout(t); } } /** * Кросс-проверка: vote-адрес (напр. от Stakewiz) ДОЛЖЕН существовать on-chain в current[], * не быть delinquent, и иметь commission ≤ 10%. Защита от спуфнутого/устаревшего внешнего ответа. */ async function verifyOnChain(conn: Connection, vote: string): Promise { const accounts = await conn.getVoteAccounts(); if (accounts.delinquent.some((v: any) => v.votePubkey === vote)) return false; const cur = accounts.current.find((v: any) => v.votePubkey === vote); return !!cur && Number(cur.commission) <= 10; } async function fromRpc(conn: Connection): Promise { const accounts = await conn.getVoteAccounts(); const delinquent = new Set(accounts.delinquent.map((v: any) => v.votePubkey)); const candidates = accounts.current .filter((v: any) => v.epochVoteAccount === true) .filter((v: any) => Number(v.commission) <= 5) .filter((v: any) => !delinquent.has(v.votePubkey)) .filter((v: any) => Number(v.activatedStake) > 100_000_000) .map((v: any) => { const ec = Array.isArray(v.epochCredits) ? v.epochCredits : []; const last = ec[ec.length - 1]; const prev = ec[ec.length - 2]; const creditGrowth = last && prev ? Number(last[1]) - Number(prev[1]) : 0; return { votePubkey: v.votePubkey as string, commission: Number(v.commission), creditGrowth }; }) .sort((a, b) => (a.commission !== b.commission ? a.commission - b.commission : b.creditGrowth - a.creditGrowth)); return candidates.length && isValidVote(candidates[0].votePubkey) ? candidates[0].votePubkey : null; }